Initial commit: HotWives Platform completa
- Backend completo com Express, TypeScript e Prisma - Sistema de autenticação JWT - API REST com todas as funcionalidades - Sistema de mensagens e chat em tempo real (Socket.io) - Upload e gerenciamento de fotos - Sistema de perfis com verificação - Busca avançada com filtros - Sistema de eventos - Dashboard administrativo - Frontend Next.js 14 com TypeScript - Design moderno com Tailwind CSS - Componentes UI com Radix UI - Tema dark/light - Configuração Nginx pronta para produção - Scripts de instalação e deploy - Documentação completa
This commit is contained in:
78
frontend/app/globals.css
Normal file
78
frontend/app/globals.css
Normal file
@@ -0,0 +1,78 @@
|
||||
@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: 339 82% 52%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--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: 339 82% 52%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 339 82% 52%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 339 82% 52%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 dark:bg-gray-900;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 dark:bg-gray-700 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400 dark:bg-gray-600;
|
||||
}
|
||||
|
||||
36
frontend/app/layout.tsx
Normal file
36
frontend/app/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { ThemeProvider } from '@/components/theme-provider'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'HotWives - Plataforma de Encontros para Casais',
|
||||
description: 'A melhor plataforma para casais que buscam novas experiências e conexões',
|
||||
keywords: 'encontros, casais, relacionamentos, swing, hotwife',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="pt-BR" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
193
frontend/app/page.tsx
Normal file
193
frontend/app/page.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Heart, Users, Shield, MessageCircle, Calendar, Star } from 'lucide-react'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 via-purple-50 to-indigo-50 dark:from-gray-900 dark:via-purple-900 dark:to-pink-900">
|
||||
{/* Header */}
|
||||
<header className="fixed top-0 w-full bg-white/80 dark:bg-gray-900/80 backdrop-blur-md z-50 border-b">
|
||||
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<Heart className="h-8 w-8 text-primary-500 fill-primary-500" />
|
||||
<span className="text-2xl font-bold bg-gradient-to-r from-primary-500 to-pink-600 bg-clip-text text-transparent">
|
||||
HotWives
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center space-x-6">
|
||||
<Link href="/explore" className="hover:text-primary-500 transition">
|
||||
Explorar
|
||||
</Link>
|
||||
<Link href="/events" className="hover:text-primary-500 transition">
|
||||
Eventos
|
||||
</Link>
|
||||
<Link href="/about" className="hover:text-primary-500 transition">
|
||||
Sobre
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/login">
|
||||
<Button variant="ghost">Entrar</Button>
|
||||
</Link>
|
||||
<Link href="/register">
|
||||
<Button className="bg-primary-500 hover:bg-primary-600">
|
||||
Cadastrar
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="pt-32 pb-20 px-4">
|
||||
<div className="container mx-auto text-center">
|
||||
<h1 className="text-5xl md:text-7xl font-bold mb-6 bg-gradient-to-r from-primary-500 via-pink-500 to-purple-600 bg-clip-text text-transparent">
|
||||
Conecte-se com Casais<br />de Forma Segura
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-gray-600 dark:text-gray-300 mb-8 max-w-3xl mx-auto">
|
||||
A plataforma mais completa e discreta para casais que buscam novas experiências e conexões autênticas.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href="/register">
|
||||
<Button size="lg" className="bg-primary-500 hover:bg-primary-600 text-lg px-8 py-6">
|
||||
Começar Agora
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/explore">
|
||||
<Button size="lg" variant="outline" className="text-lg px-8 py-6">
|
||||
Explorar Perfis
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="py-20 px-4 bg-white/50 dark:bg-gray-800/50">
|
||||
<div className="container mx-auto">
|
||||
<h2 className="text-4xl font-bold text-center mb-16">
|
||||
Por que escolher o HotWives?
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div className="p-6 rounded-xl bg-white dark:bg-gray-900 shadow-lg hover:shadow-xl transition">
|
||||
<Users className="h-12 w-12 text-primary-500 mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">Perfis Verificados</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Sistema de verificação rigoroso para garantir perfis autênticos e seguros.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl bg-white dark:bg-gray-900 shadow-lg hover:shadow-xl transition">
|
||||
<Shield className="h-12 w-12 text-primary-500 mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">Privacidade Total</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Controle completo sobre quem pode ver suas fotos e informações pessoais.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl bg-white dark:bg-gray-900 shadow-lg hover:shadow-xl transition">
|
||||
<MessageCircle className="h-12 w-12 text-primary-500 mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">Chat em Tempo Real</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Converse instantaneamente com outros casais de forma segura e privada.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl bg-white dark:bg-gray-900 shadow-lg hover:shadow-xl transition">
|
||||
<Calendar className="h-12 w-12 text-primary-500 mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">Eventos Exclusivos</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Participe de eventos e encontros organizados pela comunidade.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl bg-white dark:bg-gray-900 shadow-lg hover:shadow-xl transition">
|
||||
<Star className="h-12 w-12 text-primary-500 mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">Busca Avançada</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Encontre exatamente o que você procura com filtros inteligentes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl bg-white dark:bg-gray-900 shadow-lg hover:shadow-xl transition">
|
||||
<Heart className="h-12 w-12 text-primary-500 mb-4 fill-primary-500" />
|
||||
<h3 className="text-xl font-bold mb-2">Comunidade Ativa</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Milhares de casais conectados e novas oportunidades todos os dias.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 px-4">
|
||||
<div className="container mx-auto text-center">
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-6">
|
||||
Pronto para começar sua jornada?
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 max-w-2xl mx-auto">
|
||||
Junte-se a milhares de casais que já estão explorando novas possibilidades de forma segura e discreta.
|
||||
</p>
|
||||
<Link href="/register">
|
||||
<Button size="lg" className="bg-primary-500 hover:bg-primary-600 text-lg px-12 py-6">
|
||||
Criar Conta Grátis
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 px-4 bg-gray-900 text-white">
|
||||
<div className="container mx-auto">
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Heart className="h-6 w-6 text-primary-500 fill-primary-500" />
|
||||
<span className="text-xl font-bold">HotWives</span>
|
||||
</div>
|
||||
<p className="text-gray-400">
|
||||
A plataforma mais completa para casais.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-bold mb-4">Plataforma</h4>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><Link href="/explore" className="hover:text-white">Explorar</Link></li>
|
||||
<li><Link href="/events" className="hover:text-white">Eventos</Link></li>
|
||||
<li><Link href="/premium" className="hover:text-white">Premium</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-bold mb-4">Suporte</h4>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><Link href="/help" className="hover:text-white">Ajuda</Link></li>
|
||||
<li><Link href="/safety" className="hover:text-white">Segurança</Link></li>
|
||||
<li><Link href="/contact" className="hover:text-white">Contato</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-bold mb-4">Legal</h4>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><Link href="/terms" className="hover:text-white">Termos de Uso</Link></li>
|
||||
<li><Link href="/privacy" className="hover:text-white">Privacidade</Link></li>
|
||||
<li><Link href="/cookies" className="hover:text-white">Cookies</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
|
||||
<p>© 2025 HotWives. Todos os direitos reservados.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
10
frontend/components/theme-provider.tsx
Normal file
10
frontend/components/theme-provider.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types"
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
|
||||
56
frontend/components/ui/button.tsx
Normal file
56
frontend/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
127
frontend/components/ui/toast.tsx
Normal file
127
frontend/components/ui/toast.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
|
||||
29
frontend/components/ui/toaster.tsx
Normal file
29
frontend/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
186
frontend/components/ui/use-toast.ts
Normal file
186
frontend/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import * as React from "react"
|
||||
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
|
||||
118
frontend/lib/api.ts
Normal file
118
frontend/lib/api.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Interceptor para adicionar token
|
||||
api.interceptors.request.use((config) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Interceptor para tratar erros
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
|
||||
// Auth endpoints
|
||||
export const authAPI = {
|
||||
register: (data: any) => api.post('/auth/register', data),
|
||||
login: (data: any) => api.post('/auth/login', data),
|
||||
forgotPassword: (data: any) => api.post('/auth/forgot-password', data),
|
||||
resetPassword: (data: any) => api.post('/auth/reset-password', data),
|
||||
}
|
||||
|
||||
// User endpoints
|
||||
export const userAPI = {
|
||||
getMe: () => api.get('/users/me'),
|
||||
updateMe: (data: any) => api.put('/users/me', data),
|
||||
getFavorites: () => api.get('/users/favorites'),
|
||||
addFavorite: (userId: string) => api.post(`/users/favorites/${userId}`),
|
||||
removeFavorite: (userId: string) => api.delete(`/users/favorites/${userId}`),
|
||||
blockUser: (userId: string, data: any) => api.post(`/users/blocks/${userId}`, data),
|
||||
unblockUser: (userId: string) => api.delete(`/users/blocks/${userId}`),
|
||||
}
|
||||
|
||||
// Profile endpoints
|
||||
export const profileAPI = {
|
||||
getProfile: (username: string) => api.get(`/profiles/${username}`),
|
||||
updateProfile: (data: any) => api.put('/profiles/me', data),
|
||||
uploadAvatar: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('photo', file)
|
||||
return api.post('/profiles/me/avatar', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// Search endpoints
|
||||
export const searchAPI = {
|
||||
searchProfiles: (params: any) => api.get('/search/profiles', { params }),
|
||||
searchEvents: (params: any) => api.get('/search/events', { params }),
|
||||
}
|
||||
|
||||
// Message endpoints
|
||||
export const messageAPI = {
|
||||
getConversations: () => api.get('/messages/conversations'),
|
||||
getConversation: (userId: string) => api.get(`/messages/conversation/${userId}`),
|
||||
sendMessage: (data: any) => api.post('/messages/send', data),
|
||||
}
|
||||
|
||||
// Event endpoints
|
||||
export const eventAPI = {
|
||||
getEvents: (params?: any) => api.get('/events', { params }),
|
||||
getEvent: (id: string) => api.get(`/events/${id}`),
|
||||
createEvent: (data: any) => {
|
||||
const formData = new FormData()
|
||||
Object.keys(data).forEach(key => {
|
||||
if (data[key] !== null && data[key] !== undefined) {
|
||||
formData.append(key, data[key])
|
||||
}
|
||||
})
|
||||
return api.post('/events', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
},
|
||||
joinEvent: (id: string, data?: any) => api.post(`/events/${id}/join`, data),
|
||||
leaveEvent: (id: string) => api.post(`/events/${id}/leave`),
|
||||
}
|
||||
|
||||
// Photo endpoints
|
||||
export const photoAPI = {
|
||||
getPhotos: (userId?: string) => api.get('/photos', { params: { userId } }),
|
||||
uploadPhoto: (file: File, data: any) => {
|
||||
const formData = new FormData()
|
||||
formData.append('photo', file)
|
||||
Object.keys(data).forEach(key => {
|
||||
formData.append(key, data[key])
|
||||
})
|
||||
return api.post('/photos', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
},
|
||||
deletePhoto: (id: string) => api.delete(`/photos/${id}`),
|
||||
}
|
||||
|
||||
45
frontend/lib/socket.ts
Normal file
45
frontend/lib/socket.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
|
||||
const SOCKET_URL = process.env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3001'
|
||||
|
||||
let socket: Socket | null = null
|
||||
|
||||
export const connectSocket = (token: string) => {
|
||||
if (!socket) {
|
||||
socket = io(SOCKET_URL, {
|
||||
auth: {
|
||||
token,
|
||||
},
|
||||
})
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Socket connected')
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Socket disconnected')
|
||||
})
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('Socket connection error:', error)
|
||||
})
|
||||
}
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
export const disconnectSocket = () => {
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
|
||||
export const getSocket = () => socket
|
||||
|
||||
export default {
|
||||
connect: connectSocket,
|
||||
disconnect: disconnectSocket,
|
||||
get: getSocket,
|
||||
}
|
||||
|
||||
55
frontend/lib/utils.ts
Normal file
55
frontend/lib/utils.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = new Date(date)
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
}).format(d)
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date | string): string {
|
||||
const d = new Date(date)
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(d)
|
||||
}
|
||||
|
||||
export function formatRelativeTime(date: Date | string): string {
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 7) {
|
||||
return formatDate(d)
|
||||
} else if (days > 0) {
|
||||
return `${days}d atrás`
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h atrás`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}min atrás`
|
||||
} else {
|
||||
return 'agora'
|
||||
}
|
||||
}
|
||||
|
||||
export function truncate(str: string, length: number): string {
|
||||
if (str.length <= length) return str
|
||||
return str.slice(0, length) + '...'
|
||||
}
|
||||
|
||||
22
frontend/next.config.js
Normal file
22
frontend/next.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'hotwives.com.br',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
},
|
||||
],
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api',
|
||||
NEXT_PUBLIC_SOCKET_URL: process.env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3001',
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
||||
46
frontend/package.json
Normal file
46
frontend/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "hotwives-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.0.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"axios": "^1.6.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^3.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"socket.io-client": "^4.6.0",
|
||||
"tailwind-merge": "^2.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "14.0.4",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
7
frontend/postcss.config.js
Normal file
7
frontend/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
90
frontend/tailwind.config.ts
Normal file
90
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "#e91e63",
|
||||
50: "#fce4ec",
|
||||
100: "#f8bbd0",
|
||||
200: "#f48fb1",
|
||||
300: "#f06292",
|
||||
400: "#ec407a",
|
||||
500: "#e91e63",
|
||||
600: "#d81b60",
|
||||
700: "#c2185b",
|
||||
800: "#ad1457",
|
||||
900: "#880e4f",
|
||||
foreground: "#ffffff",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
|
||||
export default config
|
||||
|
||||
28
frontend/tsconfig.json
Normal file
28
frontend/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user