feat: 🚀
This commit is contained in:
commit
e775435c9f
53 files changed
+14240
No files matched your search
+113
@@ -0,0 +1,113 @@
|
||||
'use server'
|
||||
import prisma from "@/lib/prisma";
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function verificarLista(id: string, criar: boolean = false) {
|
||||
try {
|
||||
let lista = await prisma.lista.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!lista && criar) {
|
||||
lista = await prisma.lista.create({
|
||||
data: { id }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
exists: !!lista,
|
||||
lista
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erro ao verificar lista:', error);
|
||||
return { success: false, error: 'Erro ao processar solicitação' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function criarItens(formData: FormData, listaId: string) {
|
||||
try {
|
||||
const produto = formData.get('produtoNome') as string;
|
||||
const qtd = Number(formData.get('produtoQuantidade'));
|
||||
const tipo = formData.get('produtoTipo') as string;
|
||||
|
||||
if (!produto || !qtd || !tipo) {
|
||||
throw new Error('Todos os campos são obrigatórios');
|
||||
}
|
||||
|
||||
const novoProduto = await prisma.produtos.create({
|
||||
data: {
|
||||
nome: produto,
|
||||
qtd: qtd,
|
||||
tipoUN: tipo,
|
||||
listaId
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath(`/lista/${listaId}`);
|
||||
return { success: true, data: novoProduto };
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar produto:', error);
|
||||
return { success: false, error: 'Erro ao criar produto' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function listarProdutos(listaId: string) {
|
||||
try {
|
||||
const produtos = await prisma.produtos.findMany({
|
||||
where: { listaId },
|
||||
orderBy: {
|
||||
id: 'desc'
|
||||
}
|
||||
});
|
||||
return produtos;
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar produtos:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletarProduto(id: number, listaId: string) {
|
||||
try {
|
||||
await prisma.produtos.delete({
|
||||
where: {
|
||||
id: id,
|
||||
listaId: listaId
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath(`/lista/${listaId}`);
|
||||
return { success: true, message: 'Produto removido com sucesso' };
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar produto:', error);
|
||||
return { success: false, error: 'Erro ao remover produto' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleComprado(id: number, listaId: string) {
|
||||
try {
|
||||
const produto = await prisma.produtos.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!produto) {
|
||||
throw new Error('Produto não encontrado');
|
||||
}
|
||||
|
||||
await prisma.produtos.update({
|
||||
where: {
|
||||
id,
|
||||
listaId: listaId
|
||||
},
|
||||
data: {
|
||||
comprado: !produto.comprado
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath(`/lista/${listaId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Erro ao atualizar status:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { listarProdutos } from "@/app/actions";
|
||||
import { ProductCard } from "./ProductCard";
|
||||
|
||||
interface Produto {
|
||||
id: number;
|
||||
nome: string;
|
||||
qtd: number;
|
||||
tipoUN: string;
|
||||
comprado: boolean;
|
||||
listaId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface CardListProps {
|
||||
listaId: string;
|
||||
}
|
||||
|
||||
export async function CardList({ listaId }: CardListProps) {
|
||||
const produtos = await listarProdutos(listaId);
|
||||
|
||||
if (produtos.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-8 text-gray-500">
|
||||
Nenhum produto cadastrado ainda.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const produtosNaoComprados = produtos.filter((p: Produto) => !p.comprado);
|
||||
const produtosComprados = produtos.filter((p: Produto) => p.comprado);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-4">Produtos Pendentes</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{produtosNaoComprados.map((produto: Produto) => (
|
||||
<ProductCard
|
||||
key={produto.id}
|
||||
produto={produto}
|
||||
listaId={listaId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{produtosComprados.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-4 text-green-700">
|
||||
Produtos Comprados
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{produtosComprados.map((produto: Produto) => (
|
||||
<ProductCard
|
||||
key={produto.id}
|
||||
produto={produto}
|
||||
listaId={listaId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { deletarProduto } from "../actions";
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface DeleteButtonProps {
|
||||
produtoId: number;
|
||||
produtoNome: string;
|
||||
listaId: string;
|
||||
}
|
||||
|
||||
export function DeleteButton({ produtoId, produtoNome, listaId }: DeleteButtonProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await deletarProduto(produtoId, listaId);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="text-white hover:bg-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja excluir o produto "{produtoNome}"?
|
||||
Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Excluindo...' : 'Excluir'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="mt-16 pb-6 text-center">
|
||||
<Link
|
||||
href="https://evandro.dev.br"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-full
|
||||
bg-gradient-to-r from-slate-900 to-slate-700
|
||||
dark:from-slate-200 dark:to-slate-400
|
||||
text-white dark:text-slate-900
|
||||
hover:shadow-lg transition-all duration-300
|
||||
transform hover:scale-105 hover:-translate-y-0.5"
|
||||
>
|
||||
<span className="font-medium tracking-wide">evandro.dev.br</span>
|
||||
<svg
|
||||
className="w-4 h-4 transform transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M14 5l7 7m0 0l-7 7m7-7H3"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { verificarLista } from '../actions'
|
||||
|
||||
export function IdInput() {
|
||||
const [id, setId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
if (!id.trim()) {
|
||||
setError('Digite um ID válido')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await verificarLista(id.trim(), true) // Forçar criação se não existir
|
||||
if (result.success) {
|
||||
localStorage.setItem('listaId', id.trim())
|
||||
router.push(`/lista/${id.trim()}`)
|
||||
} else {
|
||||
setError('Erro ao acessar/criar lista')
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Erro ao processar solicitação')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-xl shadow-lg">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Acesse sua lista
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Digite um ID para acessar ou criar uma nova lista
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Digite o ID da lista"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
className="appearance-none rounded-lg relative block w-full px-3 py-2 border dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-red-500 text-sm text-center">{error}</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
{loading ? 'Carregando...' : 'Acessar/Criar Lista'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { RefreshCcw, Plus } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { verificarLista } from "../actions"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
|
||||
interface ListHeaderProps {
|
||||
listaId: string
|
||||
}
|
||||
|
||||
export function ListHeader({ listaId }: ListHeaderProps) {
|
||||
const router = useRouter()
|
||||
const [novaLista, setNovaLista] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
|
||||
const handleRefresh = () => {
|
||||
setIsRefreshing(true)
|
||||
router.refresh()
|
||||
setTimeout(() => setIsRefreshing(false), 1000) // Visual feedback
|
||||
}
|
||||
|
||||
const handleNovaLista = async () => {
|
||||
if (!novaLista.trim()) {
|
||||
setError('Digite um nome para a lista')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await verificarLista(novaLista.trim())
|
||||
|
||||
if (result.exists) {
|
||||
if (confirm(`A lista "${novaLista}" já existe. Deseja acessá-la?`)) {
|
||||
router.push(`/lista/${novaLista.trim()}`)
|
||||
}
|
||||
} else {
|
||||
if (confirm(`Tem certeza de que deseja criar uma nova lista chamada "${novaLista}"?`)) {
|
||||
const createResult = await verificarLista(novaLista.trim(), true)
|
||||
if (createResult.success) {
|
||||
router.push(`/lista/${novaLista.trim()}`)
|
||||
} else {
|
||||
setError('Erro ao criar nova lista')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Erro ao processar solicitação')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 z-50 bg-background border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
Lista Atual: {listaId}
|
||||
</h2>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nova Lista</DialogTitle>
|
||||
<DialogDescription>
|
||||
Digite o nome da nova lista ou acesse uma existente
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<Input
|
||||
placeholder="Nome da lista"
|
||||
value={novaLista}
|
||||
onChange={(e) => setNovaLista(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleNovaLista}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Processando...' : 'Continuar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
className={isRefreshing ? 'animate-spin' : ''}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { criarItens } from "../actions";
|
||||
import { useTransition, useState } from 'react';
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface MainProps {
|
||||
listaId: string;
|
||||
}
|
||||
|
||||
export function Main({ listaId }: MainProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const quantidade = Number(formData.get('produtoQuantidade'));
|
||||
|
||||
if (quantidade <= 0) {
|
||||
setError('A quantidade deve ser maior que zero');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
startTransition(async () => {
|
||||
await criarItens(formData, listaId);
|
||||
router.refresh();
|
||||
|
||||
const form = document.querySelector('form') as HTMLFormElement;
|
||||
form.reset();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col space-y-4" action={handleSubmit}>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Input
|
||||
required
|
||||
name="produtoNome"
|
||||
className="flex-1 min-w-[200px] text-base px-4 py-2 border-[#383838] focus:border-[#1118A7]"
|
||||
placeholder="Nome do produto"
|
||||
/>
|
||||
<Input
|
||||
required
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
name="produtoQuantidade"
|
||||
className="w-32 text-base px-4 py-2 border-[#383838] focus:border-[#1118A7]"
|
||||
placeholder="Quantidade"
|
||||
/>
|
||||
<Select name="produtoTipo" required>
|
||||
<SelectTrigger className="w-32 text-base border-[#383838] focus:border-[#1118A7]">
|
||||
<SelectValue placeholder="Tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Tipos de unidades</SelectLabel>
|
||||
<SelectItem value="un">un</SelectItem>
|
||||
<SelectItem value="kg">kg</SelectItem>
|
||||
<SelectItem value="g">g</SelectItem>
|
||||
<SelectItem value="L">L</SelectItem>
|
||||
<SelectItem value="ml">ml</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
className="bg-[#1118A7] hover:bg-[#080C94] text-white min-w-[100px]"
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? 'Salvando...' : 'Adicionar'}
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-[#CC2121] text-sm">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
export function PageTitle() {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="relative"
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-center">
|
||||
<span className="relative inline-block">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-secondary">
|
||||
Resenha Itapoá
|
||||
</span>
|
||||
<motion.span
|
||||
className="absolute -bottom-2 left-0 w-full h-1 bg-gradient-to-r from-primary to-secondary rounded-full"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ delay: 0.5, duration: 0.5 }}
|
||||
/>
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-4 text-center text-muted-foreground max-w-2xl mx-auto">
|
||||
Gerencie suas listas de compras de forma simples e eficiente
|
||||
</p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check } from "lucide-react";
|
||||
import { DeleteButton } from "./DeleteButton";
|
||||
import { toggleComprado } from "../actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface Produto {
|
||||
id: number;
|
||||
nome: string;
|
||||
qtd: number;
|
||||
tipoUN: string;
|
||||
comprado: boolean;
|
||||
listaId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ProductCardProps {
|
||||
produto: Produto;
|
||||
listaId: string;
|
||||
}
|
||||
|
||||
export function ProductCard({ produto, listaId }: ProductCardProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleToggleComprado = async () => {
|
||||
await toggleComprado(produto.id, listaId);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`card bg-white dark:bg-gray-800 shadow-lg rounded-lg hover:shadow-xl transition-duration-300
|
||||
${produto.comprado ? 'border-2 border-green-500 dark:border-green-400 bg-green-50 dark:bg-green-900/20' : ''}`}
|
||||
>
|
||||
<div className="card-body p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<h2 className="card-title text-xl font-bold mb-2 dark:text-white">
|
||||
{produto.nome}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleToggleComprado}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={`${produto.comprado
|
||||
? 'bg-green-500 dark:bg-green-600 text-white hover:bg-green-600 dark:hover:bg-green-700'
|
||||
: 'text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/30'
|
||||
}`}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
produtoId={produto.id}
|
||||
produtoNome={produto.nome}
|
||||
listaId={listaId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Quantidade: <span className="font-semibold">{produto.qtd}</span>
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Tipo: <span className="font-semibold">{produto.tipoUN}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
interface CardProps {
|
||||
produto: {
|
||||
id: number;
|
||||
nome: string;
|
||||
qtd: number;
|
||||
tipoUN: string;
|
||||
comprado: boolean;
|
||||
listaId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
}
|
||||
|
||||
export function Card({ produto }: CardProps) {
|
||||
return (
|
||||
<div className="card bg-white shadow-lg rounded-lg hover:shadow-xl transition-duration-300">
|
||||
<div className="card-body p-6">
|
||||
<h2 className="card-title text-xl font-bold mb-2">{produto.nome}</h2>
|
||||
<div className="space-y-2">
|
||||
<p className="text-gray-600">
|
||||
Quantidade: <span className="font-semibold">{produto.qtd}</span>
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
Tipo: <span className="font-semibold">{produto.tipoUN}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function Header(){
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<p className="font-bold text-5xl">
|
||||
RESENHA ITAPOA
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function Items() {
|
||||
const produtos = await prisma.produtos.findMany({});
|
||||
console.log(produtos);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,72 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--primary: 215 90% 50%;
|
||||
/* Azul Royal (#0055FF) */
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 185 70% 45%;
|
||||
/* Verde-azulado (#19A7A7) */
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--accent: 185 70% 95%;
|
||||
/* Verde-azulado claro */
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 215 90% 50%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 217 33% 17%;
|
||||
/* Fundo escuro azulado */
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--card: 217 33% 17%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 217 33% 17%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 215 90% 60%;
|
||||
/* Azul Royal mais claro */
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: 185 70% 40%;
|
||||
/* Verde-azulado ajustado */
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217 33% 25%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--accent: 185 70% 25%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217 33% 25%;
|
||||
--input: 217 33% 25%;
|
||||
--ring: 215 90% 60%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import "./globals.css"
|
||||
import { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import { ThemeProvider } from '@/app/providers'
|
||||
|
||||
// Use Inter como fallback já que é uma fonte similar à Geist
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL('https://resenhaitapoa.com.br'),
|
||||
title: {
|
||||
default: 'Resenha Itapoá - Gerenciador de Listas de Compras',
|
||||
template: '%s | Resenha Itapoá'
|
||||
},
|
||||
description: 'Gerencie suas listas de compras de forma simples e eficiente. Compartilhe listas, acompanhe itens em tempo real.',
|
||||
keywords: ['lista de compras', 'gerenciador de compras', 'Itapoá', 'compras colaborativas', 'organização de compras'],
|
||||
authors: [{ name: 'Evandro', url: 'https://evandro.dev.br' }],
|
||||
creator: 'Evandro',
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'pt_BR',
|
||||
url: 'https://resenhaitapoa.com.br',
|
||||
title: 'Resenha Itapoá - Gerenciador de Listas de Compras',
|
||||
description: 'Gerencie suas listas de compras de forma simples e eficiente.',
|
||||
siteName: 'Resenha Itapoá',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Resenha Itapoá - Gerenciador de Listas de Compras',
|
||||
description: 'Gerencie suas listas de compras de forma simples e eficiente.',
|
||||
creator: '@evandrodev',
|
||||
},
|
||||
manifest: '/manifest.json',
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="pt-BR"
|
||||
suppressHydrationWarning
|
||||
className={inter.className}
|
||||
>
|
||||
<body className="min-h-screen bg-background antialiased">
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Suspense } from 'react';
|
||||
import { Main } from '@/app/components/Main';
|
||||
import { CardList } from '@/app/components/CardList';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { ListHeader } from '@/app/components/ListHeader';
|
||||
import { Footer } from '@/app/components/Footer';
|
||||
import { verificarLista } from '@/app/actions';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
interface ListaPageProps {
|
||||
params: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ListaPage({ params }: ListaPageProps) {
|
||||
const { success, exists } = await verificarLista(params.id);
|
||||
|
||||
if (!success) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-300">
|
||||
<ListHeader listaId={params.id} />
|
||||
<ThemeToggle />
|
||||
<div className="container mx-auto px-4 pt-20 pb-8">
|
||||
<div className="mb-8">
|
||||
<Main listaId={params.id} />
|
||||
</div>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#1118A7] dark:border-[#212FCC]"></div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CardList listaId={params.id} />
|
||||
</Suspense>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { IdInput } from './components/IdInput';
|
||||
|
||||
export default function Home() {
|
||||
return <IdInput />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface ProvidersProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ProvidersProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: '/private/',
|
||||
},
|
||||
sitemap: 'https://resenhaitapoa.com.br/sitemap.xml',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: 'https://resenhaitapoa.com.br',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily',
|
||||
priority: 1,
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user