chore: initialize project
Some checks failed
Deploy monie-landing (kaniko) / build-and-deploy (push) Failing after 9m46s

This commit is contained in:
2026-04-03 16:32:58 +03:00
commit 49b976c53f
79 changed files with 12499 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
export { LocaleProvider } from "#/shared/lib/i18n";
export { PostHogProvider } from "./posthog-provider";

View File

@@ -0,0 +1,20 @@
import { PostHogProvider as BasePostHogProvider } from "@posthog/react";
import posthog from "posthog-js";
import type { ReactNode } from "react";
if (typeof window !== "undefined" && import.meta.env.VITE_POSTHOG_KEY) {
posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
api_host: import.meta.env.VITE_POSTHOG_HOST || "https://us.i.posthog.com",
person_profiles: "identified_only",
capture_pageview: false,
defaults: "2025-11-30",
});
}
interface PostHogProviderProps {
children: ReactNode;
}
export function PostHogProvider({ children }: PostHogProviderProps) {
return <BasePostHogProvider client={posthog}>{children}</BasePostHogProvider>;
}

269
src/app/styles/index.css Normal file
View File

@@ -0,0 +1,269 @@
@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Manrope:wght@400;500;600;700;800&display=swap");
@import "denjs-ui/styles.css";
@import "tailwindcss";
@import "../../styles/tokens.css";
@import "../../styles/light.css";
@import "../../styles/dark.css";
@plugin "@tailwindcss/typography";
@theme {
--font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif;
}
:root {
--sea-ink: #173a40;
--sea-ink-soft: #416166;
--lagoon: #4fb8b2;
--lagoon-deep: #328f97;
--coral: #e68061;
--palm: #2f6a4a;
--sand: #e7f0e8;
--foam: #f3faf5;
--glass-surface: rgba(255, 255, 255, 0.74);
--surface-strong: rgba(255, 255, 255, 0.9);
--line: rgba(23, 58, 64, 0.14);
--inset-glint: rgba(255, 255, 255, 0.82);
--kicker: rgba(47, 106, 74, 0.9);
--bg-base: #e7f3ec;
--header-bg: rgba(251, 255, 248, 0.84);
--chip-bg: rgba(255, 255, 255, 0.8);
--chip-line: rgba(47, 106, 74, 0.18);
--link-bg-hover: rgba(255, 255, 255, 0.9);
--hero-a: rgba(79, 184, 178, 0.36);
--hero-b: rgba(47, 106, 74, 0.2);
}
:root[data-theme="dark"] {
--sea-ink: #d7ece8;
--sea-ink-soft: #afcdc8;
--lagoon: #60d7cf;
--lagoon-deep: #8de5db;
--palm: #6ec89a;
--sand: #0f1a1e;
--foam: #101d22;
--glass-surface: rgba(16, 30, 34, 0.8);
--surface-strong: rgba(15, 27, 31, 0.92);
--line: rgba(141, 229, 219, 0.18);
--inset-glint: rgba(194, 247, 238, 0.14);
--kicker: #b8efe5;
--bg-base: #0a1418;
--header-bg: rgba(10, 20, 24, 0.8);
--chip-bg: rgba(13, 28, 32, 0.9);
--chip-line: rgba(141, 229, 219, 0.24);
--link-bg-hover: rgba(24, 44, 49, 0.8);
--hero-a: rgba(96, 215, 207, 0.18);
--hero-b: rgba(110, 200, 154, 0.12);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--sea-ink: #d7ece8;
--sea-ink-soft: #afcdc8;
--lagoon: #60d7cf;
--lagoon-deep: #8de5db;
--palm: #6ec89a;
--sand: #0f1a1e;
--foam: #101d22;
--glass-surface: rgba(16, 30, 34, 0.8);
--surface-strong: rgba(15, 27, 31, 0.92);
--line: rgba(141, 229, 219, 0.18);
--inset-glint: rgba(194, 247, 238, 0.14);
--kicker: #b8efe5;
--bg-base: #0a1418;
--header-bg: rgba(10, 20, 24, 0.8);
--chip-bg: rgba(13, 28, 32, 0.9);
--chip-line: rgba(141, 229, 219, 0.24);
--link-bg-hover: rgba(24, 44, 49, 0.8);
--hero-a: rgba(96, 215, 207, 0.18);
--hero-b: rgba(110, 200, 154, 0.12);
}
}
* {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 86px;
}
body {
margin: 0;
color: var(--sea-ink);
font-family: var(--font-sans);
background:
radial-gradient(1100px 620px at -8% -10%, var(--hero-a), transparent 58%),
radial-gradient(1050px 620px at 112% -12%, var(--hero-b), transparent 62%),
radial-gradient(
720px 380px at 50% 115%,
rgba(79, 184, 178, 0.1),
transparent 68%
),
linear-gradient(
180deg,
color-mix(in oklab, var(--sand) 68%, white) 0%,
var(--foam) 44%,
var(--bg-base) 100%
);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
opacity: 0.28;
background:
radial-gradient(
circle at 20% 15%,
rgba(255, 255, 255, 0.8),
transparent 34%
),
radial-gradient(circle at 78% 26%, rgba(79, 184, 178, 0.2), transparent 42%),
radial-gradient(circle at 42% 82%, rgba(47, 106, 74, 0.14), transparent 36%);
}
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
opacity: 0.14;
background-image:
linear-gradient(rgba(255, 255, 255, 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: radial-gradient(circle at 50% 30%, black, transparent 78%);
}
a:not([class]) {
color: var(--lagoon-deep);
text-decoration-color: rgba(50, 143, 151, 0.4);
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
a:not([class]):hover {
color: #246f76;
}
code {
font-size: 0.9em;
border: 1px solid var(--line);
background: color-mix(in oklab, var(--surface-strong) 82%, white 18%);
border-radius: 7px;
padding: 2px 7px;
}
pre code {
border: 0;
background: transparent;
padding: 0;
border-radius: 0;
font-size: inherit;
color: inherit;
}
.page-wrap {
width: min(1080px, calc(100% - 2rem));
margin-inline: auto;
}
.display-title {
font-family: "Fraunces", Georgia, serif;
}
.island-shell {
border: 1px solid var(--line);
background: linear-gradient(
165deg,
var(--surface-strong),
var(--glass-surface)
);
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 22px 44px rgba(30, 90, 72, 0.1),
0 6px 18px rgba(23, 58, 64, 0.08);
backdrop-filter: blur(4px);
}
.feature-card {
background: linear-gradient(
165deg,
color-mix(in oklab, var(--surface-strong) 93%, white 7%),
var(--glass-surface)
);
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 18px 34px rgba(30, 90, 72, 0.1),
0 4px 14px rgba(23, 58, 64, 0.06);
}
.feature-card:hover {
transform: translateY(-2px);
border-color: color-mix(in oklab, var(--lagoon-deep) 35%, var(--line));
}
.metric-card {
border: 1px solid color-mix(in oklab, var(--line) 82%, white 18%);
background: linear-gradient(
165deg,
color-mix(in oklab, white 82%, var(--lagoon) 18%),
color-mix(in oklab, white 90%, var(--coral) 10%)
);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.85) inset,
0 16px 28px rgba(23, 58, 64, 0.09);
}
button,
.island-shell,
a {
transition:
background-color 180ms ease,
color 180ms ease,
border-color 180ms ease,
transform 180ms ease;
}
.island-kicker {
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 700;
font-size: 0.69rem;
color: var(--kicker);
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.rise-in {
animation: rise-in 560ms var(--ease) both;
}
@media (prefers-reduced-motion: reduce) {
.rise-in {
animation: none;
}
}

View File

@@ -0,0 +1 @@
export * from "./model/content";

View File

@@ -0,0 +1,148 @@
import type { m } from "#/paraglide/messages";
type Messages = typeof m;
export type MetricItem = {
value: string;
label: string;
};
export type ContentCardItem = {
title: string;
text: string;
};
export type StepItem = {
title: string;
text: string;
};
export type FaqItem = {
value: string;
title: string;
content: string;
};
export function getHeroMetrics(messages: Messages): MetricItem[] {
return [
{ value: "24/7", label: messages.hero_metric_label_1() },
{ value: "-18%", label: messages.hero_metric_label_2() },
{ value: "+26%", label: messages.hero_metric_label_3() },
];
}
export function getProblems(messages: Messages): ContentCardItem[] {
return [
{
title: messages.problem_card_1_title(),
text: messages.problem_card_1_text(),
},
{
title: messages.problem_card_2_title(),
text: messages.problem_card_2_text(),
},
{
title: messages.problem_card_3_title(),
text: messages.problem_card_3_text(),
},
];
}
export function getSolutionItems(messages: Messages): string[] {
return [
messages.solution_item_1(),
messages.solution_item_2(),
messages.solution_item_3(),
messages.solution_item_4(),
messages.solution_item_5(),
];
}
export function getBenefits(messages: Messages): ContentCardItem[] {
return [
{
title: messages.benefit_card_1_title(),
text: messages.benefit_card_1_text(),
},
{
title: messages.benefit_card_2_title(),
text: messages.benefit_card_2_text(),
},
{
title: messages.benefit_card_3_title(),
text: messages.benefit_card_3_text(),
},
{
title: messages.benefit_card_4_title(),
text: messages.benefit_card_4_text(),
},
];
}
export function getSteps(messages: Messages): StepItem[] {
return [
{
title: messages.how_step_1_title(),
text: messages.how_step_1_text(),
},
{
title: messages.how_step_2_title(),
text: messages.how_step_2_text(),
},
{
title: messages.how_step_3_title(),
text: messages.how_step_3_text(),
},
{
title: messages.how_step_4_title(),
text: messages.how_step_4_text(),
},
];
}
export function getTrustItems(messages: Messages): ContentCardItem[] {
return [
{
title: messages.trust_card_1_title(),
text: messages.trust_card_1_text(),
},
{
title: messages.trust_card_2_title(),
text: messages.trust_card_2_text(),
},
{
title: messages.trust_card_3_title(),
text: messages.trust_card_3_text(),
},
];
}
export function getFaqItems(messages: Messages): FaqItem[] {
return [
{
value: "need-website",
title: messages.faq_item_1_title(),
content: messages.faq_item_1_text(),
},
{
value: "services-prices",
title: messages.faq_item_2_title(),
content: messages.faq_item_2_text(),
},
{
value: "online-booking",
title: messages.faq_item_3_title(),
content: messages.faq_item_3_text(),
},
{
value: "solo-only",
title: messages.faq_item_4_title(),
content: messages.faq_item_4_text(),
},
{
value: "mobile",
title: messages.faq_item_5_title(),
content: messages.faq_item_5_text(),
},
];
}

39
src/env.ts Normal file
View File

@@ -0,0 +1,39 @@
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const env = createEnv({
server: {
SERVER_URL: z.string().url().optional(),
},
/**
* The prefix that client-side variables must have. This is enforced both at
* a type-level and at runtime.
*/
clientPrefix: "VITE_",
client: {
VITE_APP_TITLE: z.string().min(1).optional(),
},
/**
* What object holds the environment variables at runtime. This is usually
* `process.env` or `import.meta.env`.
*/
runtimeEnv: import.meta.env,
/**
* By default, this library will feed the environment variables directly to
* the Zod validator.
*
* This means that if you have an empty string for a value that is supposed
* to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag
* it as a type mismatch violation. Additionally, if you have an empty string
* for a value that is supposed to be a string with a default value (e.g.
* `DOMAIN=` in an ".env" file), the default value will never be applied.
*
* In order to solve these issues, we recommend that all new projects
* explicitly specify this option as true.
*/
emptyStringAsUndefined: true,
});

View File

@@ -0,0 +1 @@
export { LanguageSwitcher } from "./ui/language-switcher";

View File

@@ -0,0 +1,26 @@
import { ToggleGroup } from "denjs-ui";
import { useLocale, useMessages } from "#/shared/lib/i18n";
export function LanguageSwitcher() {
const m = useMessages();
const { locale, setLocale } = useLocale();
return (
<ToggleGroup
type="single"
size="sm"
value={locale}
aria-label={m.switch_language_aria()}
className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] p-0.5"
items={[
{ value: "ru", label: "RU" },
{ value: "en", label: "EN" },
]}
onValueChange={async (value) => {
if (value === "ru" || value === "en") {
await setLocale(value);
}
}}
/>
);
}

View File

@@ -0,0 +1 @@
export { RequestDemoForm } from "./ui/request-demo-form";

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { buildDemoRequestMailto } from "./build-demo-request-mailto";
function parseMailtoQuery(mailtoHref: string) {
const query = mailtoHref.split("?")[1] ?? "";
const params = new URLSearchParams(query);
return {
subject: params.get("subject"),
body: params.get("body"),
};
}
describe("buildDemoRequestMailto", () => {
it("builds a mailto link with normalized payload fields", () => {
const mailtoHref = buildDemoRequestMailto({
name: " Anna ",
phone: " +1 555 1234 ",
specialization: "Nail artist",
city: "Moscow",
comment: "Need migration help",
locale: "ru",
pageUrl: "https://monie.app/",
});
const { subject, body } = parseMailtoQuery(mailtoHref);
expect(mailtoHref.startsWith("mailto:hello@monie.app?")).toBe(true);
expect(subject).toBe("Monie demo request - Anna");
expect(body).toContain("Name: Anna");
expect(body).toContain("Phone: +1 555 1234");
expect(body).toContain("Specialization: Nail artist");
expect(body).toContain("City: Moscow");
expect(body).toContain("Comment: Need migration help");
expect(body).toContain("Locale: ru");
expect(body).toContain("Page: https://monie.app/");
});
it("uses dash fallback for empty fields and keeps base subject", () => {
const mailtoHref = buildDemoRequestMailto({
name: " ",
phone: " ",
specialization: " ",
city: "",
comment: "",
locale: "",
pageUrl: "",
});
const { subject, body } = parseMailtoQuery(mailtoHref);
expect(subject).toBe("Monie demo request");
expect(body).toContain("Name: -");
expect(body).toContain("Phone: -");
expect(body).toContain("Specialization: -");
expect(body).toContain("City: -");
expect(body).toContain("Comment: -");
expect(body).toContain("Locale: -");
expect(body).toContain("Page: -");
});
});

View File

@@ -0,0 +1,36 @@
type DemoRequestMailtoPayload = {
name: string;
phone: string;
specialization: string;
city: string;
comment: string;
locale: string;
pageUrl: string;
};
function normalizeField(value: string): string {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : "-";
}
export function buildDemoRequestMailto(
payload: DemoRequestMailtoPayload,
): string {
const name = payload.name.trim();
const subject = encodeURIComponent(
`Monie demo request${name ? ` - ${name}` : ""}`,
);
const body = encodeURIComponent(
[
`Name: ${normalizeField(payload.name)}`,
`Phone: ${normalizeField(payload.phone)}`,
`Specialization: ${normalizeField(payload.specialization)}`,
`City: ${normalizeField(payload.city)}`,
`Comment: ${normalizeField(payload.comment)}`,
`Locale: ${normalizeField(payload.locale)}`,
`Page: ${normalizeField(payload.pageUrl)}`,
].join("\n"),
);
return `mailto:hello@monie.app?subject=${subject}&body=${body}`;
}

View File

@@ -0,0 +1,14 @@
import type { m } from "#/paraglide/messages";
type Messages = typeof m;
export function getSpecializationOptions(messages: Messages) {
return [
{ value: "nails", label: messages.spec_nails() },
{ value: "brows", label: messages.spec_brows() },
{ value: "lashes", label: messages.spec_lashes() },
{ value: "barber", label: messages.spec_barber() },
{ value: "massage", label: messages.spec_massage() },
{ value: "other", label: messages.spec_other() },
];
}

View File

@@ -0,0 +1,150 @@
import { Button, Field, Input, NativeSelect, Text, Textarea } from "denjs-ui";
import { type FormEvent, useMemo, useState } from "react";
import { useMessages } from "#/shared/lib/i18n";
import { buildDemoRequestMailto } from "../model/build-demo-request-mailto";
import { getSpecializationOptions } from "../model/specialization-options";
export function RequestDemoForm() {
const m = useMessages();
const [submitState, setSubmitState] = useState<
"idle" | "sending" | "success" | "error"
>("idle");
const options = useMemo(() => getSpecializationOptions(m), [m]);
const specializationByValue = useMemo(
() => Object.fromEntries(options.map((item) => [item.value, item.label])),
[options],
);
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (typeof window === "undefined") {
setSubmitState("error");
return;
}
setSubmitState("sending");
try {
const formData = new FormData(event.currentTarget);
const name = String(formData.get("name") ?? "").trim();
const phone = String(formData.get("phone") ?? "").trim();
const specializationValue = String(
formData.get("specialization") ?? "",
).trim();
const city = String(formData.get("city") ?? "").trim();
const comment = String(formData.get("comment") ?? "").trim();
const specialization =
specializationByValue[specializationValue] ?? specializationValue;
window.location.href = buildDemoRequestMailto({
name,
phone,
specialization,
city,
comment,
locale: document.documentElement.lang,
pageUrl: window.location.href,
});
setSubmitState("success");
} catch {
setSubmitState("error");
}
};
return (
<form
className="rise-in grid gap-3 rounded-2xl border border-[var(--line)] bg-white/75 p-4 sm:grid-cols-2 sm:p-5"
style={{ animationDelay: "220ms" }}
onSubmit={onSubmit}
>
<Field label={m.form_name_label()} controlId="demo-name" required>
<Input
id="demo-name"
name="name"
placeholder={m.form_name_placeholder()}
/>
</Field>
<Field label={m.form_phone_label()} controlId="demo-phone" required>
<Input
id="demo-phone"
type="tel"
name="phone"
placeholder="+7 (999) 000-00-00"
/>
</Field>
<Field
label={m.form_specialization_label()}
controlId="demo-specialization"
required
>
<NativeSelect
id="demo-specialization"
name="specialization"
options={options}
/>
</Field>
<Field label={m.form_city_label()} controlId="demo-city">
<Input
id="demo-city"
name="city"
placeholder={m.form_city_placeholder()}
/>
</Field>
<Field
label={m.form_comment_label()}
controlId="demo-comment"
className="sm:col-span-2"
>
<Textarea
id="demo-comment"
name="comment"
rows={4}
placeholder={m.form_comment_placeholder()}
resizable={false}
/>
</Field>
<div className="sm:col-span-2 flex flex-wrap items-center justify-between gap-3 pt-1">
<Text
as="p"
size="sm"
tone="muted"
className="m-0 max-w-[36ch] text-xs text-[var(--sea-ink-soft)]"
>
{m.form_privacy_text()}
</Text>
<Button
type="submit"
size="large"
className="rounded-full"
disabled={submitState === "sending"}
>
{submitState === "sending" ? m.form_submit_busy() : m.form_submit()}
</Button>
{submitState === "success" && (
<Text
as="p"
size="sm"
className="m-0 w-full text-xs text-[var(--palm)]"
>
{m.form_submit_success()}
</Text>
)}
{submitState === "error" && (
<Text
as="p"
size="sm"
className="m-0 w-full text-xs text-[var(--coral)]"
>
{m.form_submit_error()}
</Text>
)}
</div>
</form>
);
}

View File

@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { useMessages } from "#/shared/lib/i18n";
import {
getNextThemeMode,
parseThemeMode,
resolveThemeMode,
type ThemeMode,
} from "./model/theme-mode";
function getInitialMode(): ThemeMode {
if (typeof window === "undefined") {
return "auto";
}
return parseThemeMode(window.localStorage.getItem("theme"));
}
function applyThemeMode(mode: ThemeMode) {
const resolved = resolveThemeMode(
mode,
window.matchMedia("(prefers-color-scheme: dark)").matches,
);
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(resolved);
if (mode === "auto") {
document.documentElement.removeAttribute("data-theme");
} else {
document.documentElement.setAttribute("data-theme", mode);
}
document.documentElement.style.colorScheme = resolved;
}
export function ThemeToggle() {
const m = useMessages();
const [mode, setMode] = useState<ThemeMode>("auto");
useEffect(() => {
const initialMode = getInitialMode();
setMode(initialMode);
applyThemeMode(initialMode);
}, []);
useEffect(() => {
if (mode !== "auto") {
return;
}
const media = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyThemeMode("auto");
media.addEventListener("change", onChange);
return () => {
media.removeEventListener("change", onChange);
};
}, [mode]);
function toggleMode() {
const nextMode = getNextThemeMode(mode);
setMode(nextMode);
applyThemeMode(nextMode);
window.localStorage.setItem("theme", nextMode);
}
const label =
mode === "auto"
? m.theme_toggle_aria_auto()
: mode === "dark"
? m.theme_toggle_aria_dark()
: m.theme_toggle_aria_light();
const modeLabel =
mode === "auto"
? m.theme_toggle_mode_auto()
: mode === "dark"
? m.theme_toggle_mode_dark()
: m.theme_toggle_mode_light();
return (
<button
type="button"
onClick={toggleMode}
aria-label={label}
title={label}
className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm font-semibold text-[var(--sea-ink)] shadow-[0_8px_22px_rgba(30,90,72,0.08)] transition hover:-translate-y-0.5"
>
{modeLabel}
</button>
);
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
getNextThemeMode,
parseThemeMode,
resolveThemeMode,
} from "./theme-mode";
describe("theme-mode helpers", () => {
it("parses stored mode and falls back to auto for unknown values", () => {
expect(parseThemeMode("light")).toBe("light");
expect(parseThemeMode("dark")).toBe("dark");
expect(parseThemeMode("auto")).toBe("auto");
expect(parseThemeMode("night")).toBe("auto");
expect(parseThemeMode(null)).toBe("auto");
});
it("resolves auto mode based on system preference", () => {
expect(resolveThemeMode("auto", true)).toBe("dark");
expect(resolveThemeMode("auto", false)).toBe("light");
expect(resolveThemeMode("dark", false)).toBe("dark");
expect(resolveThemeMode("light", true)).toBe("light");
});
it("cycles modes in expected order", () => {
expect(getNextThemeMode("light")).toBe("dark");
expect(getNextThemeMode("dark")).toBe("auto");
expect(getNextThemeMode("auto")).toBe("light");
});
});

View File

@@ -0,0 +1,33 @@
export type ThemeMode = "light" | "dark" | "auto";
type ResolvedThemeMode = Exclude<ThemeMode, "auto">;
export function parseThemeMode(value: string | null | undefined): ThemeMode {
if (value === "light" || value === "dark" || value === "auto") {
return value;
}
return "auto";
}
export function resolveThemeMode(
mode: ThemeMode,
prefersDark: boolean,
): ResolvedThemeMode {
if (mode === "auto") {
return prefersDark ? "dark" : "light";
}
return mode;
}
export function getNextThemeMode(mode: ThemeMode): ThemeMode {
if (mode === "light") {
return "dark";
}
if (mode === "dark") {
return "auto";
}
return "light";
}

1
src/pages/about/index.ts Normal file
View File

@@ -0,0 +1 @@
export { AboutPage } from "./ui/about-page";

View File

@@ -0,0 +1,67 @@
import { Badge, Button, Card, SectionHeader, Text } from "denjs-ui";
import { scrollToSectionOrNavigateHome } from "#/shared/lib/dom";
import { useMessages } from "#/shared/lib/i18n";
export function AboutPage() {
const m = useMessages();
return (
<main className="page-wrap px-4 py-12 sm:py-14">
<Card padding="lg" className="island-shell rounded-2xl sm:p-8">
<Badge
color="teal"
rounded="full"
bordered
className="mb-3 w-fit px-3 py-1 text-[11px] tracking-[0.12em] uppercase"
>
{m.about_badge()}
</Badge>
<SectionHeader
title={m.about_title()}
subtitle={m.about_subtitle()}
align="left"
titleAs="h1"
className="max-w-3xl"
/>
</Card>
<section className="mt-6 grid gap-4 md:grid-cols-2">
<Card
padding="lg"
title={m.about_focus_title()}
className="island-shell rounded-2xl"
>
<ul className="m-0 space-y-2 pl-5 text-sm leading-7 text-[var(--sea-ink-soft)]">
<li>{m.about_focus_point_1()}</li>
<li>{m.about_focus_point_2()}</li>
<li>{m.about_focus_point_3()}</li>
<li>{m.about_focus_point_4()}</li>
</ul>
</Card>
<Card
padding="lg"
title={m.about_principle_title()}
className="island-shell rounded-2xl"
>
<Text
as="p"
tone="muted"
className="m-0 text-sm leading-7 text-[var(--sea-ink-soft)]"
>
{m.about_principle_text()}
</Text>
<Button
size="large"
className="mt-5 rounded-full"
onClick={() => scrollToSectionOrNavigateHome("demo")}
>
{m.about_cta()}
</Button>
</Card>
</section>
</main>
);
}

1
src/pages/home/index.ts Normal file
View File

@@ -0,0 +1 @@
export { HomePage } from "./ui/home-page";

View File

@@ -0,0 +1,29 @@
import {
BenefitsSection,
FaqSection,
FinalCtaSection,
HeroSection,
HowItWorksSection,
PersonalPageSection,
ProblemSection,
ProductPreviewSection,
SolutionSection,
TrustSection,
} from "#/widgets/landing";
export function HomePage() {
return (
<main className="page-wrap px-4 pb-16 pt-10 sm:pt-14">
<HeroSection />
<ProblemSection />
<SolutionSection />
<ProductPreviewSection />
<BenefitsSection />
<HowItWorksSection />
<PersonalPageSection />
<TrustSection />
<FaqSection />
<FinalCtaSection />
</main>
);
}

86
src/routeTree.gen.ts Normal file
View File

@@ -0,0 +1,86 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
const AboutRoute = AboutRouteImport.update({
id: '/about',
path: '/about',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof AboutRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/about'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/about'
id: '__root__' | '/' | '/about'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/about': {
id: '/about'
path: '/about'
fullPath: '/about'
preLoaderRoute: typeof AboutRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

19
src/router.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
});
return router;
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}

75
src/routes/__root.tsx Normal file
View File

@@ -0,0 +1,75 @@
import { TanStackDevtools } from "@tanstack/react-devtools";
import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { FooterSimpleRow } from "denjs-ui";
import { LocaleProvider, PostHogProvider } from "#/app/providers";
import appCss from "#/app/styles/index.css?url";
import { m } from "#/paraglide/messages";
import { getLocale } from "#/paraglide/runtime";
import { Header } from "#/widgets/layout";
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
{
title: m.seo_home_title(),
},
{
name: "description",
content: m.seo_home_description(),
},
],
links: [
{
rel: "icon",
type: "image/svg+xml",
href: "/favicon-light.svg",
id: "app-favicon",
},
{
rel: "stylesheet",
href: appCss,
},
],
}),
shellComponent: RootDocument,
});
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang={getLocale()} suppressHydrationWarning>
<head>
<script src="/theme-init.js" />
<HeadContent />
</head>
<body className="font-sans antialiased wrap-anywhere">
<PostHogProvider>
<LocaleProvider>
<Header />
{children}
<FooterSimpleRow />
<TanStackDevtools
config={{
position: "bottom-right",
}}
plugins={[
{
name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
</LocaleProvider>
</PostHogProvider>
<Scripts />
</body>
</html>
);
}

18
src/routes/about.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { createFileRoute } from "@tanstack/react-router";
import { AboutPage } from "#/pages/about";
import { m } from "#/paraglide/messages";
export const Route = createFileRoute("/about")({
head: () => ({
meta: [
{
title: m.seo_about_title(),
},
{
name: "description",
content: m.seo_about_description(),
},
],
}),
component: AboutPage,
});

6
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { HomePage } from "#/pages/home";
export const Route = createFileRoute("/")({
component: HomePage,
});

View File

@@ -0,0 +1 @@
export * from "./scroll-to-section";

View File

@@ -0,0 +1,33 @@
export function scrollToSection(id: string) {
if (typeof document === "undefined") {
return;
}
const section = document.getElementById(id);
if (!section) {
return;
}
section.scrollIntoView({ behavior: "smooth", block: "start" });
}
export function scrollToSectionOrNavigateHome(id: string) {
if (typeof window === "undefined") {
return;
}
if (window.location.pathname !== "/") {
window.location.href = `/#${id}`;
return;
}
const section = document.getElementById(id);
if (!section) {
window.location.hash = `#${id}`;
return;
}
section.scrollIntoView({ behavior: "smooth", block: "start" });
}

View File

@@ -0,0 +1 @@
export * from "./locale-context";

View File

@@ -0,0 +1,125 @@
import { useRouterState } from "@tanstack/react-router";
import type { ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { m } from "#/paraglide/messages";
import type { locales } from "#/paraglide/runtime";
import {
getLocale,
setLocale as setParaglideLocale,
} from "#/paraglide/runtime";
import { resolveSeoPage } from "./seo";
type Locale = (typeof locales)[number];
type LocaleContextValue = {
locale: Locale;
setLocale: (locale: Locale) => Promise<void>;
};
const LocaleContext = createContext<LocaleContextValue | null>(null);
function getCurrentLocale(): Locale {
return getLocale() as Locale;
}
export function LocaleProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(getCurrentLocale);
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
const setLocale = useCallback(async (nextLocale: Locale) => {
await setParaglideLocale(nextLocale, { reload: false });
setLocaleState(getCurrentLocale());
}, []);
useEffect(() => {
if (typeof document !== "undefined") {
document.documentElement.lang = locale;
const seoPage = resolveSeoPage(pathname);
document.title =
seoPage === "about" ? m.seo_about_title() : m.seo_home_title();
const descriptionMeta = document.querySelector(
'meta[name="description"]',
);
if (descriptionMeta) {
descriptionMeta.setAttribute(
"content",
seoPage === "about"
? m.seo_about_description()
: m.seo_home_description(),
);
}
}
}, [locale, pathname]);
useEffect(() => {
if (typeof document === "undefined" || typeof window === "undefined") {
return;
}
const link = document.querySelector<HTMLLinkElement>("link#app-favicon");
if (!link) {
return;
}
const applyFavicon = () => {
const explicitTheme = document.documentElement.getAttribute("data-theme");
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
const isDark =
explicitTheme === "dark" || (explicitTheme !== "light" && prefersDark);
link.href = isDark ? "/favicon-dark.svg" : "/favicon-light.svg";
};
const media = window.matchMedia("(prefers-color-scheme: dark)");
const observer = new MutationObserver(applyFavicon);
applyFavicon();
media.addEventListener("change", applyFavicon);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
return () => {
media.removeEventListener("change", applyFavicon);
observer.disconnect();
};
}, []);
const value = useMemo(
() => ({
locale,
setLocale,
}),
[locale, setLocale],
);
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
}
export function useLocale() {
const context = useContext(LocaleContext);
if (!context) {
throw new Error("useLocale must be used within LocaleProvider");
}
return context;
}
export function useMessages() {
useLocale();
return m;
}

View File

@@ -0,0 +1,33 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
function readMessages(locale: "ru" | "en") {
const filepath = resolve(process.cwd(), "messages", `${locale}.json`);
return JSON.parse(readFileSync(filepath, "utf-8")) as Record<string, string>;
}
describe("message catalogs", () => {
it("have the same keyset for ru and en", () => {
const ru = readMessages("ru");
const en = readMessages("en");
const ruKeys = Object.keys(ru).sort();
const enKeys = Object.keys(en).sort();
expect(ruKeys).toEqual(enKeys);
});
it("have non-empty translations", () => {
const catalogs = [readMessages("ru"), readMessages("en")];
for (const catalog of catalogs) {
for (const [key, value] of Object.entries(catalog)) {
expect(
value.trim().length,
`Empty translation for key: ${key}`,
).toBeGreaterThan(0);
}
}
});
});

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { resolveSeoPage } from "./seo";
describe("resolveSeoPage", () => {
it("returns about page for /about and nested about routes", () => {
expect(resolveSeoPage("/about")).toBe("about");
expect(resolveSeoPage("/about/team")).toBe("about");
});
it("returns home page for non-about routes", () => {
expect(resolveSeoPage("/")).toBe("home");
expect(resolveSeoPage("/pricing")).toBe("home");
expect(resolveSeoPage("/aboutness")).toBe("home");
});
});

View File

@@ -0,0 +1,9 @@
export type SeoPage = "home" | "about";
export function resolveSeoPage(pathname: string): SeoPage {
if (pathname === "/about" || pathname.startsWith("/about/")) {
return "about";
}
return "home";
}

View File

@@ -0,0 +1,26 @@
import type { SVGProps } from "react";
type LowerLeftLogoProps = SVGProps<SVGSVGElement>;
export function LowerLeftLogo({ className, ...props }: LowerLeftLogoProps) {
return (
<svg
viewBox="0 0 218 78"
width="218"
height="78"
className={className}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Monie</title>
<g fill="currentColor" fillRule="evenodd">
<path d="M 167 39 L 166 40 L 166 47 L 165 48 L 165 53 L 164 54 L 164 59 L 163 60 L 163 61 L 164 62 L 164 63 L 166 65 L 171 65 L 172 64 L 174 64 L 175 63 L 174 62 L 172 62 L 171 61 L 171 56 L 172 55 L 172 49 L 173 48 L 173 43 L 174 42 L 174 41 L 173 40 L 173 39 Z" />
<path d="M 130 39 L 129 40 L 129 48 L 128 49 L 128 54 L 127 55 L 127 60 L 126 61 L 126 64 L 133 64 L 133 63 L 134 62 L 134 56 L 135 55 L 135 51 L 142 44 L 143 44 L 144 43 L 148 43 L 149 44 L 149 45 L 150 46 L 150 48 L 149 49 L 149 53 L 148 54 L 148 58 L 147 59 L 147 63 L 149 65 L 154 65 L 155 64 L 157 64 L 158 63 L 157 63 L 155 61 L 155 55 L 156 54 L 156 49 L 157 48 L 157 43 L 156 42 L 156 41 L 154 39 L 148 39 L 147 40 L 145 40 L 143 42 L 142 42 L 141 43 L 140 43 L 138 45 L 138 46 L 137 47 L 136 46 L 136 40 L 135 39 Z" />
<path d="M 196 38 L 195 39 L 191 39 L 190 40 L 189 40 L 188 41 L 187 41 L 181 47 L 181 48 L 180 49 L 180 51 L 179 52 L 179 58 L 180 59 L 180 60 L 183 63 L 184 63 L 185 64 L 187 64 L 188 65 L 195 65 L 196 64 L 199 64 L 200 63 L 201 63 L 202 62 L 203 62 L 205 60 L 206 60 L 206 59 L 205 59 L 204 60 L 203 60 L 202 61 L 201 61 L 200 62 L 198 62 L 197 63 L 192 63 L 191 62 L 190 62 L 187 59 L 187 55 L 186 54 L 188 52 L 196 52 L 197 51 L 200 51 L 201 50 L 203 50 L 207 46 L 207 43 L 206 42 L 206 41 L 205 40 L 204 40 L 203 39 L 200 39 L 199 38 Z M 194 40 L 199 40 L 201 42 L 201 46 L 197 50 L 196 50 L 195 51 L 188 51 L 187 50 L 187 49 L 188 48 L 188 47 L 189 46 L 189 45 Z" />
<path d="M 108 38 L 107 39 L 103 39 L 102 40 L 101 40 L 100 41 L 99 41 L 98 42 L 97 42 L 96 43 L 96 44 L 94 46 L 94 47 L 93 48 L 93 49 L 92 50 L 92 57 L 93 58 L 93 59 L 94 60 L 94 61 L 95 62 L 96 62 L 98 64 L 101 64 L 102 65 L 109 65 L 110 64 L 113 64 L 114 63 L 115 63 L 120 58 L 120 57 L 121 56 L 121 55 L 122 54 L 122 47 L 121 46 L 121 44 L 117 40 L 116 40 L 115 39 L 112 39 L 111 38 Z M 106 41 L 107 40 L 111 40 L 114 43 L 114 44 L 115 45 L 115 53 L 114 54 L 114 56 L 113 57 L 113 58 L 112 59 L 112 60 L 109 63 L 108 63 L 107 64 L 105 64 L 104 63 L 103 63 L 100 60 L 100 49 L 101 48 L 101 46 L 102 45 L 102 44 L 105 41 Z" />
<path d="M 173 23 L 172 24 L 170 24 L 169 25 L 169 26 L 168 27 L 168 30 L 170 32 L 173 32 L 174 31 L 175 31 L 177 29 L 177 25 L 176 24 L 175 24 L 174 23 Z" />
<path d="M 52 10 L 51 11 L 47 11 L 46 12 L 45 12 L 44 13 L 43 13 L 41 15 L 40 15 L 30 25 L 30 26 L 27 29 L 27 30 L 25 32 L 25 33 L 23 35 L 23 36 L 22 37 L 22 38 L 21 39 L 21 40 L 19 42 L 19 43 L 18 44 L 18 45 L 17 46 L 17 47 L 16 48 L 16 49 L 15 50 L 15 51 L 14 52 L 14 54 L 13 55 L 13 56 L 12 57 L 12 58 L 11 59 L 11 61 L 10 62 L 10 65 L 11 66 L 13 66 L 14 67 L 18 67 L 19 66 L 21 66 L 22 65 L 23 65 L 25 63 L 25 61 L 26 60 L 26 59 L 27 58 L 27 56 L 28 55 L 28 53 L 29 52 L 29 50 L 30 49 L 30 47 L 31 46 L 31 45 L 32 44 L 32 43 L 33 42 L 33 41 L 34 40 L 34 39 L 35 38 L 35 37 L 36 36 L 36 35 L 37 34 L 37 33 L 39 31 L 39 30 L 41 28 L 41 27 L 47 21 L 48 22 L 48 25 L 47 26 L 47 29 L 46 30 L 46 32 L 45 33 L 45 36 L 44 37 L 44 40 L 43 41 L 43 45 L 42 46 L 42 54 L 43 55 L 43 57 L 46 60 L 47 60 L 48 61 L 53 61 L 54 60 L 55 60 L 57 58 L 58 58 L 59 57 L 59 56 L 62 53 L 62 52 L 63 51 L 63 50 L 65 48 L 65 47 L 66 46 L 66 45 L 68 43 L 68 42 L 69 41 L 69 40 L 71 38 L 71 37 L 73 35 L 73 34 L 76 31 L 76 30 L 79 27 L 80 27 L 81 28 L 80 29 L 80 31 L 79 32 L 79 34 L 78 35 L 78 37 L 77 38 L 77 40 L 76 41 L 76 44 L 75 45 L 75 48 L 74 49 L 74 54 L 73 55 L 73 57 L 74 58 L 74 61 L 75 62 L 75 63 L 77 65 L 78 65 L 79 66 L 81 66 L 82 67 L 90 67 L 91 66 L 89 66 L 88 65 L 87 65 L 85 63 L 85 62 L 84 61 L 84 50 L 85 49 L 85 46 L 86 45 L 86 42 L 87 41 L 87 38 L 88 37 L 88 34 L 89 33 L 89 31 L 90 30 L 90 27 L 91 26 L 91 23 L 92 22 L 92 19 L 90 17 L 84 17 L 83 18 L 82 18 L 80 20 L 79 20 L 73 26 L 73 27 L 69 31 L 69 32 L 66 35 L 66 36 L 64 38 L 64 39 L 62 41 L 62 42 L 59 45 L 59 46 L 56 49 L 55 49 L 54 48 L 54 47 L 53 46 L 53 44 L 54 43 L 54 38 L 55 37 L 55 34 L 56 33 L 56 31 L 57 30 L 57 27 L 58 26 L 58 24 L 59 23 L 59 19 L 60 18 L 60 16 L 59 15 L 59 13 L 58 12 L 57 12 L 56 11 L 53 11 Z" />
</g>
</svg>
);
}

93
src/styles/dark.css Normal file
View File

@@ -0,0 +1,93 @@
:root[data-theme="dark"] {
color-scheme: dark;
--background: #000000; /* --geist-background in dark */
--foreground: #ffffff; /* --geist-foreground in dark */
--surface: #111111; /* --accents-1 */
--surface-muted: #111111; /* --accents-1 */
--surface-hover: #333333; /* --accents-2 */
--border: #333333; /* --accents-2 */
--input: #000000;
--input-border: #333333;
--muted: #111111; /* --accents-1 */
--muted-foreground: #888888; /* --accents-5 */
/* In dark, primary often uses a light button */
--primary: #fafafa; /* --accents-8 */
--primary-foreground: #000000;
--primary-hover: #eaeaea; /* --accents-7 */
--primary-muted: #333333; /* --accents-2 */
--accent: #3291ff; /* --geist-success-light */
--accent-foreground: #000000;
--accent-text: #3291ff;
--card: var(--surface);
--card-foreground: var(--foreground);
--popover: var(--surface);
--popover-foreground: var(--foreground);
--secondary: #111111;
--secondary-foreground: #fafafa;
--ring: #3291ff;
--focus: #3291ff;
--success: #3291ff;
--success-foreground: #000000;
--warning: #f7b955; /* --geist-warning-light */
--warning-foreground: #000000;
--danger: #ff1a1a; /* --geist-error-light */
--danger-foreground: #000000;
--disabled: rgba(255, 255, 255, 0.1);
--disabled-foreground: rgba(255, 255, 255, 0.5);
}
/* Optional: OS auto-dark when no explicit theme set */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
color-scheme: dark;
--background: #000000;
--foreground: #ffffff;
--surface: #111111;
--surface-muted: #111111;
--surface-hover: #333333;
--border: #333333;
--input: #000000;
--input-border: #333333;
--muted: #111111;
--muted-foreground: #888888;
--primary: #fafafa;
--primary-foreground: #000000;
--primary-hover: #eaeaea;
--primary-muted: #333333;
--accent: #3291ff;
--accent-foreground: #000000;
--accent-text: #3291ff;
--card: var(--surface);
--card-foreground: var(--foreground);
--popover: var(--surface);
--popover-foreground: var(--foreground);
--secondary: #111111;
--secondary-foreground: #fafafa;
--ring: #3291ff;
--focus: #3291ff;
}
}

4
src/styles/index.css Normal file
View File

@@ -0,0 +1,4 @@
@import "tailwindcss";
@import "./tokens.css";
@import "./light.css";
@import "./dark.css";

70
src/styles/light.css Normal file
View File

@@ -0,0 +1,70 @@
:root {
color-scheme: light;
/* ===== Core surfaces ===== */
--background: #ffffff; /* --geist-background */
--foreground: #000000; /* --geist-foreground */
--surface: #ffffff;
--surface-muted: #fafafa; /* --accents-1 */
--surface-hover: #eaeaea; /* --accents-2 */
/* ===== Borders / inputs ===== */
--border: #eaeaea; /* --accents-2 */
--input: #ffffff;
--input-border: #eaeaea;
/* ===== Text tokens ===== */
--muted: #fafafa; /* --accents-1 */
--muted-foreground: #666666; /* --accents-5 */
/* ===== Brand tokens (Next/Vercel) ===== */
--primary: #111111; /* close to black UI: --accents-8 */
--primary-foreground: #ffffff;
--primary-hover: #333333; /* --accents-7 */
--primary-muted: #eaeaea; /* --accents-2 */
--accent: #0070f3; /* --geist-success */
--accent-foreground: #ffffff;
--accent-text: #0070f3;
/* ===== Semantic component tokens (shadcn-like) ===== */
--card: var(--surface);
--card-foreground: var(--foreground);
--popover: var(--surface);
--popover-foreground: var(--foreground);
--secondary: #fafafa; /* --accents-1 */
--secondary-foreground: #111111;
--ring: #0070f3; /* --geist-success */
--focus: #0070f3;
/* ===== States (Geist palette) ===== */
--success: #0070f3; /* Geist "success" is blue */
--success-foreground: #ffffff;
--warning: #f5a623;
--warning-foreground: #111111;
--danger: #ee0000; /* --geist-error */
--danger-foreground: #ffffff;
/* Disabled */
--disabled: rgba(0, 0, 0, 0.08);
--disabled-foreground: rgba(0, 0, 0, 0.45);
/* ===== Data viz (Geist highlights) ===== */
--chart-1: #60a5fa; /* light blue */
--chart-2: #c084fc; /* light violet */
--chart-3: #34d399; /* mint */
--chart-4: #f472b6; /* pink */
--chart-5: #fbbf24; /* amber */
--chart-6: #22d3ee; /* sky/cyan */
/* ===== Motion ===== */
--ease: cubic-bezier(0.2, 0.8, 0.2, 1);
--dur-1: 120ms;
--dur-2: 200ms;
--dur-3: 320ms;
}

62
src/styles/tokens.css Normal file
View File

@@ -0,0 +1,62 @@
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
@theme {
/* colors */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-surface: var(--surface);
--color-surfaceMuted: var(--surface-muted);
--color-surfaceHover: var(--surface-hover);
--color-border: var(--border);
--color-input: var(--input);
--color-input-border: var(--input-border);
--color-muted: var(--muted);
--color-mutedForeground: var(--muted-foreground);
--color-primary: var(--primary);
--color-primaryForeground: var(--primary-foreground);
--color-primaryHover: var(--primary-hover);
--color-primaryMuted: var(--primary-muted);
--color-accent: var(--accent);
--color-accentForeground: var(--accent-foreground);
--color-accentText: var(--accent-text);
--color-card: var(--card);
--color-cardForeground: var(--card-foreground);
--color-popover: var(--popover);
--color-popoverForeground: var(--popover-foreground);
--color-secondary: var(--secondary);
--color-secondaryForeground: var(--secondary-foreground);
--color-success: var(--success);
--color-successForeground: var(--success-foreground);
--color-warning: var(--warning);
--color-warningForeground: var(--warning-foreground);
--color-danger: var(--danger);
--color-dangerForeground: var(--danger-foreground);
--color-ring: var(--ring);
--color-focus: var(--focus);
/* motion */
--ease: var(--ease);
--dur-1: var(--dur-1);
--dur-2: var(--dur-2);
--dur-3: var(--dur-3);
/* charts */
--chart-1: var(--chart-1);
--chart-2: var(--chart-2);
--chart-3: var(--chart-3);
--chart-4: var(--chart-4);
--chart-5: var(--chart-5);
--chart-6: var(--chart-6);
}

View File

@@ -0,0 +1,31 @@
import { Card, SectionHeader } from "denjs-ui";
import { getBenefits } from "#/entities/landing";
import { useMessages } from "#/shared/lib/i18n";
export function BenefitsSection() {
const m = useMessages();
return (
<section id="benefits" className="mt-10 sm:mt-14">
<SectionHeader
eyebrow={m.benefits_eyebrow()}
title={m.benefits_title()}
align="left"
className="max-w-3xl"
/>
<div className="mt-5 grid gap-4 md:grid-cols-2">
{getBenefits(m).map((item, index) => (
<Card
key={item.title}
title={item.title}
description={item.text}
padding="lg"
className="feature-card rise-in rounded-2xl"
style={{ animationDelay: `${index * 90 + 100}ms` }}
/>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import { Accordion, SectionHeader, Text } from "denjs-ui";
import { getFaqItems } from "#/entities/landing";
import { useMessages } from "#/shared/lib/i18n";
export function FaqSection() {
const m = useMessages();
return (
<section id="faq" className="mt-10 sm:mt-14">
<SectionHeader
eyebrow={m.faq_eyebrow()}
title={m.faq_title()}
align="left"
className="max-w-3xl"
/>
<Accordion
className="mt-5 rounded-2xl border border-[var(--line)] bg-[var(--glass-surface)]"
items={getFaqItems(m).map((item) => ({
value: item.value,
title: item.title,
content: (
<Text
as="p"
tone="muted"
className="m-0 text-sm leading-6 text-[var(--sea-ink-soft)]"
>
{item.content}
</Text>
),
}))}
type="single"
collapsible
/>
</section>
);
}

View File

@@ -0,0 +1,32 @@
import { Card, SectionHeader } from "denjs-ui";
import { RequestDemoForm } from "#/features/request-demo";
import { useMessages } from "#/shared/lib/i18n";
export function FinalCtaSection() {
const m = useMessages();
return (
<section id="demo" className="mt-10 sm:mt-14">
<Card
padding="lg"
className="island-shell rounded-[2rem] border-[rgba(31,96,99,0.3)] bg-[linear-gradient(160deg,rgba(255,255,255,0.95),rgba(220,245,241,0.8))] sm:p-10"
>
<div className="grid gap-6 lg:grid-cols-[1fr_1.08fr]">
<div className="rise-in" style={{ animationDelay: "120ms" }}>
<SectionHeader
eyebrow={m.final_eyebrow()}
title={m.final_title()}
subtitle={m.final_subtitle()}
align="left"
/>
<div className="mt-4 inline-flex rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1 text-xs font-semibold text-[var(--sea-ink-soft)]">
{m.final_hint()}
</div>
</div>
<RequestDemoForm />
</div>
</Card>
</section>
);
}

View File

@@ -0,0 +1,84 @@
import { Badge, Button, Card, Heading, Text } from "denjs-ui";
import { getHeroMetrics } from "#/entities/landing";
import { scrollToSection } from "#/shared/lib/dom";
import { useMessages } from "#/shared/lib/i18n";
export function HeroSection() {
const m = useMessages();
return (
<section className="island-shell rise-in relative overflow-hidden rounded-[2.2rem] px-6 py-10 sm:px-10 sm:py-14">
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.32),transparent_66%)]" />
<div className="pointer-events-none absolute -bottom-24 -right-16 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(230,128,97,0.2),transparent_66%)]" />
<Badge
color="teal"
rounded="full"
bordered
className="w-fit px-3 py-1 text-[11px] tracking-[0.12em] uppercase"
>
{m.hero_badge()}
</Badge>
<Heading
level={1}
className="mt-4 max-w-4xl text-4xl leading-[0.98] font-bold tracking-tight text-[var(--sea-ink)] sm:text-6xl"
>
{m.hero_title()}
</Heading>
<Text
size="lg"
tone="muted"
className="mt-5 max-w-3xl text-base leading-7 text-[var(--sea-ink-soft)] sm:text-lg"
>
{m.hero_subtitle()}
</Text>
<div className="mt-8 flex flex-wrap gap-3">
<Button
size="large"
className="rounded-full"
onClick={() => scrollToSection("demo")}
>
{m.hero_cta_primary()}
</Button>
<Button
tone="secondary"
size="large"
className="rounded-full"
onClick={() => scrollToSection("how")}
>
{m.hero_cta_secondary()}
</Button>
</div>
<div className="mt-8 grid gap-3 sm:grid-cols-3">
{getHeroMetrics(m).map((item, index) => (
<Card
key={item.label}
padding="md"
className="metric-card rise-in rounded-2xl"
style={{ animationDelay: `${index * 100 + 80}ms` }}
>
<Text
as="p"
weight="semibold"
className="m-0 text-2xl font-extrabold text-[var(--sea-ink)]"
>
{item.value}
</Text>
<Text
as="p"
size="sm"
tone="muted"
className="mt-1 text-[var(--sea-ink-soft)]"
>
{item.label}
</Text>
</Card>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,49 @@
import { Badge, Card, Heading, SectionHeader, Text } from "denjs-ui";
import { getSteps } from "#/entities/landing";
import { useMessages } from "#/shared/lib/i18n";
export function HowItWorksSection() {
const m = useMessages();
return (
<section id="how" className="mt-10 sm:mt-14">
<SectionHeader
eyebrow={m.how_eyebrow()}
title={m.how_title()}
align="left"
className="max-w-3xl"
/>
<div className="mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{getSteps(m).map((step, index) => (
<Card
key={step.title}
padding="lg"
className="island-shell rise-in rounded-2xl"
style={{ animationDelay: `${index * 80 + 120}ms` }}
>
<Badge
color="teal"
rounded="full"
className="mb-3 w-fit px-2.5 py-1 text-[11px] font-semibold"
>
{m.how_step_label()} {index + 1}
</Badge>
<Heading
level={4}
className="text-base font-semibold text-[var(--sea-ink)]"
>
{step.title}
</Heading>
<Text
tone="muted"
className="mt-2 text-sm leading-6 text-[var(--sea-ink-soft)]"
>
{step.text}
</Text>
</Card>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,10 @@
export { BenefitsSection } from "./benefits-section/ui/benefits-section";
export { FaqSection } from "./faq-section/ui/faq-section";
export { FinalCtaSection } from "./final-cta-section/ui/final-cta-section";
export { HeroSection } from "./hero-section/ui/hero-section";
export { HowItWorksSection } from "./how-it-works-section/ui/how-it-works-section";
export { PersonalPageSection } from "./personal-page-section/ui/personal-page-section";
export { ProblemSection } from "./problem-section/ui/problem-section";
export { ProductPreviewSection } from "./product-preview-section/ui/product-preview-section";
export { SolutionSection } from "./solution-section/ui/solution-section";
export { TrustSection } from "./trust-section/ui/trust-section";

View File

@@ -0,0 +1,56 @@
import { Button, Card, SectionHeader, Text } from "denjs-ui";
import { scrollToSection } from "#/shared/lib/dom";
import { useMessages } from "#/shared/lib/i18n";
export function PersonalPageSection() {
const m = useMessages();
return (
<section id="personal" className="mt-10 sm:mt-14">
<Card
padding="lg"
className="island-shell rounded-[2rem] border-[rgba(31,96,99,0.28)] bg-[linear-gradient(155deg,rgba(255,255,255,0.95),rgba(220,245,241,0.74))] sm:p-10"
>
<div className="grid gap-6 lg:grid-cols-[1.1fr_1fr] lg:items-center">
<div>
<SectionHeader
eyebrow={m.personal_eyebrow()}
title={m.personal_title()}
subtitle={m.personal_subtitle()}
align="left"
/>
<div className="mt-4 inline-flex rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1 text-xs font-semibold text-[var(--sea-ink-soft)]">
{m.personal_example()}
</div>
</div>
<Card
padding="lg"
className="rounded-2xl border border-[var(--line)] bg-white/82"
>
<Text
as="p"
size="sm"
tone="muted"
className="m-0 text-[var(--sea-ink-soft)]"
>
{m.personal_card_title()}
</Text>
<ul className="m-0 mt-3 space-y-2 pl-5 text-sm leading-6 text-[var(--sea-ink-soft)]">
<li>{m.personal_point_1()}</li>
<li>{m.personal_point_2()}</li>
<li>{m.personal_point_3()}</li>
</ul>
<Button
size="large"
className="mt-5 w-full rounded-full"
onClick={() => scrollToSection("demo")}
>
{m.personal_cta()}
</Button>
</Card>
</div>
</Card>
</section>
);
}

View File

@@ -0,0 +1,32 @@
import { Card, SectionHeader } from "denjs-ui";
import { getProblems } from "#/entities/landing";
import { useMessages } from "#/shared/lib/i18n";
export function ProblemSection() {
const m = useMessages();
return (
<section id="problem" className="mt-10 sm:mt-14">
<SectionHeader
eyebrow={m.problem_eyebrow()}
title={m.problem_title()}
subtitle={m.problem_subtitle()}
align="left"
className="max-w-3xl"
/>
<div className="mt-5 grid gap-4 md:grid-cols-3">
{getProblems(m).map((item, index) => (
<Card
key={item.title}
title={item.title}
description={item.text}
padding="lg"
className="feature-card rise-in rounded-2xl"
style={{ animationDelay: `${index * 90 + 110}ms` }}
/>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,96 @@
import { Badge, Card, Heading, SectionHeader, Text } from "denjs-ui";
import { useMessages } from "#/shared/lib/i18n";
export function ProductPreviewSection() {
const m = useMessages();
return (
<section id="preview" className="mt-10 sm:mt-14">
<SectionHeader
eyebrow={m.preview_eyebrow()}
title={m.preview_title()}
subtitle={m.preview_subtitle()}
align="left"
className="max-w-3xl"
/>
<div className="mt-5 grid gap-4 lg:grid-cols-[1.1fr_1fr]">
<Card padding="lg" className="island-shell rounded-2xl">
<Text as="p" size="sm" tone="muted" className="island-kicker mb-2">
{m.preview_profile_title()}
</Text>
<Heading
level={3}
className="text-2xl font-semibold text-[var(--sea-ink)] sm:text-3xl"
>
monie.app/anna-nails
</Heading>
<Text
tone="muted"
className="mt-3 text-sm leading-6 text-[var(--sea-ink-soft)]"
>
{m.preview_profile_description()}
</Text>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<Card
padding="sm"
title={m.preview_service_1_title()}
description={m.preview_service_1_description()}
className="rounded-xl border border-[var(--line)] bg-white/76"
/>
<Card
padding="sm"
title={m.preview_service_2_title()}
description={m.preview_service_2_description()}
className="rounded-xl border border-[var(--line)] bg-white/76"
/>
</div>
</Card>
<Card padding="lg" className="island-shell rounded-2xl">
<Text as="p" size="sm" tone="muted" className="island-kicker mb-2">
{m.preview_schedule_title()}
</Text>
<div className="space-y-3">
<Card
padding="sm"
title={m.preview_appt_1_title()}
description={m.preview_appt_1_description()}
className="rounded-xl border border-[var(--line)] bg-white/80"
footer={
<Badge
color="emerald"
rounded="full"
className="w-fit px-2 py-0.5 text-[11px]"
>
{m.preview_appt_1_badge()}
</Badge>
}
/>
<Card
padding="sm"
title={m.preview_appt_2_title()}
description={m.preview_appt_2_description()}
className="rounded-xl border border-[var(--line)] bg-white/80"
footer={
<Badge
color="sky"
rounded="full"
className="w-fit px-2 py-0.5 text-[11px]"
>
{m.preview_appt_2_badge()}
</Badge>
}
/>
<Card
padding="sm"
title={m.preview_income_title()}
description={m.preview_income_description()}
className="rounded-xl border border-[var(--line)] bg-white/80"
/>
</div>
</Card>
</div>
</section>
);
}

View File

@@ -0,0 +1,32 @@
import { Card, SectionHeader } from "denjs-ui";
import { getSolutionItems } from "#/entities/landing";
import { useMessages } from "#/shared/lib/i18n";
export function SolutionSection() {
const m = useMessages();
return (
<section
id="solution"
className="mt-10 grid gap-6 lg:grid-cols-[1.05fr_1fr] sm:mt-14"
>
<Card padding="lg" className="island-shell rounded-2xl">
<SectionHeader
eyebrow={m.solution_eyebrow()}
title={m.solution_title()}
subtitle={m.solution_subtitle()}
align="left"
className="max-w-2xl"
/>
</Card>
<Card padding="lg" className="island-shell rounded-2xl">
<ul className="m-0 space-y-3 pl-5 text-sm leading-6 text-[var(--sea-ink-soft)]">
{getSolutionItems(m).map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</Card>
</section>
);
}

View File

@@ -0,0 +1,31 @@
import { Card, SectionHeader } from "denjs-ui";
import { getTrustItems } from "#/entities/landing";
import { useMessages } from "#/shared/lib/i18n";
export function TrustSection() {
const m = useMessages();
return (
<section id="trust" className="mt-10 sm:mt-14">
<SectionHeader
eyebrow={m.trust_eyebrow()}
title={m.trust_title()}
align="left"
className="max-w-3xl"
/>
<div className="mt-5 grid gap-4 md:grid-cols-3">
{getTrustItems(m).map((item, index) => (
<Card
key={item.title}
title={item.title}
description={item.text}
padding="lg"
className="feature-card rise-in rounded-2xl"
style={{ animationDelay: `${index * 90 + 100}ms` }}
/>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,2 @@
export { Footer } from "./ui/footer";
export { Header } from "./ui/header";

View File

@@ -0,0 +1,105 @@
import { Link as RouterLink } from "@tanstack/react-router";
import { Card, Divider, Link, Text } from "denjs-ui";
import { useMessages } from "#/shared/lib/i18n";
export function Footer() {
const m = useMessages();
const year = new Date().getFullYear();
return (
<footer className="site-footer mt-16 px-4 pb-12 pt-10 text-[var(--sea-ink-soft)]">
<div className="page-wrap">
<Card
padding="lg"
className="island-shell rounded-[1.8rem] border-[var(--line)] bg-[linear-gradient(165deg,var(--surface-strong),var(--glass-surface))]"
>
<div className="grid gap-6 md:grid-cols-[1.1fr_1fr] md:items-end">
<div>
<Text
as="p"
weight="semibold"
className="m-0 text-sm text-[var(--sea-ink)]"
>
Monie
</Text>
<Text
as="p"
tone="muted"
className="m-0 mt-2 max-w-md text-sm leading-6 text-[var(--sea-ink-soft)]"
>
{m.footer_description()}
</Text>
</div>
<nav className="flex flex-wrap justify-start gap-4 text-sm md:justify-end">
<Link
href="/#benefits"
tone="muted"
underline="hover"
className="text-sm font-medium text-[var(--sea-ink-soft)] hover:text-[var(--sea-ink)]"
>
{m.footer_nav_benefits()}
</Link>
<Link
href="/#faq"
tone="muted"
underline="hover"
className="text-sm font-medium text-[var(--sea-ink-soft)] hover:text-[var(--sea-ink)]"
>
{m.footer_nav_faq()}
</Link>
<Link
href="mailto:hello@monie.app"
external
tone="muted"
underline="hover"
className="text-sm font-medium text-[var(--sea-ink-soft)] hover:text-[var(--sea-ink)]"
>
hello@monie.app
</Link>
</nav>
</div>
<Divider className="my-6" tone="muted" />
<div className="flex flex-wrap items-center justify-between gap-3 text-xs">
<Text
as="p"
size="sm"
tone="muted"
className="m-0 text-xs text-[var(--sea-ink-soft)]"
>
&copy; {year} Monie. {m.footer_rights()}
</Text>
<div className="flex gap-4">
<RouterLink
to="/about"
className="text-xs font-medium text-[var(--sea-ink-soft)] no-underline transition hover:text-[var(--sea-ink)]"
>
{m.footer_about()}
</RouterLink>
<Link
href="/#demo"
tone="muted"
underline="hover"
className="text-xs font-medium text-[var(--sea-ink-soft)] hover:text-[var(--sea-ink)]"
>
{m.footer_demo()}
</Link>
<Link
href="mailto:legal@monie.app"
external
tone="muted"
underline="hover"
className="text-xs font-medium text-[var(--sea-ink-soft)] hover:text-[var(--sea-ink)]"
>
legal@monie.app
</Link>
</div>
</div>
</Card>
</div>
</footer>
);
}

View File

@@ -0,0 +1,85 @@
import { Header as DenjsHeader } from "denjs-ui";
import {
AlertCircle,
CircleHelp,
Lightbulb,
Mail,
PlayCircle,
WandSparkles,
} from "lucide-react";
import { LanguageSwitcher } from "#/features/change-language";
import { ThemeToggle } from "#/features/theme-toggle/ThemeToggle";
import { useMessages } from "#/shared/lib/i18n";
import { LowerLeftLogo } from "#/shared/ui/lower-left-logo";
export function Header() {
const m = useMessages();
return (
<DenjsHeader
brand={
<div className="flex items-center gap-3">
<a
href="/"
className="inline-flex no-underline"
aria-label="Monie home"
>
<LowerLeftLogo
aria-label="Monie"
className="h-8 w-auto max-w-40 select-none text-foreground transition-colors"
/>
</a>
<div className="hidden lg:flex items-center gap-2">
<LanguageSwitcher />
<ThemeToggle />
</div>
</div>
}
productLabel={m.header_nav_solution()}
products={[
{
name: m.header_nav_problem(),
href: "/#problem",
icon: AlertCircle,
},
{
name: m.header_nav_solution(),
href: "/#solution",
icon: Lightbulb,
},
{
name: m.header_nav_how(),
href: "/#how",
icon: WandSparkles,
},
{
name: m.header_nav_faq(),
href: "/#faq",
icon: CircleHelp,
},
]}
callsToAction={[
{
name: m.header_create_page(),
href: "/#demo",
icon: PlayCircle,
},
{
name: m.header_email_aria(),
href: "mailto:hello@monie.app",
icon: Mail,
},
]}
links={[
{
name: m.header_about(),
href: "/about",
},
]}
loginLink={{
name: m.header_create_page(),
href: "/#demo",
}}
/>
);
}