35d8d57984
The colour band chips only aimed the mixer before; now they open the same panel the eyedropper opens, seeded with the colour the chip is named after (hexToRgb turns that readout back into the RGB bytes the picker stores). A pick already on the photo keeps its spot — only a mixer with nowhere to hang takes the middle of the frame. The HSL column itself is untouched: 8 chips, the rule, IMAGE, HUE/SAT/LUM, the readout and RESET all stay where they were.
1305 lines
74 KiB
TypeScript
1305 lines
74 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { api, type Highlights, type Photo, type PhotoSlot, type Rating, type User } from './api';
|
||
import { clearSession } from './engine/session';
|
||
import { useI18n } from './i18n/I18nProvider';
|
||
import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from './theme/ThemeProvider';
|
||
import { AuthModal } from './ui/AuthModal';
|
||
import { Avatar } from './ui/Avatar';
|
||
import { QrCode } from './ui/QrCode';
|
||
|
||
// Landing page, built to the landing blueprint: the 10-section funnel. The
|
||
// marketing prose is bilingual and lives here next to the markup as `Txt`
|
||
// pairs — the app dictionary holds UI strings, this is a page of copy, and the
|
||
// blueprint stays the source of truth for it. The page follows the same theme
|
||
// and language switches as the workspace (buttons in the nav, top-right).
|
||
// The Google Play listing is the internal-test track, so the store badge is a
|
||
// real link from today. The App Store has no listing yet (WEB_PLAN.md W6), so
|
||
// that badge still raises the toast until its URL lands here.
|
||
const STORE_LINKS = {
|
||
play: 'https://play.google.com/apps/internaltest/4700963739341162920',
|
||
appstore: '#',
|
||
};
|
||
|
||
type Txt = { en: string; vi: string };
|
||
|
||
// The hero's award column: how long one frame stands before the next, and how
|
||
// long the two fades take. The swap is a crossfade — the frame leaving fades out
|
||
// over AWARD_FADE_MS while the one arriving fades in over the same — so a step
|
||
// reads as one movement, and the clock restarts whenever a visitor steps it by
|
||
// hand (see the effect).
|
||
const AWARD_MS = 5000;
|
||
const AWARD_FADE_MS = 1000;
|
||
// What a frame's votes won it. The first two are windows of the clock; `ever`
|
||
// is the fallback the API sends when neither window has a vote in it, and it
|
||
// claims no window — a quiet week must not dress a month-old best as today's.
|
||
const AWARD_LABEL = {
|
||
day: { en: 'Recipe of the day', vi: 'Công thức của ngày' },
|
||
week: { en: 'Recipe of the week', vi: 'Công thức của tuần' },
|
||
ever: { en: 'Most-loved recipe', vi: 'Công thức được yêu nhất' },
|
||
} as const;
|
||
|
||
// No page can really stop a save — the bytes are already on the machine. What it
|
||
// can do is refuse to hand over more pixels than it draws: the photo is decoded,
|
||
// redrawn at the size of its box times the screen's pixel ratio (2x at most) and
|
||
// that smaller bitmap is what the tag carries, so a saved copy is a screen-sized
|
||
// one and not the uploader's 4000px original. The save gestures are turned off
|
||
// on top of it. `data-full` keeps the original address for tests and debugging.
|
||
//
|
||
// The redraw covers what `object-fit: cover` will show, not the whole frame: a
|
||
// 4:3 upload in a 4:5 card would otherwise be handed a stubby bitmap that the
|
||
// card had to blow up again — the soft thumbnails the strip used to show.
|
||
function Shot({ src, alt, filter }: { src: string; alt: string; filter?: string }) {
|
||
const tag = useRef<HTMLImageElement>(null);
|
||
const [shown, setShown] = useState(src);
|
||
useEffect(() => {
|
||
setShown(src);
|
||
let alive = true;
|
||
const source = new Image();
|
||
source.onload = () => {
|
||
const boxW = tag.current?.clientWidth ?? 0;
|
||
const boxH = tag.current?.clientHeight ?? 0;
|
||
const scale = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
|
||
const width = Math.round(boxW * scale);
|
||
const height = Math.round(boxH * scale);
|
||
if (!alive || width === 0 || height === 0) return;
|
||
if (width >= source.naturalWidth && height >= source.naturalHeight) return;
|
||
// The centred slice `cover` would show, cut to exactly the box: scale up
|
||
// to fill both sides, keep the middle of the overflow.
|
||
const grow = Math.max(width / source.naturalWidth, height / source.naturalHeight);
|
||
const sw = width / grow;
|
||
const sh = height / grow;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return;
|
||
ctx.imageSmoothingQuality = 'high';
|
||
ctx.drawImage(source, (source.naturalWidth - sw) / 2, (source.naturalHeight - sh) / 2, sw, sh, 0, 0, width, height);
|
||
setShown(canvas.toDataURL('image/jpeg', 0.85));
|
||
};
|
||
source.src = src;
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [src]);
|
||
return (
|
||
<img
|
||
ref={tag}
|
||
className="lp-photo"
|
||
src={shown}
|
||
data-full={src}
|
||
alt={alt}
|
||
loading="lazy"
|
||
style={filter ? { filter } : undefined}
|
||
draggable={false}
|
||
onDragStart={(e) => e.preventDefault()}
|
||
onContextMenu={(e) => e.preventDefault()}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// The ‹ › pair that steps a preview through the stills the curator tagged for
|
||
// that slot, wrapping at both ends. A slot with one frame — or none — gets no
|
||
// arrows: there is nowhere to step.
|
||
function FrameArrows({ count, id, labels, onStep }: {
|
||
count: number;
|
||
id: string;
|
||
labels: { prev: string; next: string };
|
||
onStep: (delta: number) => void;
|
||
}) {
|
||
if (count < 2) return null;
|
||
return (
|
||
<>
|
||
<button type="button" className="lp-arrow lp-arrow-prev" data-key={`${id}-prev`} aria-label={labels.prev} onClick={() => onStep(-1)}>
|
||
{'\u2039'}
|
||
</button>
|
||
<button type="button" className="lp-arrow lp-arrow-next" data-key={`${id}-next`} aria-label={labels.next} onClick={() => onStep(1)}>
|
||
{'\u203a'}
|
||
</button>
|
||
</>
|
||
);
|
||
}
|
||
|
||
interface Look {
|
||
tag: string;
|
||
title: Txt;
|
||
meta: string;
|
||
photo: string;
|
||
filter: string;
|
||
// The subject this frame is rated under: `photo:<id>`. Stamped when the reel
|
||
// is assembled so the literal cannot drift from the key form.
|
||
rateKey: string;
|
||
}
|
||
|
||
type PresetGroup = 'landscape' | 'portrait' | 'streetlife';
|
||
|
||
interface Preset {
|
||
id: string;
|
||
group: PresetGroup;
|
||
// Stock and recipe names are proper nouns — never translated (see i18n/vi.ts).
|
||
label: string;
|
||
name: string;
|
||
desc: Txt;
|
||
filter: string;
|
||
// warmth, grain, contrast, halation bloom — 0..100
|
||
metrics: [number, number, number, number];
|
||
}
|
||
|
||
// The tester's dropdown is grouped by genre, five stocks each.
|
||
const PRESET_GROUPS: { id: PresetGroup; label: Txt }[] = [
|
||
{ id: 'landscape', label: { en: 'Landscape', vi: 'Phong cảnh' } },
|
||
{ id: 'portrait', label: { en: 'Portrait', vi: 'Chân dung' } },
|
||
{ id: 'streetlife', label: { en: 'Streetlife', vi: 'Đường phố' } },
|
||
];
|
||
|
||
const PRESETS: Preset[] = [
|
||
// --- landscape ---------------------------------------------------------
|
||
{ id: 'velvia', group: 'landscape', label: 'Fuji Velvia 50', name: 'VELVIA 50', desc: { en: 'Saturated slide film for moss, ridge lines and sunset skies — colour that refuses to whisper.', vi: 'Phim slide bão hoà cho rêu, sống núi và trời hoàng hôn — màu không chịu thì thầm.' }, filter: 'saturate(1.5) contrast(1.24) hue-rotate(-5deg)', metrics: [20, 26, 82, 30] },
|
||
{ id: 'ektar', group: 'landscape', label: 'Kodak Ektar 100', name: 'EKTAR 100', desc: { en: 'Ultra-fine grain and punchy primaries, the wide-open landscape stock.', vi: 'Hạt cực mịn, màu gốc căng — phim phong cảnh khẩu độ mở.' }, filter: 'saturate(1.32) contrast(1.14) sepia(.06)', metrics: [46, 20, 72, 34] },
|
||
{ id: 'provia', group: 'landscape', label: 'Fuji Provia 100F', name: 'PROVIA 100F', desc: { en: 'Neutral slide film that holds the sky and the shadows without pushing either.', vi: 'Phim slide trung tính giữ được cả trời và vùng tối mà không đẩy bên nào.' }, filter: 'saturate(1.14) contrast(1.1) brightness(1.03)', metrics: [34, 24, 64, 22] },
|
||
{ id: 'vivid', group: 'landscape', label: 'Classic Vivid', name: 'CLASSIC VIVID', desc: { en: 'The camera’s own vivid profile: deep blues, warm rock, nothing held back.', vi: 'Profile vivid của máy: xanh sâu, đá ấm, không giữ lại gì.' }, filter: 'saturate(1.22) contrast(1.2)', metrics: [30, 28, 76, 26] },
|
||
{ id: 'sunset', group: 'landscape', 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] },
|
||
// --- portrait ----------------------------------------------------------
|
||
{ id: 'portra', group: 'portrait', 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: 'astia', group: 'portrait', label: 'Fuji Astia', name: 'ASTIA', desc: { en: 'Soft contrast and gentle skin rendering, the portrait film that flatters without lying.', vi: 'Tương phản mềm, da lên dịu — phim chân dung làm đẹp mà không nói dối.' }, filter: 'sepia(.1) saturate(1.06) contrast(.98) brightness(1.06)', metrics: [62, 30, 42, 26] },
|
||
{ id: 'eterna', group: 'portrait', label: 'Fuji Eterna', name: 'ETERNA', desc: { en: 'Cinema negative: muted highlights, even skin, the grade of a quiet two-shot.', vi: 'Phim âm bản điện ảnh: vùng sáng trầm, da đều, đúng tông cảnh hai người.' }, filter: 'sepia(.07) saturate(.88) contrast(1.06) brightness(1.02)', metrics: [54, 26, 50, 20] },
|
||
{ id: 'classicneg', group: 'portrait', label: 'Classic Negative', name: 'CLASSIC NEG', desc: { en: 'Amber shadows and restrained skin — the modern editorial portrait look.', vi: 'Vùng tối hổ phách, da tiết chế — chất chân dung editorial hiện đại.' }, filter: 'sepia(.12) saturate(.94) contrast(1.16)', metrics: [66, 32, 60, 24] },
|
||
{ id: 'gold', group: 'portrait', label: 'Kodak Gold 200', name: 'GOLD 200', desc: { en: 'Sunlit warmth, honeyed highlights and the grain of a family album.', vi: 'Ấm nắng, vùng sáng mật ong và hạt phim như album gia đình.' }, filter: 'sepia(.22) saturate(1.18) contrast(1.04) brightness(1.05)', metrics: [78, 36, 46, 30] },
|
||
// --- streetlife --------------------------------------------------------
|
||
{ id: 'chrome', group: 'streetlife', 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: 'cyber', group: 'streetlife', 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', group: 'streetlife', 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] },
|
||
{ id: 'streetclassic', group: 'streetlife', label: 'LC Streetlife Classic', name: 'STREETLIFE CLASSIC', desc: { en: 'Cool shadows and hard mid-tones for concrete, signage and passing crowds.', vi: 'Vùng tối lạnh, trung tính gắt cho bê tông, biển hiệu và dòng người qua.' }, filter: 'saturate(.82) contrast(1.24) hue-rotate(-6deg) brightness(.98)', metrics: [30, 38, 74, 16] },
|
||
{ id: 'streetvivid', group: 'streetlife', label: 'LC Streetlife Vivid', name: 'STREETLIFE VIVID', desc: { en: 'The same street, turned up: tougher contrast, louder colour, night-market energy.', vi: 'Vẫn đường phố đó, vặn to hơn: tương phản gắt, màu rực, năng lượng chợ đêm.' }, filter: 'saturate(1.4) contrast(1.28) hue-rotate(8deg)', metrics: [26, 40, 80, 34] },
|
||
];
|
||
|
||
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 { icon: 'aperture' | 'grain' | 'batch' | 'qr' | 'hsl' | 'raw'; title: Txt; text: Txt }
|
||
const FEATURES: Feature[] = [
|
||
{ 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: { 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: { 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: { 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: { 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: { en: 'RAW Support & 4K Export', vi: 'Hỗ trợ RAW & xuất 4K' }, text: { en: 'Full EXIF retention, print metadata and lossless JPEG at up to 4K.', vi: 'Giữ nguyên EXIF, metadata in ấn và JPEG không mất dữ liệu tới 4K.' } },
|
||
];
|
||
|
||
const QUOTES = [
|
||
{ 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: { 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: { 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 = [
|
||
{ 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 print-ready 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 chuẩn in 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: { 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: { 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.' } },
|
||
];
|
||
|
||
// The About copy — the source is the About RecipesCam brief, kept here as one
|
||
// bilingual block rather than a route of its own: the landing is a one-pager
|
||
// with anchor nav, and this way the section carries the same nav, theme and
|
||
// language switches as the rest of the page for free.
|
||
type AboutBlock = { title: Txt; body: Txt[]; list?: { label: Txt; items: { title: Txt; text: Txt }[] } };
|
||
const ABOUT: {
|
||
lead: Txt;
|
||
welcome: Txt;
|
||
blocks: AboutBlock[];
|
||
contact: Txt;
|
||
email: string;
|
||
closing: Txt;
|
||
} = {
|
||
lead: { en: 'Where computational photography meets the analog film era.', vi: 'Nơi nhiếp ảnh tính toán gặp vẻ đẹp của phim analog.' },
|
||
welcome: { en: 'Welcome to RecipesCam — the power of modern computational photography meeting the timeless artistic beauty of the analog film era.', vi: 'Chào mừng đến với RecipesCam — sức mạnh của nhiếp ảnh tính toán hiện đại hoà cùng vẻ đẹp nghệ thuật vượt thời gian của thời đại phim analog.' },
|
||
blocks: [
|
||
{
|
||
title: { en: 'Our Vision', vi: 'Tầm nhìn' },
|
||
body: [
|
||
{ en: 'In the digital age, as smartphone cameras increasingly try to smooth, brighten and “perfect” every shot with AI, we quietly lose authentic emotion. The imperfections, the natural film grain and the soft colour transitions of a roll of film are exactly what give a photograph its soul.', vi: 'Thời đại số, khi camera điện thoại ngày càng cố làm mịn, làm sáng và “hoàn hảo hoá” mọi khung hình bằng AI, chúng ta dần đánh mất cảm xúc thật. Chính những khiếm khuyết, hạt phim tự nhiên và chuyển màu mềm mại của một cuộn phim mới là thứ cho bức ảnh một linh hồn.' },
|
||
{ en: 'RecipesCam was born with a single mission: to hand the control of colour and emotion back to the photographer — right on an Android phone.', vi: 'RecipesCam ra đời với một sứ mệnh duy nhất: trả lại quyền kiểm soát màu sắc và cảm xúc cho người chụp — ngay trên chiếc điện thoại Android.' },
|
||
],
|
||
},
|
||
{
|
||
title: { en: 'The RecipesCam Android App', vi: 'Ứng dụng RecipesCam cho Android' },
|
||
body: [
|
||
{ en: 'Not just another post-processing filter app. RecipesCam is a real-time camera engine that puts the GPU of your Android device to work.', vi: 'Không chỉ là một app filter hậu kỳ. RecipesCam là engine máy ảnh thời gian thực, tận dụng sức mạnh GPU của thiết bị Android.' },
|
||
],
|
||
list: {
|
||
label: { en: 'App Highlights', vi: 'Điểm nổi bật' },
|
||
items: [
|
||
{ title: { en: 'Live Viewfinder:', vi: 'Ngắm trực tiếp:' }, text: { en: 'shader technology shows the exact colour, contrast and film grain of a colour recipe (Recipe) on screen at 60FPS, before you press the shutter.', vi: 'công nghệ shader hiển thị đúng màu, tương phản và hạt phim của công thức màu (Recipe) ngay trên màn hình ở 60FPS, trước khi bạn bấm máy.' } },
|
||
{ title: { en: 'Pristine Image Quality:', vi: 'Chất lượng ảnh nguyên bản:' }, text: { en: 'the pipeline interfaces deeply with the camera hardware — highlight detail protected, natural sharpness retained, artificial noise reduction replaced by procedural film grain simulation.', vi: 'pipeline giao tiếp sâu với phần cứng camera — giữ chi tiết vùng sáng, giữ độ nét tự nhiên, thay thuật toán khử nhiễu nhân tạo bằng mô phỏng hạt phim thủ tục.' } },
|
||
{ title: { en: 'Diverse Recipes Library:', vi: 'Thư viện công thức đa dạng:' }, text: { en: 'the feeling of shooting with renowned cameras, through meticulously crafted colour recipes.', vi: 'cảm giác chụp bằng những chiếc máy ảnh nổi tiếng, qua các công thức màu được chăm chút tỉ mỉ.' } },
|
||
],
|
||
},
|
||
},
|
||
{
|
||
title: { en: 'About the RecipesCam Website', vi: 'Về website RecipesCam' },
|
||
body: [
|
||
{ en: 'The official RecipesCam website (at this address) was built with three core purposes:', vi: 'Website chính thức của RecipesCam (tại địa chỉ này) được dựng với ba mục đích chính:' },
|
||
],
|
||
list: {
|
||
label: { en: 'Core Purposes', vi: 'Mục đích chính' },
|
||
items: [
|
||
{ title: { en: 'Official Information Portal:', vi: 'Cổng thông tin chính thức:' }, text: { en: 'where new features are introduced, colour recipes updated and photography knowledge shared with the RecipesCam community.', vi: 'nơi giới thiệu tính năng mới, cập nhật công thức màu và chia sẻ kiến thức nhiếp ảnh với cộng đồng RecipesCam.' } },
|
||
{ title: { en: 'Download & Support Center:', vi: 'Trung tâm tải & hỗ trợ:' }, text: { en: 'installation guides and optimisation tips for the range of Android devices.', vi: 'hướng dẫn cài đặt và mẹo tối ưu cho nhiều dòng thiết bị Android.' } },
|
||
{ title: { en: 'Legal & License:', vi: 'Pháp lý & bản quyền:' }, text: { en: 'the official publication of our Terms of Service, EULA and software licensing rules; every individual and organisation using the app follows what is listed here.', vi: 'nơi công bố chính thức Điều khoản dịch vụ, EULA và quy định cấp phép phần mềm; mọi cá nhân và tổ chức dùng app đều tuân theo những gì công bố ở đây.' } },
|
||
],
|
||
},
|
||
},
|
||
],
|
||
contact: { en: 'Technical inquiries, partnerships, bug reports or licensing — email the development team at', vi: 'Thắc mắc kỹ thuật, hợp tác, báo lỗi hay vấn đề bản quyền — gửi email cho đội ngũ phát triển tại' },
|
||
email: 'recipescam.app@gmail.com',
|
||
closing: { en: 'Thank you for choosing RecipesCam to tell your story through every frame.', vi: 'Cảm ơn bạn đã chọn RecipesCam để kể câu chuyện của mình qua từng khung hình.' },
|
||
};
|
||
|
||
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, fonts, setMode, setAccent, setFonts } = useTheme();
|
||
const [menu, setMenu] = useState(false);
|
||
const [themeMenu, setThemeMenu] = useState(false);
|
||
const [accountMenu, setAccountMenu] = useState(false);
|
||
const tools = useRef<HTMLSpanElement>(null);
|
||
const account = useRef<HTMLSpanElement>(null);
|
||
const [presetId, setPresetId] = useState(PRESETS[0].id);
|
||
const [testerIdx, setTesterIdx] = useState(0);
|
||
const [creatorIdx, setCreatorIdx] = useState(0);
|
||
const [qrIdx, setQrIdx] = useState(0);
|
||
const [temp, setTemp] = useState(0);
|
||
const [sat, setSat] = useState(100);
|
||
const [con, setCon] = useState(100);
|
||
// `lead` is the short bold tag in front of the message; the marketing lines
|
||
// share "Coming soon —", the verification ones carry their own.
|
||
const [toast, setToast] = useState<{ lead: string; text: string } | null>(null);
|
||
const [user, setUser] = useState<User | null>(null);
|
||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||
const [ratings, setRatings] = useState<Record<string, Rating>>({});
|
||
const [highlights, setHighlights] = useState<Highlights>({ day: [], week: [], ever: [] });
|
||
// Which award frame the hero shows. It steps forward forever and wraps modulo
|
||
// the list, so a reload that changes the list cannot land it out of range.
|
||
const [awardIdx, setAwardIdx] = useState(0);
|
||
// Sign up / log in happens in a dialog on this page, not by a trip to the
|
||
// studio: the visitor keeps their scroll position and lands back on the reel.
|
||
const [auth, setAuth] = useState<'login' | 'signup' | null>(() => {
|
||
const asked = new URLSearchParams(window.location.search).get('auth');
|
||
return asked === 'signup' ? 'signup' : asked ? 'login' : null;
|
||
});
|
||
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 toastLead: Txt = { en: 'Coming soon —', vi: 'Sắp có —' };
|
||
|
||
const showToast = useCallback((text: Txt | string, lead?: Txt) => {
|
||
setToast({
|
||
lead: c(lead ?? toastLead),
|
||
text: typeof text === 'string' ? text : c(text),
|
||
});
|
||
clearTimeout(toastTimer.current);
|
||
toastTimer.current = setTimeout(() => setToast(null), 3400);
|
||
}, [c]);
|
||
useEffect(() => () => clearTimeout(toastTimer.current), []);
|
||
|
||
// The community half of the reel: whoever is signed in gets a picker, and
|
||
// everybody sees the result on the next load. A guest is sent to sign up.
|
||
const reload = useCallback(() => {
|
||
api.listPhotos().then((r) => setPhotos(r.photos)).catch(() => undefined);
|
||
api.ratings().then((r) => setRatings(r.ratings)).catch(() => undefined);
|
||
api.highlights().then((r) => setHighlights(r.highlights)).catch(() => undefined);
|
||
return api.me().then((r) => {
|
||
setUser(r.user);
|
||
return r.user;
|
||
}).catch(() => null);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
reload();
|
||
}, [reload]);
|
||
|
||
// The verification link in the letter redirects here with `?verified=1` (or
|
||
// `0` for a stale or unknown token). Say which it was, re-read the session so
|
||
// a just-proven account comes back PRO, and drop the flag so a reload does
|
||
// not repeat the toast.
|
||
useEffect(() => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const flag = params.get('verified');
|
||
if (flag === null) return;
|
||
params.delete('verified');
|
||
const rest = params.toString();
|
||
window.history.replaceState(null, '', `${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}`);
|
||
if (flag === '1') {
|
||
showToast(
|
||
{ en: 'Your email is confirmed — the studio is unlocked.', vi: 'Đã xác thực email — studio đã mở khoá.' },
|
||
{ en: 'Verified —', vi: 'Đã xác thực —' },
|
||
);
|
||
reload();
|
||
} else {
|
||
showToast(
|
||
{ en: 'That verification link is invalid or has expired.', vi: 'Liên kết xác thực không hợp lệ hoặc đã hết hạn.' },
|
||
{ en: 'Not verified —', vi: 'Chưa xác thực —' },
|
||
);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// One vote. The visitor may change their mind: the API upserts under the same
|
||
// address hash, so a second click replaces the first rather than stacking. A
|
||
// vote that never lands is silently dropped — it is not worth a toast.
|
||
const rate = useCallback(async (key: string, stars: number) => {
|
||
try {
|
||
const r = await api.rate(key, stars);
|
||
setRatings((prev) => ({ ...prev, [r.key]: r.rating }));
|
||
} catch {
|
||
// Offline or rate-limited: the row keeps the score it already had.
|
||
}
|
||
}, []);
|
||
|
||
// The reel holds only stills the studio's own uploads put in the strip slot.
|
||
// Every frame is repeated once, so the marquee's -50% translation still loops
|
||
// seamlessly.
|
||
const reel = useMemo<Look[]>(
|
||
() => [
|
||
// The labels the uploader's studio stored — the tagline, the title and
|
||
// the ISO/grain line. A contribution from before those existed keeps the
|
||
// plain community defaults.
|
||
...photos.filter((p) => p.slots.includes('strip') && p.consent !== false).map((p) => ({
|
||
tag: p.tag ?? '#COMMUNITY',
|
||
title: { en: p.title ?? 'Community Frame', vi: p.title ?? 'Khung cộng đồng' },
|
||
meta: p.meta ?? `#${p.id}`,
|
||
photo: api.photoUrl(p.id),
|
||
filter: 'none',
|
||
rateKey: `photo:${p.id}`,
|
||
})),
|
||
],
|
||
[photos],
|
||
);
|
||
|
||
// The marquee loops by translating half the track, so one half has to be at
|
||
// least as wide as the window: a reel too short for the screen runs out of
|
||
// frames before the loop restarts, and the band on the right goes blank until
|
||
// the next pass arrives. Lay out one pass, then repeat the reel until a half
|
||
// covers the window — and again on resize, which changes what "covers" means.
|
||
const track = useRef<HTMLDivElement>(null);
|
||
const [reelCopies, setReelCopies] = useState(1);
|
||
useEffect(() => {
|
||
const el = track.current;
|
||
const fan = el?.querySelector<HTMLElement>('.lp-frame');
|
||
if (!el || !fan || !reel.length) return;
|
||
const pitch = fan.getBoundingClientRect().width + (parseFloat(getComputedStyle(fan).marginRight) || 0);
|
||
if (pitch <= 0) return;
|
||
const view = () => el.parentElement?.clientWidth || window.innerWidth;
|
||
const fit = () => setReelCopies(Math.max(1, Math.ceil(view() / (reel.length * pitch))));
|
||
fit();
|
||
window.addEventListener('resize', fit);
|
||
return () => window.removeEventListener('resize', fit);
|
||
}, [reel]);
|
||
const loop = useMemo(() => Array.from({ length: reelCopies }, () => reel).flat(), [reel, reelCopies]);
|
||
|
||
// The three live sections, each cycling the stills the curator tagged for its
|
||
// slot. Every set is drawn from a random start once per page load, so the
|
||
// preview does not change under the visitor's eyes on every render; a slot
|
||
// that holds nothing simply shows no frame. A photo the curator put in
|
||
// several slots can turn up in each of them.
|
||
const slotPhoto = useMemo(() => {
|
||
const urls = (slot: PhotoSlot) => photos.filter((p) => p.slots.includes(slot)).map((p) => api.photoUrl(p.id));
|
||
// A plain start index on top of the whole set keeps the frame random per
|
||
// load without an effect that would reset it on every re-render.
|
||
const start = (n: number) => (n ? Math.floor(Math.random() * n) : 0);
|
||
// The QR card's payload is the curated photo's own look, as the link the
|
||
// phone app downloads it from.
|
||
const qrs = photos.filter((p) => p.slots.includes('qr')).map((p) => ({
|
||
url: api.photoUrl(p.id),
|
||
link: p.hasPreset ? new URL(api.presetUrl(p.id), window.location.origin).href : `${window.location.origin}/app`,
|
||
// The card prints the frame's own labels, exactly as the uploader saved
|
||
// them — the tagline over the still, the title and the technical line
|
||
// under it.
|
||
tag: p.tag,
|
||
title: p.title,
|
||
meta: p.meta,
|
||
}));
|
||
const testers = urls('tester');
|
||
const creators = urls('creator');
|
||
return {
|
||
testers,
|
||
testerStart: start(testers.length),
|
||
creators,
|
||
creatorStart: start(creators.length),
|
||
qrs,
|
||
qrStart: start(qrs.length),
|
||
};
|
||
}, [photos]);
|
||
|
||
// The hero's award column: today's best-rated contributions first, then this
|
||
// week's, then — only when neither window has a vote — the all-time bests.
|
||
// Each frame carries the award its own votes won, and a frame that took more
|
||
// than one keeps the tighter claim: the day's before the week's, both before
|
||
// ever. The labels and the file come from the public listing, so a tally whose
|
||
// photo is no longer in it is dropped rather than shown bare.
|
||
const awards = useMemo(() => {
|
||
const by = new Map(photos.map((p) => [p.id, p]));
|
||
const seen = new Set<number>();
|
||
const out: { photo: Photo; avg: number; n: number; kind: keyof typeof AWARD_LABEL }[] = [];
|
||
for (const [kind, list] of [
|
||
['day', highlights.day],
|
||
['week', highlights.week],
|
||
['ever', highlights.ever],
|
||
] as const) {
|
||
for (const tally of list) {
|
||
const photo = by.get(tally.id);
|
||
if (!photo || seen.has(tally.id)) continue;
|
||
seen.add(tally.id);
|
||
out.push({ photo, avg: tally.avg, n: tally.n, kind });
|
||
}
|
||
}
|
||
return out;
|
||
}, [photos, highlights]);
|
||
|
||
// The column steps itself: five seconds a frame, the clock restarted by a
|
||
// manual step (the index is a dependency) and never started at all for a list
|
||
// with nothing to step to.
|
||
useEffect(() => {
|
||
if (awards.length < 2) return;
|
||
const timer = setTimeout(() => setAwardIdx((i) => i + 1), AWARD_MS);
|
||
return () => clearTimeout(timer);
|
||
}, [awards.length, awardIdx]);
|
||
|
||
const award = awards.length ? awards[awardIdx % awards.length] : null;
|
||
|
||
// The QR card cycles its stills the way the previews do, but the payload has
|
||
// to follow: the card is rendered from the one on screen.
|
||
const curQr = slotPhoto.qrs.length ? slotPhoto.qrs[(slotPhoto.qrStart + qrIdx) % slotPhoto.qrs.length] : null;
|
||
|
||
// The theme popover closes on a click anywhere else, like the workspace's.
|
||
useEffect(() => {
|
||
if (!themeMenu) return;
|
||
const away = (e: MouseEvent) => {
|
||
if (!tools.current?.contains(e.target as Node)) setThemeMenu(false);
|
||
};
|
||
document.addEventListener('mousedown', away);
|
||
return () => document.removeEventListener('mousedown', away);
|
||
}, [themeMenu]);
|
||
|
||
// The account popover closes the same way, and signing out puts the chip back
|
||
// to its signed-out state without a reload.
|
||
useEffect(() => {
|
||
if (!accountMenu) return;
|
||
const away = (e: MouseEvent) => {
|
||
if (!account.current?.contains(e.target as Node)) setAccountMenu(false);
|
||
};
|
||
document.addEventListener('mousedown', away);
|
||
return () => document.removeEventListener('mousedown', away);
|
||
}, [accountMenu]);
|
||
|
||
const signOut = useCallback(async () => {
|
||
await api.logout().catch(() => undefined);
|
||
void clearSession();
|
||
setUser(null);
|
||
setAccountMenu(false);
|
||
}, []);
|
||
|
||
// 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: { href: string; label: Txt }[] = [
|
||
{ href: '#film', label: { en: 'Film Strip', vi: 'Dải phim' } },
|
||
{ href: '#presets', label: { en: 'Try Recipes', vi: 'Thử công thức' } },
|
||
{ href: '#creator', label: { en: 'Custom Recipe Creator', vi: 'Tạo công thức riêng' } },
|
||
{ href: '#features', label: { en: 'Features', vi: 'Tính năng' } },
|
||
{ href: '#qr', label: { en: 'QR Code', vi: 'Mã QR' } },
|
||
{ href: '#pricing', label: { en: 'Pricing', vi: 'Bảng giá' } },
|
||
{ href: '#about', label: { en: 'About', vi: 'Giới thiệu' } },
|
||
];
|
||
|
||
const toastStore: Txt = { en: 'The App Store listing arrives with the public release — use the web studio in the meantime.', vi: 'App Store sẽ mở khi phát hành chính thức — trong lúc chờ, hãy dùng web studio.' };
|
||
|
||
// Sharing. The URL is the page's own origin, so the links point at whatever
|
||
// host is serving this build (the crawler meta in index.html does the same
|
||
// with relative URLs). Each network opens in a new tab with the URL already
|
||
// filled in; the last control uses the OS share sheet when there is one and
|
||
// copies the link when there is not.
|
||
const shareUrl = `${window.location.origin}/`;
|
||
const shareText = c({
|
||
en: 'RecipesCam — Kodak and Fuji film colour on your own photos, on the phone and in the browser.',
|
||
vi: 'RecipesCam — màu phim Kodak và Fuji trên ảnh của bạn, trên điện thoại và trên trình duyệt.',
|
||
});
|
||
const shareLinks: { id: string; label: string; href: string }[] = [
|
||
{ id: 'facebook', label: 'FACEBOOK', href: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}` },
|
||
{ id: 'x', label: 'X', href: `https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(shareText)}` },
|
||
{ id: 'linkedin', label: 'LINKEDIN', href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}` },
|
||
{ id: 'telegram', label: 'TELEGRAM', href: `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(shareText)}` },
|
||
];
|
||
const share = async () => {
|
||
if (navigator.share) {
|
||
// Dismissing the sheet is not a failure — say nothing.
|
||
await navigator.share({ title: 'RecipesCam', text: shareText, url: shareUrl }).catch(() => undefined);
|
||
return;
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(shareUrl);
|
||
showToast(c({ en: 'Link copied to the clipboard.', vi: 'Đã sao chép liên kết.' }));
|
||
} catch {
|
||
// No clipboard (an insecure origin): hand the URL over in the toast.
|
||
showToast(c({ en: `Copy this link: ${shareUrl}`, vi: `Sao chép liên kết này: ${shareUrl}` }));
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="lp" data-lang={lang}>
|
||
{/* 1 — fixed glass navigation */}
|
||
<nav className="lp-nav">
|
||
<div className="lp-wrap">
|
||
<a className="lp-logo" href="/" onClick={() => setMenu(false)}>
|
||
<img className="lp-logo-mark" src="/assets/RecipesCamIcon.png" alt="" width="30" height="30" />
|
||
{/* One span, not two siblings: .lp-logo is a flex row, so a bare
|
||
text node + <em> would get the 6px gap between them. */}
|
||
<span className="lp-logo-word">Recipes<em>Cam</em></span>
|
||
</a>
|
||
<span className="lp-spacer" />
|
||
{/* The browser studio is live today, so it leads the row and wears
|
||
the same primary pill as the store CTA. */}
|
||
<a className="lp-btn lp-sm lp-primary" data-key="nav-studio" href="/app">
|
||
{c({ en: 'RecipesCam web studio', vi: 'Studio web RecipesCam' })}
|
||
</a>
|
||
<a
|
||
className="lp-btn lp-sm lp-primary"
|
||
data-key="download-play"
|
||
href={STORE_LINKS.play}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
{c({ en: 'Download RecipesCam', vi: 'Tải RecipesCam' })}
|
||
</a>
|
||
{/* Theme + language, the same two switches the workspace TopBar has.
|
||
Theme opens a popover: light/dark plus the font group, which
|
||
retypes the whole page. */}
|
||
<span className="lp-tools" ref={tools}>
|
||
<div className="menu-wrap">
|
||
<button
|
||
type="button"
|
||
className="lp-tool"
|
||
title={t('nav.theme')}
|
||
aria-label={t('nav.theme')}
|
||
aria-expanded={themeMenu}
|
||
onClick={() => setThemeMenu((v) => !v)}
|
||
>
|
||
◐
|
||
</button>
|
||
{themeMenu ? (
|
||
<div className="popover lp-theme-pop">
|
||
<div className="chip-row">
|
||
<button
|
||
type="button"
|
||
className={`chip${mode === 'light' ? ' on' : ''}`}
|
||
onClick={() => setMode('light')}
|
||
>
|
||
<span className="chip-label">Light</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`chip${mode === 'dark' ? ' on' : ''}`}
|
||
onClick={() => setMode('dark')}
|
||
>
|
||
<span className="chip-label">Dark</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`chip${mode === 'auto' ? ' on' : ''}`}
|
||
data-key="theme-auto"
|
||
onClick={() => setMode('auto')}
|
||
>
|
||
<span className="chip-label">Auto</span>
|
||
</button>
|
||
</div>
|
||
{/* The same colour groups the workspace offers; the page's own
|
||
amber/red palette follows the chosen hue. */}
|
||
<div className="chip-row" style={{ marginTop: 8 }}>
|
||
{ACCENTS.map((entry) => (
|
||
<button
|
||
key={entry.id}
|
||
type="button"
|
||
data-key={`accent-${entry.id}`}
|
||
className={`chip${accent === entry.id ? ' on' : ''}`}
|
||
onClick={() => setAccent(entry.id)}
|
||
>
|
||
<span className="chip-label">{entry.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<select
|
||
className="chip font-select"
|
||
data-key="fonts"
|
||
title={FONT_SETS.find((f) => f.id === fonts)?.note}
|
||
value={fonts}
|
||
onChange={(e) => setFonts(e.target.value as FontSetId)}
|
||
>
|
||
{FONT_SETS.map((entry) => (
|
||
<option key={entry.id} value={entry.id} title={entry.note}>{entry.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<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>
|
||
{/* Sign in is the default: the dialog opens here, on the page the
|
||
visitor is on, and flips to sign-up from its own toggle. Once
|
||
signed in the same slot names the account, so the session is
|
||
visible on the page that took the credentials. */}
|
||
{user ? (
|
||
<span className="menu-wrap" ref={account}>
|
||
<button
|
||
type="button"
|
||
className="lp-btn lp-sm lp-account"
|
||
data-key="nav-account"
|
||
title={user.email}
|
||
aria-expanded={accountMenu}
|
||
onClick={() => setAccountMenu((v) => !v)}
|
||
>
|
||
<Avatar user={user} small />
|
||
{user.email.split('@')[0]}
|
||
</button>
|
||
{accountMenu ? (
|
||
<div className="popover lp-theme-pop lp-acc-pop">
|
||
<p className="hint">{user.email}</p>
|
||
{user.admin ? (
|
||
<a className="btn" data-key="nav-admin" href="/admin">{t('nav.admin')}</a>
|
||
) : (
|
||
<a className="btn" data-key="nav-profile" href="/profile">{t('nav.profile')}</a>
|
||
)}
|
||
<button type="button" className="btn" data-key="nav-logout" onClick={signOut}>
|
||
{t('nav.logout')}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</span>
|
||
) : (
|
||
<button type="button" className="lp-btn lp-sm" data-key="nav-login" onClick={() => setAuth('login')}>
|
||
{t('nav.login')}
|
||
</button>
|
||
)}
|
||
<button type="button" className="lp-burger" aria-label="menu" aria-expanded={menu} onClick={() => setMenu((v) => !v)}>
|
||
<i /><i /><i />
|
||
</button>
|
||
</div>
|
||
{/* The six section jumps live on their own shelf under the bar: the
|
||
top row stays a row of actions, and each jump wears a border so it
|
||
reads as a button rather than a word in a menu. */}
|
||
<div className="lp-subnav" data-key="subnav">
|
||
<div className="lp-wrap">
|
||
{nav.map((entry) => (
|
||
<a key={entry.href} className="lp-subnav-link" href={entry.href}>{c(entry.label)}</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{/* The trial notice: the last shelf of the bar, under the menu line. It
|
||
scrolls because it is one long sentence that would otherwise wrap on
|
||
a phone and stand as a paragraph. One copy only, and it walks the
|
||
whole way — in past the right edge, out past the left — so exactly
|
||
one sentence is in flight at any moment, phone included. */}
|
||
<div className="lp-notice" role="status" data-key="trial-notice">
|
||
<div className="lp-notice-track">
|
||
<span>{t('notice.trial')}</span>
|
||
</div>
|
||
</div>
|
||
{menu ? (
|
||
<div className="lp-sheet">
|
||
{nav.map((entry) => <a key={entry.href} href={entry.href} onClick={() => setMenu(false)}>{c(entry.label)}</a>)}
|
||
<a href="/app" onClick={() => setMenu(false)}>{c({ en: 'Open studio', vi: 'Mở studio' })}</a>
|
||
{user ? (
|
||
<a className="lp-btn" data-key="sheet-account" href={user.admin ? '/admin' : '/profile'} onClick={() => setMenu(false)}>
|
||
{user.email.split('@')[0]}
|
||
</a>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="lp-btn"
|
||
data-key="sheet-login"
|
||
onClick={() => { setMenu(false); setAuth('login'); }}
|
||
>
|
||
{t('nav.login')}
|
||
</button>
|
||
)}
|
||
<a
|
||
className="lp-btn lp-primary"
|
||
data-key="download-play"
|
||
href={STORE_LINKS.play}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
onClick={() => setMenu(false)}
|
||
>
|
||
{c({ en: 'Download RecipesCam', vi: 'Tải RecipesCam' })}
|
||
</a>
|
||
</div>
|
||
) : null}
|
||
</nav>
|
||
|
||
{/* 2 — hero. The copy, and beside it the award column: the day's and the
|
||
week's best-rated contributions from the reel, stepped by hand or by
|
||
itself. The split only exists while there is an award to show, so a
|
||
page nobody has voted on keeps its one-column hero. */}
|
||
<header className="lp-hero" id="top">
|
||
<div className="lp-glow" />
|
||
<div className={`lp-wrap${award ? ' lp-hero-split' : ''}`}>
|
||
<div className="lp-hero-copy">
|
||
<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>{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">
|
||
{c({
|
||
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.',
|
||
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>
|
||
<div className="lp-cta">
|
||
<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">{c({ en: 'Try Color Presets', vi: 'Thử công thức màu' })}</a>
|
||
</div>
|
||
<div className="lp-stats">
|
||
<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>{c({ en: 'DOWNLOADS', vi: 'LƯỢT TẢI' })}</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>
|
||
{award ? (
|
||
<aside className="lp-award" data-key="lp-award">
|
||
{/* The award is the frame's own: today's winner wears the day's,
|
||
a frame only this week's votes name wears the week's, and one
|
||
no window named wears the all-time label. */}
|
||
<span className="lp-award-kind" data-key="lp-award-kind">
|
||
{c(AWARD_LABEL[award.kind])}
|
||
</span>
|
||
{/* Every frame of the column is drawn and only the one standing is
|
||
opaque, so the swap IS the two fades — no state machine on top
|
||
of a transition the stylesheet can do alone. */}
|
||
<div className="lp-award-view" data-key="lp-award-view">
|
||
{awards.map((entry, i) => (
|
||
<span
|
||
key={entry.photo.id}
|
||
className={`lp-award-slide${i === awardIdx % awards.length ? ' on' : ''}`}
|
||
data-key={`lp-award-slide-${entry.photo.id}`}
|
||
>
|
||
<Shot
|
||
src={api.photoUrl(entry.photo.id)}
|
||
alt={entry.photo.title ?? c({ en: 'Award frame', vi: 'Khung được thưởng' })}
|
||
/>
|
||
</span>
|
||
))}
|
||
<FrameArrows
|
||
count={awards.length}
|
||
id="lp-award"
|
||
labels={{ prev: c({ en: 'Previous frame', vi: 'Khung trước' }), next: c({ en: 'Next frame', vi: 'Khung sau' }) }}
|
||
onStep={(d) => setAwardIdx((i) => i + d)}
|
||
/>
|
||
<span className="lp-award-score" data-key="lp-award-score">
|
||
<b>{award.avg.toFixed(1)}★</b>
|
||
{award.n === 1
|
||
? c({ en: '1 vote', vi: '1 phiếu' })
|
||
: c({ en: `${award.n} votes`, vi: `${award.n} phiếu` })}
|
||
</span>
|
||
</div>
|
||
<div className="lp-award-meta">
|
||
<b data-key="lp-award-recipe">{award.photo.tag ?? `#${award.photo.id}`}</b>
|
||
<span data-key="lp-award-title">{award.photo.title ?? `#${award.photo.id}`}</span>
|
||
{award.photo.meta ? <span data-key="lp-award-meta">{award.photo.meta}</span> : null}
|
||
</div>
|
||
</aside>
|
||
) : null}
|
||
</div>
|
||
</header>
|
||
|
||
{/* 3 — 35mm film strip marquee */}
|
||
<section className="lp-marquee" id="film" aria-label={c({ en: 'Film strip gallery', vi: 'Dải phim' })}>
|
||
<div className="lp-wrap lp-film-head">
|
||
<div>
|
||
<span className="lp-eyebrow">{c({ en: 'Community film strip', vi: 'Dải phim cộng đồng' })}</span>
|
||
<h2>{c({ en: 'Shoot it, grade it, hang it on the reel', vi: 'Chụp, chỉnh màu, rồi treo lên dải phim' })}</h2>
|
||
<p>{c({ en: 'Every still here is picked and placed by the studio — this reel is curated, not crowd-sourced.', vi: 'Mọi khung ảnh ở đây đều do studio chọn và đặt — dải phim này được tuyển chọn, không phải ai cũng tự thêm được.' })}</p>
|
||
</div>
|
||
</div>
|
||
<div className="lp-track" ref={track}>
|
||
{[...loop, ...loop].map((look, i) => {
|
||
const score = ratings[look.rateKey] ?? { avg: 0, n: 0, mine: 0 };
|
||
return (
|
||
<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">
|
||
<Shot src={look.photo} alt={c(look.title)} filter={look.filter} />
|
||
<span className="lp-grain" aria-hidden="true" />
|
||
<span className="lp-tagline">{look.tag}</span>
|
||
</div>
|
||
<div className="lp-frame-meta">
|
||
<b>{c(look.title)}</b>
|
||
<span>{look.meta}</span>
|
||
{/* The tally first, then the row that changes it: a visitor
|
||
reads the score, then casts one of their own. Clicking
|
||
another star replaces the vote instead of adding one. */}
|
||
<div className="lp-rate" data-key={`lp-rate-${look.rateKey}`}>
|
||
<b className="lp-rate-index" data-key={`lp-rate-index-${look.rateKey}`}>
|
||
{score.avg.toFixed(1)}
|
||
</b>
|
||
<span
|
||
className="lp-stars"
|
||
role="group"
|
||
aria-label={c({ en: 'Rate this frame', vi: 'Đánh giá khung này' })}
|
||
>
|
||
{[1, 2, 3, 4, 5].map((n) => (
|
||
<button
|
||
type="button"
|
||
key={n}
|
||
className={`lp-star${score.mine >= n ? ' on' : ''}`}
|
||
data-key={`lp-star-${look.rateKey}-${n}`}
|
||
aria-label={`${n}/5`}
|
||
onClick={() => void rate(look.rateKey, n)}
|
||
>
|
||
★
|
||
</button>
|
||
))}
|
||
</span>
|
||
<span className="lp-rate-count" data-key={`lp-rate-count-${look.rateKey}`}>
|
||
({score.n})
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="lp-perf" aria-hidden="true">
|
||
<i /><i /><i /><i /><i /><i />
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
{/* 4 — live preset tester */}
|
||
<section className="lp-sec" id="presets">
|
||
<div className="lp-wrap">
|
||
<div className="lp-sec-head">
|
||
<span className="lp-eyebrow">{c({ en: 'Live preset tester', vi: 'Thử công thức trực tiếp' })}</span>
|
||
<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>{c({ en: 'Fifteen stocks across landscape, portrait and streetlife, applied to the same frame with the same colour maths the exporter uses.', vi: 'Mười lăm loại phim cho phong cảnh, chân dung và đường phố, á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 className="lp-tester">
|
||
<div>
|
||
<label className="lp-preset-pick">
|
||
<span className="lp-eyebrow">{c({ en: 'Genre & recipe', vi: 'Thể loại & công thức' })}</span>
|
||
<select
|
||
className="lp-select"
|
||
data-key="lp-preset"
|
||
aria-label={c({ en: 'Color presets', vi: 'Công thức màu' })}
|
||
value={presetId}
|
||
onChange={(e) => setPresetId(e.target.value)}
|
||
>
|
||
{PRESET_GROUPS.map((g) => (
|
||
<optgroup key={g.id} label={c(g.label)}>
|
||
{PRESETS.filter((p) => p.group === g.id).map((p) => (
|
||
<option key={p.id} value={p.id}>{p.label}</option>
|
||
))}
|
||
</optgroup>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<div className="lp-view">
|
||
{slotPhoto.testers.length > 0 && (
|
||
<Shot
|
||
src={slotPhoto.testers[(slotPhoto.testerStart + testerIdx) % slotPhoto.testers.length]}
|
||
alt={`${preset.name} preview`}
|
||
filter={preset.filter}
|
||
/>
|
||
)}
|
||
<FrameArrows
|
||
count={slotPhoto.testers.length}
|
||
id="lp-tester"
|
||
labels={{ prev: c({ en: 'Previous frame', vi: 'Khung trước' }), next: c({ en: 'Next frame', vi: 'Khung sau' }) }}
|
||
onStep={(d) => setTesterIdx((i) => (i + d + slotPhoto.testers.length) % slotPhoto.testers.length)}
|
||
/>
|
||
<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">{c({ en: 'Recipe specs', vi: 'Thông số công thức' })}</span>
|
||
<h3>{preset.name}</h3>
|
||
<p>{c(preset.desc)}</p>
|
||
<div className="lp-bars">
|
||
{preset.metrics.map((value, i) => (
|
||
<div className="lp-bar" key={BAR_LABELS[i].en}>
|
||
<span>{c(BAR_LABELS[i])}<em className="lp-muted">{value}</em></span>
|
||
<i style={{ ['--v' as string]: `${value}%` }} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 5 — custom recipe simulator */}
|
||
<section className="lp-sec" id="creator">
|
||
<div className="lp-wrap">
|
||
<div className="lp-sec-head">
|
||
<span className="lp-eyebrow">{c({ en: 'Custom recipe creator', vi: 'Tạo công thức riêng' })}</span>
|
||
<h2>{c({ en: 'Build your own grade in three moves', vi: 'Tự dựng công thức trong ba bước' })}</h2>
|
||
<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 className="lp-lab">
|
||
<div className="lp-view">
|
||
{slotPhoto.creators.length > 0 && (
|
||
<Shot
|
||
src={slotPhoto.creators[(slotPhoto.creatorStart + creatorIdx) % slotPhoto.creators.length]}
|
||
alt={c({ en: 'Custom recipe preview', vi: 'Xem trước công thức riêng' })}
|
||
filter={labFilter}
|
||
/>
|
||
)}
|
||
<FrameArrows
|
||
count={slotPhoto.creators.length}
|
||
id="lp-creator"
|
||
labels={{ prev: c({ en: 'Previous frame', vi: 'Khung trước' }), next: c({ en: 'Next frame', vi: 'Khung sau' }) }}
|
||
onStep={(d) => setCreatorIdx((i) => (i + d + slotPhoto.creators.length) % slotPhoto.creators.length)}
|
||
/>
|
||
<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 className="lp-knobs">
|
||
<div className="lp-knob">
|
||
<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))} />
|
||
</div>
|
||
<div className="lp-knob">
|
||
<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))} />
|
||
</div>
|
||
<div className="lp-knob">
|
||
<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))} />
|
||
</div>
|
||
<div className="lp-code">
|
||
<div>RECIPE CUSTOM_01 <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); }}
|
||
>
|
||
{c({ en: 'Reset knobs', vi: 'Đặt lại' })}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 6 — features */}
|
||
<section className="lp-sec" id="features">
|
||
<div className="lp-wrap">
|
||
<div className="lp-sec-head">
|
||
<span className="lp-eyebrow">{c({ en: 'Features', vi: 'Tính năng' })}</span>
|
||
<h2>{c({ en: 'Everything the phone build ships with', vi: 'Mọi thứ bản điện thoại có' })}</h2>
|
||
</div>
|
||
<div className="lp-grid">
|
||
{FEATURES.map((f) => (
|
||
<article className="lp-feat" key={f.title.en}>
|
||
<Icon name={f.icon} />
|
||
<h3>{c(f.title)}</h3>
|
||
<p>{c(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">
|
||
{curQr && (
|
||
<Shot
|
||
src={curQr.url}
|
||
alt={c({ en: 'Shared recipe card', vi: 'Thẻ công thức được chia sẻ' })}
|
||
filter={(PRESETS.find((p) => p.id === 'sunset') ?? PRESETS[0]).filter}
|
||
/>
|
||
)}
|
||
<span className="lp-grain" aria-hidden="true" />
|
||
<span className="lp-tagline">{curQr?.tag ?? '#SUNSET_GLOW'}</span>
|
||
<FrameArrows
|
||
count={slotPhoto.qrs.length}
|
||
id="lp-qr"
|
||
labels={{ prev: c({ en: 'Previous frame', vi: 'Khung trước' }), next: c({ en: 'Next frame', vi: 'Khung sau' }) }}
|
||
onStep={(d) => setQrIdx((i) => (i + d + slotPhoto.qrs.length) % slotPhoto.qrs.length)}
|
||
/>
|
||
</div>
|
||
<div className="lp-rc-row">
|
||
<div className="lp-rc-qr">
|
||
<QrCode url={curQr ? curQr.link : `${window.location.origin}/app`} />
|
||
</div>
|
||
<div className="lp-rc-meta">
|
||
<b>{curQr?.title ?? 'SUNSET GLOW'}</b>
|
||
<span>{curQr?.meta ?? 'WARMTH 88 · GRAIN 28 · BLOOM 61'}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span className="lp-eyebrow">{c({ en: 'Share by QR', vi: 'Chia sẻ bằng QR' })}</span>
|
||
<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-step">
|
||
<span>1</span>
|
||
<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 className="lp-step">
|
||
<span>2</span>
|
||
<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>
|
||
</section>
|
||
|
||
{/* 8 — pricing */}
|
||
<section className="lp-sec" id="pricing">
|
||
<div className="lp-wrap">
|
||
<div className="lp-sec-head">
|
||
<span className="lp-eyebrow">{c({ en: 'Pricing', vi: 'Bảng giá' })}</span>
|
||
<h2>{c({ en: 'Free to use, forever. Upgrade to Pro only when you\'re ready for more.', vi: 'Miễn phí dùng mãi. Chỉ lên Pro khi bạn cần nhiều hơn.' })}</h2>
|
||
</div>
|
||
<div className="lp-price">
|
||
<article className="lp-plan">
|
||
<span className="lp-eyebrow">{c({ en: 'Lite', vi: 'Lite' })}</span>
|
||
<div className="lp-amount">$0<small>{c({ en: ' / FOREVER', vi: ' / MÃI MÃI' })}</small></div>
|
||
<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>
|
||
<li>{c({ en: '15 basic recipes', vi: '15 công thức cơ bản' })}</li>
|
||
<li>{c({ en: 'HD live viewfinder', vi: 'Ngắm trực tiếp HD' })}</li>
|
||
<li>{c({ en: 'Basic grain adjustment', vi: 'Chỉnh hạt phim cơ bản' })}</li>
|
||
<li>{c({ en: 'Print-ready JPEG export', vi: 'Xuất JPEG sẵn sàng in' })}</li>
|
||
</ul>
|
||
<div className="lp-plan-cta">
|
||
<a className="lp-btn" href="/app">{c({ en: 'Open the studio', vi: 'Mở studio' })}</a>
|
||
<a
|
||
className="lp-btn"
|
||
data-key="download-play"
|
||
href={STORE_LINKS.play}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
{c({ en: 'Get it on Google Play', vi: 'Tải trên Google Play' })}
|
||
</a>
|
||
</div>
|
||
</article>
|
||
<article className="lp-plan vip">
|
||
<span className="lp-eyebrow">Pro</span>
|
||
<div className="lp-amount">$0.99<small>{c({ en: ' / FOREVER', vi: ' / MÃI MÃI' })}</small></div>
|
||
<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>
|
||
<li>{c({ en: '250+ VIP recipes', vi: '250+ công thức VIP' })}</li>
|
||
<li>{c({ en: 'Batch processing', vi: 'Xử lý hàng loạt' })}</li>
|
||
<li>{c({ en: 'Tone curve & HSL tools', vi: 'Công cụ tone curve & HSL' })}</li>
|
||
<li>{c({ en: 'Lossless RAW & 4K export', vi: 'Xuất RAW & 4K không mất dữ liệu' })}</li>
|
||
<li>{c({ en: 'Unlimited QR generation', vi: 'Tạo QR không giới hạn' })}</li>
|
||
</ul>
|
||
<div className="lp-plan-cta">
|
||
<a
|
||
className="lp-btn"
|
||
data-key="download-play"
|
||
href={STORE_LINKS.play}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
{c({ en: 'Get it on Google Play', vi: 'Tải trên Google Play' })}
|
||
</a>
|
||
<button type="button" className="lp-btn" data-key="download-appstore" onClick={() => showToast(toastStore)}>
|
||
{c({ en: 'Download on the App Store', vi: 'Tải trên App Store' })}
|
||
</button>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 9 — about */}
|
||
<section className="lp-sec" id="about">
|
||
<div className="lp-wrap">
|
||
<div className="lp-sec-head">
|
||
<span className="lp-eyebrow">{c({ en: 'About', vi: 'Giới thiệu' })}</span>
|
||
<h2>{c(ABOUT.lead)}</h2>
|
||
<p>{c(ABOUT.welcome)}</p>
|
||
</div>
|
||
<div className="lp-grid">
|
||
{ABOUT.blocks.map((b) => (
|
||
<article className="lp-feat" key={b.title.en}>
|
||
<h3>{c(b.title)}</h3>
|
||
{b.body.map((p) => <p key={p.en}>{c(p)}</p>)}
|
||
{b.list ? (
|
||
<>
|
||
<p className="lp-eyebrow" style={{ marginTop: 16 }}>{c(b.list.label)}</p>
|
||
<div className="lp-steps" style={{ marginTop: 14 }}>
|
||
{b.list.items.map((item, i) => (
|
||
<div className="lp-step" key={item.title.en}>
|
||
<span>{i + 1}</span>
|
||
<p><b>{c(item.title)}</b> {c(item.text)}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</article>
|
||
))}
|
||
</div>
|
||
<p className="lp-muted" style={{ marginTop: 34 }}>
|
||
{c(ABOUT.contact)}{' '}
|
||
<a href={`mailto:${ABOUT.email}`}>{ABOUT.email}</a>
|
||
</p>
|
||
<p className="lp-muted" style={{ marginTop: 10 }}><em>{c(ABOUT.closing)}</em></p>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 10 — testimonials + FAQ */}
|
||
<section className="lp-sec">
|
||
<div className="lp-wrap">
|
||
<div className="lp-sec-head">
|
||
<span className="lp-eyebrow">{c({ en: 'Reviews', vi: 'Đánh giá' })}</span>
|
||
<h2>{c({ en: 'Shot on RecipesCam', vi: 'Chụp bằng RecipesCam' })}</h2>
|
||
</div>
|
||
<div className="lp-quotes">
|
||
{QUOTES.map((q) => (
|
||
<article className="lp-quote" key={q.name}>
|
||
<p>“{c(q.text)}”</p>
|
||
<footer><b>{q.name}</b>{c(q.role)}</footer>
|
||
</article>
|
||
))}
|
||
</div>
|
||
<div className="lp-sec-head">
|
||
<span className="lp-eyebrow">FAQ</span>
|
||
<h2>{c({ en: 'Questions we get every week', vi: 'Câu hỏi tuần nào cũng có' })}</h2>
|
||
</div>
|
||
<div className="lp-faq">
|
||
{FAQ.map((item) => (
|
||
<details key={item.q.en}>
|
||
<summary>{c(item.q)}</summary>
|
||
<p>{c(item.a)}</p>
|
||
</details>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 11 — final CTA + footer */}
|
||
<section className="lp-final" id="download">
|
||
<div className="lp-glow" />
|
||
<div className="lp-wrap">
|
||
<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>{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">
|
||
<button type="button" className="lp-btn" data-key="download-appstore" onClick={() => showToast(toastStore)}>
|
||
<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>
|
||
<a
|
||
className="lp-btn"
|
||
data-key="download-play"
|
||
href={STORE_LINKS.play}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<span className="lp-store"><small>{c({ en: 'AVAILABLE NOW', vi: 'ĐÃ CÓ' })}</small><b>{c({ en: 'Get it on Google Play', vi: 'Tải trên Google Play' })}</b></span>
|
||
</a>
|
||
<a className="lp-btn lp-primary" href="/app">{c({ en: 'Open the Web Studio', vi: 'Mở Web Studio' })}</a>
|
||
</div>
|
||
{/* Share targets: the four networks take the URL as a query param,
|
||
the last control uses the OS sheet (phones) or the clipboard. */}
|
||
<div className="lp-share" data-key="share-row">
|
||
<span className="lp-share-label">{c({ en: 'SHARE THE PAGE', vi: 'CHIA SẺ TRANG' })}</span>
|
||
{shareLinks.map((s) => (
|
||
<a
|
||
key={s.id}
|
||
className="lp-btn lp-sm"
|
||
data-key={`share-${s.id}`}
|
||
href={s.href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
{s.label}
|
||
</a>
|
||
))}
|
||
<button type="button" className="lp-btn lp-sm" data-key="share-native" onClick={() => void share()}>
|
||
{c({ en: 'SHARE…', vi: 'CHIA SẺ…' })}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<footer className="lp-footer">
|
||
<div className="lp-wrap">
|
||
<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">{c({ en: 'Privacy', vi: 'Quyền riêng tư' })}</a>
|
||
<a href="#top">{c({ en: 'Terms', vi: 'Điều khoản' })}</a>
|
||
<a href={STORE_LINKS.play}>{c({ en: 'Support', vi: 'Hỗ trợ' })}</a>
|
||
<span className="lp-social">
|
||
<a href="#top">INSTAGRAM</a>
|
||
<a href="#top">TIKTOK</a>
|
||
<a href="#top">PINTEREST</a>
|
||
</span>
|
||
</div>
|
||
</footer>
|
||
|
||
{auth ? (
|
||
<AuthModal
|
||
initialMode={auth}
|
||
email={user?.email}
|
||
onClose={() => setAuth(null)}
|
||
onDone={() => {
|
||
setAuth(null);
|
||
// An admin signs in here to moderate, so send them on to the page
|
||
// that asked for the session in the first place.
|
||
reload().then((u) => u?.admin && window.location.assign('/admin'));
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{toast ? <div className="lp-toast" role="status"><b>{toast.lead}</b> {toast.text}</div> : null}
|
||
</div>
|
||
);
|
||
}
|