Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/web/components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
4 changes: 2 additions & 2 deletions apps/web/index.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<!doctype html>
<html lang="pt-BR">
<html lang="pt-BR" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark light" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#14171b" />
<title>Prumo</title>
</head>
Expand Down
9 changes: 8 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,22 @@
"clean": "rm -rf dist *.tsbuildinfo"
},
"dependencies": {
"@base-ui/react": "^1.7.0",
"@fontsource-variable/ibm-plex-sans": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@orpc/client": "^1.15.0",
"@orpc/openapi-client": "^1.15.0",
"@orpc/tanstack-query": "^1.15.0",
"@prumo/contract": "*",
"@tanstack/react-query": "^5.102.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.34.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"shadcn": "^4.19.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
Expand Down
48 changes: 22 additions & 26 deletions apps/web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { api, formatUsd, newCommandId } from './api.js'
import { Credentials } from './credentials.js'
import { Indice } from './indice.js'
import { Indice } from './features/precos/indice.js'

/**
* The shape of the product, in the order a person actually needs it.
Expand Down Expand Up @@ -40,9 +40,9 @@ export function App() {
<div className="flex flex-col gap-1">
<div className="flex items-baseline gap-3">
<h1 className="text-3xl font-semibold tracking-tight">Prumo</h1>
<span className="font-mono text-xs text-(--color-brass)">M3</span>
<span className="font-mono text-xs text-primary">M3</span>
</div>
<p className="max-w-xl text-sm text-(--color-ink-muted)">
<p className="max-w-xl text-sm text-muted-foreground">
O preço real de gerar imagem com IA. Nenhuma geração existe ainda — é o próximo marco.
</p>
</div>
Expand Down Expand Up @@ -70,11 +70,7 @@ export function App() {
}

function Panel({ children }: { children: React.ReactNode }) {
return (
<section className="rounded-lg border border-(--color-line) bg-(--color-surface) p-5">
{children}
</section>
)
return <section className="rounded-lg border border-border bg-card p-5">{children}</section>
}

function Settings({ user }: { user: { email: string; role: string; timezone: string } }) {
Expand All @@ -94,13 +90,13 @@ function Settings({ user }: { user: { email: string; role: string; timezone: str
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm">{user.email}</p>
<p className="font-mono text-xs text-(--color-ink-muted)">
<p className="font-mono text-xs text-muted-foreground">
{user.role} · {user.timezone}
</p>
</div>
<button
onClick={() => signOut.mutate()}
className="min-h-10 rounded-lg border border-(--color-edge) px-3 text-sm"
className="min-h-10 rounded-lg border border-input px-3 text-sm"
>
Sair
</button>
Expand All @@ -111,13 +107,13 @@ function Settings({ user }: { user: { email: string; role: string; timezone: str

<Panel>
<h2 className="mb-1 text-sm font-medium">Tetos de gasto</h2>
<p className="mb-4 text-xs text-(--color-ink-muted)">
<p className="mb-4 text-xs text-muted-foreground">
Uma conta nova começa com teto zero. Nada é gasto até alguém escrever um número — o
contrário seria um sistema que gasta dinheiro que ninguém autorizou.
</p>

{budgets.isPending ? (
<p className="text-sm text-(--color-ink-muted)">Carregando…</p>
<p className="text-sm text-muted-foreground">Carregando…</p>
) : (
<ul className="flex flex-col gap-3">
{budgets.data?.budgets.map((budget) => (
Expand Down Expand Up @@ -163,7 +159,7 @@ function SignedOut({ registrationOpen }: { registrationOpen: boolean }) {

return (
<Panel>
<p className="mb-4 text-xs text-(--color-ink-muted)">
<p className="mb-4 text-xs text-muted-foreground">
A conta serve para guardar chave e teto de gasto. O índice de preços continua aberto sem
ela.
</p>
Expand All @@ -187,41 +183,41 @@ function SignedOut({ registrationOpen }: { registrationOpen: boolean }) {
}}
>
<label className="flex flex-col gap-1">
<span className="text-xs text-(--color-ink-muted)">E-mail</span>
<span className="text-xs text-muted-foreground">E-mail</span>
<input
type="email"
autoComplete="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="w-full rounded-lg border border-(--color-edge) bg-(--color-ground) px-3 py-2.5 text-sm"
className="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm"
/>
</label>

<label className="flex flex-col gap-1">
<span className="text-xs text-(--color-ink-muted)">Senha</span>
<span className="text-xs text-muted-foreground">Senha</span>
<input
type="password"
autoComplete={mode === 'register' ? 'new-password' : 'current-password'}
required
minLength={12}
value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded-lg border border-(--color-edge) bg-(--color-ground) px-3 py-2.5 text-sm"
className="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm"
/>
<span className="text-xs text-(--color-ink-muted)">Mínimo de 12 caracteres.</span>
<span className="text-xs text-muted-foreground">Mínimo de 12 caracteres.</span>
</label>

<button
type="submit"
disabled={submit.isPending}
className="mt-1 rounded bg-(--color-brass) px-4 py-2 text-sm font-medium text-(--color-ground) disabled:opacity-60"
className="mt-1 rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-60"
>
{submit.isPending ? 'Enviando…' : mode === 'register' ? 'Criar conta' : 'Entrar'}
</button>

{error && (
<p role="status" aria-live="polite" className="text-sm text-(--color-state-failed)">
<p role="status" aria-live="polite" className="text-sm text-(--color-falha-fg)">
{error}
</p>
)}
Expand Down Expand Up @@ -255,10 +251,10 @@ function BudgetRow({
})

return (
<li className="flex flex-wrap items-center justify-between gap-3 border-t border-(--color-line) pt-3 first:border-t-0 first:pt-0">
<li className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-3 first:border-t-0 first:pt-0">
<div>
<p className="text-sm">{budget.period === 'month' ? 'Mês' : 'Dia'}</p>
<p className="font-mono text-xs text-(--color-ink-muted)">
<p className="font-mono text-xs text-muted-foreground">
gasto {formatUsd(budget.spentNanoUsd)} · reservado {formatUsd(budget.reservedNanoUsd)} ·
teto {formatUsd(budget.capNanoUsd)}
</p>
Expand All @@ -277,12 +273,12 @@ function BudgetRow({
aria-label={`Novo teto em dólares para ${budget.period === 'month' ? 'o mês' : 'o dia'}`}
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="w-24 min-h-10 rounded-lg border border-(--color-edge) bg-(--color-ground) px-2 text-right font-mono text-sm tabular-nums"
className="w-24 min-h-10 rounded-lg border border-input bg-background px-2 text-right font-mono text-sm tabular-nums"
/>
<button
type="submit"
disabled={save.isPending}
className="min-h-10 rounded-lg border border-(--color-edge) px-3 text-sm disabled:opacity-60"
className="min-h-10 rounded-lg border border-input px-3 text-sm disabled:opacity-60"
>
Salvar
</button>
Expand All @@ -309,8 +305,8 @@ function Tab({
// 3:1). A aba ativa ganha peso, cor de tinta cheia e uma régua de latão
// embaixo — três canais, nenhum deles só matiz.
active
? 'min-h-10 rounded-lg border-b-2 border-(--color-brass) px-3 font-medium text-(--color-ink)'
: 'min-h-10 rounded-lg border-b-2 border-transparent px-3 text-(--color-ink-muted) hover:text-(--color-ink)'
? 'min-h-10 rounded-lg border-b-2 border-primary px-3 font-medium text-foreground'
: 'min-h-10 rounded-lg border-b-2 border-transparent px-3 text-muted-foreground hover:text-foreground'
}
>
{children}
Expand Down
81 changes: 81 additions & 0 deletions apps/web/src/components/padroes/estados.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { CircleAlert, RotateCw, SearchX } from 'lucide-react'
import type { ReactNode } from 'react'
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'

/**
* Os quatro estados que toda tela tem e quase nenhuma escreve.
*
* Estavam improvisados em três arquivos, cada um com um texto e nenhum com saída.
* Vazio POR AUSÊNCIA e vazio POR FILTRO são coisas diferentes — numa tela com
* quatro filtros, é a diferença entre "não existe" e "você filtrou demais", e só
* a segunda tem botão.
*/

export function Carregando({ linhas = 8 }: { linhas?: number }) {
return (
<div className="flex flex-col gap-2" aria-busy="true" aria-live="polite">
<span className="sr-only">Carregando o índice de preços…</span>
{/* Esqueleto com a FORMA do conteúdo, não um giro genérico: a pessoa
entende o que vai chegar antes de chegar. */}
{Array.from({ length: linhas }, (_, i) => (
<div key={i} className="flex items-center gap-4 rounded-lg border border-border p-3">
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
)
}

export function EstadoErro({
titulo,
detalhe,
aoTentarNovamente,
}: {
titulo: string
detalhe: string
aoTentarNovamente?: () => void
}) {
return (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>{titulo}</AlertTitle>
<AlertDescription>{detalhe}</AlertDescription>
{aoTentarNovamente && (
<AlertAction>
<Button variant="outline" size="sm" onClick={aoTentarNovamente}>
<RotateCw />
Tentar de novo
</Button>
</AlertAction>
)}
</Alert>
)
}

export function EstadoVazio({
titulo,
detalhe,
acao,
}: {
titulo: string
detalhe: ReactNode
acao?: ReactNode
}) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-input px-6 py-14 text-center">
<SearchX className="size-6 text-muted-foreground" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-medium">{titulo}</p>
<p className="max-w-md text-xs text-muted-foreground">{detalhe}</p>
</div>
{acao}
</div>
)
}
69 changes: 69 additions & 0 deletions apps/web/src/components/ui/alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'

import { cn } from '@/lib/utils'

const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
},
},
defaultVariants: {
variant: 'default',
},
},
)

function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}

function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn(
'font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground',
className,
)}
{...props}
/>
)
}

function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4',
className,
)}
{...props}
/>
)
}

function AlertAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="alert-action" className={cn('absolute top-2 right-2', className)} {...props} />
)
}

export { Alert, AlertTitle, AlertDescription, AlertAction }
Loading
Loading