feat: módulo de sugerencias (backend) + página pública /sugerencias (admin)
- Backend: POST /suggestions (público) + GET /suggestions (JWT admin) - Admin: página pública /sugerencias con formulario de sugerencias - schema.prisma: modelo suggestions - AuthGuard + Sidebar: /sugerencias como ruta pública Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
4e932c2347
commit
42d41eb199
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { MessageSquarePlus, CheckCircle2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function SugerenciasPage() {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!message.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.post('/suggestions', { name: name.trim() || undefined, message: message.trim() });
|
||||||
|
setSent(true);
|
||||||
|
} catch {
|
||||||
|
setError('No se pudo enviar la sugerencia. Por favor intenta de nuevo.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||||
|
<Card className="w-full max-w-md shadow-lg">
|
||||||
|
<CardHeader className="text-center pb-2">
|
||||||
|
<div className="flex justify-center mb-3">
|
||||||
|
<div className="bg-blue-100 rounded-full p-3">
|
||||||
|
<MessageSquarePlus className="h-7 w-7 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Sugerencias</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ayúdanos a mejorar ProsApp con tus comentarios y sugerencias
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{sent ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
|
<CheckCircle2 className="h-14 w-14 text-green-500" />
|
||||||
|
<p className="text-lg font-semibold text-green-700">¡Gracias por tu sugerencia!</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Tu mensaje fue recibido. Lo tendremos en cuenta para mejorar la app.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => { setSent(false); setName(''); setMessage(''); }}
|
||||||
|
>
|
||||||
|
Enviar otra sugerencia
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="name">Nombre (opcional)</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="Tu nombre"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="message">Sugerencia <span className="text-red-500">*</span></Label>
|
||||||
|
<Textarea
|
||||||
|
id="message"
|
||||||
|
placeholder="Cuéntanos qué podemos mejorar..."
|
||||||
|
rows={5}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
<Button type="submit" className="w-full" disabled={loading || !message.trim()}>
|
||||||
|
{loading ? 'Enviando...' : 'Enviar sugerencia'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export default function AuthGuard({ children }: { children: ReactNode }) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const isLoginPage = pathname === '/login';
|
const isLoginPage = pathname === '/login' || pathname === '/sugerencias';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoginPage && !isLoading && !user) router.replace('/login');
|
if (!isLoginPage && !isLoading && !user) router.replace('/login');
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function Sidebar({ children }: { children: React.ReactNode }) {
|
|||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
if (path === '/login') return <>{children}</>;
|
if (path === '/login' || path === '/sugerencias') return <>{children}</>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
|
|||||||
@@ -185,6 +185,13 @@ model settings {
|
|||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model suggestions {
|
||||||
|
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||||
|
name String? @db.VarChar(255)
|
||||||
|
message String
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
}
|
||||||
|
|
||||||
model specializations {
|
model specializations {
|
||||||
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||||
professional_id String @db.Uuid
|
professional_id String @db.Uuid
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ProfessionsModule } from './professions/professions.module';
|
|||||||
import { StorageModule } from './storage/storage.module';
|
import { StorageModule } from './storage/storage.module';
|
||||||
import { NotificationsModule } from './notifications/notifications.module';
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
import { SmsModule } from './sms/sms.module';
|
import { SmsModule } from './sms/sms.module';
|
||||||
|
import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -30,6 +31,7 @@ import { SmsModule } from './sms/sms.module';
|
|||||||
StorageModule,
|
StorageModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
SmsModule,
|
SmsModule,
|
||||||
|
SuggestionsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsString, IsOptional, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateSuggestionDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Controller, Get, Post, Body, UseGuards, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { SuggestionsService } from './suggestions.service';
|
||||||
|
import { CreateSuggestionDto } from './dto/suggestion.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
|
||||||
|
@ApiTags('Suggestions')
|
||||||
|
@Controller('suggestions')
|
||||||
|
export class SuggestionsController {
|
||||||
|
constructor(private suggestions: SuggestionsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateSuggestionDto) {
|
||||||
|
return this.suggestions.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
findAll(@Query('page') page = '1', @Query('limit') limit = '50') {
|
||||||
|
return this.suggestions.findAll(+page, +limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SuggestionsService } from './suggestions.service';
|
||||||
|
import { SuggestionsController } from './suggestions.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [SuggestionsService],
|
||||||
|
controllers: [SuggestionsController],
|
||||||
|
})
|
||||||
|
export class SuggestionsModule {}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SuggestionsService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
create(data: { name?: string; message: string }) {
|
||||||
|
return this.prisma.suggestions.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
findAll(page = 1, limit = 50) {
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
return Promise.all([
|
||||||
|
this.prisma.suggestions.findMany({
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
}),
|
||||||
|
this.prisma.suggestions.count(),
|
||||||
|
]).then(([data, total]) => ({
|
||||||
|
data,
|
||||||
|
meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user