Give the landing the app's theme and language switches

The blueprint page was dark-only and English-only. It now carries the same
two controls the workspace TopBar has, in the nav's top-right cluster: ◐ flips
light/dark (a paper palette for the same funnel — surfaces and ink flip, the
amber/red accents stay) and VI/EN flips the language, with the whole page of
copy, the FAQ, the pricing tables and the VIP badge all following it. Amber
text switches to a darker #a16207 on the light theme so it keeps ~4.9:1 on
white.

The register account is back as the old landing had it: a "ĐĂNG KÝ" button
pointing at /app?auth=1, and that URL now actually opens the auth dialog on
its sign-up tab (AuthModal takes an initialMode). The nav also collapses to
the hamburger below 1160px now, and the logo/tools shrink below 680px, so the
row still fits a 360px phone.
This commit is contained in:
2026-09-17 18:40:05 +07:00
parent 340f0374fc
commit f2e5e9abc9
4 changed files with 262 additions and 154 deletions
+10 -6
View File
@@ -120,7 +120,10 @@ export function Workspace() {
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [saved, setSaved] = useState<SavedRecipe[]>([]); const [saved, setSaved] = useState<SavedRecipe[]>([]);
const [authOpen, setAuthOpen] = useState(false); // null = closed. The landing's register link is /app?auth=1, so it opens the
// dialog straight on the sign-up tab.
const [authMode, setAuthMode] = useState<'login' | 'signup' | null>(() =>
(new URLSearchParams(window.location.search).has('auth') ? 'signup' : null));
const renderToken = useRef(0); const renderToken = useRef(0);
@@ -282,7 +285,7 @@ export function Workspace() {
const saveRecipe = useCallback(async () => { const saveRecipe = useCallback(async () => {
if (!user) { if (!user) {
setAuthOpen(true); setAuthMode('login');
return; return;
} }
const name = window.prompt(t('save.name'), recipe.name); const name = window.prompt(t('save.name'), recipe.name);
@@ -688,7 +691,7 @@ export function Workspace() {
onReset={reset} onReset={reset}
onExport={exportPhoto} onExport={exportPhoto}
onSave={saveRecipe} onSave={saveRecipe}
onAuth={() => setAuthOpen(true)} onAuth={() => setAuthMode('login')}
onLogout={async () => { onLogout={async () => {
await api.logout().catch(() => undefined); await api.logout().catch(() => undefined);
setUser(null); setUser(null);
@@ -791,11 +794,12 @@ export function Workspace() {
/> />
</div> </div>
{authOpen ? ( {authMode ? (
<AuthModal <AuthModal
onClose={() => setAuthOpen(false)} initialMode={authMode}
onClose={() => setAuthMode(null)}
onDone={async () => { onDone={async () => {
setAuthOpen(false); setAuthMode(null);
const me = await api.me().catch(() => null); const me = await api.me().catch(() => null);
if (me) setUser(me.user); if (me) setUser(me.user);
}} }}
+185 -134
View File
@@ -1,17 +1,23 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from './i18n/I18nProvider';
import { useTheme } from './theme/ThemeProvider';
// Landing page, built to the landing blueprint: dark cinematic 10-section // Landing page, built to the landing blueprint: the 10-section funnel. The
// funnel. Marketing copy is English (the blueprint's own copy) — the workspace // marketing prose is bilingual and lives here next to the markup as `Txt`
// keeps its VI/EN dictionary. The store URLs are still unknown (WEB_PLAN.md W6), // pairs — the app dictionary holds UI strings, this is a page of copy, and the
// so both badges raise the toast instead of navigating; fill STORE_LINKS in and // blueprint stays the source of truth for it. The page follows the same theme
// drop the onClick to go live. // and language switches as the workspace (buttons in the nav, top-right).
// The store URLs are still unknown (WEB_PLAN.md W6), so the store badges raise
// the toast instead of navigating; fill STORE_LINKS in and drop the onClick.
const STORE_LINKS = { play: '#', appstore: '#' }; const STORE_LINKS = { play: '#', appstore: '#' };
type Txt = { en: string; vi: string };
const SAMPLE = (n: number) => `/assets/samples/s${n}.jpg`; const SAMPLE = (n: number) => `/assets/samples/s${n}.jpg`;
interface Look { interface Look {
tag: string; tag: string;
title: string; title: Txt;
meta: string; meta: string;
photo: string; photo: string;
filter: string; filter: string;
@@ -21,56 +27,62 @@ interface Look {
// emulation. ponytail: CSS filters, not the CanvasKit pipeline — a landing page // 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. // does not need a 12MP render; swap in real graded stills when we have them.
const REEL: Look[] = [ 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: '#KODAK_PORTRA_400', title: { en: 'Golden Hour Portrait', vi: 'Chân dung giờ vàng' }, 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: '#FUJI_CLASSIC_CHROME', title: { en: 'Quiet Street, 7am', vi: 'Phố vắng, 7 giờ sáng' }, 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: '#CYBER_NEON_88', title: { en: 'Neon Crossing', vi: 'Ngã tư neon' }, 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: '#CINESTILL_800T', title: { en: 'Harbour Tungsten', vi: 'Cảng đèn vàng' }, 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: '#ILFORD_HP5_BW', title: { en: 'Alley Study', vi: 'Hẻm nhỏ' }, 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: '#VELVIA_50', title: { en: 'Ridge Line', vi: 'Sống núi' }, 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: '#ACROS_100', title: { en: 'Stone Facade', vi: 'Mặt tường đá' }, 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)' }, { tag: '#ETRNA_VINTAGE', title: { en: 'Faded Postcard', vi: 'Bưu thiếp bạc màu' }, meta: 'ISO 200 · GRAIN 25 · WARMTH +10', photo: SAMPLE(1015), filter: 'sepia(.28) saturate(.9) contrast(.94) brightness(1.08)' },
]; ];
interface Preset { interface Preset {
id: string; id: string;
// Stock and recipe names are proper nouns — never translated (see i18n/vi.ts).
label: string; label: string;
name: string; name: string;
desc: string; desc: Txt;
filter: string; filter: string;
// warmth, grain, contrast, halation bloom — 0..100 // warmth, grain, contrast, halation bloom — 0..100
metrics: [number, number, number, number]; metrics: [number, number, number, number];
} }
const PRESETS: Preset[] = [ 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: 'portra', label: 'Kodak Portra 400', name: 'PORTRA 400', desc: { en: 'Warm daylight stock, forgiving highlights and natural skin tones — the everyday portrait recipe.', vi: 'Phim chân dung ánh sáng ban ngày ấm, vùng sáng mềm và tông da tự nhiên — công thức chụp hằng ngày.' }, 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: 'chrome', label: 'Fuji Classic Chrome', name: 'CLASSIC CHROME', desc: { en: 'Deep greens, muted reds and a documentary edge straight out of the rangefinder era.', vi: 'Xanh lá sâu, đỏ trầm và chất tài liệu đúng kiểu máy rangefinder.' }, 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: 'sunset', label: 'Warm Sunset Glow', name: 'SUNSET GLOW', desc: { en: 'Amber backlight with a soft halation around every highlight, made for late golden hour.', vi: 'Ngược sáng hổ phách với quầng sáng mềm quanh vùng cháy, dành cho giờ vàng muộn.' }, 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: 'cyber', label: "Cyber Neon '88", name: 'CYBER NEON 88', desc: { en: 'Magenta and cyan push, crushed blacks and a bloom that reads like wet asphalt at night.', vi: 'Đẩy magenta và cyan, đen bị nén và quầng sáng như nhựa đường ướt ban đêm.' }, 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] }, { id: 'mono', label: 'Monochromatic Film', name: 'ACROS 100', desc: { en: 'High-contrast black and white with a fine grain structure and clean mid-tone separation.', vi: 'Đen trắng tương phản cao, hạt mịn và vùng trung tính tách bạch.' }, filter: 'grayscale(1) contrast(1.4) brightness(1.02)', metrics: [6, 41, 92, 18] },
]; ];
const BAR_LABELS = ['WARMTH', 'FILM GRAIN', 'CONTRAST', 'HALATION BLOOM']; const BAR_LABELS: Txt[] = [
{ en: 'WARMTH', vi: 'ĐỘ ẤM' },
{ en: 'FILM GRAIN', vi: 'HẠT PHIM' },
{ en: 'CONTRAST', vi: 'TƯƠNG PHẢN' },
{ en: 'HALATION BLOOM', vi: 'QUẦNG SÁNG' },
];
interface Feature { title: string; text: string; icon: 'aperture' | 'grain' | 'batch' | 'qr' | 'hsl' | 'raw' } interface Feature { icon: 'aperture' | 'grain' | 'batch' | 'qr' | 'hsl' | 'raw'; title: Txt; text: Txt }
const FEATURES: Feature[] = [ 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: 'aperture', title: { en: 'Live LUT Viewfinder', vi: 'Ngắm LUT trực tiếp' }, text: { en: 'See the recipe through the lens before you press the shutter — no round trip to an editor.', vi: 'Xem công thức ngay trong ống kính trước khi bấm máy — không cần vòng qua trình chỉnh sửa.' } },
{ icon: 'grain', title: '35mm & 120 Film Grain', text: 'Authentic grain structures plus halation bloom, tuned per stock rather than one global overlay.' }, { icon: 'grain', title: { en: '35mm & 120 Film Grain', vi: 'Hạt phim 35mm & 120' }, text: { en: 'Authentic grain structures plus halation bloom, tuned per stock rather than one global overlay.', vi: 'Cấu trúc hạt đúng chất phim kèm quầng sáng, tinh chỉnh theo từng loại phim thay vì một lớp phủ chung.' } },
{ 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: 'batch', title: { en: 'Batch Color Editing', vi: 'Chỉnh màu hàng loạt' }, text: { en: 'Apply one recipe across hundreds of photos at once and export the whole set in a single pass.', vi: 'Áp một công thức cho hàng trăm ảnh cùng lúc và xuất cả bộ trong một lượt.' } },
{ 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: 'qr', title: { en: 'QR Recipe Sharing', vi: 'Chia sẻ công thức bằng QR' }, text: { en: 'Export a recipe as a scannable card and let a friend import the exact look in one scan.', vi: 'Xuất công thức thành thẻ quét được để bạn bè nhập đúng tông màu chỉ bằng một lần quét.' } },
{ icon: 'hsl', title: 'HSL & Selective Color', text: 'Fine-tune eight individual colour channels when a stock needs a nudge, not a replacement.' }, { icon: 'hsl', title: { en: 'HSL & Selective Color', vi: 'HSL & màu chọn lọc' }, text: { en: 'Fine-tune eight individual colour channels when a stock needs a nudge, not a replacement.', vi: 'Tinh chỉnh tám kênh màu riêng khi loại phim chỉ cần nhích một chút, không phải thay hẳn.' } },
{ icon: 'raw', title: 'RAW Support & 4K Export', text: 'Full EXIF retention, 300 ppi print metadata and lossless JPEG at up to 4K.' }, { icon: 'raw', title: { en: 'RAW Support & 4K Export', vi: 'Hỗ trợ RAW & xuất 4K' }, text: { en: 'Full EXIF retention, 300 ppi print metadata and lossless JPEG at up to 4K.', vi: 'Giữ nguyên EXIF, metadata in 300 ppi và JPEG không mất dữ liệu tới 4K.' } },
]; ];
const QUOTES = [ 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: { en: 'I grade a whole trip before the flight lands. The QR hand-off means my editor gets the exact same look, not a screenshot.', vi: 'Tôi chỉnh màu cả chuyến đi trước khi máy bay hạ cánh. Chia sẻ bằng QR nên editor nhận đúng tông màu, không phải ảnh chụp màn hình.' }, name: 'Mai Anh', role: { en: '@maianh.travel · Travel Blogger', vi: '@maianh.travel · Blogger du lịch' } },
{ 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: { en: 'Classic Chrome with a whisper of grain is 90% of my feed now. It reads like film without the scanning workflow.', vi: 'Classic Chrome thêm một chút hạt phim giờ chiếm 90% feed của tôi. Nhìn ra chất phim mà không phải scan.' }, name: 'Daniel Rowe', role: { en: '@rowe.frames · Street Photographer', vi: '@rowe.frames · Nhiếp ảnh đường phố' } },
{ 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' }, { text: { en: 'The live viewfinder is the whole trick — clients pick a recipe on set instead of asking for fifteen revisions later.', vi: 'Ngắm trực tiếp là điểm ăn tiền — khách chọn công thức ngay tại chỗ thay vì đòi sửa mười lăm lần sau đó.' }, name: 'Trúc Lâm', role: { en: '@truclam.studio · Content Creator', vi: '@truclam.studio · Nhà sáng tạo nội dung' } },
]; ];
const FAQ = [ 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: { en: 'Does it preserve original image quality?', vi: 'Ảnh gốc có bị giảm chất lượng không?' }, a: { en: '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.', vi: 'Không. Web studio render từ ảnh gốc chưa chỉnh khi xuất, ghi mật độ JFIF 300 ppi và giữ nguyên EXIF cùng dấu GPS của máy ảnh. Xuất RAW và 4K nằm trong gói Pro.' } },
{ 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: { en: 'How do I scan a recipe QR code?', vi: 'Quét mã QR công thức thế nào?' }, a: { en: '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.', vi: 'Mở nút Quét QR trong app rồi chĩa vào thẻ. Công thức được nhập kèm toàn bộ thông số — không phải gõ lại tay.' } },
{ 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.' }, { q: { en: 'Is there an export watermark?', vi: 'Ảnh xuất có watermark không?' }, a: { en: 'No watermarks on your own exports. Only the free guest tier on the web adds a small corner mark; sign in and it disappears.', vi: 'Ảnh bạn xuất không có watermark. Chỉ khách chưa đăng nhập trên web mới có một vết nhỏ ở góc; đăng nhập là mất.' } },
]; ];
function Icon({ name }: { name: Feature['icon'] }) { function Icon({ name }: { name: Feature['icon'] }) {
@@ -86,6 +98,8 @@ function Icon({ name }: { name: Feature['icon'] }) {
} }
export function Landing() { export function Landing() {
const { t, lang, setLang } = useI18n();
const { mode, setMode } = useTheme();
const [menu, setMenu] = useState(false); const [menu, setMenu] = useState(false);
const [presetId, setPresetId] = useState(PRESETS[0].id); const [presetId, setPresetId] = useState(PRESETS[0].id);
const [temp, setTemp] = useState(0); const [temp, setTemp] = useState(0);
@@ -95,29 +109,35 @@ export function Landing() {
const [qrOk, setQrOk] = useState(true); const [qrOk, setQrOk] = useState(true);
const toastTimer = useRef<ReturnType<typeof setTimeout>>(); const toastTimer = useRef<ReturnType<typeof setTimeout>>();
// Marketing copy, picked by the workspace's language switch.
const c = useCallback((v: Txt) => v[lang], [lang]);
const preset = PRESETS.find((p) => p.id === presetId) ?? PRESETS[0]; const preset = PRESETS.find((p) => p.id === presetId) ?? PRESETS[0];
const showToast = useCallback((text: string) => { const showToast = useCallback((text: Txt) => {
setToast(text); setToast(c(text));
clearTimeout(toastTimer.current); clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setToast(null), 3400); toastTimer.current = setTimeout(() => setToast(null), 3400);
}, []); }, [c]);
useEffect(() => () => clearTimeout(toastTimer.current), []); useEffect(() => () => clearTimeout(toastTimer.current), []);
// The simulator's output: three knobs, one CSS filter string, applied live. // 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 labFilter = `sepia(${(Math.abs(temp) / 260).toFixed(3)}) hue-rotate(${(-temp * 0.45).toFixed(1)}deg) saturate(${sat}%) contrast(${con}%)`;
const nav = [ const nav: { href: string; label: Txt }[] = [
['#film', 'Film Strip'], { href: '#film', label: { en: 'Film Strip', vi: 'Dải phim' } },
['#presets', 'Try Recipes'], { href: '#presets', label: { en: 'Try Recipes', vi: 'Thử công thức' } },
['#creator', 'Custom Recipe Creator'], { href: '#creator', label: { en: 'Custom Recipe Creator', vi: 'Tạo công thức riêng' } },
['#features', 'Features'], { href: '#features', label: { en: 'Features', vi: 'Tính năng' } },
['#qr', 'QR Code'], { href: '#qr', label: { en: 'QR Code', vi: 'Mã QR' } },
['#pricing', 'Pricing'], { href: '#pricing', label: { en: 'Pricing', vi: 'Bảng giá' } },
] as const; ];
const toastDownload: Txt = { en: 'Download links land with the public release — the web studio is live now.', vi: 'Link tải sẽ có khi phát hành chính thức — web studio đã chạy được ngay.' };
const toastStore: Txt = { en: 'Store listing arrives with the public release — use the web studio in the meantime.', vi: 'Cửa hàng ứng dụng sẽ mở khi phát hành chính thức — trong lúc chờ, hãy dùng web studio.' };
return ( return (
<div className="lp"> <div className="lp" data-lang={lang}>
{/* 1 — fixed glass navigation */} {/* 1 — fixed glass navigation */}
<nav className="lp-nav"> <nav className="lp-nav">
<div className="lp-wrap"> <div className="lp-wrap">
@@ -129,12 +149,35 @@ export function Landing() {
Recipes<em>Cam</em> Recipes<em>Cam</em>
</a> </a>
<div className="lp-nav-links"> <div className="lp-nav-links">
{nav.map(([href, label]) => <a key={href} href={href}>{label}</a>)} {nav.map((entry) => <a key={entry.href} href={entry.href}>{c(entry.label)}</a>)}
</div> </div>
<span className="lp-spacer" /> <span className="lp-spacer" />
<a className="lp-btn lp-sm" href="/app">Open studio</a> {/* Theme + language, the same two switches the workspace TopBar has. */}
<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.')}> <span className="lp-tools">
Download RecipesCam <button
type="button"
className="lp-tool"
title={t('nav.theme')}
aria-label={t('nav.theme')}
aria-pressed={mode === 'light'}
onClick={() => setMode(mode === 'dark' ? 'light' : 'dark')}
>
◐
</button>
<button
type="button"
className="lp-tool"
title={t('nav.language')}
aria-label={t('nav.language')}
onClick={() => setLang(lang === 'vi' ? 'en' : 'vi')}
>
{lang.toUpperCase()}
</button>
</span>
{/* Register, as the old landing had: straight into the sign-up dialog. */}
<a className="lp-btn lp-sm" href="/app?auth=1">{t('nav.signup')}</a>
<button type="button" className="lp-btn lp-sm lp-primary" onClick={() => showToast(toastDownload)}>
{c({ en: 'Download RecipesCam', vi: 'Tải RecipesCam' })}
</button> </button>
<button type="button" className="lp-burger" aria-label="menu" aria-expanded={menu} onClick={() => setMenu((v) => !v)}> <button type="button" className="lp-burger" aria-label="menu" aria-expanded={menu} onClick={() => setMenu((v) => !v)}>
<i /><i /><i /> <i /><i /><i />
@@ -142,14 +185,15 @@ export function Landing() {
</div> </div>
{menu ? ( {menu ? (
<div className="lp-sheet"> <div className="lp-sheet">
{nav.map(([href, label]) => <a key={href} href={href} onClick={() => setMenu(false)}>{label}</a>)} {nav.map((entry) => <a key={entry.href} href={entry.href} onClick={() => setMenu(false)}>{c(entry.label)}</a>)}
<a href="/app">Open studio</a> <a href="/app" onClick={() => setMenu(false)}>{c({ en: 'Open studio', vi: 'Mở studio' })}</a>
<a href="/app?auth=1" onClick={() => setMenu(false)}>{t('nav.signup')}</a>
<button <button
type="button" type="button"
className="lp-btn lp-primary" className="lp-btn lp-primary"
onClick={() => { setMenu(false); showToast('Download links land with the public release — the web studio is live now.'); }} onClick={() => { setMenu(false); showToast(toastDownload); }}
> >
Download RecipesCam {c({ en: 'Download RecipesCam', vi: 'Tải RecipesCam' })}
</button> </button>
</div> </div>
) : null} ) : null}
@@ -159,27 +203,28 @@ export function Landing() {
<header className="lp-hero" id="top"> <header className="lp-hero" id="top">
<div className="lp-glow" /> <div className="lp-glow" />
<div className="lp-wrap"> <div className="lp-wrap">
<span className="lp-badge">● NEXT-GEN COLOR RECIPES 4.0 — LIVE LUTS</span> <span className="lp-badge">{c({ en: '● NEXT-GEN COLOR RECIPES 4.0 — LIVE LUTS', vi: '● CÔNG THỨC MÀU THẾ HỆ MỚI 4.0 — LUT TRỰC TIẾP' })}</span>
<h1>Capture &amp; Grade Photos With Exclusive Color Recipes</h1> <h1>{c({ en: 'Capture & Grade Photos With Exclusive Color Recipes', vi: 'Chụp & chỉnh màu ảnh bằng công thức màu độc quyền' })}</h1>
<p className="lp-lede"> <p className="lp-lede">
RecipesCam reproduces Kodak and Fuji film colour — tone curves, grain structure and {c({
halation — then applies it to your own photos. Same pipeline on the phone and in the en: '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.',
browser, no upload, nothing leaves your machine. vi: 'RecipesCam tái tạo màu phim Kodak và Fuji — đường cong tông màu, cấu trúc hạt và quầng sáng — rồi áp lên ảnh của bạn. Cùng một pipeline trên điện thoại và trên trình duyệt, không tải lên, không gì rời khỏi máy bạn.',
})}
</p> </p>
<div className="lp-cta"> <div className="lp-cta">
<a className="lp-btn lp-primary" href="/app">Open the Web Studio</a> <a className="lp-btn lp-primary" href="/app">{c({ en: 'Open the Web Studio', vi: 'Mở Web Studio' })}</a>
<a className="lp-btn" href="#presets">Try Color Presets</a> <a className="lp-btn" href="#presets">{c({ en: 'Try Color Presets', vi: 'Thử công thức màu' })}</a>
</div> </div>
<div className="lp-stats"> <div className="lp-stats">
<div className="lp-stat"><b>250+</b><span>COLOR RECIPES</span></div> <div className="lp-stat"><b>250+</b><span>{c({ en: 'COLOR RECIPES', vi: 'CÔNG THỨC MÀU' })}</span></div>
<div className="lp-stat"><b>1M+</b><span>DOWNLOADS</span></div> <div className="lp-stat"><b>1M+</b><span>{c({ en: 'DOWNLOADS', vi: 'LƯỢT TẢI' })}</span></div>
<div className="lp-stat"><b>4.9★</b><span>STORE RATING</span></div> <div className="lp-stat"><b>4.9★</b><span>{c({ en: 'STORE RATING', vi: 'ĐIỂM CỬA HÀNG' })}</span></div>
</div> </div>
</div> </div>
</header> </header>
{/* 3 — 35mm film strip marquee */} {/* 3 — 35mm film strip marquee */}
<section className="lp-marquee" id="film" aria-label="Film strip gallery"> <section className="lp-marquee" id="film" aria-label={c({ en: 'Film strip gallery', vi: 'Dải phim' })}>
<div className="lp-track"> <div className="lp-track">
{[...REEL, ...REEL].map((look, i) => ( {[...REEL, ...REEL].map((look, i) => (
<article className="lp-frame" key={`${look.tag}-${i}`}> <article className="lp-frame" key={`${look.tag}-${i}`}>
@@ -187,12 +232,12 @@ export function Landing() {
<i /><i /><i /><i /><i /><i /> <i /><i /><i /><i /><i /><i />
</div> </div>
<div className="lp-shot"> <div className="lp-shot">
<img src={look.photo} alt={look.title} loading="lazy" style={{ filter: look.filter }} /> <img src={look.photo} alt={c(look.title)} loading="lazy" style={{ filter: look.filter }} />
<span className="lp-grain" aria-hidden="true" /> <span className="lp-grain" aria-hidden="true" />
<span className="lp-tagline">{look.tag}</span> <span className="lp-tagline">{look.tag}</span>
</div> </div>
<div className="lp-frame-meta"> <div className="lp-frame-meta">
<b>{look.title}</b> <b>{c(look.title)}</b>
<span>{look.meta}</span> <span>{look.meta}</span>
</div> </div>
<div className="lp-perf" aria-hidden="true"> <div className="lp-perf" aria-hidden="true">
@@ -207,13 +252,13 @@ export function Landing() {
<section className="lp-sec" id="presets"> <section className="lp-sec" id="presets">
<div className="lp-wrap"> <div className="lp-wrap">
<div className="lp-sec-head"> <div className="lp-sec-head">
<span className="lp-eyebrow">Live preset tester</span> <span className="lp-eyebrow">{c({ en: 'Live preset tester', vi: 'Thử công thức trực tiếp' })}</span>
<h2>Tap a recipe, watch the negative change</h2> <h2>{c({ en: 'Tap a recipe, watch the negative change', vi: 'Chọn công thức, xem âm bản đổi ngay' })}</h2>
<p>Five stocks from the library, applied to the same frame with the same colour maths the exporter uses.</p> <p>{c({ en: 'Five stocks from the library, applied to the same frame with the same colour maths the exporter uses.', vi: 'Năm loại phim trong thư viện, áp lên cùng một khung với đúng phép màu mà bộ xuất ảnh dùng.' })}</p>
</div> </div>
<div className="lp-tester"> <div className="lp-tester">
<div> <div>
<div className="lp-tabs" role="tablist" aria-label="Color presets"> <div className="lp-tabs" role="tablist" aria-label={c({ en: 'Color presets', vi: 'Công thức màu' })}>
{PRESETS.map((p) => ( {PRESETS.map((p) => (
<button key={p.id} type="button" role="tab" className="lp-tab" aria-pressed={p.id === presetId} onClick={() => setPresetId(p.id)}> <button key={p.id} type="button" role="tab" className="lp-tab" aria-pressed={p.id === presetId} onClick={() => setPresetId(p.id)}>
{p.label} {p.label}
@@ -232,13 +277,13 @@ export function Landing() {
</div> </div>
</div> </div>
<aside className="lp-specs"> <aside className="lp-specs">
<span className="lp-eyebrow">Recipe specs</span> <span className="lp-eyebrow">{c({ en: 'Recipe specs', vi: 'Thông số công thức' })}</span>
<h3>{preset.name}</h3> <h3>{preset.name}</h3>
<p>{preset.desc}</p> <p>{c(preset.desc)}</p>
<div className="lp-bars"> <div className="lp-bars">
{preset.metrics.map((value, i) => ( {preset.metrics.map((value, i) => (
<div className="lp-bar" key={BAR_LABELS[i]}> <div className="lp-bar" key={BAR_LABELS[i].en}>
<span>{BAR_LABELS[i]}<em className="lp-muted">{value}</em></span> <span>{c(BAR_LABELS[i])}<em className="lp-muted">{value}</em></span>
<i style={{ ['--v' as string]: `${value}%` }} /> <i style={{ ['--v' as string]: `${value}%` }} />
</div> </div>
))} ))}
@@ -252,26 +297,26 @@ export function Landing() {
<section className="lp-sec" id="creator"> <section className="lp-sec" id="creator">
<div className="lp-wrap"> <div className="lp-wrap">
<div className="lp-sec-head"> <div className="lp-sec-head">
<span className="lp-eyebrow">Custom recipe creator</span> <span className="lp-eyebrow">{c({ en: 'Custom recipe creator', vi: 'Tạo công thức riêng' })}</span>
<h2>Build your own grade in three moves</h2> <h2>{c({ en: 'Build your own grade in three moves', vi: 'Tự dựng công thức trong ba bước' })}</h2>
<p>Temperature, saturation and contrast, applied to the preview as you drag — the same knobs the studio exposes, minus the render wait.</p> <p>{c({ en: 'Temperature, saturation and contrast, applied to the preview as you drag — the same knobs the studio exposes, minus the render wait.', vi: 'Nhiệt độ, độ bão hoà và tương phản, áp ngay khi bạn kéo — đúng những núm studio có, chỉ bỏ thời gian chờ render.' })}</p>
</div> </div>
<div className="lp-lab"> <div className="lp-lab">
<div className="lp-view"> <div className="lp-view">
<img src={SAMPLE(1015)} alt="Custom recipe preview" style={{ filter: labFilter }} /> <img src={SAMPLE(1015)} alt={c({ en: 'Custom recipe preview', vi: 'Xem trước công thức riêng' })} 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 className="lp-hud"><span>{c({ en: 'CUSTOM RECIPE', vi: 'CÔNG THỨC RIÊNG' })}</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>
<div className="lp-knobs"> <div className="lp-knobs">
<div className="lp-knob"> <div className="lp-knob">
<label htmlFor="lp-temp">TEMPERATURE<b>{temp > 0 ? `+${temp}` : temp}</b></label> <label htmlFor="lp-temp">{c({ en: 'TEMPERATURE', vi: 'NHIỆT ĐỘ' })}<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))} /> <input id="lp-temp" type="range" min={-50} max={50} step={1} value={temp} onChange={(e) => setTemp(Number(e.target.value))} />
</div> </div>
<div className="lp-knob"> <div className="lp-knob">
<label htmlFor="lp-sat">SATURATION<b>{sat}%</b></label> <label htmlFor="lp-sat">{c({ en: 'SATURATION', vi: 'ĐỘ BÃO HOÀ' })}<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))} /> <input id="lp-sat" type="range" min={0} max={200} step={1} value={sat} onChange={(e) => setSat(Number(e.target.value))} />
</div> </div>
<div className="lp-knob"> <div className="lp-knob">
<label htmlFor="lp-con">CONTRAST<b>{con}%</b></label> <label htmlFor="lp-con">{c({ en: 'CONTRAST', vi: 'TƯƠNG PHẢN' })}<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))} /> <input id="lp-con" type="range" min={50} max={180} step={1} value={con} onChange={(e) => setCon(Number(e.target.value))} />
</div> </div>
<div className="lp-code"> <div className="lp-code">
@@ -284,7 +329,7 @@ export function Landing() {
style={{ marginTop: 12 }} style={{ marginTop: 12 }}
onClick={() => { setTemp(0); setSat(100); setCon(100); }} onClick={() => { setTemp(0); setSat(100); setCon(100); }}
> >
Reset knobs {c({ en: 'Reset knobs', vi: 'Đặt lại' })}
</button> </button>
</div> </div>
</div> </div>
@@ -296,15 +341,15 @@ export function Landing() {
<section className="lp-sec" id="features"> <section className="lp-sec" id="features">
<div className="lp-wrap"> <div className="lp-wrap">
<div className="lp-sec-head"> <div className="lp-sec-head">
<span className="lp-eyebrow">Features</span> <span className="lp-eyebrow">{c({ en: 'Features', vi: 'Tính năng' })}</span>
<h2>Everything the phone build ships with</h2> <h2>{c({ en: 'Everything the phone build ships with', vi: 'Mọi thứ bản điện thoại có' })}</h2>
</div> </div>
<div className="lp-grid"> <div className="lp-grid">
{FEATURES.map((f) => ( {FEATURES.map((f) => (
<article className="lp-feat" key={f.title}> <article className="lp-feat" key={f.title.en}>
<Icon name={f.icon} /> <Icon name={f.icon} />
<h3>{f.title}</h3> <h3>{c(f.title)}</h3>
<p>{f.text}</p> <p>{c(f.text)}</p>
</article> </article>
))} ))}
</div> </div>
@@ -317,7 +362,7 @@ export function Landing() {
<div className="lp-qr"> <div className="lp-qr">
<div className="lp-recipe-card"> <div className="lp-recipe-card">
<div className="lp-shot"> <div className="lp-shot">
<img src={SAMPLE(1069)} alt="Shared recipe card" style={{ filter: PRESETS[2].filter }} /> <img src={SAMPLE(1069)} alt={c({ en: 'Shared recipe card', vi: 'Thẻ công thức được chia sẻ' })} style={{ filter: PRESETS[2].filter }} />
<span className="lp-grain" aria-hidden="true" /> <span className="lp-grain" aria-hidden="true" />
<span className="lp-tagline">#SUNSET_GLOW</span> <span className="lp-tagline">#SUNSET_GLOW</span>
</div> </div>
@@ -326,7 +371,7 @@ export function Landing() {
{qrOk ? ( {qrOk ? (
<img <img
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(`${window.location.origin}/app`)}`} src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(`${window.location.origin}/app`)}`}
alt="Recipe QR code" alt="QR"
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={() => setQrOk(false)} onError={() => setQrOk(false)}
@@ -341,16 +386,22 @@ export function Landing() {
</div> </div>
</div> </div>
<div> <div>
<span className="lp-eyebrow">Share by QR</span> <span className="lp-eyebrow">{c({ en: 'Share by QR', vi: 'Chia sẻ bằng QR' })}</span>
<h2 style={{ marginTop: 10 }}>One scan, byte-identical recipe</h2> <h2 style={{ marginTop: 10 }}>{c({ en: 'One scan, byte-identical recipe', vi: 'Quét một lần, công thức y nguyên' })}</h2>
<div className="lp-steps"> <div className="lp-steps">
<div className="lp-step"> <div className="lp-step">
<span>1</span> <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> <p>
<b>{c({ en: 'Export a recipe card.', vi: 'Xuất thẻ công thức.' })}</b>{' '}
{c({ en: 'The app designs the card — graded thumbnail, parameter readout, author tag — and saves it as an image you can post anywhere.', vi: 'App tự thiết kế thẻ — ảnh thu nhỏ đã chỉnh màu, bảng thông số, tên tác giả — và lưu thành ảnh để bạn đăng ở đâu cũng được.' })}
</p>
</div> </div>
<div className="lp-step"> <div className="lp-step">
<span>2</span> <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> <p>
<b>{c({ en: 'Your friend scans it.', vi: 'Bạn bè quét thẻ.' })}</b>{' '}
{c({ en: 'The QR opens the recipe in RecipesCam and imports every parameter at once, so the look lands exactly as you built it.', vi: 'Mã QR mở công thức trong RecipesCam và nhập toàn bộ thông số một lần, nên tông màu đúng y như bạn dựng.' })}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -362,35 +413,35 @@ export function Landing() {
<section className="lp-sec" id="pricing"> <section className="lp-sec" id="pricing">
<div className="lp-wrap"> <div className="lp-wrap">
<div className="lp-sec-head"> <div className="lp-sec-head">
<span className="lp-eyebrow">Pricing</span> <span className="lp-eyebrow">{c({ en: 'Pricing', vi: 'Bảng giá' })}</span>
<h2>Start free. Go Pro when the grain matters.</h2> <h2>{c({ en: 'Start free. Go Pro when the grain matters.', vi: 'Bắt đầu miễn phí. Lên Pro khi bạn cần hạt phim.' })}</h2>
</div> </div>
<div className="lp-price"> <div className="lp-price">
<article className="lp-plan"> <article className="lp-plan">
<span className="lp-eyebrow">Starter</span> <span className="lp-eyebrow">{c({ en: 'Starter', vi: 'Khởi đầu' })}</span>
<div className="lp-amount">$0<small> / FOREVER</small></div> <div className="lp-amount">$0<small>{c({ en: ' / FOREVER', vi: ' / MÃI MÃI' })}</small></div>
<p className="lp-muted">Everything needed to shoot your first roll.</p> <p className="lp-muted">{c({ en: 'Everything needed to shoot your first roll.', vi: 'Đủ để bạn chụp cuộn phim đầu tiên.' })}</p>
<ul> <ul>
<li>15 basic recipes</li> <li>{c({ en: '15 basic recipes', vi: '15 công thức cơ bản' })}</li>
<li>HD live viewfinder</li> <li>{c({ en: 'HD live viewfinder', vi: 'Ngắm trực tiếp HD' })}</li>
<li>Basic grain adjustment</li> <li>{c({ en: 'Basic grain adjustment', vi: 'Chỉnh hạt phim cơ bản' })}</li>
<li>300 ppi JPEG export</li> <li>{c({ en: '300 ppi JPEG export', vi: 'Xuất JPEG 300 ppi' })}</li>
</ul> </ul>
<a className="lp-btn" href="/app">Open the studio</a> <a className="lp-btn" href="/app">{c({ en: 'Open the studio', vi: 'Mở studio' })}</a>
</article> </article>
<article className="lp-plan vip"> <article className="lp-plan vip">
<span className="lp-eyebrow">VIP Pro</span> <span className="lp-eyebrow">VIP Pro</span>
<div className="lp-amount">$2.99<small> / MONTH</small></div> <div className="lp-amount">$2.99<small>{c({ en: ' / MONTH', vi: ' / THÁNG' })}</small></div>
<p className="lp-muted">For the people who grade every frame.</p> <p className="lp-muted">{c({ en: 'For the people who grade every frame.', vi: 'Dành cho người chỉnh màu từng khung hình.' })}</p>
<ul> <ul>
<li>250+ VIP recipes</li> <li>{c({ en: '250+ VIP recipes', vi: '250+ công thức VIP' })}</li>
<li>Batch processing</li> <li>{c({ en: 'Batch processing', vi: 'Xử lý hàng loạt' })}</li>
<li>Tone curve &amp; HSL tools</li> <li>{c({ en: 'Tone curve & HSL tools', vi: 'Công cụ tone curve & HSL' })}</li>
<li>Lossless RAW &amp; 4K export</li> <li>{c({ en: 'Lossless RAW & 4K export', vi: 'Xuất RAW & 4K không mất dữ liệu' })}</li>
<li>Unlimited QR generation</li> <li>{c({ en: 'Unlimited QR generation', vi: 'Tạo QR không giới hạn' })}</li>
</ul> </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.')}> <button type="button" className="lp-btn lp-primary" onClick={() => showToast({ en: 'Pro checkout opens with the public release — Starter is free in the web studio today.', vi: 'Thanh toán Pro sẽ mở khi phát hành chính thức — gói Khởi đầu miễn phí ngay trong web studio.' })}>
Go Pro {c({ en: 'Go Pro', vi: 'Lên Pro' })}
</button> </button>
</article> </article>
</div> </div>
@@ -401,26 +452,26 @@ export function Landing() {
<section className="lp-sec"> <section className="lp-sec">
<div className="lp-wrap"> <div className="lp-wrap">
<div className="lp-sec-head"> <div className="lp-sec-head">
<span className="lp-eyebrow">Reviews</span> <span className="lp-eyebrow">{c({ en: 'Reviews', vi: 'Đánh giá' })}</span>
<h2>Shot on RecipesCam</h2> <h2>{c({ en: 'Shot on RecipesCam', vi: 'Chụp bằng RecipesCam' })}</h2>
</div> </div>
<div className="lp-quotes"> <div className="lp-quotes">
{QUOTES.map((q) => ( {QUOTES.map((q) => (
<article className="lp-quote" key={q.name}> <article className="lp-quote" key={q.name}>
<p>“{q.text}”</p> <p>“{c(q.text)}”</p>
<footer><b>{q.name}</b>{q.role}</footer> <footer><b>{q.name}</b>{c(q.role)}</footer>
</article> </article>
))} ))}
</div> </div>
<div className="lp-sec-head"> <div className="lp-sec-head">
<span className="lp-eyebrow">FAQ</span> <span className="lp-eyebrow">FAQ</span>
<h2>Questions we get every week</h2> <h2>{c({ en: 'Questions we get every week', vi: 'Câu hỏi tuần nào cũng có' })}</h2>
</div> </div>
<div className="lp-faq"> <div className="lp-faq">
{FAQ.map((item) => ( {FAQ.map((item) => (
<details key={item.q}> <details key={item.q.en}>
<summary>{item.q}</summary> <summary>{c(item.q)}</summary>
<p>{item.a}</p> <p>{c(item.a)}</p>
</details> </details>
))} ))}
</div> </div>
@@ -431,26 +482,26 @@ export function Landing() {
<section className="lp-final" id="download"> <section className="lp-final" id="download">
<div className="lp-glow" /> <div className="lp-glow" />
<div className="lp-wrap"> <div className="lp-wrap">
<h2>Load a recipe. Shoot the rest of the day.</h2> <h2>{c({ en: 'Load a recipe. Shoot the rest of the day.', vi: 'Nạp một công thức. Chụp cả ngày còn lại.' })}</h2>
<p>RecipesCam is free on both stores, and the web studio runs right here in your browser.</p> <p>{c({ en: 'RecipesCam is free on both stores, and the web studio runs right here in your browser.', vi: 'RecipesCam miễn phí trên cả hai cửa hàng, và web studio chạy ngay trong trình duyệt của bạn.' })}</p>
<div className="lp-stores"> <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.')}> <button type="button" className="lp-btn" onClick={() => showToast(toastStore)}>
<span className="lp-store"><small>TBD</small><b>Download on the App Store</b></span> <span className="lp-store"><small>{c({ en: 'COMING SOON', vi: 'SẮP CÓ' })}</small><b>{c({ en: 'Download on the App Store', vi: 'Tải trên App Store' })}</b></span>
</button> </button>
<button type="button" className="lp-btn" onClick={() => showToast('Google Play listing arrives with the public release — use the web studio in the meantime.')}> <button type="button" className="lp-btn" onClick={() => showToast(toastStore)}>
<span className="lp-store"><small>TBD</small><b>Get it on Google Play</b></span> <span className="lp-store"><small>{c({ en: 'COMING SOON', vi: 'SẮP CÓ' })}</small><b>{c({ en: 'Get it on Google Play', vi: 'Tải trên Google Play' })}</b></span>
</button> </button>
<a className="lp-btn lp-primary" href="/app">Open the Web Studio</a> <a className="lp-btn lp-primary" href="/app">{c({ en: 'Open the Web Studio', vi: 'Mở Web Studio' })}</a>
</div> </div>
</div> </div>
</section> </section>
<footer className="lp-footer"> <footer className="lp-footer">
<div className="lp-wrap"> <div className="lp-wrap">
<span>© {new Date().getFullYear()} RecipesCam — Color Recipe &amp; Film Camera Studio</span> <span>© {new Date().getFullYear()} RecipesCam — {c({ en: 'Color Recipe & Film Camera Studio', vi: 'Studio công thức màu & máy ảnh phim' })}</span>
<a href="#top">Privacy</a> <a href="#top">{c({ en: 'Privacy', vi: 'Quyền riêng tư' })}</a>
<a href="#top">Terms</a> <a href="#top">{c({ en: 'Terms', vi: 'Điều khoản' })}</a>
<a href={STORE_LINKS.play}>Support</a> <a href={STORE_LINKS.play}>{c({ en: 'Support', vi: 'Hỗ trợ' })}</a>
<span className="lp-social"> <span className="lp-social">
<a href="#top">INSTAGRAM</a> <a href="#top">INSTAGRAM</a>
<a href="#top">TIKTOK</a> <a href="#top">TIKTOK</a>
@@ -459,7 +510,7 @@ export function Landing() {
</div> </div>
</footer> </footer>
{toast ? <div className="lp-toast" role="status"><b>Coming soon —</b> {toast}</div> : null} {toast ? <div className="lp-toast" role="status"><b>{c({ en: 'Coming soon —', vi: 'Sắp có —' })}</b> {toast}</div> : null}
</div> </div>
); );
} }
+64 -13
View File
@@ -22,6 +22,9 @@
--lp-text: #f3f4f6; --lp-text: #f3f4f6;
--lp-muted: #9ca3af; --lp-muted: #9ca3af;
--lp-line: rgba(255, 255, 255, 0.08); --lp-line: rgba(255, 255, 255, 0.08);
/* Amber for text sitting on the page background — darker in light mode,
where plain #fbbf24 would fail contrast on white. */
--lp-ink-amber: var(--lp-amber);
--lp-mono: 'Cousine', ui-monospace, SFMono-Regular, Menlo, monospace; --lp-mono: 'Cousine', ui-monospace, SFMono-Regular, Menlo, monospace;
--lp-display: 'Syne', ui-sans-serif, system-ui, sans-serif; --lp-display: 'Syne', ui-sans-serif, system-ui, sans-serif;
@@ -31,6 +34,31 @@
font-size: 15px; font-size: 15px;
line-height: 1.6; line-height: 1.6;
} }
/* The blueprint is a dark page, but the landing follows the app's theme switch
like every other screen. Same funnel, paper palette — only the surfaces and
the text flip; the amber/red accents carry over. */
[data-theme="light"] .lp {
--lp-bg: #f7f7f8;
--lp-bg2: #ffffff;
--lp-card: #ffffff;
--lp-text: #14161c;
--lp-muted: #5b6472;
--lp-line: rgba(20, 22, 28, 0.12);
--lp-ink-amber: #a16207;
}
[data-theme="light"] .lp-hero h1 { background: linear-gradient(100deg, #14161c 8%, var(--lp-amber) 62%, var(--lp-red) 98%); -webkit-background-clip: text; background-clip: text; }
[data-theme="light"] .lp-glow { opacity: 0.5; }
[data-theme="light"] .lp-btn,
[data-theme="light"] .lp-tool,
[data-theme="light"] .lp-tab { background: rgba(20, 22, 28, 0.04); }
[data-theme="light"] .lp-stat { background: linear-gradient(180deg, rgba(20, 22, 28, 0.04), transparent); }
[data-theme="light"] .lp-bar i { background: rgba(20, 22, 28, 0.12); }
[data-theme="light"] .lp-frame { background: #ffffff; }
[data-theme="light"] .lp-perf i { background: rgba(20, 22, 28, 0.55); box-shadow: none; }
[data-theme="light"] .lp-recipe-card { background: linear-gradient(160deg, #ffffff, #eef0f4); box-shadow: 0 24px 60px rgba(20, 22, 28, 0.18); }
[data-theme="light"] .lp-plan.vip { background: linear-gradient(180deg, rgba(245, 158, 11, 0.14), rgba(255, 255, 255, 0.92)); }
[data-theme="light"] .lp-toast { background: rgba(255, 255, 255, 0.98); box-shadow: 0 20px 50px rgba(20, 22, 28, 0.22); }
html { scroll-behavior: smooth; scroll-padding-top: 78px; } html { scroll-behavior: smooth; scroll-padding-top: 78px; }
.lp ::selection { background: var(--lp-amber); color: #10121a; } .lp ::selection { background: var(--lp-amber); color: #10121a; }
.lp-wrap { max-width: 1180px; margin: 0 auto; padding: 0 22px; } .lp-wrap { max-width: 1180px; margin: 0 auto; padding: 0 22px; }
@@ -42,7 +70,7 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
font-family: var(--lp-mono); font-family: var(--lp-mono);
font-size: 11px; font-size: 11px;
letter-spacing: 0.22em; letter-spacing: 0.22em;
color: var(--lp-amber); color: var(--lp-ink-amber);
text-transform: uppercase; text-transform: uppercase;
} }
.lp-muted { color: var(--lp-muted); } .lp-muted { color: var(--lp-muted); }
@@ -89,14 +117,29 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
} }
.lp-nav .lp-wrap { display: flex; align-items: center; gap: 18px; height: 62px; } .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 { 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 em { font-style: normal; color: var(--lp-ink-amber); }
.lp-logo svg { 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 { display: flex; gap: 16px; margin-left: 14px; font-size: 12.5px; color: var(--lp-muted); }
.lp-nav-links a:hover { color: var(--lp-text); } .lp-nav-links a:hover { color: var(--lp-text); }
.lp-nav .lp-spacer { flex: 1 1 auto; } .lp-nav .lp-spacer { flex: 1 1 auto; }
.lp-tools { display: flex; gap: 6px; }
.lp-tool {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 999px;
border: 1px solid var(--lp-line);
background: rgba(255, 255, 255, 0.04);
color: var(--lp-muted);
font-family: var(--lp-mono);
font-size: 12px;
cursor: pointer;
}
.lp-tool:hover { color: var(--lp-text); border-color: rgba(251, 191, 36, 0.5); }
.lp-burger { display: none; } .lp-burger { display: none; }
@media (max-width: 940px) { @media (max-width: 1160px) {
.lp-nav-links, .lp-nav .lp-wrap > .lp-btn { display: none; } .lp-nav-links, .lp-nav .lp-wrap > .lp-btn { display: none; }
.lp-burger { .lp-burger {
display: inline-flex; display: inline-flex;
@@ -119,7 +162,7 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
.lp-sheet a:hover { color: var(--lp-text); } .lp-sheet a:hover { color: var(--lp-text); }
.lp-sheet .lp-btn { margin-top: 12px; } .lp-sheet .lp-btn { margin-top: 12px; }
} }
@media (min-width: 941px) { .lp-sheet { display: none; } } @media (min-width: 1161px) { .lp-sheet { display: none; } }
/* --- 2. hero ------------------------------------------------------------ */ /* --- 2. hero ------------------------------------------------------------ */
.lp-hero { position: relative; padding: 140px 0 74px; overflow: hidden; } .lp-hero { position: relative; padding: 140px 0 74px; overflow: hidden; }
@@ -142,7 +185,7 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(251, 191, 36, 0.35); border: 1px solid rgba(251, 191, 36, 0.35);
background: rgba(251, 191, 36, 0.08); background: rgba(251, 191, 36, 0.08);
color: var(--lp-amber); color: var(--lp-ink-amber);
font-family: var(--lp-mono); font-family: var(--lp-mono);
font-size: 11px; font-size: 11px;
letter-spacing: 0.12em; letter-spacing: 0.12em;
@@ -226,7 +269,7 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
font-family: var(--lp-mono); font-family: var(--lp-mono);
cursor: pointer; cursor: pointer;
} }
.lp-tab[aria-pressed="true"] { border-color: var(--lp-amber); color: var(--lp-amber); background: rgba(251, 191, 36, 0.1); } .lp-tab[aria-pressed="true"] { border-color: var(--lp-amber); color: var(--lp-ink-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 { 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-view img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; transition: filter 380ms ease; }
.lp-hud { .lp-hud {
@@ -254,7 +297,7 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
.lp-lab { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 26px; align-items: start; } .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-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 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-knob b { color: var(--lp-ink-amber); font-size: 13px; }
.lp input[type=range] { width: 100%; margin-top: 10px; accent-color: var(--lp-amber); } .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 { 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); } .lp-code b { color: var(--lp-cyan); }
@@ -262,7 +305,7 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
/* --- 6. features -------------------------------------------------------- */ /* --- 6. features -------------------------------------------------------- */
.lp-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } .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 { border: 1px solid var(--lp-line); background: var(--lp-card); border-radius: 16px; padding: 22px; }
.lp-feat svg { color: var(--lp-amber); } .lp-feat svg { color: var(--lp-ink-amber); }
.lp-feat h3 { font-size: 17px; margin: 14px 0 8px; } .lp-feat h3 { font-size: 17px; margin: 14px 0 8px; }
.lp-feat p { color: var(--lp-muted); font-size: 13.5px; } .lp-feat p { color: var(--lp-muted); font-size: 13.5px; }
@@ -294,7 +337,7 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
place-items: center; place-items: center;
background: rgba(251, 191, 36, 0.12); background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.4); border: 1px solid rgba(251, 191, 36, 0.4);
color: var(--lp-amber); color: var(--lp-ink-amber);
font-family: var(--lp-mono); font-family: var(--lp-mono);
font-size: 12px; font-size: 12px;
} }
@@ -318,10 +361,12 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
font-size: 9.5px; font-size: 9.5px;
letter-spacing: 0.12em; letter-spacing: 0.12em;
} }
/* Pseudo-element copy cannot call t(), so it follows the page's data-lang. */
[data-lang="vi"] .lp-plan.vip::after { content: 'PHỔ BIẾN NHẤT'; }
.lp-plan .lp-amount { font-family: var(--lp-display); font-size: 38px; margin: 12px 0 4px; } .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 .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 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 li::before { content: '▸ '; color: var(--lp-ink-amber); }
.lp-plan .lp-btn { margin-top: auto; } .lp-plan .lp-btn { margin-top: auto; }
/* --- 9. testimonials + faq --------------------------------------------- */ /* --- 9. testimonials + faq --------------------------------------------- */
@@ -333,7 +378,7 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
.lp-faq { display: flex; flex-direction: column; gap: 10px; max-width: 820px; } .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 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 { cursor: pointer; font-weight: 600; }
.lp-faq summary::marker { color: var(--lp-amber); } .lp-faq summary::marker { color: var(--lp-ink-amber); }
.lp-faq p { color: var(--lp-muted); font-size: 13.5px; margin-top: 10px; } .lp-faq p { color: var(--lp-muted); font-size: 13.5px; margin-top: 10px; }
/* --- 10. final CTA + footer -------------------------------------------- */ /* --- 10. final CTA + footer -------------------------------------------- */
@@ -364,7 +409,7 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55); box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55);
font-size: 13px; font-size: 13px;
} }
.lp-toast b { color: var(--lp-amber); } .lp-toast b { color: var(--lp-ink-amber); }
/* --- responsive --------------------------------------------------------- */ /* --- responsive --------------------------------------------------------- */
@media (max-width: 980px) { @media (max-width: 980px) {
@@ -373,6 +418,12 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
.lp-recipe-card { transform: none; } .lp-recipe-card { transform: none; }
} }
@media (max-width: 680px) { @media (max-width: 680px) {
/* logo + theme + language + burger have to fit a 360px phone */
.lp-nav .lp-wrap { gap: 10px; }
.lp-logo { font-size: 15px; }
.lp-logo svg { width: 18px; height: 18px; }
.lp-tool { width: 30px; height: 30px; font-size: 11px; }
.lp-burger { padding: 6px; }
.lp-sec { padding: 64px 0; } .lp-sec { padding: 64px 0; }
.lp-hero { padding: 112px 0 52px; } .lp-hero { padding: 112px 0 52px; }
.lp-grid, .lp-quotes, .lp-price, .lp-stats { grid-template-columns: minmax(0, 1fr); } .lp-grid, .lp-quotes, .lp-price, .lp-stats { grid-template-columns: minmax(0, 1fr); }
+3 -1
View File
@@ -6,14 +6,16 @@ import { api } from '../api';
// just drives it. The demo is always one click away — the app is usable signed // just drives it. The demo is always one click away — the app is usable signed
// out, and only export resolution / saving are gated (see config/tiers.ts). // out, and only export resolution / saving are gated (see config/tiers.ts).
export function AuthModal({ export function AuthModal({
initialMode = 'login',
onClose, onClose,
onDone, onDone,
}: { }: {
initialMode?: 'login' | 'signup';
onClose: () => void; onClose: () => void;
onDone: () => void; onDone: () => void;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [mode, setMode] = useState<'login' | 'signup'>('login'); const [mode, setMode] = useState<'login' | 'signup'>(initialMode);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);