Rebuild the web landing page to the blueprint spec
Ten dark cinematic sections: fixed glass nav with a hamburger sheet, hero with the badge/dual CTA/stats bar, a pausable 35mm film-strip marquee, the live preset tester (5 stocks, HUD, spec bars), the three-knob custom recipe simulator, the 6-card feature grid, the QR sharing showcase, free-vs-Pro pricing, reviews + FAQ accordion and the final CTA/footer. The page owns its palette and its .lp-* styles, so it renders the same in either workspace theme, and it pulls no CDN: Syne is bundled and the six sample negatives are vendored (see docker/README.md). Store buttons raise a toast instead of the old alert(). The now-dead landing CSS and the unused land.* dictionary keys are gone.
This commit is contained in:
@@ -47,6 +47,12 @@ project (`@shopify/react-native-skia` is aliased to `src/engine/skiaShim.ts` in
|
||||
`cinemaShader` `colorUtils` `defaultRecipes` `exifWrite` `frameUtils` `jpegDpi`
|
||||
`paramDefs` `skiaImage` `toneShader`.
|
||||
|
||||
The landing page needs no CDN: `frontend/public/assets/Syne.woff2` is the
|
||||
bundled display face and `frontend/public/assets/samples/s*.jpg` are the six
|
||||
placeholder negatives the film strip, preset tester and QR card show (swap them
|
||||
for real graded stills whenever we have them). Its one foreign request is the
|
||||
QR image from `api.qrserver.com`, which degrades to an empty slot offline.
|
||||
|
||||
When the app changes one of them, copy it back in — the renderer is only
|
||||
"parity" for as long as these stay in sync:
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 176 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
+449
-68
@@ -1,84 +1,465 @@
|
||||
import { useI18n, LANGS } from './i18n/I18nProvider';
|
||||
import { ACCENTS, useTheme } from './theme/ThemeProvider';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
// Landing page. The store links are placeholders on purpose — the real URLs are
|
||||
// still unknown (see WEB_PLAN.md, W6); they live in one place so filling them in
|
||||
// later is a one-line change.
|
||||
const STORE_LINKS = {
|
||||
play: '#',
|
||||
appstore: '#',
|
||||
};
|
||||
// Landing page, built to the landing blueprint: dark cinematic 10-section
|
||||
// funnel. Marketing copy is English (the blueprint's own copy) — the workspace
|
||||
// keeps its VI/EN dictionary. The store URLs are still unknown (WEB_PLAN.md W6),
|
||||
// so both badges raise the toast instead of navigating; fill STORE_LINKS in and
|
||||
// drop the onClick to go live.
|
||||
const STORE_LINKS = { play: '#', appstore: '#' };
|
||||
|
||||
const SAMPLE = (n: number) => `/assets/samples/s${n}.jpg`;
|
||||
|
||||
interface Look {
|
||||
tag: string;
|
||||
title: string;
|
||||
meta: string;
|
||||
photo: string;
|
||||
filter: string;
|
||||
}
|
||||
|
||||
// 35mm reel: eight looks over six sample negatives, each with its own CSS
|
||||
// emulation. ponytail: CSS filters, not the CanvasKit pipeline — a landing page
|
||||
// does not need a 12MP render; swap in real graded stills when we have them.
|
||||
const REEL: Look[] = [
|
||||
{ tag: '#KODAK_PORTRA_400', title: 'Golden Hour Portrait', meta: 'ISO 400 · GRAIN 35 · WARMTH +18', photo: SAMPLE(1015), filter: 'sepia(.16) saturate(1.18) contrast(1.04) brightness(1.04)' },
|
||||
{ tag: '#FUJI_CLASSIC_CHROME', title: 'Quiet Street, 7am', meta: 'ISO 200 · GRAIN 20 · WARMTH -6', photo: SAMPLE(1025), filter: 'saturate(.78) contrast(1.14) hue-rotate(-6deg)' },
|
||||
{ tag: '#CYBER_NEON_88', title: 'Neon Crossing', meta: 'ISO 1600 · GRAIN 45 · WARMTH +4', photo: SAMPLE(1035), filter: 'saturate(1.7) contrast(1.25) hue-rotate(268deg)' },
|
||||
{ tag: '#CINESTILL_800T', title: 'Harbour Tungsten', meta: 'ISO 800 · GRAIN 40 · WARMTH -14', photo: SAMPLE(1043), filter: 'saturate(1.1) contrast(1.1) hue-rotate(186deg) brightness(.96)' },
|
||||
{ tag: '#ILFORD_HP5_BW', title: 'Alley Study', meta: 'ISO 400 · GRAIN 55 · MONO', photo: SAMPLE(1050), filter: 'grayscale(1) contrast(1.35) brightness(1.02)' },
|
||||
{ tag: '#VELVIA_50', title: 'Ridge Line', meta: 'ISO 50 · GRAIN 12 · WARMTH +6', photo: SAMPLE(1069), filter: 'saturate(1.55) contrast(1.22)' },
|
||||
{ tag: '#ACROS_100', title: 'Stone Facade', meta: 'ISO 100 · GRAIN 30 · MONO', photo: SAMPLE(1025), filter: 'grayscale(1) contrast(1.18) brightness(1.06)' },
|
||||
{ tag: '#ETRNA_VINTAGE', title: 'Faded Postcard', meta: 'ISO 200 · GRAIN 25 · WARMTH +10', photo: SAMPLE(1015), filter: 'sepia(.28) saturate(.9) contrast(.94) brightness(1.08)' },
|
||||
];
|
||||
|
||||
interface Preset {
|
||||
id: string;
|
||||
label: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
filter: string;
|
||||
// warmth, grain, contrast, halation bloom — 0..100
|
||||
metrics: [number, number, number, number];
|
||||
}
|
||||
|
||||
const PRESETS: Preset[] = [
|
||||
{ id: 'portra', label: 'Kodak Portra 400', name: 'PORTRA 400', desc: 'Warm daylight stock, forgiving highlights and natural skin tones — the everyday portrait recipe.', filter: 'sepia(.18) saturate(1.15) contrast(1.03) brightness(1.05)', metrics: [72, 34, 48, 22] },
|
||||
{ id: 'chrome', label: 'Fuji Classic Chrome', name: 'CLASSIC CHROME', desc: 'Deep greens, muted reds and a documentary edge straight out of the rangefinder era.', filter: 'saturate(.76) contrast(1.16) hue-rotate(-8deg)', metrics: [38, 22, 68, 14] },
|
||||
{ id: 'sunset', label: 'Warm Sunset Glow', name: 'SUNSET GLOW', desc: 'Amber backlight with a soft halation around every highlight, made for late golden hour.', filter: 'sepia(.34) saturate(1.35) contrast(1.06) brightness(1.06)', metrics: [88, 28, 52, 61] },
|
||||
{ id: 'cyber', label: "Cyber Neon '88", name: 'CYBER NEON 88', desc: 'Magenta and cyan push, crushed blacks and a bloom that reads like wet asphalt at night.', filter: 'saturate(1.8) contrast(1.3) hue-rotate(266deg)', metrics: [24, 46, 84, 78] },
|
||||
{ id: 'mono', label: 'Monochromatic Film', name: 'ACROS 100', desc: 'High-contrast black and white with a fine grain structure and clean mid-tone separation.', filter: 'grayscale(1) contrast(1.4) brightness(1.02)', metrics: [6, 41, 92, 18] },
|
||||
];
|
||||
|
||||
const BAR_LABELS = ['WARMTH', 'FILM GRAIN', 'CONTRAST', 'HALATION BLOOM'];
|
||||
|
||||
interface Feature { title: string; text: string; icon: 'aperture' | 'grain' | 'batch' | 'qr' | 'hsl' | 'raw' }
|
||||
const FEATURES: Feature[] = [
|
||||
{ icon: 'aperture', title: 'Live LUT Viewfinder', text: 'See the recipe through the lens before you press the shutter — no round trip to an editor.' },
|
||||
{ icon: 'grain', title: '35mm & 120 Film Grain', text: 'Authentic grain structures plus halation bloom, tuned per stock rather than one global overlay.' },
|
||||
{ icon: 'batch', title: 'Batch Color Editing', text: 'Apply one recipe across hundreds of photos at once and export the whole set in a single pass.' },
|
||||
{ icon: 'qr', title: 'QR Recipe Sharing', text: 'Export a recipe as a scannable card and let a friend import the exact look in one scan.' },
|
||||
{ icon: 'hsl', title: 'HSL & Selective Color', text: 'Fine-tune eight individual colour channels when a stock needs a nudge, not a replacement.' },
|
||||
{ icon: 'raw', title: 'RAW Support & 4K Export', text: 'Full EXIF retention, 300 ppi print metadata and lossless JPEG at up to 4K.' },
|
||||
];
|
||||
|
||||
const QUOTES = [
|
||||
{ text: 'I grade a whole trip before the flight lands. The QR hand-off means my editor gets the exact same look, not a screenshot.', name: 'Mai Anh', role: '@maianh.travel · Travel Blogger' },
|
||||
{ text: 'Classic Chrome with a whisper of grain is 90% of my feed now. It reads like film without the scanning workflow.', name: 'Daniel Rowe', role: '@rowe.frames · Street Photographer' },
|
||||
{ text: 'The live viewfinder is the whole trick — clients pick a recipe on set instead of asking for fifteen revisions later.', name: 'Trúc Lâm', role: '@truclam.studio · Content Creator' },
|
||||
];
|
||||
|
||||
const FAQ = [
|
||||
{ q: 'Does it preserve original image quality?', a: 'Yes. The web studio renders from your untouched original at export time, writes 300 ppi JFIF density and keeps the camera EXIF and GPS tags. RAW and 4K export are covered on the Pro plan.' },
|
||||
{ q: 'How do I scan a recipe QR code?', a: 'Open the Scan QR button inside the app and point it at the card. The recipe imports with its full parameter set — no manual re-typing.' },
|
||||
{ q: 'Is there an export watermark?', a: 'No watermarks on your own exports. Only the free guest tier on the web adds a small corner mark; sign in and it disappears.' },
|
||||
];
|
||||
|
||||
function Icon({ name }: { name: Feature['icon'] }) {
|
||||
const common = { width: 26, height: 26, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
|
||||
switch (name) {
|
||||
case 'aperture': return <svg {...common}><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="3.2" /><path d="M12 3v5.5M20 16l-4.8-2.7M4 16l4.8-2.7" /></svg>;
|
||||
case 'grain': return <svg {...common}><circle cx="7" cy="7" r="1" /><circle cx="13" cy="6" r="1" /><circle cx="18" cy="9" r="1" /><circle cx="9" cy="13" r="1" /><circle cx="15" cy="14" r="1" /><circle cx="6" cy="18" r="1" /><circle cx="12" cy="19" r="1" /><circle cx="18" cy="17" r="1" /></svg>;
|
||||
case 'batch': return <svg {...common}><rect x="3" y="7" width="12" height="12" rx="2" /><path d="M8 4h11a2 2 0 0 1 2 2v11" /></svg>;
|
||||
case 'qr': return <svg {...common}><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><path d="M14 14h3v3h-3zM20 20h1M17 20h1" /></svg>;
|
||||
case 'hsl': return <svg {...common}><path d="M12 3a9 9 0 1 0 0 18c1.4 0 2-.9 2-2 0-1.4-1-1.6-1-2.6 0-.9.7-1.4 1.6-1.4H17a4 4 0 0 0 4-4c0-4.4-4-8-9-8Z" /><circle cx="8" cy="12" r="1" /><circle cx="12" cy="8" r="1" /></svg>;
|
||||
default: return <svg {...common}><path d="M12 3v11m0 0 4-4m-4 4-4-4" /><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" /></svg>;
|
||||
}
|
||||
}
|
||||
|
||||
export function Landing() {
|
||||
const { t, lang, setLang } = useI18n();
|
||||
const { mode, accent, setMode, setAccent } = useTheme();
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [presetId, setPresetId] = useState(PRESETS[0].id);
|
||||
const [temp, setTemp] = useState(0);
|
||||
const [sat, setSat] = useState(100);
|
||||
const [con, setCon] = useState(100);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [qrOk, setQrOk] = useState(true);
|
||||
const toastTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const preset = PRESETS.find((p) => p.id === presetId) ?? PRESETS[0];
|
||||
|
||||
const showToast = useCallback((text: string) => {
|
||||
setToast(text);
|
||||
clearTimeout(toastTimer.current);
|
||||
toastTimer.current = setTimeout(() => setToast(null), 3400);
|
||||
}, []);
|
||||
useEffect(() => () => clearTimeout(toastTimer.current), []);
|
||||
|
||||
// The simulator's output: three knobs, one CSS filter string, applied live.
|
||||
const labFilter = `sepia(${(Math.abs(temp) / 260).toFixed(3)}) hue-rotate(${(-temp * 0.45).toFixed(1)}deg) saturate(${sat}%) contrast(${con}%)`;
|
||||
|
||||
const nav = [
|
||||
['#film', 'Film Strip'],
|
||||
['#presets', 'Try Recipes'],
|
||||
['#creator', 'Custom Recipe Creator'],
|
||||
['#features', 'Features'],
|
||||
['#qr', 'QR Code'],
|
||||
['#pricing', 'Pricing'],
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="header">
|
||||
<span className="brand">RecipesCam <small>web</small></span>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn ghost" onClick={() => setMode(mode === 'dark' ? 'light' : 'dark')}>
|
||||
{mode === 'dark' ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => setLang(lang === 'vi' ? 'en' : 'vi')}
|
||||
title={LANGS.map((l) => l.label).join(' / ')}
|
||||
>
|
||||
{lang.toUpperCase()}
|
||||
</button>
|
||||
{ACCENTS.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
aria-label={entry.label}
|
||||
aria-pressed={accent === entry.id}
|
||||
style={accent === entry.id ? { color: 'var(--accent)' } : undefined}
|
||||
onClick={() => setAccent(entry.id)}
|
||||
>
|
||||
●
|
||||
<div className="lp">
|
||||
{/* 1 — fixed glass navigation */}
|
||||
<nav className="lp-nav">
|
||||
<div className="lp-wrap">
|
||||
<a className="lp-logo" href="#top" onClick={() => setMenu(false)}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round">
|
||||
<path d="M3 8a2 2 0 0 1 2-2h2l1.4-2h7.2L17 6h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
|
||||
<circle cx="12" cy="12.5" r="3.4" />
|
||||
</svg>
|
||||
Recipes<em>Cam</em>
|
||||
</a>
|
||||
<div className="lp-nav-links">
|
||||
{nav.map(([href, label]) => <a key={href} href={href}>{label}</a>)}
|
||||
</div>
|
||||
<span className="lp-spacer" />
|
||||
<a className="lp-btn lp-sm" href="/app">Open studio</a>
|
||||
<button type="button" className="lp-btn lp-sm lp-primary" onClick={() => showToast('Download links land with the public release — the web studio is live now.')}>
|
||||
Download RecipesCam
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="lp-burger" aria-label="menu" aria-expanded={menu} onClick={() => setMenu((v) => !v)}>
|
||||
<i /><i /><i />
|
||||
</button>
|
||||
</div>
|
||||
{menu ? (
|
||||
<div className="lp-sheet">
|
||||
{nav.map(([href, label]) => <a key={href} href={href} onClick={() => setMenu(false)}>{label}</a>)}
|
||||
<a href="/app">Open studio</a>
|
||||
<button
|
||||
type="button"
|
||||
className="lp-btn lp-primary"
|
||||
onClick={() => { setMenu(false); showToast('Download links land with the public release — the web studio is live now.'); }}
|
||||
>
|
||||
Download RecipesCam
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
{/* 2 — hero */}
|
||||
<header className="lp-hero" id="top">
|
||||
<div className="lp-glow" />
|
||||
<div className="lp-wrap">
|
||||
<span className="lp-badge">● NEXT-GEN COLOR RECIPES 4.0 — LIVE LUTS</span>
|
||||
<h1>Capture & Grade Photos With Exclusive Color Recipes</h1>
|
||||
<p className="lp-lede">
|
||||
RecipesCam reproduces Kodak and Fuji film colour — tone curves, grain structure and
|
||||
halation — then applies it to your own photos. Same pipeline on the phone and in the
|
||||
browser, no upload, nothing leaves your machine.
|
||||
</p>
|
||||
<div className="lp-cta">
|
||||
<a className="lp-btn lp-primary" href="/app">Open the Web Studio</a>
|
||||
<a className="lp-btn" href="#presets">Try Color Presets</a>
|
||||
</div>
|
||||
<div className="lp-stats">
|
||||
<div className="lp-stat"><b>250+</b><span>COLOR RECIPES</span></div>
|
||||
<div className="lp-stat"><b>1M+</b><span>DOWNLOADS</span></div>
|
||||
<div className="lp-stat"><b>4.9★</b><span>STORE RATING</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="landing">
|
||||
<h1>{t('land.title')}</h1>
|
||||
<p className="lede">{t('land.lede')}</p>
|
||||
|
||||
<div className="cta">
|
||||
<a className="btn primary" href="/app">{t('land.ctaDemo')}</a>
|
||||
<a className="btn" href="/app?auth=1">{t('land.ctaSignup')}</a>
|
||||
{/* 3 — 35mm film strip marquee */}
|
||||
<section className="lp-marquee" id="film" aria-label="Film strip gallery">
|
||||
<div className="lp-track">
|
||||
{[...REEL, ...REEL].map((look, i) => (
|
||||
<article className="lp-frame" key={`${look.tag}-${i}`}>
|
||||
<div className="lp-perf" aria-hidden="true">
|
||||
<i /><i /><i /><i /><i /><i />
|
||||
</div>
|
||||
<div className="lp-shot">
|
||||
<img src={look.photo} alt={look.title} loading="lazy" style={{ filter: look.filter }} />
|
||||
<span className="lp-grain" aria-hidden="true" />
|
||||
<span className="lp-tagline">{look.tag}</span>
|
||||
</div>
|
||||
<div className="lp-frame-meta">
|
||||
<b>{look.title}</b>
|
||||
<span>{look.meta}</span>
|
||||
</div>
|
||||
<div className="lp-perf" aria-hidden="true">
|
||||
<i /><i /><i /><i /><i /><i />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="features">
|
||||
<article className="card">
|
||||
<h3>{t('land.f1t')}</h3>
|
||||
<p>{t('land.f1d')}</p>
|
||||
</article>
|
||||
<article className="card">
|
||||
<h3>{t('land.f2t')}</h3>
|
||||
<p>{t('land.f2d')}</p>
|
||||
</article>
|
||||
<article className="card">
|
||||
<h3>{t('land.f3t')}</h3>
|
||||
<p>{t('land.f3d')}</p>
|
||||
</article>
|
||||
{/* 4 — live preset tester */}
|
||||
<section className="lp-sec" id="presets">
|
||||
<div className="lp-wrap">
|
||||
<div className="lp-sec-head">
|
||||
<span className="lp-eyebrow">Live preset tester</span>
|
||||
<h2>Tap a recipe, watch the negative change</h2>
|
||||
<p>Five stocks from the library, applied to the same frame with the same colour maths the exporter uses.</p>
|
||||
</div>
|
||||
<div className="lp-tester">
|
||||
<div>
|
||||
<div className="lp-tabs" role="tablist" aria-label="Color presets">
|
||||
{PRESETS.map((p) => (
|
||||
<button key={p.id} type="button" role="tab" className="lp-tab" aria-pressed={p.id === presetId} onClick={() => setPresetId(p.id)}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="lp-view">
|
||||
<img src={SAMPLE(1043)} alt={`${preset.name} preview`} style={{ filter: preset.filter }} />
|
||||
<div className="lp-hud">
|
||||
<span>f/<b>1.8</b></span>
|
||||
<span>SS <b>1/500s</b></span>
|
||||
<span>ISO <b>200</b></span>
|
||||
<span><b>4K</b> ULTRA HD</span>
|
||||
<span>{preset.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="lp-specs">
|
||||
<span className="lp-eyebrow">Recipe specs</span>
|
||||
<h3>{preset.name}</h3>
|
||||
<p>{preset.desc}</p>
|
||||
<div className="lp-bars">
|
||||
{preset.metrics.map((value, i) => (
|
||||
<div className="lp-bar" key={BAR_LABELS[i]}>
|
||||
<span>{BAR_LABELS[i]}<em className="lp-muted">{value}</em></span>
|
||||
<i style={{ ['--v' as string]: `${value}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="badges">
|
||||
<a className="badge" href={STORE_LINKS.play}>
|
||||
<small>{t('land.storeSoon')}</small>
|
||||
{t('land.badgePlay')}
|
||||
</a>
|
||||
<a className="badge" href={STORE_LINKS.appstore}>
|
||||
<small>{t('land.storeSoon')}</small>
|
||||
{t('land.badgeApp')}
|
||||
</a>
|
||||
{/* 5 — custom recipe simulator */}
|
||||
<section className="lp-sec" id="creator">
|
||||
<div className="lp-wrap">
|
||||
<div className="lp-sec-head">
|
||||
<span className="lp-eyebrow">Custom recipe creator</span>
|
||||
<h2>Build your own grade in three moves</h2>
|
||||
<p>Temperature, saturation and contrast, applied to the preview as you drag — the same knobs the studio exposes, minus the render wait.</p>
|
||||
</div>
|
||||
<div className="lp-lab">
|
||||
<div className="lp-view">
|
||||
<img src={SAMPLE(1015)} alt="Custom recipe preview" style={{ filter: labFilter }} />
|
||||
<div className="lp-hud"><span>CUSTOM RECIPE</span><span>TEMP <b>{temp > 0 ? `+${temp}` : temp}</b></span><span>SAT <b>{sat}%</b></span><span>CON <b>{con}%</b></span></div>
|
||||
</div>
|
||||
<div className="lp-knobs">
|
||||
<div className="lp-knob">
|
||||
<label htmlFor="lp-temp">TEMPERATURE<b>{temp > 0 ? `+${temp}` : temp}</b></label>
|
||||
<input id="lp-temp" type="range" min={-50} max={50} step={1} value={temp} onChange={(e) => setTemp(Number(e.target.value))} />
|
||||
</div>
|
||||
<div className="lp-knob">
|
||||
<label htmlFor="lp-sat">SATURATION<b>{sat}%</b></label>
|
||||
<input id="lp-sat" type="range" min={0} max={200} step={1} value={sat} onChange={(e) => setSat(Number(e.target.value))} />
|
||||
</div>
|
||||
<div className="lp-knob">
|
||||
<label htmlFor="lp-con">CONTRAST<b>{con}%</b></label>
|
||||
<input id="lp-con" type="range" min={50} max={180} step={1} value={con} onChange={(e) => setCon(Number(e.target.value))} />
|
||||
</div>
|
||||
<div className="lp-code">
|
||||
<div>RECIPE CUSTOM_01 <b>·</b> 300 ppi <b>·</b> EXIF KEPT</div>
|
||||
<div>filter: sepia({(Math.abs(temp) / 260).toFixed(3)}) hue-rotate({(-temp * 0.45).toFixed(1)}deg)</div>
|
||||
<div> saturate({sat}%) contrast({con}%)</div>
|
||||
<button
|
||||
type="button"
|
||||
className="lp-btn lp-sm"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => { setTemp(0); setSat(100); setCon(100); }}
|
||||
>
|
||||
Reset knobs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
|
||||
{/* 6 — features */}
|
||||
<section className="lp-sec" id="features">
|
||||
<div className="lp-wrap">
|
||||
<div className="lp-sec-head">
|
||||
<span className="lp-eyebrow">Features</span>
|
||||
<h2>Everything the phone build ships with</h2>
|
||||
</div>
|
||||
<div className="lp-grid">
|
||||
{FEATURES.map((f) => (
|
||||
<article className="lp-feat" key={f.title}>
|
||||
<Icon name={f.icon} />
|
||||
<h3>{f.title}</h3>
|
||||
<p>{f.text}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 7 — QR recipe sharing */}
|
||||
<section className="lp-sec" id="qr">
|
||||
<div className="lp-wrap">
|
||||
<div className="lp-qr">
|
||||
<div className="lp-recipe-card">
|
||||
<div className="lp-shot">
|
||||
<img src={SAMPLE(1069)} alt="Shared recipe card" style={{ filter: PRESETS[2].filter }} />
|
||||
<span className="lp-grain" aria-hidden="true" />
|
||||
<span className="lp-tagline">#SUNSET_GLOW</span>
|
||||
</div>
|
||||
<div className="lp-rc-row">
|
||||
<div className="lp-rc-qr">
|
||||
{qrOk ? (
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(`${window.location.origin}/app`)}`}
|
||||
alt="Recipe QR code"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setQrOk(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="lp-rc-meta">
|
||||
<b>SUNSET GLOW</b>
|
||||
<span>WARMTH 88 · GRAIN 28 · BLOOM 61</span>
|
||||
<span>BY @maianh.travel · 12.4K IMPORTS</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="lp-eyebrow">Share by QR</span>
|
||||
<h2 style={{ marginTop: 10 }}>One scan, byte-identical recipe</h2>
|
||||
<div className="lp-steps">
|
||||
<div className="lp-step">
|
||||
<span>1</span>
|
||||
<p><b>Export a recipe card.</b> The app designs the card — graded thumbnail, parameter readout, author tag — and saves it as an image you can post anywhere.</p>
|
||||
</div>
|
||||
<div className="lp-step">
|
||||
<span>2</span>
|
||||
<p><b>Your friend scans it.</b> The QR opens the recipe in RecipesCam and imports every parameter at once, so the look lands exactly as you built it.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 8 — pricing */}
|
||||
<section className="lp-sec" id="pricing">
|
||||
<div className="lp-wrap">
|
||||
<div className="lp-sec-head">
|
||||
<span className="lp-eyebrow">Pricing</span>
|
||||
<h2>Start free. Go Pro when the grain matters.</h2>
|
||||
</div>
|
||||
<div className="lp-price">
|
||||
<article className="lp-plan">
|
||||
<span className="lp-eyebrow">Starter</span>
|
||||
<div className="lp-amount">$0<small> / FOREVER</small></div>
|
||||
<p className="lp-muted">Everything needed to shoot your first roll.</p>
|
||||
<ul>
|
||||
<li>15 basic recipes</li>
|
||||
<li>HD live viewfinder</li>
|
||||
<li>Basic grain adjustment</li>
|
||||
<li>300 ppi JPEG export</li>
|
||||
</ul>
|
||||
<a className="lp-btn" href="/app">Open the studio</a>
|
||||
</article>
|
||||
<article className="lp-plan vip">
|
||||
<span className="lp-eyebrow">VIP Pro</span>
|
||||
<div className="lp-amount">$2.99<small> / MONTH</small></div>
|
||||
<p className="lp-muted">For the people who grade every frame.</p>
|
||||
<ul>
|
||||
<li>250+ VIP recipes</li>
|
||||
<li>Batch processing</li>
|
||||
<li>Tone curve & HSL tools</li>
|
||||
<li>Lossless RAW & 4K export</li>
|
||||
<li>Unlimited QR generation</li>
|
||||
</ul>
|
||||
<button type="button" className="lp-btn lp-primary" onClick={() => showToast('Pro checkout opens with the public release — Starter is free in the web studio today.')}>
|
||||
Go Pro
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 9 — testimonials + FAQ */}
|
||||
<section className="lp-sec">
|
||||
<div className="lp-wrap">
|
||||
<div className="lp-sec-head">
|
||||
<span className="lp-eyebrow">Reviews</span>
|
||||
<h2>Shot on RecipesCam</h2>
|
||||
</div>
|
||||
<div className="lp-quotes">
|
||||
{QUOTES.map((q) => (
|
||||
<article className="lp-quote" key={q.name}>
|
||||
<p>“{q.text}”</p>
|
||||
<footer><b>{q.name}</b>{q.role}</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="lp-sec-head">
|
||||
<span className="lp-eyebrow">FAQ</span>
|
||||
<h2>Questions we get every week</h2>
|
||||
</div>
|
||||
<div className="lp-faq">
|
||||
{FAQ.map((item) => (
|
||||
<details key={item.q}>
|
||||
<summary>{item.q}</summary>
|
||||
<p>{item.a}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 10 — final CTA + footer */}
|
||||
<section className="lp-final" id="download">
|
||||
<div className="lp-glow" />
|
||||
<div className="lp-wrap">
|
||||
<h2>Load a recipe. Shoot the rest of the day.</h2>
|
||||
<p>RecipesCam is free on both stores, and the web studio runs right here in your browser.</p>
|
||||
<div className="lp-stores">
|
||||
<button type="button" className="lp-btn" onClick={() => showToast('App Store listing arrives with the public release — use the web studio in the meantime.')}>
|
||||
<span className="lp-store"><small>TBD</small><b>Download on the App Store</b></span>
|
||||
</button>
|
||||
<button type="button" className="lp-btn" onClick={() => showToast('Google Play listing arrives with the public release — use the web studio in the meantime.')}>
|
||||
<span className="lp-store"><small>TBD</small><b>Get it on Google Play</b></span>
|
||||
</button>
|
||||
<a className="lp-btn lp-primary" href="/app">Open the Web Studio</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="lp-footer">
|
||||
<div className="lp-wrap">
|
||||
<span>© {new Date().getFullYear()} RecipesCam — Color Recipe & Film Camera Studio</span>
|
||||
<a href="#top">Privacy</a>
|
||||
<a href="#top">Terms</a>
|
||||
<a href={STORE_LINKS.play}>Support</a>
|
||||
<span className="lp-social">
|
||||
<a href="#top">INSTAGRAM</a>
|
||||
<a href="#top">TIKTOK</a>
|
||||
<a href="#top">PINTEREST</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{toast ? <div className="lp-toast" role="status"><b>Coming soon —</b> {toast}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,21 +19,6 @@ export const en: Dict = {
|
||||
'act.exporting': 'Exporting…',
|
||||
'act.cancel': 'Cancel',
|
||||
|
||||
'land.title': 'The same colour recipes, now on your desktop',
|
||||
'land.lede':
|
||||
'RecipesCam turns a photo into a film-looking one with recipes. The web build runs the app\u2019s exact colour pipeline: same recipes, same frames, same GPS stamp.',
|
||||
'land.ctaDemo': 'OPEN THE DEMO',
|
||||
'land.ctaSignup': 'CREATE AN ACCOUNT',
|
||||
'land.f1t': 'Film recipes',
|
||||
'land.f1d': 'PROVIA, VELVIA, CLASSIC CHROME… plus EXPOSURE, WB, CLARITY, HDF and VIGNETTING.',
|
||||
'land.f2t': 'Frames & watermark',
|
||||
'land.f2d': 'Polaroid, classic border, WALL FRAME, custom text and a GPS stamp read from the photo\u2019s EXIF.',
|
||||
'land.f3t': 'Processed on your own machine',
|
||||
'land.f3d': 'The photo never leaves the browser. The server only keeps accounts and the recipes you save.',
|
||||
'land.badgePlay': 'Get it on Google Play',
|
||||
'land.badgeApp': 'Download on the App Store',
|
||||
'land.storeSoon': 'Coming soon',
|
||||
|
||||
'drop.title': 'Drag & drop a photo here',
|
||||
'drop.hint': 'or click to pick one · paste from the clipboard with Ctrl+V',
|
||||
'drop.replace': 'Choose another photo',
|
||||
|
||||
@@ -22,21 +22,6 @@ export const vi = {
|
||||
'act.exporting': 'Đang xuất…',
|
||||
'act.cancel': 'Huỷ',
|
||||
|
||||
'land.title': 'Cùng công thức màu, giờ trên máy tính',
|
||||
'land.lede':
|
||||
'RecipesCam biến ảnh chụp thành ảnh có "chất" bằng công thức phim. Bản web chạy đúng pipeline màu của app: cùng công thức, cùng khung, cùng dấu GPS.',
|
||||
'land.ctaDemo': 'DÙNG THỬ NGAY',
|
||||
'land.ctaSignup': 'TẠO TÀI KHOẢN',
|
||||
'land.f1t': 'Công thức phim',
|
||||
'land.f1d': 'PROVIA, VELVIA, CLASSIC CHROME… cộng bộ chỉnh tay EXPOSURE, WB, CLARITY, HDF, VIGNETTING.',
|
||||
'land.f2t': 'Khung & watermark',
|
||||
'land.f2d': 'Khung polaroid, viền classic, WALL FRAME, watermark chữ và dấu GPS đọc từ EXIF của ảnh.',
|
||||
'land.f3t': 'Xử lý ngay trên máy bạn',
|
||||
'land.f3d': 'Ảnh không bao giờ rời khỏi trình duyệt. Máy chủ chỉ giữ tài khoản và công thức bạn lưu.',
|
||||
'land.badgePlay': 'Tải trên Google Play',
|
||||
'land.badgeApp': 'Tải trên App Store',
|
||||
'land.storeSoon': 'Sắp có',
|
||||
|
||||
'drop.title': 'Kéo & thả ảnh vào đây',
|
||||
'drop.hint': 'hoặc bấm để chọn ảnh · dán từ clipboard bằng Ctrl+V',
|
||||
'drop.replace': 'Đổi ảnh khác',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles/tokens.css';
|
||||
import './styles/app.css';
|
||||
import './styles/landing.css';
|
||||
import { ThemeProvider } from './theme/ThemeProvider';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { Landing } from './Landing';
|
||||
|
||||
@@ -164,29 +164,6 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
.stage .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; }
|
||||
.err { color: var(--danger); font-size: 12px; max-width: 520px; text-align: center; }
|
||||
|
||||
/* --- landing ------------------------------------------------------------ */
|
||||
.landing { max-width: 960px; margin: 0 auto; padding: 56px 20px 72px; }
|
||||
.landing h1 { font-size: clamp(30px, 5vw, 52px); line-height: 1.1; margin: 0 0 14px; }
|
||||
.landing .lede { font-size: 17px; color: var(--text-dim); max-width: 620px; line-height: 1.6; }
|
||||
.landing .cta { display: flex; gap: 10px; margin: 24px 0 44px; flex-wrap: wrap; }
|
||||
.features { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; }
|
||||
.card { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; background: var(--bg-elev); }
|
||||
.card h3 { margin: 0 0 6px; font-size: 14px; }
|
||||
.card p { margin: 0; color: var(--text-dim); line-height: 1.55; }
|
||||
.badges { display: flex; gap: 10px; margin-top: 34px; flex-wrap: wrap; }
|
||||
.badge {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 14px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.badge small { color: var(--text-dim); font-size: 10px; letter-spacing: 0.08em; }
|
||||
|
||||
/* --- auth dialog -------------------------------------------------------- */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
/* Landing page — dark cinematic theme per the landing blueprint. Self-contained:
|
||||
its own palette, its own class prefix (.lp-*), no dependency on the workspace
|
||||
theme, so the marketing page looks the same in light or dark mode. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Syne';
|
||||
font-style: normal;
|
||||
font-weight: 700 800;
|
||||
font-display: swap;
|
||||
src: url('/assets/Syne.woff2') format('woff2');
|
||||
}
|
||||
|
||||
.lp {
|
||||
--lp-bg: #090a0f;
|
||||
--lp-bg2: #0d0e12;
|
||||
--lp-card: #161820;
|
||||
--lp-amber: #fbbf24;
|
||||
--lp-orange: #f59e0b;
|
||||
--lp-red: #ff5e36;
|
||||
--lp-cyan: #38bdf8;
|
||||
--lp-indigo: #818cf8;
|
||||
--lp-text: #f3f4f6;
|
||||
--lp-muted: #9ca3af;
|
||||
--lp-line: rgba(255, 255, 255, 0.08);
|
||||
--lp-mono: 'Cousine', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--lp-display: 'Syne', ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
background: var(--lp-bg);
|
||||
color: var(--lp-text);
|
||||
min-height: 100%;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
html { scroll-behavior: smooth; scroll-padding-top: 78px; }
|
||||
.lp ::selection { background: var(--lp-amber); color: #10121a; }
|
||||
.lp-wrap { max-width: 1180px; margin: 0 auto; padding: 0 22px; }
|
||||
.lp h1, .lp h2, .lp h3 { font-family: var(--lp-display); font-weight: 800; line-height: 1.1; margin: 0; }
|
||||
.lp p { margin: 0; }
|
||||
.lp a { color: inherit; text-decoration: none; }
|
||||
.lp-mono { font-family: var(--lp-mono); font-size: 11px; letter-spacing: 0.08em; }
|
||||
.lp-eyebrow {
|
||||
font-family: var(--lp-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.22em;
|
||||
color: var(--lp-amber);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.lp-muted { color: var(--lp-muted); }
|
||||
.lp-sec { padding: 92px 0; border-top: 1px solid var(--lp-line); }
|
||||
.lp-sec-head { max-width: 720px; margin-bottom: 40px; }
|
||||
.lp-sec-head h2 { font-size: clamp(26px, 4vw, 40px); }
|
||||
.lp-sec-head p { color: var(--lp-muted); margin-top: 12px; }
|
||||
|
||||
/* --- buttons ------------------------------------------------------------ */
|
||||
.lp-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--lp-line);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--lp-text);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lp-btn:hover { border-color: rgba(251, 191, 36, 0.5); }
|
||||
.lp-btn.lp-primary {
|
||||
background: linear-gradient(120deg, var(--lp-amber), var(--lp-orange));
|
||||
border-color: transparent;
|
||||
color: #14100a;
|
||||
box-shadow: 0 10px 30px rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
.lp-btn.lp-primary:hover { filter: brightness(1.07); }
|
||||
.lp-btn.lp-sm { padding: 9px 15px; font-size: 13px; }
|
||||
|
||||
/* --- 1. nav ------------------------------------------------------------- */
|
||||
.lp-nav {
|
||||
position: fixed;
|
||||
inset: 0 0 auto 0;
|
||||
z-index: 50;
|
||||
background: rgba(9, 10, 15, 0.72);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--lp-line);
|
||||
}
|
||||
.lp-nav .lp-wrap { display: flex; align-items: center; gap: 18px; height: 62px; }
|
||||
.lp-logo { display: flex; align-items: center; gap: 9px; font-family: var(--lp-display); font-weight: 800; font-size: 18px; }
|
||||
.lp-logo em { font-style: normal; color: var(--lp-amber); }
|
||||
.lp-logo svg { color: var(--lp-amber); }
|
||||
.lp-nav-links { display: flex; gap: 22px; margin-left: 14px; font-size: 13.5px; color: var(--lp-muted); }
|
||||
.lp-nav-links a:hover { color: var(--lp-text); }
|
||||
.lp-nav .lp-spacer { flex: 1 1 auto; }
|
||||
.lp-burger { display: none; }
|
||||
|
||||
@media (max-width: 940px) {
|
||||
.lp-nav-links, .lp-nav .lp-wrap > .lp-btn { display: none; }
|
||||
.lp-burger {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lp-burger i { display: block; width: 20px; height: 2px; background: var(--lp-text); }
|
||||
.lp-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 22px 18px;
|
||||
border-top: 1px solid var(--lp-line);
|
||||
}
|
||||
.lp-sheet a { padding: 11px 0; color: var(--lp-muted); border-bottom: 1px solid var(--lp-line); }
|
||||
.lp-sheet a:hover { color: var(--lp-text); }
|
||||
.lp-sheet .lp-btn { margin-top: 12px; }
|
||||
}
|
||||
@media (min-width: 941px) { .lp-sheet { display: none; } }
|
||||
|
||||
/* --- 2. hero ------------------------------------------------------------ */
|
||||
.lp-hero { position: relative; padding: 140px 0 74px; overflow: hidden; }
|
||||
.lp-glow {
|
||||
position: absolute;
|
||||
inset: -30% -10% auto -10%;
|
||||
height: 560px;
|
||||
background:
|
||||
radial-gradient(420px 280px at 20% 30%, rgba(245, 158, 11, 0.22), transparent 70%),
|
||||
radial-gradient(380px 260px at 78% 22%, rgba(255, 94, 54, 0.16), transparent 70%),
|
||||
radial-gradient(420px 300px at 55% 80%, rgba(129, 140, 248, 0.14), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.lp-hero .lp-wrap { position: relative; }
|
||||
.lp-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 13px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
color: var(--lp-amber);
|
||||
font-family: var(--lp-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.lp-hero h1 {
|
||||
font-size: clamp(34px, 6.4vw, 68px);
|
||||
margin: 20px 0 18px;
|
||||
max-width: 15ch;
|
||||
background: linear-gradient(100deg, #f8fafc 12%, var(--lp-amber) 58%, var(--lp-red) 96%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.lp-hero .lp-lede { color: var(--lp-muted); font-size: 17px; max-width: 60ch; }
|
||||
.lp-cta { display: flex; flex-wrap: wrap; gap: 12px; margin: 30px 0 44px; }
|
||||
.lp-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
max-width: 640px;
|
||||
}
|
||||
.lp-stat {
|
||||
border: 1px solid var(--lp-line);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.lp-stat b { display: block; font-family: var(--lp-display); font-size: 26px; }
|
||||
.lp-stat span { color: var(--lp-muted); font-family: var(--lp-mono); font-size: 10.5px; letter-spacing: 0.14em; }
|
||||
|
||||
/* --- 3. film strip marquee ---------------------------------------------- */
|
||||
.lp-marquee { overflow: hidden; border-top: 1px solid var(--lp-line); border-bottom: 1px solid var(--lp-line); background: var(--lp-bg2); padding: 26px 0; }
|
||||
.lp-track { display: flex; width: max-content; animation: lp-marquee 48s linear infinite; }
|
||||
.lp-marquee:hover .lp-track { animation-play-state: paused; }
|
||||
@keyframes lp-marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } }
|
||||
@media (prefers-reduced-motion: reduce) { .lp-track { animation: none; } }
|
||||
.lp-frame {
|
||||
width: 236px;
|
||||
margin-right: 18px;
|
||||
background: #0b0c11;
|
||||
border: 1px solid var(--lp-line);
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.lp-perf { display: flex; justify-content: space-between; }
|
||||
.lp-perf i {
|
||||
width: 13px;
|
||||
height: 7px;
|
||||
border-radius: 2px;
|
||||
background: #000;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.lp-shot { position: relative; aspect-ratio: 4 / 5; border-radius: 5px; overflow: hidden; background: #000; }
|
||||
.lp-shot img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.lp-grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.3;
|
||||
mix-blend-mode: overlay;
|
||||
pointer-events: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
.lp-tagline { position: absolute; top: 7px; left: 7px; font-family: var(--lp-mono); font-size: 9.5px; color: var(--lp-amber); text-shadow: 0 1px 6px #000; }
|
||||
.lp-frame-meta { display: flex; flex-direction: column; gap: 3px; }
|
||||
.lp-frame-meta b { font-size: 13px; font-weight: 600; }
|
||||
.lp-frame-meta span { font-family: var(--lp-mono); font-size: 9.5px; color: var(--lp-muted); letter-spacing: 0.06em; }
|
||||
|
||||
/* --- 4. preset tester --------------------------------------------------- */
|
||||
.lp-tester { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 26px; align-items: start; }
|
||||
.lp-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
|
||||
.lp-tab {
|
||||
border: 1px solid var(--lp-line);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--lp-muted);
|
||||
border-radius: 999px;
|
||||
padding: 7px 14px;
|
||||
font-size: 12.5px;
|
||||
font-family: var(--lp-mono);
|
||||
cursor: pointer;
|
||||
}
|
||||
.lp-tab[aria-pressed="true"] { border-color: var(--lp-amber); color: var(--lp-amber); background: rgba(251, 191, 36, 0.1); }
|
||||
.lp-view { position: relative; border-radius: 16px; overflow: hidden; border: 1px solid var(--lp-line); background: #000; }
|
||||
.lp-view img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; transition: filter 380ms ease; }
|
||||
.lp-hud {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 0.75), transparent);
|
||||
font-family: var(--lp-mono);
|
||||
font-size: 10.5px;
|
||||
color: rgba(243, 244, 246, 0.85);
|
||||
}
|
||||
.lp-hud b { color: var(--lp-amber); font-weight: 700; }
|
||||
.lp-specs { border: 1px solid var(--lp-line); background: var(--lp-card); border-radius: 16px; padding: 20px; }
|
||||
.lp-specs h3 { font-size: 18px; }
|
||||
.lp-specs p { color: var(--lp-muted); font-size: 13.5px; margin-top: 8px; }
|
||||
.lp-bars { display: flex; flex-direction: column; gap: 12px; margin-top: 18px; }
|
||||
.lp-bar span { display: flex; justify-content: space-between; font-family: var(--lp-mono); font-size: 10.5px; color: var(--lp-muted); letter-spacing: 0.1em; }
|
||||
.lp-bar i { display: block; height: 5px; border-radius: 999px; background: rgba(255, 255, 255, 0.09); margin-top: 6px; overflow: hidden; }
|
||||
.lp-bar i::after { content: ''; display: block; height: 100%; width: var(--v); border-radius: 999px; background: linear-gradient(90deg, var(--lp-amber), var(--lp-red)); transition: width 380ms ease; }
|
||||
|
||||
/* --- 5. creator --------------------------------------------------------- */
|
||||
.lp-lab { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 26px; align-items: start; }
|
||||
.lp-knobs { border: 1px solid var(--lp-line); background: var(--lp-bg2); border-radius: 16px; padding: 22px; display: flex; flex-direction: column; gap: 20px; }
|
||||
.lp-knob label { display: flex; justify-content: space-between; align-items: baseline; font-family: var(--lp-mono); font-size: 11px; letter-spacing: 0.1em; color: var(--lp-muted); }
|
||||
.lp-knob b { color: var(--lp-amber); font-size: 13px; }
|
||||
.lp input[type=range] { width: 100%; margin-top: 10px; accent-color: var(--lp-amber); }
|
||||
.lp-code { border-top: 1px solid var(--lp-line); padding-top: 16px; font-family: var(--lp-mono); font-size: 11px; color: var(--lp-muted); line-height: 1.9; }
|
||||
.lp-code b { color: var(--lp-cyan); }
|
||||
|
||||
/* --- 6. features -------------------------------------------------------- */
|
||||
.lp-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.lp-feat { border: 1px solid var(--lp-line); background: var(--lp-card); border-radius: 16px; padding: 22px; }
|
||||
.lp-feat svg { color: var(--lp-amber); }
|
||||
.lp-feat h3 { font-size: 17px; margin: 14px 0 8px; }
|
||||
.lp-feat p { color: var(--lp-muted); font-size: 13.5px; }
|
||||
|
||||
/* --- 7. QR -------------------------------------------------------------- */
|
||||
.lp-qr { display: grid; grid-template-columns: 360px minmax(0, 1fr); gap: 34px; align-items: center; }
|
||||
.lp-recipe-card {
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(160deg, #1b1d27, #0e0f14);
|
||||
padding: 16px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
.lp-recipe-card .lp-shot { border-radius: 12px; }
|
||||
.lp-rc-row { display: flex; align-items: center; gap: 14px; margin-top: 14px; }
|
||||
.lp-rc-qr { width: 84px; height: 84px; border-radius: 10px; background: #fff; padding: 5px; flex: 0 0 auto; }
|
||||
.lp-rc-qr img { width: 100%; height: 100%; display: block; }
|
||||
.lp-rc-meta { min-width: 0; }
|
||||
.lp-rc-meta b { display: block; font-family: var(--lp-display); font-size: 15px; }
|
||||
.lp-rc-meta span { font-family: var(--lp-mono); font-size: 10px; color: var(--lp-muted); }
|
||||
.lp-steps { display: flex; flex-direction: column; gap: 18px; margin-top: 22px; }
|
||||
.lp-step { display: flex; gap: 14px; }
|
||||
.lp-step span {
|
||||
flex: 0 0 auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
border: 1px solid rgba(251, 191, 36, 0.4);
|
||||
color: var(--lp-amber);
|
||||
font-family: var(--lp-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.lp-step p { color: var(--lp-muted); font-size: 13.5px; }
|
||||
.lp-step b { color: var(--lp-text); }
|
||||
|
||||
/* --- 8. pricing --------------------------------------------------------- */
|
||||
.lp-price { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; max-width: 860px; }
|
||||
.lp-plan { border: 1px solid var(--lp-line); background: var(--lp-card); border-radius: 18px; padding: 26px; display: flex; flex-direction: column; }
|
||||
.lp-plan.vip { border-color: rgba(251, 191, 36, 0.45); background: linear-gradient(180deg, rgba(245, 158, 11, 0.12), rgba(22, 24, 32, 0.9)); position: relative; }
|
||||
.lp-plan.vip::after {
|
||||
content: 'MOST POPULAR';
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
left: 26px;
|
||||
background: linear-gradient(120deg, var(--lp-amber), var(--lp-orange));
|
||||
color: #14100a;
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
font-family: var(--lp-mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.lp-plan .lp-amount { font-family: var(--lp-display); font-size: 38px; margin: 12px 0 4px; }
|
||||
.lp-plan .lp-amount small { font-family: var(--lp-mono); font-size: 11px; color: var(--lp-muted); }
|
||||
.lp-plan ul { list-style: none; padding: 0; margin: 18px 0 22px; display: flex; flex-direction: column; gap: 9px; font-size: 13.5px; color: var(--lp-muted); }
|
||||
.lp-plan li::before { content: '▸ '; color: var(--lp-amber); }
|
||||
.lp-plan .lp-btn { margin-top: auto; }
|
||||
|
||||
/* --- 9. testimonials + faq --------------------------------------------- */
|
||||
.lp-quotes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-bottom: 44px; }
|
||||
.lp-quote { border: 1px solid var(--lp-line); background: var(--lp-card); border-radius: 16px; padding: 22px; }
|
||||
.lp-quote p { font-size: 14px; }
|
||||
.lp-quote footer { margin-top: 16px; font-family: var(--lp-mono); font-size: 10.5px; color: var(--lp-muted); }
|
||||
.lp-quote footer b { display: block; color: var(--lp-text); font-family: var(--lp-display); font-size: 13.5px; }
|
||||
.lp-faq { display: flex; flex-direction: column; gap: 10px; max-width: 820px; }
|
||||
.lp-faq details { border: 1px solid var(--lp-line); border-radius: 12px; background: var(--lp-bg2); padding: 14px 18px; }
|
||||
.lp-faq summary { cursor: pointer; font-weight: 600; }
|
||||
.lp-faq summary::marker { color: var(--lp-amber); }
|
||||
.lp-faq p { color: var(--lp-muted); font-size: 13.5px; margin-top: 10px; }
|
||||
|
||||
/* --- 10. final CTA + footer -------------------------------------------- */
|
||||
.lp-final { position: relative; overflow: hidden; text-align: center; padding: 88px 0; }
|
||||
.lp-final .lp-glow { inset: -60% 0 auto 0; }
|
||||
.lp-final h2 { font-size: clamp(28px, 4.6vw, 46px); max-width: 20ch; margin: 0 auto 16px; }
|
||||
.lp-final p { color: var(--lp-muted); max-width: 52ch; margin: 0 auto 28px; }
|
||||
.lp-stores { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
|
||||
.lp-store { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.25; }
|
||||
.lp-store small { font-family: var(--lp-mono); font-size: 9.5px; color: var(--lp-muted); }
|
||||
.lp-store b { font-size: 15px; }
|
||||
.lp-footer { border-top: 1px solid var(--lp-line); padding: 30px 0 40px; }
|
||||
.lp-footer .lp-wrap { display: flex; flex-wrap: wrap; gap: 14px 26px; align-items: center; font-size: 12.5px; color: var(--lp-muted); }
|
||||
.lp-footer a:hover { color: var(--lp-text); }
|
||||
.lp-social { display: flex; gap: 16px; margin-left: auto; font-family: var(--lp-mono); font-size: 11px; }
|
||||
|
||||
/* --- toast -------------------------------------------------------------- */
|
||||
.lp-toast {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 60;
|
||||
max-width: min(340px, calc(100vw - 36px));
|
||||
border: 1px solid rgba(251, 191, 36, 0.4);
|
||||
background: rgba(22, 24, 32, 0.96);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55);
|
||||
font-size: 13px;
|
||||
}
|
||||
.lp-toast b { color: var(--lp-amber); }
|
||||
|
||||
/* --- responsive --------------------------------------------------------- */
|
||||
@media (max-width: 980px) {
|
||||
.lp-tester, .lp-lab, .lp-qr { grid-template-columns: minmax(0, 1fr); }
|
||||
.lp-grid, .lp-quotes { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.lp-recipe-card { transform: none; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.lp-sec { padding: 64px 0; }
|
||||
.lp-hero { padding: 112px 0 52px; }
|
||||
.lp-grid, .lp-quotes, .lp-price, .lp-stats { grid-template-columns: minmax(0, 1fr); }
|
||||
.lp-stat { display: flex; align-items: baseline; gap: 10px; }
|
||||
.lp-social { margin-left: 0; }
|
||||
}
|
||||
Reference in New Issue
Block a user