feat: reformula landing page e experiência mobile

This commit is contained in:
Felipe Coutinho
2026-03-20 18:35:12 +00:00
parent 33a5d6f5f0
commit 5b8d25d894
41 changed files with 692 additions and 453 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -7,11 +7,6 @@ export const america = localFont({
weight: "400",
style: "normal",
},
// {
// path: "./america-medium.woff2",
// weight: "500",
// style: "normal",
// },
],
display: "swap",
variable: "--font-america",

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,252 +1,38 @@
import {
RiBankCard2Line,
RiBarChartBoxLine,
RiCalendarLine,
RiCheckLine,
RiCodeSSlashLine,
RiDatabase2Line,
RiDeviceLine,
RiDownloadCloudLine,
RiEyeOffLine,
RiFileTextLine,
RiFlashlightLine,
RiGitBranchLine,
RiAndroidLine,
RiGithubFill,
RiInformationLine,
RiLayoutGridLine,
RiLineChartLine,
RiLockLine,
RiNotification3Line,
RiPercentLine,
RiPieChartLine,
RiRobot2Line,
RiShieldCheckLine,
RiSmartphoneLine,
RiStarLine,
RiTeamLine,
RiTimeLine,
RiWalletLine,
} from "@remixicon/react";
import { headers } from "next/headers";
import Image from "next/image";
import Link from "next/link";
import type { ComponentType } from "react";
import { AnimateOnScroll } from "@/features/landing/components/animate-on-scroll";
import { MobileNav } from "@/features/landing/components/mobile-nav";
import { ScreenshotTabs } from "@/features/landing/components/screenshot-tabs";
import { SetupTabs } from "@/features/landing/components/setup-tabs";
import {
companionBanks,
companionSteps,
extraFeatures,
getMetricsItems,
mainFeatures,
navbarActionClassName,
navLinks,
pwaHighlights,
stackItems,
whoIsItForItems,
} from "@/features/landing/constants";
import { landingImages } from "@/features/landing/images";
import { fetchGitHubStats } from "@/features/landing/queries";
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
import { Logo } from "@/shared/components/logo";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/shared/components/ui/alert";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
async function fetchGitHubStats() {
try {
const res = await fetch(
"https://api.github.com/repos/felipegcoutinho/openmonetis",
{ next: { revalidate: 3600 } },
);
if (!res.ok) return { stars: 200, forks: 60 };
const data = await res.json();
return {
stars: data.stargazers_count as number,
forks: data.forks_count as number,
};
} catch {
return { stars: 200, forks: 60 };
}
}
const navbarActionClassName =
"border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black";
type FeatureItem = {
icon: ComponentType<{ className?: string; style?: React.CSSProperties }>;
title: string;
description: string;
colorVar: string;
};
const mainFeatures: FeatureItem[] = [
{
icon: RiWalletLine,
title: "Contas e transações",
description:
"Registre suas contas bancárias, cartões e dinheiro. Adicione receitas, despesas e transferências. Organize por categorias. Extratos detalhados por conta.",
colorVar: "var(--data-9)",
},
{
icon: RiPercentLine,
title: "Parcelamentos avançados",
description:
"Controle completo de compras parceladas. Antecipe parcelas com cálculo automático de desconto. Veja análise consolidada de todas as parcelas em aberto.",
colorVar: "var(--data-4)",
},
{
icon: RiRobot2Line,
title: "Insights com IA",
description:
"Análises financeiras geradas por IA (Claude, GPT, Gemini). Insights personalizados sobre seus padrões de gastos e recomendações inteligentes.",
colorVar: "var(--data-8)",
},
{
icon: RiBarChartBoxLine,
title: "Relatórios e gráficos",
description:
"Dashboard com 20+ widgets interativos. Relatórios detalhados por categoria. Gráficos de evolução e comparativos. Exportação em PDF e Excel.",
colorVar: "var(--data-5)",
},
{
icon: RiBankCard2Line,
title: "Faturas de cartão",
description:
"Cadastre seus cartões e acompanhe as faturas por período. Veja o que ainda não foi fechado. Controle limites, vencimentos e fechamentos.",
colorVar: "var(--data-1)",
},
{
icon: RiTeamLine,
title: "Gestão colaborativa",
description:
"Compartilhe pagadores com permissões granulares (admin/viewer). Notificações automáticas por e-mail. Colabore em lançamentos compartilhados.",
colorVar: "var(--data-3)",
},
];
const extraFeatures: FeatureItem[] = [
{
icon: RiPieChartLine,
title: "Categorias e orçamentos",
description:
"Crie categorias personalizadas e defina orçamentos mensais com indicadores visuais.",
colorVar: "var(--data-7)",
},
{
icon: RiFileTextLine,
title: "Anotações e tarefas",
description:
"Notas de texto e listas de tarefas com checkboxes. Arquivamento para manter histórico.",
colorVar: "var(--data-6)",
},
{
icon: RiCalendarLine,
title: "Calendário financeiro",
description:
"Visualize transações em calendário mensal. Nunca perca prazos de pagamentos.",
colorVar: "var(--data-2)",
},
{
icon: RiDownloadCloudLine,
title: "Importação em massa",
description: "Lance múltiplos lançamentos de uma vez",
colorVar: "var(--data-9)",
},
{
icon: RiEyeOffLine,
title: "Modo privacidade",
description:
"Oculte valores sensíveis com um clique. Tema dark/light. Calculadora integrada.",
colorVar: "var(--data-4)",
},
{
icon: RiFlashlightLine,
title: "Performance otimizada",
description: "Sistema rápido e com alta performance",
colorVar: "var(--data-5)",
},
];
const screenshotSections = [
{
title: "Lançamentos",
description: "Registre e organize todas as suas transações financeiras",
lightSrc: "/images/preview-lancamentos-light.webp",
darkSrc: "/images/preview-lancamentos-dark.webp",
},
{
title: "Calendário",
description: "Visualize suas finanças no calendário mensal",
lightSrc: "/images/preview-calendario-light.webp",
darkSrc: "/images/preview-calendario-dark.webp",
},
{
title: "Cartões",
description: "Acompanhe faturas, limites e vencimentos dos seus cartões",
lightSrc: "/images/preview-cartao-light.webp",
darkSrc: "/images/preview-cartao-dark.webp",
},
];
const companionBanks = [
{ name: "Nubank", logo: "/logos/nubank.png" },
{ name: "Itaú", logo: "/logos/itau.png" },
{ name: "Inter", logo: "/logos/interpj.png" },
{ name: "Mercado Pago", logo: "/logos/mercadopago.png" },
{ name: "Outros", logo: null },
];
const stackItems = [
{
icon: RiCodeSSlashLine,
title: "Frontend",
subtitle: "Next.js 16, TypeScript, Tailwind CSS, shadcn/ui",
description: "Interface moderna e responsiva com React 19 e App Router",
colorVar: "var(--data-3)",
},
{
icon: RiDatabase2Line,
title: "Backend",
subtitle: "PostgreSQL 18, Drizzle ORM, Better Auth",
description: "Banco relacional robusto com type-safe ORM",
colorVar: "var(--data-9)",
},
{
icon: RiShieldCheckLine,
title: "Segurança",
subtitle: "Better Auth com OAuth (Google) e autenticação por email",
description: "Sessões seguras e proteção de rotas por middleware",
colorVar: "var(--data-1)",
},
{
icon: RiDeviceLine,
title: "Deploy",
subtitle:
"Docker com multi-stage build, health checks e volumes persistentes",
description: "Fácil de rodar localmente ou em qualquer servidor",
colorVar: "var(--data-5)",
},
];
const whoIsItForItems = [
{
icon: RiTimeLine,
title: "Tem disciplina de registrar gastos",
description:
"Não se importa em dedicar alguns minutos por dia ou semana para manter tudo atualizado",
colorVar: "var(--data-4)",
},
{
icon: RiLockLine,
title: "Quer controle total sobre seus dados",
description:
"Prefere hospedar seus próprios dados ao invés de depender de serviços terceiros",
colorVar: "var(--data-9)",
},
{
icon: RiLineChartLine,
title: "Gosta de entender exatamente onde o dinheiro vai",
description:
"Quer visualizar padrões de gastos e tomar decisões informadas",
colorVar: "var(--data-3)",
},
];
export default async function Page() {
const [session, headersList, githubStats] = await Promise.all([
getOptionalUserSession(),
@@ -259,6 +45,7 @@ export default async function Page() {
"",
).replace(/:\d+$/, "");
const isPublicDomain = !!(publicDomain && hostname === publicDomain);
const metricsItems = getMetricsItems(githubStats.stars, githubStats.forks);
return (
<div className="flex min-h-screen flex-col">
@@ -273,13 +60,7 @@ export default async function Page() {
{/* Center Navigation Links */}
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
{[
{ href: "#telas", label: "conheça as telas" },
{ href: "#funcionalidades", label: "funcionalidades" },
{ href: "#companion", label: "companion" },
{ href: "#stack", label: "stack" },
{ href: "#como-usar", label: "como usar" },
].map(({ href, label }) => (
{navLinks.map(({ href, label }) => (
<a
key={href}
href={href}
@@ -333,9 +114,8 @@ export default async function Page() {
</div>
</header>
{/* Hero Section — texto + preview integrado */}
{/* Hero Section */}
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
{/* Background — DotPattern fade conectando com navbar */}
<div className="pointer-events-none absolute inset-x-0 top-0 h-80 overflow-hidden">
<DotPattern
width={20}
@@ -349,7 +129,6 @@ export default async function Page() {
</div>
<div className="max-w-8xl mx-auto px-4 relative">
{/* Texto */}
<div className="mx-auto flex max-w-3xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14">
<Badge variant="outline">
<RiGithubFill className="size-4 mr-1" />
@@ -397,7 +176,6 @@ export default async function Page() {
</div>
</div>
{/* Dashboard preview integrado ao hero */}
<div className="mx-auto max-w-6xl">
<div className="rounded-t-xl overflow-hidden border-x border-t bg-card">
<div className="flex items-center gap-1.5 px-3 h-8 border-b bg-muted/50">
@@ -407,7 +185,7 @@ export default async function Page() {
<div className="ml-2 flex-1 max-w-52 h-4 rounded bg-muted-foreground/10" />
</div>
<Image
src="/images/dashboard-preview-light.webp"
src={landingImages.hero.light}
alt="openmonetis Dashboard Preview"
width={1920}
height={1080}
@@ -415,7 +193,7 @@ export default async function Page() {
priority
/>
<Image
src="/images/dashboard-preview-dark.webp"
src={landingImages.hero.dark}
alt="openmonetis Dashboard Preview"
width={1920}
height={1080}
@@ -432,32 +210,7 @@ export default async function Page() {
<div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-4xl">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 md:gap-8">
{[
{
icon: RiLayoutGridLine,
value: "20+",
label: "Widgets no dashboard",
colorVar: "var(--data-9)",
},
{
icon: RiShieldCheckLine,
value: "100%",
label: "Self-hosted",
colorVar: "var(--data-1)",
},
{
icon: RiStarLine,
value: `${githubStats.stars}`,
label: "Stars no GitHub",
colorVar: "var(--data-4)",
},
{
icon: RiGitBranchLine,
value: `${githubStats.forks}`,
label: "Forks no GitHub",
colorVar: "var(--data-3)",
},
].map(({ icon: Icon, value, label, colorVar }) => (
{metricsItems.map(({ icon: Icon, value, label, colorVar }) => (
<div
key={label}
className="flex flex-col items-center text-center gap-1.5"
@@ -494,50 +247,17 @@ export default async function Page() {
</div>
</AnimateOnScroll>
<div className="space-y-10 md:space-y-14">
{screenshotSections.map((section) => (
<AnimateOnScroll key={section.title}>
<div className="mb-3 text-center">
<h3 className="font-semibold text-base md:text-lg">
{section.title}
</h3>
<p className="text-sm text-muted-foreground">
{section.description}
</p>
</div>
<div className="rounded-lg overflow-hidden border bg-card">
<div className="flex items-center gap-1.5 px-3 h-8 border-b bg-muted/50">
<div className="size-2.5 rounded-full bg-muted-foreground/20" />
<div className="size-2.5 rounded-full bg-muted-foreground/20" />
<div className="size-2.5 rounded-full bg-muted-foreground/20" />
<div className="ml-2 flex-1 max-w-52 h-4 rounded bg-muted-foreground/10" />
</div>
<Image
src={section.lightSrc}
alt={`Preview ${section.title}`}
width={1920}
height={1080}
className="w-full h-auto dark:hidden"
/>
<Image
src={section.darkSrc}
alt={`Preview ${section.title}`}
width={1920}
height={1080}
className="w-full h-auto hidden dark:block"
/>
</div>
</AnimateOnScroll>
))}
</div>
<AnimateOnScroll>
<ScreenshotTabs />
</AnimateOnScroll>
</div>
</div>
</section>
{/* Features Section */}
<section id="funcionalidades" className="py-12 md:py-24">
<section id="funcionalidades" className="py-12 md:py-24 bg-muted/40">
<div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-5xl">
<div className="mx-auto max-w-6xl">
<AnimateOnScroll>
<div className="text-center mb-8 md:mb-12">
<Badge variant="outline" className="mb-4">
@@ -553,7 +273,6 @@ export default async function Page() {
</div>
</AnimateOnScroll>
{/* Main Features - larger cards */}
<AnimateOnScroll>
<div className="grid gap-4 md:gap-6 sm:grid-cols-2 lg:grid-cols-3">
{mainFeatures.map((feature) => (
@@ -586,11 +305,10 @@ export default async function Page() {
</div>
</AnimateOnScroll>
{/* Extra Features - compact list */}
<AnimateOnScroll>
<div className="mt-8 md:mt-12">
<h3 className="text-lg font-semibold text-center mb-4 md:mb-6 text-muted-foreground">
E mais...
<h3 className="text-sm font-medium text-center mb-4 md:mb-6 text-muted-foreground">
Também inclui
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{extraFeatures.map((feature) => (
@@ -626,85 +344,141 @@ export default async function Page() {
</div>
</section>
{/* Companion Section */}
<section id="companion" className="py-12 md:py-24">
{/* Mobile Section */}
<section id="mobile" className="py-12 md:py-24">
<div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-5xl">
<div className="mx-auto max-w-6xl">
{/* Header */}
<AnimateOnScroll>
<div className="grid gap-8 md:gap-12 md:grid-cols-2 items-center">
{/* Text content */}
<div className="order-2 md:order-1">
<div className="text-center mb-12 md:mb-20">
<Badge variant="outline" className="mb-4">
<RiSmartphoneLine className="size-3.5 mr-1" />
Mobile
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
Use o OpenMonetis no celular sem perder o fluxo
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
Instale como PWA para acesso rápido no dia a dia. No Android,
use o Companion para capturar notificações bancárias
automaticamente.
</p>
</div>
</AnimateOnScroll>
{/* PWA — imagem esquerda, texto direita */}
<AnimateOnScroll>
<div className="grid gap-10 lg:gap-16 lg:grid-cols-2 items-center mb-16 md:mb-24">
<div className="flex justify-center">
<div className="relative">
<div className="absolute inset-0 bg-primary/8 rounded-3xl blur-3xl scale-90" />
<Image
src={landingImages.pwa.light}
alt="Preview PWA"
width={390}
height={844}
className="relative h-auto w-56 md:w-64 rounded-3xl shadow-lg dark:hidden"
/>
<Image
src={landingImages.pwa.dark}
alt="Preview PWA"
width={390}
height={844}
className="relative h-auto w-56 md:w-64 rounded-3xl shadow-lg hidden dark:block"
/>
</div>
</div>
<div>
<Badge variant="outline" className="mb-4">
<RiSmartphoneLine className="size-3.5 mr-1" />
App Android
PWA instalável
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
Capture automaticamente do seu celular
</h2>
<p className="text-base md:text-lg text-muted-foreground mb-6">
O OpenMonetis Companion captura notificações de apps
bancários e cria pré-lançamentos automaticamente para você
revisar.
<h3 className="text-2xl md:text-3xl font-bold tracking-tight mb-3">
Leve o OpenMonetis para a tela inicial
</h3>
<p className="text-muted-foreground mb-6 leading-relaxed">
Adicione à tela inicial e abra direto, como um app. Sem
depender de uma aba perdida no navegador. Funciona em
Android, iOS e desktop.
</p>
{/* Flow steps */}
<div className="space-y-3 mb-6">
{[
{
icon: RiNotification3Line,
title: "Notificação bancária chega",
subtitle: "O Companion intercepta automaticamente",
colorVar: "var(--data-1)",
},
{
icon: RiSmartphoneLine,
title: "Dados extraídos e enviados",
subtitle: "Valor, descrição e banco são identificados",
colorVar: "var(--data-4)",
},
{
icon: RiCheckLine,
title: "Revise e confirme no OpenMonetis",
subtitle:
"Pré-lançamentos ficam na inbox para sua aprovação",
colorVar: "var(--data-9)",
},
].map((step) => (
<div key={step.title} className="flex items-start gap-3">
<ul className="space-y-3">
{pwaHighlights.map((item) => (
<li key={item.title} className="flex items-start gap-3">
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
style={{
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`,
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`,
}}
>
<step.icon
className="size-4"
style={{ color: step.colorVar }}
<item.icon
className="size-[15px]"
style={{ color: item.colorVar }}
/>
</div>
<div>
<p className="text-sm font-medium">{step.title}</p>
<p className="text-xs text-muted-foreground">
{step.subtitle}
</p>
</div>
</div>
<p className="text-sm">
<span className="font-medium">{item.title}</span>
<span className="text-muted-foreground">
{" "}
{item.description}
</span>
</p>
</li>
))}
</div>
</ul>
</div>
</div>
</AnimateOnScroll>
{/* Supported banks */}
<div className="mb-6">
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">
Bancos suportados
{/* Companion — texto esquerda, imagem direita */}
<AnimateOnScroll>
<div className="grid gap-10 lg:gap-16 lg:grid-cols-2 items-center border-t pt-16 md:pt-24">
<div>
<div className="mb-4">
<Badge variant="outline">
<RiAndroidLine className="size-3.5 mr-1" />
Companion Android
</Badge>
</div>
<h3 className="text-2xl md:text-3xl font-bold tracking-tight mb-3">
Capture, envie e revise no mesmo fluxo
</h3>
<p className="text-muted-foreground mb-6 leading-relaxed">
O Companion captura notificações de apps bancários e cria
pré-lançamentos automaticamente para você revisar na inbox.
</p>
<ol className="space-y-3 mb-6">
{companionSteps.map((step, index) => (
<li key={step.title} className="flex items-start gap-3">
<span
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold"
style={{
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`,
color: step.colorVar,
}}
>
{index + 1}
</span>
<p className="text-sm">
<span className="font-medium">{step.title}</span>
<span className="text-muted-foreground">
{" "}
{step.description}
</span>
</p>
</li>
))}
</ol>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
Bancos testados
</p>
<div className="flex flex-wrap gap-2">
{companionBanks.map((bank) => (
<span
key={bank.name}
className="inline-flex items-center gap-1.5 rounded-full border bg-muted/50 py-1 text-xs font-medium"
className="inline-flex items-center gap-1.5 rounded-full border py-1 pr-3 text-xs font-medium"
style={{
paddingLeft: bank.logo ? "4px" : "12px",
paddingRight: "12px",
}}
>
{bank.logo && (
@@ -720,28 +494,32 @@ export default async function Page() {
</span>
))}
</div>
</div>
<Link
href="https://github.com/felipegcoutinho/openmonetis-companion"
target="_blank"
>
<Button variant="outline" className="gap-2">
<RiGithubFill className="size-4" />
<Link
href="https://github.com/felipegcoutinho/openmonetis-companion"
target="_blank"
className="mt-4 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<RiGithubFill className="size-3.5" />
Ver no GitHub
</Button>
</Link>
</Link>
</div>
</div>
{/* Companion Screenshot */}
<div className="order-1 md:order-2 flex items-center justify-center">
<div className="w-full max-w-[220px] md:max-w-[260px]">
<div className="flex justify-center order-first lg:order-last">
<div className="relative">
<div className="absolute inset-0 bg-primary/8 rounded-3xl blur-3xl scale-90" />
<Image
src="/images/openmonetis_companion.webp"
alt="OpenMonetis Companion App"
width={1080}
height={2217}
className="w-full h-auto rounded-2xl"
src={landingImages.companion.light}
alt="Preview Companion"
width={390}
height={844}
className="relative h-auto w-56 md:w-64 rounded-3xl shadow-lg dark:hidden"
/>
<Image
src={landingImages.companion.dark}
alt="Preview Companion"
width={390}
height={844}
className="relative h-auto w-56 md:w-64 rounded-3xl shadow-lg hidden dark:block"
/>
</div>
</div>
@@ -752,19 +530,19 @@ export default async function Page() {
</section>
{/* Tech Stack Section */}
<section id="stack" className="py-12 md:py-24">
<section id="stack" className="py-12 md:py-24 bg-muted/40">
<div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-5xl">
<div className="mx-auto max-w-6xl">
<AnimateOnScroll>
<div className="text-center mb-8 md:mb-12">
<Badge variant="outline" className="mb-4">
Stack técnica
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
Construído com tecnologias modernas
O que roda por baixo
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
Open source, self-hosted e fácil de customizar
Self-hosted, open source, type-safe do banco ao frontend
</p>
</div>
</AnimateOnScroll>
@@ -793,9 +571,6 @@ export default async function Page() {
<p className="text-sm text-muted-foreground mb-2 md:mb-3">
{item.subtitle}
</p>
<p className="text-xs text-muted-foreground">
{item.description}
</p>
</div>
</div>
</CardContent>
@@ -803,13 +578,6 @@ export default async function Page() {
))}
</div>
</AnimateOnScroll>
<div className="mt-6 md:mt-8 text-center">
<p className="text-sm text-muted-foreground">
Seus dados ficam no seu controle. Pode rodar localmente ou no
seu próprio servidor.
</p>
</div>
</div>
</div>
</section>
@@ -817,7 +585,7 @@ export default async function Page() {
{/* How to run Section */}
<section id="como-usar" className="py-12 md:py-24">
<div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-3xl">
<div className="mx-auto max-w-4xl">
<AnimateOnScroll>
<div className="text-center mb-8 md:mb-12">
<Badge variant="outline" className="mb-4">
@@ -850,19 +618,19 @@ export default async function Page() {
</section>
{/* Who is this for Section */}
<section className="py-12 md:py-24">
<section id="para-quem-e" className="py-12 md:py-24 bg-muted/40">
<div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-3xl">
<div className="mx-auto max-w-4xl">
<AnimateOnScroll>
<div className="text-center mb-8 md:mb-12">
<Badge variant="outline" className="mb-4">
Para quem é?
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
Para quem funciona?
Feito para quem gosta de controle
</h2>
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
O openmonetis funciona melhor se você:
O OpenMonetis não é para todo mundo.
</p>
</div>
</AnimateOnScroll>
@@ -896,19 +664,6 @@ export default async function Page() {
))}
</div>
</AnimateOnScroll>
<AnimateOnScroll>
<Alert className="mt-6 md:mt-8">
<RiInformationLine />
<AlertTitle>Não é para todo mundo</AlertTitle>
<AlertDescription>
Se você não se encaixa nisso, provavelmente vai abandonar
depois de uma semana. Tudo certo! Existem outras ferramentas
com sincronização automática que podem funcionar melhor pra
você.
</AlertDescription>
</Alert>
</AnimateOnScroll>
</div>
</div>
</section>
@@ -917,7 +672,7 @@ export default async function Page() {
<section className="py-12 md:py-24">
<div className="max-w-8xl mx-auto px-4">
<AnimateOnScroll>
<div className="mx-auto max-w-3xl rounded-2xl border bg-card px-6 py-12 md:py-16 text-center">
<div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center">
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
Pronto para testar?
</h2>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -51,7 +51,6 @@
--chart-9: var(--color-cyan-500);
--chart-10: var(--color-lime-500);
/* Data palette — análoga quente (hue 0120), família do primary */
--data-1: oklch(58% 0.22 18); /* vermelho-tijolo */
--data-2: oklch(64% 0.22 30); /* vermelho-laranja */
--data-3: oklch(69% 0.21 42); /* laranja (≈ primary) */
@@ -93,23 +92,23 @@
}
.dark {
--background: oklch(22% 0.004 55);
--background: oklch(18% 0.004 55);
--foreground: oklch(93% 0.008 80);
--card: oklch(25.5% 0.004 55);
--card: oklch(21.5% 0.004 55);
--card-foreground: var(--foreground);
--popover: oklch(28% 0.004 55);
--popover: oklch(24% 0.004 55);
--popover-foreground: var(--foreground);
--primary: oklch(72.069% 0.18335 44.069);
--primary-foreground: oklch(16% 0.004 60);
--secondary: oklch(29% 0.004 55);
--secondary: oklch(25% 0.004 55);
--secondary-foreground: var(--foreground);
--muted: oklch(32% 0.0035 55);
--muted: oklch(28% 0.0035 55);
--muted-foreground: oklch(73% 0.006 75);
--accent: oklch(33% 0.005 55);
--accent: oklch(29% 0.005 55);
--accent-foreground: var(--foreground);
--success: oklch(62% 0.16 150);
@@ -121,7 +120,7 @@
--destructive: oklch(62% 0.2 28);
--destructive-foreground: oklch(98% 0.005 30);
--border: oklch(35% 0.004 55);
--border: oklch(31% 0.004 55);
--input: var(--border);
--ring: var(--primary);
@@ -136,8 +135,6 @@
--chart-9: var(--color-cyan-500);
--chart-10: var(--color-lime-500);
/* Data palette — dark mode (ligeiramente mais vivos) */
/* Data palette dark — ligeiramente mais vivos para contrastar no fundo escuro */
--data-1: oklch(66% 0.22 18);
--data-2: oklch(72% 0.22 30);
--data-3: oklch(76% 0.21 42);
@@ -149,13 +146,13 @@
--data-9: oklch(69% 0.17 120);
--data-10: oklch(63% 0.15 10);
--sidebar: oklch(19.5% 0.004 55);
--sidebar: oklch(15.5% 0.004 55);
--sidebar-foreground: var(--foreground);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: oklch(30% 0.004 55);
--sidebar-accent: oklch(26% 0.004 55);
--sidebar-accent-foreground: var(--foreground);
--sidebar-border: oklch(34% 0.004 55);
--sidebar-border: oklch(30% 0.004 55);
--sidebar-ring: var(--primary);
--radius: 0.625rem;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -15,9 +15,10 @@ import {
const navLinks = [
{ href: "#telas", label: "Conheça as telas" },
{ href: "#funcionalidades", label: "Funcionalidades" },
{ href: "#companion", label: "Companion" },
{ href: "#mobile", label: "Mobile" },
{ href: "#stack", label: "Stack" },
{ href: "#como-usar", label: "Como usar" },
{ href: "#para-quem-e", label: "Para quem é?" },
];
interface MobileNavProps {
@@ -26,7 +27,11 @@ interface MobileNavProps {
triggerClassName?: string;
}
export function MobileNav({ isPublicDomain, isLoggedIn, triggerClassName }: MobileNavProps) {
export function MobileNav({
isPublicDomain,
isLoggedIn,
triggerClassName,
}: MobileNavProps) {
const [open, setOpen] = useState(false);
return (

View File

@@ -0,0 +1,107 @@
"use client";
import {
RiArrowLeftRightLine,
RiAtLine,
RiBankCard2Line,
RiBarChart2Line,
RiCalendarEventLine,
RiSecurePaymentLine,
} from "@remixicon/react";
import Image from "next/image";
import { landingImages } from "@/features/landing/images";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
const { screenshots } = landingImages;
const sections = [
{
value: "lancamentos",
label: "Lançamentos",
icon: RiArrowLeftRightLine,
...screenshots.lancamentos,
},
{
value: "pre-lancamentos",
label: "Pré-lançamentos",
icon: RiAtLine,
...screenshots.preLancamentos,
},
{
value: "orcamentos",
label: "Orçamentos",
icon: RiBarChart2Line,
...screenshots.orcamentos,
},
{
value: "parcelas",
label: "Análise de Parcelas",
icon: RiSecurePaymentLine,
...screenshots.parcelas,
},
{
value: "calendario",
label: "Calendário",
icon: RiCalendarEventLine,
...screenshots.calendario,
},
{
value: "cartoes",
label: "Cartões",
icon: RiBankCard2Line,
...screenshots.cartoes,
},
];
export function ScreenshotTabs() {
return (
<Tabs defaultValue="lancamentos" className="w-full">
<div className="flex flex-col gap-6">
<TabsList className="w-full h-auto flex-wrap gap-1">
{sections.map((s) => (
<TabsTrigger
key={s.value}
value={s.value}
className="flex-1 gap-1.5 lowercase"
>
<s.icon className="size-4" />
{s.label}
</TabsTrigger>
))}
</TabsList>
{sections.map((s) => (
<TabsContent key={s.value} value={s.value} className="w-full mt-0">
<div className="rounded-lg overflow-hidden border bg-card">
<div className="flex items-center gap-1.5 px-3 h-8 border-b bg-muted/50">
<div className="size-2.5 rounded-full bg-muted-foreground/20" />
<div className="size-2.5 rounded-full bg-muted-foreground/20" />
<div className="size-2.5 rounded-full bg-muted-foreground/20" />
<div className="ml-2 flex-1 max-w-52 h-4 rounded bg-muted-foreground/10" />
</div>
<Image
src={s.light}
alt={`Preview ${s.label}`}
width={1920}
height={1080}
className="w-full h-auto dark:hidden"
/>
<Image
src={s.dark}
alt={`Preview ${s.label}`}
width={1920}
height={1080}
className="w-full h-auto hidden dark:block"
/>
</div>
</TabsContent>
))}
</div>
</Tabs>
);
}

View File

@@ -0,0 +1,292 @@
import {
RiBankCard2Line,
RiBarChartBoxLine,
RiCalendarLine,
RiCheckLine,
RiCodeSSlashLine,
RiDatabase2Line,
RiDeviceLine,
RiDownloadCloudLine,
RiEyeOffLine,
RiFileTextLine,
RiFlashlightLine,
RiGitBranchLine,
RiLayoutGridLine,
RiLineChartLine,
RiLockLine,
RiNotification3Line,
RiPercentLine,
RiPieChartLine,
RiRobot2Line,
RiShieldCheckLine,
RiSmartphoneLine,
RiStarLine,
RiTeamLine,
RiTimeLine,
RiWalletLine,
} from "@remixicon/react";
import type { ComponentType } from "react";
export type FeatureItem = {
icon: ComponentType<{ className?: string; style?: React.CSSProperties }>;
title: string;
description: string;
colorVar: string;
};
export const navbarActionClassName =
"border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black";
export const navLinks = [
{ href: "#telas", label: "conheça as telas" },
{ href: "#funcionalidades", label: "funcionalidades" },
{ href: "#mobile", label: "mobile" },
{ href: "#stack", label: "stack" },
{ href: "#como-usar", label: "como usar" },
{ href: "#para-quem-e", label: "para quem é" },
] as const;
export const mainFeatures: FeatureItem[] = [
{
icon: RiWalletLine,
title: "Contas e transações",
description:
"Registre suas contas bancárias, cartões e dinheiro. Adicione receitas, despesas e transferências. Organize por categorias. Extratos detalhados por conta.",
colorVar: "var(--data-9)",
},
{
icon: RiPercentLine,
title: "Parcelamentos avançados",
description:
"Controle completo de compras parceladas. Antecipe parcelas com cálculo automático de desconto. Veja análise consolidada de todas as parcelas em aberto.",
colorVar: "var(--data-4)",
},
{
icon: RiRobot2Line,
title: "Insights com IA",
description:
"Análises financeiras geradas por IA (Claude, GPT, Gemini). Insights personalizados sobre seus padrões de gastos e recomendações inteligentes.",
colorVar: "var(--data-8)",
},
{
icon: RiBarChartBoxLine,
title: "Relatórios e gráficos",
description:
"Dashboard com 20+ widgets interativos. Relatórios detalhados por categoria. Gráficos de evolução e comparativos. Exportação em PDF e Excel.",
colorVar: "var(--data-5)",
},
{
icon: RiBankCard2Line,
title: "Faturas de cartão",
description:
"Cadastre seus cartões e acompanhe as faturas por período. Veja o que ainda não foi fechado. Controle limites, vencimentos e fechamentos.",
colorVar: "var(--data-1)",
},
{
icon: RiTeamLine,
title: "Gestão colaborativa",
description:
"Compartilhe pagadores com permissões granulares (admin/viewer). Notificações automáticas por e-mail. Colabore em lançamentos compartilhados.",
colorVar: "var(--data-3)",
},
];
export const extraFeatures: FeatureItem[] = [
{
icon: RiPieChartLine,
title: "Categorias e orçamentos",
description:
"Crie categorias personalizadas e defina orçamentos mensais com indicadores visuais.",
colorVar: "var(--data-7)",
},
{
icon: RiFileTextLine,
title: "Anotações e tarefas",
description:
"Notas de texto e listas de tarefas com checkboxes. Arquivamento para manter histórico.",
colorVar: "var(--data-6)",
},
{
icon: RiCalendarLine,
title: "Calendário financeiro",
description:
"Visualize transações em calendário mensal. Nunca perca prazos de pagamentos.",
colorVar: "var(--data-2)",
},
{
icon: RiDownloadCloudLine,
title: "Importação em massa",
description: "Lance múltiplos lançamentos de uma vez",
colorVar: "var(--data-9)",
},
{
icon: RiEyeOffLine,
title: "Modo privacidade",
description:
"Oculte valores sensíveis com um clique. Tema dark/light. Calculadora integrada.",
colorVar: "var(--data-4)",
},
{
icon: RiFlashlightLine,
title: "Performance otimizada",
description: "Sistema rápido e com alta performance",
colorVar: "var(--data-5)",
},
];
export const companionBanks = [
{ name: "Nubank", logo: "/logos/nubank.png" },
{ name: "Itaú", logo: "/logos/itau.png" },
{ name: "Inter", logo: "/logos/intermedium.png" },
{ name: "Mercado Pago", logo: "/logos/mercadopagocartao.png" },
];
export const pwaHighlights: FeatureItem[] = [
{
icon: RiSmartphoneLine,
title: "Instale direto da web",
description: "Adicione à tela inicial e abra como app, sem loja.",
colorVar: "var(--data-3)",
},
{
icon: RiLayoutGridLine,
title: "Acesso rápido ao que importa",
description: "Dashboard, inbox e lançamentos a um toque.",
colorVar: "var(--data-9)",
},
{
icon: RiFlashlightLine,
title: "Experiência mobile mais direta",
description: "Modo standalone com navegação limpa e fluida.",
colorVar: "var(--data-4)",
},
];
export const pwaCompatList = [
{
label: "Android",
description:
"Chrome e Edge — instale pelo banner ou pelo menu do navegador",
},
{
label: "iOS / iPadOS",
description: "Safari — adicione à tela inicial pelo menu compartilhar",
},
{
label: "Desktop",
description: "Chrome, Edge e outros — instale pela barra de endereço",
},
] as const;
export const companionSteps: FeatureItem[] = [
{
icon: RiNotification3Line,
title: "Notificação bancária chega",
description: "O Companion intercepta automaticamente",
colorVar: "var(--data-1)",
},
{
icon: RiSmartphoneLine,
title: "Dados extraídos e enviados",
description: "Valor, descrição e banco são identificados",
colorVar: "var(--data-4)",
},
{
icon: RiCheckLine,
title: "Revise e confirme no OpenMonetis",
description: "Pré-lançamentos ficam na inbox para sua aprovação",
colorVar: "var(--data-9)",
},
];
export const stackItems = [
{
icon: RiCodeSSlashLine,
title: "Frontend",
subtitle: "Next.js, TypeScript, Tailwind CSS, shadcn/ui",
description: "Interface moderna e responsiva com React 19 e App Router",
colorVar: "var(--data-3)",
},
{
icon: RiDatabase2Line,
title: "Backend",
subtitle: "PostgreSQL, Drizzle ORM, Better Auth",
description: "Banco relacional robusto com type-safe ORM",
colorVar: "var(--data-9)",
},
{
icon: RiShieldCheckLine,
title: "Segurança",
subtitle: "Better Auth com OAuth (Google) e autenticação por email",
description: "Sessões seguras e proteção de rotas por middleware",
colorVar: "var(--data-1)",
},
{
icon: RiDeviceLine,
title: "Deploy",
subtitle:
"Docker com multi-stage build, health checks e volumes persistentes",
description: "Fácil de rodar localmente ou em qualquer servidor",
colorVar: "var(--data-5)",
},
];
export const whoIsItForItems: FeatureItem[] = [
{
icon: RiTimeLine,
title: "Tem disciplina de registrar gastos",
description:
"Não se importa em dedicar alguns minutos por dia ou semana para manter tudo atualizado",
colorVar: "var(--data-4)",
},
{
icon: RiLockLine,
title: "Quer controle total sobre seus dados",
description:
"Prefere hospedar seus próprios dados ao invés de depender de serviços terceiros",
colorVar: "var(--data-9)",
},
{
icon: RiLineChartLine,
title: "Gosta de entender exatamente onde o dinheiro vai",
description:
"Quer visualizar padrões de gastos e tomar decisões informadas",
colorVar: "var(--data-3)",
},
{
icon: RiTimeLine,
title: "Não é plug and play",
description:
"Você vai precisar configurar as coisas, conectar suas contas e ajustar o sistema para o seu jeito de usar.",
colorVar: "var(--data-4)",
},
];
export function getMetricsItems(stars: number, forks: number) {
return [
{
icon: RiLayoutGridLine,
value: "20+",
label: "Widgets no dashboard",
colorVar: "var(--data-9)",
},
{
icon: RiShieldCheckLine,
value: "100%",
label: "Self-hosted",
colorVar: "var(--data-1)",
},
{
icon: RiStarLine,
value: `${stars}`,
label: "Stars no GitHub",
colorVar: "var(--data-4)",
},
{
icon: RiGitBranchLine,
value: `${forks}`,
label: "Forks no GitHub",
colorVar: "var(--data-3)",
},
];
}

View File

@@ -0,0 +1,59 @@
/**
* Centraliza todos os assets de imagem da landing page.
* Para adicionar ou renomear uma imagem, altere apenas aqui.
*
* Convenção:
* - { light, dark } → imagem com variante de tema
* - string → imagem única (sem variante dark)
*/
export const landingImages = {
/** Preview do dashboard no hero da página */
hero: {
light: "/images/dashboard-preview-light.webp",
dark: "/images/dashboard-preview-dark.webp",
},
/** Mockup do app instalado como PWA */
pwa: {
light: "/images/pwa-preview-light.webp",
dark: "/images/pwa-preview-dark.webp",
},
/** Mockup do Companion Android */
companion: {
light: "/images/companion-preview-light.webp",
dark: "/images/companion-preview-dark.webp",
},
/** Screenshots usados nas abas da seção "Conheça as telas" */
screenshots: {
lancamentos: {
light: "/images/preview-lancamentos-light.webp",
dark: "/images/preview-lancamentos-dark.webp",
},
/** Ainda sem print próprio — usando lançamentos como placeholder */
preLancamentos: {
light: "/images/preview-pre-lancamentos-light.webp",
dark: "/images/preview-pre-lancamentos-dark.webp",
},
/** Ainda sem print próprio — usando lançamentos como placeholder */
orcamentos: {
light: "/images/preview-orcamentos-light.webp",
dark: "/images/preview-orcamentos-dark.webp",
},
/** Ainda sem print próprio — usando lançamentos como placeholder */
parcelas: {
light: "/images/preview-parcelas-light.webp",
dark: "/images/preview-parcelas-dark.webp",
},
calendario: {
light: "/images/preview-calendario-light.webp",
dark: "/images/preview-calendario-dark.webp",
},
cartoes: {
light: "/images/preview-cartao-light.webp",
dark: "/images/preview-cartao-dark.webp",
},
},
} as const;

View File

@@ -0,0 +1,19 @@
export async function fetchGitHubStats(): Promise<{
stars: number;
forks: number;
}> {
try {
const res = await fetch(
"https://api.github.com/repos/felipegcoutinho/openmonetis",
{ next: { revalidate: 3600 } },
);
if (!res.ok) return { stars: 200, forks: 60 };
const data = await res.json();
return {
stars: data.stargazers_count as number,
forks: data.forks_count as number,
};
} catch {
return { stars: 200, forks: 60 };
}
}

View File

@@ -4,12 +4,9 @@ import { DEFAULT_PAYER_AVATAR } from "@/shared/lib/payers/constants";
const AVATAR_DIRECTORY = path.join(process.cwd(), "public", "avatars");
const AVATAR_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]);
let avatarOptionsPromise: Promise<string[]> | null = null;
/**
* Loads available avatar files from the public/avatars directory
* @returns Array of unique avatar filenames sorted alphabetically
*/
export async function loadAvatarOptions() {
async function readAvatarOptions() {
try {
const files = await readdir(AVATAR_DIRECTORY, { withFileTypes: true });
@@ -28,3 +25,12 @@ export async function loadAvatarOptions() {
return [DEFAULT_PAYER_AVATAR];
}
}
/**
* Loads available avatar files from the public/avatars directory
* @returns Array of unique avatar filenames sorted alphabetically
*/
export async function loadAvatarOptions() {
avatarOptionsPromise ??= readAvatarOptions();
return avatarOptionsPromise;
}

View File

@@ -26,7 +26,10 @@ export function Logo({
alt="OpenMonetis"
width={32}
height={32}
className={cn("object-contain", !colorIcon && "brightness-0 saturate-0")}
className={cn(
"object-contain",
!colorIcon && "brightness-0 saturate-0",
)}
priority
/>
<Image

View File

@@ -3,12 +3,9 @@ import path from "node:path";
const LOGOS_DIRECTORY = path.join(process.cwd(), "public", "logos");
const LOGO_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]);
let logoOptionsPromise: Promise<string[]> | null = null;
/**
* Loads available logo files from the public/logos directory
* @returns Array of logo filenames sorted alphabetically
*/
export async function loadLogoOptions() {
async function readLogoOptions() {
try {
const files = await readdir(LOGOS_DIRECTORY, { withFileTypes: true });
@@ -21,3 +18,8 @@ export async function loadLogoOptions() {
return [];
}
}
export async function loadLogoOptions() {
logoOptionsPromise ??= readLogoOptions();
return logoOptionsPromise;
}