From 205b018bb459d7979978376865dab4fe4d3aa50a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 17 Feb 2026 00:04:22 -0300 Subject: [PATCH] Add petitions page with basic structure and shadcn components --- components.json | 23 ++ .../(frontend)/abaixo-assinado/[id]/page.tsx | 101 +++++++- src/app/(frontend)/styles.css | 121 +++++++++ src/components/ui/field.tsx | 244 ++++++++++++++++++ src/components/ui/label.tsx | 26 ++ src/components/ui/separator.tsx | 31 +++ src/lib/utils.ts | 6 + 7 files changed, 547 insertions(+), 5 deletions(-) create mode 100644 components.json create mode 100644 src/components/ui/field.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/lib/utils.ts diff --git a/components.json b/components.json new file mode 100644 index 00000000..691e860a --- /dev/null +++ b/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/(frontend)/styles.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/src/app/(frontend)/abaixo-assinado/[id]/page.tsx b/src/app/(frontend)/abaixo-assinado/[id]/page.tsx index cf2ef2d5..7a051fd8 100644 --- a/src/app/(frontend)/abaixo-assinado/[id]/page.tsx +++ b/src/app/(frontend)/abaixo-assinado/[id]/page.tsx @@ -1,7 +1,11 @@ import { getCachedDocumentById, getDocuments } from '@/utilities/documents' import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html' import { notFound } from 'next/navigation' -import { headers as nextHeaders } from 'next/headers' +import Link from 'next/link' +import { FieldDescription, FieldLegend, FieldSet } from '@/components/ui/field' +import z from 'zod' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' export async function generateStaticParams() { const payload = await getDocuments('petition') @@ -14,17 +18,104 @@ export async function generateStaticParams() { export default async function Page({ params }: { params: Promise<{ id: string }> }) { const petition = await getCachedDocumentById('petition', (await params).id)() + const methods = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + email: '', + phone: '', + state: '', + city: '', + postalCode: '', + comment: '', + }, + }) + if (!petition || !petition.enabled) { return notFound() } + const onSubmit = (data: z.infer) => { + console.log(data) + } + return ( -
-

{petition.title}

-

{petition.subtitle}

+
+
+

{petition.title}

+

{petition.subtitle}

+ Assinar +
-
+
+
+ {petition.formTitle} + {petition.formDescription} +
+
+ ) } + +const STATES = [ + 'AC', + 'AL', + 'AM', + 'AP', + 'BA', + 'CE', + 'DF', + 'ES', + 'GO', + 'MA', + 'MG', + 'MS', + 'MT', + 'PA', + 'PB', + 'PE', + 'PI', + 'PR', + 'RJ', + 'RN', + 'RO', + 'RR', + 'RS', + 'SC', + 'SE', + 'SP', + 'TO', +] + +const formSchema = z.object({ + name: z + .string() + .trim() + .min(2, 'Nome deve ter pelo menos 2 caracteres') + .max(120, 'Nome deve ter no máximo 100 caracteres') + .regex( + /^(?=.* )[\p{L}\p{M}]+(?:[- ][\p{L}\p{M}]+)*$/u, + 'Informe nome e sobrenome. Use apenas letras, no máximo um espaço ou hífen entre termos, e sem espaço no início ou no fim.', + ), + email: z.email('Email inválido'), + phone: z + .string() + .trim() + .length(11, 'Telefone celular inválido') + .regex(/^\d{11}$/, 'Telefone celular inválido'), + state: z.literal(STATES, 'Estado inválido'), + city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'), + postalCode: z + .string() + .trim() + .regex(/^(?:\d{8})?$/, 'CEP inválido') + .optional(), + comment: z + .string() + .trim() + .max(1000, 'Comentário muito longo') + .refine((v) => !/<\/?[a-z][\s\S]*>/i.test(v), 'O comentário não pode conter HTML') + .optional(), +}) diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index d31e080a..62453e5d 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -1,8 +1,45 @@ @import 'tailwindcss'; + +@plugin 'tailwindcss-animate'; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); @source "../../**/*.{js,ts,jsx,tsx,mdx}"; :root { --font-mono: 'Roboto Mono', monospace; + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } * { @@ -95,3 +132,87 @@ svg { button { cursor: pointer; } + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx new file mode 100644 index 00000000..0a276fcb --- /dev/null +++ b/src/components/ui/field.tsx @@ -0,0 +1,244 @@ +"use client" + +import { useMemo } from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-slot=field-group]]:gap-4", + className + )} + {...props} + /> + ) +} + +const fieldVariants = cva( + "group/field data-[invalid=true]:text-destructive flex w-full gap-3", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start", + ], + responsive: [ + "@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +