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:
root
2025-11-22 01:00:35 +00:00
commit 5e4a2283bf
51 changed files with 5158 additions and 0 deletions

78
frontend/app/globals.css Normal file
View 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
View 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
View 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 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>&copy; 2025 HotWives. Todos os direitos reservados.</p>
</div>
</div>
</footer>
</div>
)
}

View 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>
}

View 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 }

View 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,
}

View 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>
)
}

View 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
View 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
View 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
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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
View 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"]
}