feat(v1.5.3): status de pagamento no painel do pagador + SEO landing + WebP

- Card de Status de Pagamento com totais pagos/pendentes e lista de boletos individuais
- Validação obrigatória de categoria/conta/cartão no dialog de lançamento (client + server)
- SEO completo na landing: Open Graph, Twitter Card, JSON-LD, sitemap.xml, robots.txt
- Imagens convertidas de PNG para WebP (performance)
- HTML lang corrigido para pt-BR; template de título dinâmico

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-02-21 17:48:52 +00:00
parent 5638ccc36a
commit 31fe752b7d
32 changed files with 600 additions and 176 deletions

View File

@@ -182,6 +182,30 @@ const refineLancamento = (
data: z.infer<typeof baseFields> & { id?: string },
ctx: z.RefinementCtx,
) => {
if (!data.categoriaId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["categoriaId"],
message: "Selecione uma categoria.",
});
}
if (data.paymentMethod === "Cartão de crédito") {
if (!data.cartaoId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["cartaoId"],
message: "Selecione o cartão.",
});
}
} else if (!data.contaId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["contaId"],
message: "Selecione a conta.",
});
}
if (data.condition === "Parcelado") {
if (!data.installmentCount) {
ctx.addIssue({

View File

@@ -1,3 +1,8 @@
import {
RiBankCard2Line,
RiBarcodeLine,
RiWallet3Line,
} from "@remixicon/react";
import { notFound } from "next/navigation";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
@@ -13,9 +18,13 @@ import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-histo
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
import { PagadorMonthlySummaryCard } from "@/components/pagadores/details/pagador-monthly-summary-card";
import { PagadorBoletoCard } from "@/components/pagadores/details/pagador-payment-method-cards";
import {
PagadorBoletoCard,
PagadorPaymentStatusCard,
} from "@/components/pagadores/details/pagador-payment-method-cards";
import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import WidgetCard from "@/components/widget-card";
import type { pagadores } from "@/db/schema";
import { getUserId } from "@/lib/auth/server";
import {
@@ -34,10 +43,12 @@ import {
} from "@/lib/lancamentos/page-helpers";
import { getPagadorAccess } from "@/lib/pagadores/access";
import {
fetchPagadorBoletoItems,
fetchPagadorBoletoStats,
fetchPagadorCardUsage,
fetchPagadorHistory,
fetchPagadorMonthlyBreakdown,
fetchPagadorPaymentStatus,
} from "@/lib/pagadores/details";
import { parsePeriodParam } from "@/lib/utils/period";
import {
@@ -152,6 +163,8 @@ export default async function Page({ params, searchParams }: PageProps) {
historyData,
cardUsage,
boletoStats,
boletoItems,
paymentStatus,
shareRows,
currentUserShare,
estabelecimentos,
@@ -177,6 +190,16 @@ export default async function Page({ params, searchParams }: PageProps) {
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorBoletoItems({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorPaymentStatus({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
sharesPromise,
currentUserSharePromise,
getRecentEstablishmentsAction(),
@@ -308,7 +331,7 @@ export default async function Page({ params, searchParams }: PageProps) {
</TabsContent>
<TabsContent value="painel" className="space-y-4">
<section className="grid gap-4 lg:grid-cols-2">
<section className="grid gap-3 lg:grid-cols-2">
<PagadorMonthlySummaryCard
periodLabel={periodLabel}
breakdown={monthlyBreakdown}
@@ -316,9 +339,28 @@ export default async function Page({ params, searchParams }: PageProps) {
<PagadorHistoryCard data={historyData} />
</section>
<section className="grid gap-4 lg:grid-cols-2">
<PagadorCardUsageCard items={cardUsage} />
<PagadorBoletoCard stats={boletoStats} />
<section className="grid gap-3 lg:grid-cols-3">
<WidgetCard
title="Minhas Faturas"
subtitle="Valores por cartão neste período"
icon={<RiBankCard2Line className="size-4" />}
>
<PagadorCardUsageCard items={cardUsage} />
</WidgetCard>
<WidgetCard
title="Boletos"
subtitle="Boletos registrados neste período"
icon={<RiBarcodeLine className="size-4" />}
>
<PagadorBoletoCard items={boletoItems} />
</WidgetCard>
<WidgetCard
title="Status de Pagamento"
subtitle="Situação das despesas no período"
icon={<RiWallet3Line className="size-4" />}
>
<PagadorPaymentStatusCard data={paymentStatus} />
</WidgetCard>
</section>
</TabsContent>

View File

@@ -0,0 +1,95 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
const BASE_URL = process.env.PUBLIC_DOMAIN
? `https://${process.env.PUBLIC_DOMAIN}`
: "https://openmonetis.com";
const TITLE = "OpenMonetis | Finanças pessoais self-hosted e open source";
const DESCRIPTION =
"Aplicativo self-hosted de finanças pessoais. Controle lançamentos, cartões, orçamentos e categorias com total privacidade. Open source e gratuito.";
export const metadata: Metadata = {
metadataBase: new URL(BASE_URL),
title: TITLE,
description: DESCRIPTION,
keywords: [
"finanças pessoais",
"controle financeiro",
"self-hosted",
"open source",
"gestão financeira",
"orçamento pessoal",
"lançamentos financeiros",
"cartão de crédito",
"planejamento financeiro",
],
alternates: {
canonical: "/",
},
openGraph: {
type: "website",
locale: "pt_BR",
url: "/",
siteName: "OpenMonetis",
title: TITLE,
description: DESCRIPTION,
images: [
{
url: "/dashboard-preview-light.webp",
width: 1920,
height: 1080,
alt: "OpenMonetis — Dashboard de finanças pessoais",
},
],
},
twitter: {
card: "summary_large_image",
title: TITLE,
description: DESCRIPTION,
images: ["/dashboard-preview-light.webp"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
},
},
};
export default function LandingLayout({ children }: { children: ReactNode }) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "OpenMonetis",
applicationCategory: "FinanceApplication",
operatingSystem: "Web",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "BRL",
},
description: DESCRIPTION,
url: BASE_URL,
isAccessibleForFree: true,
author: {
"@type": "Organization",
name: "OpenMonetis",
url: BASE_URL,
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{children}
</>
);
}

View File

@@ -120,20 +120,20 @@ const screenshotSections = [
{
title: "Lançamentos",
description: "Registre e organize todas as suas transações financeiras",
lightSrc: "/preview-lancamentos-light.png",
darkSrc: "/preview-lancamentos-dark.png",
lightSrc: "/preview-lancamentos-light.webp",
darkSrc: "/preview-lancamentos-dark.webp",
},
{
title: "Calendário",
description: "Visualize suas finanças no calendário mensal",
lightSrc: "/preview-calendario-light.png",
darkSrc: "/preview-calendario-dark.png",
lightSrc: "/preview-calendario-light.webp",
darkSrc: "/preview-calendario-dark.webp",
},
{
title: "Cartões",
description: "Acompanhe faturas, limites e vencimentos dos seus cartões",
lightSrc: "/preview-cartao-light.png",
darkSrc: "/preview-cartao-dark.png",
lightSrc: "/preview-cartao-light.webp",
darkSrc: "/preview-cartao-dark.webp",
},
];
@@ -355,7 +355,7 @@ export default async function Page() {
<AnimateOnScroll>
<div>
<Image
src="/dashboard-preview-light.png"
src="/dashboard-preview-light.webp"
alt="openmonetis Dashboard Preview"
width={1920}
height={1080}
@@ -363,7 +363,7 @@ export default async function Page() {
priority
/>
<Image
src="/dashboard-preview-dark.png"
src="/dashboard-preview-dark.webp"
alt="openmonetis Dashboard Preview"
width={1920}
height={1080}
@@ -609,7 +609,7 @@ export default async function Page() {
<div className="order-1 md:order-2 flex items-center justify-center">
<div className="w-full max-w-[220px] md:max-w-[260px]">
<Image
src="/openmonetis_companion.png"
src="/openmonetis_companion.webp"
alt="OpenMonetis Companion App"
width={1080}
height={2217}

View File

@@ -7,7 +7,10 @@ import { allFontVariables } from "@/public/fonts/font_index";
import "./globals.css";
export const metadata: Metadata = {
title: "OpenMonetis | Suas finanças, do seu jeito",
title: {
default: "OpenMonetis | Suas finanças, do seu jeito",
template: "%s | OpenMonetis",
},
description:
"Controle suas finanças pessoais de forma simples e transparente.",
};
@@ -18,7 +21,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className={allFontVariables} suppressHydrationWarning>
<html lang="pt-BR" className={allFontVariables} suppressHydrationWarning>
<head>
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
</head>

36
app/robots.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { MetadataRoute } from "next";
const BASE_URL = process.env.PUBLIC_DOMAIN
? `https://${process.env.PUBLIC_DOMAIN}`
: "https://openmonetis.com";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/dashboard",
"/lancamentos",
"/contas",
"/cartoes",
"/categorias",
"/orcamentos",
"/pagadores",
"/anotacoes",
"/insights",
"/calendario",
"/consultor",
"/ajustes",
"/relatorios",
"/top-estabelecimentos",
"/pre-lancamentos",
"/login",
"/api/",
],
},
],
sitemap: `${BASE_URL}/sitemap.xml`,
};
}

16
app/sitemap.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { MetadataRoute } from "next";
const BASE_URL = process.env.PUBLIC_DOMAIN
? `https://${process.env.PUBLIC_DOMAIN}`
: "https://openmonetis.com";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: BASE_URL,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 1,
},
];
}