feat: add MinIO S3 integration for avatar upload and editable profile
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 49s
Details
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 49s
Details
This commit is contained in:
parent
647338fe75
commit
1ab4326778
|
|
@ -73,9 +73,48 @@ services:
|
|||
- "traefik.http.services.microtecflix-admin.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=network_public"
|
||||
|
||||
# =============================================================
|
||||
# MINIO S3 — Storage de Arquivos e Mídias (Fotos/Imagens)
|
||||
# =============================================================
|
||||
minio-microtecflix:
|
||||
image: minio/minio:latest
|
||||
restart: always
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: microtecflix_admin
|
||||
MINIO_ROOT_PASSWORD: MicrotecFlixS3SecurePass2026!
|
||||
MINIO_BROWSER_REDIRECT_URL: https://minio-estudo.microtecinformaticacurso.com.br
|
||||
volumes:
|
||||
- miniodata-microtecflix:/data
|
||||
networks:
|
||||
- microtecflix-net
|
||||
- network_public
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Rota para a API do S3 (Acesso aos arquivos/bucket público)
|
||||
- "traefik.http.routers.minio-s3.rule=Host(`s3-estudo.microtecinformaticacurso.com.br`)"
|
||||
- "traefik.http.routers.minio-s3.entrypoints=websecure"
|
||||
- "traefik.http.routers.minio-s3.tls=true"
|
||||
- "traefik.http.routers.minio-s3.tls.certresolver=leresolver"
|
||||
- "traefik.http.routers.minio-s3.service=minio-s3"
|
||||
- "traefik.http.services.minio-s3.loadbalancer.server.port=9000"
|
||||
# Rota para o Painel Web do MinIO
|
||||
- "traefik.http.routers.minio-console.rule=Host(`minio-estudo.microtecinformaticacurso.com.br`)"
|
||||
- "traefik.http.routers.minio-console.entrypoints=websecure"
|
||||
- "traefik.http.routers.minio-console.tls=true"
|
||||
- "traefik.http.routers.minio-console.tls.certresolver=leresolver"
|
||||
- "traefik.http.routers.minio-console.service=minio-console"
|
||||
- "traefik.http.services.minio-console.loadbalancer.server.port=9001"
|
||||
- "traefik.docker.network=network_public"
|
||||
|
||||
volumes:
|
||||
pgdata-microtecflix:
|
||||
appdata-microtecflix:
|
||||
miniodata-microtecflix:
|
||||
|
||||
networks:
|
||||
microtecflix-net:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@
|
|||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@google/genai": "^2.4.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
|
|
@ -28,6 +30,7 @@
|
|||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
|
|
|
|||
116
server.ts
116
server.ts
|
|
@ -2,6 +2,8 @@ import express, { Request, Response, NextFunction } from 'express';
|
|||
import path from 'path';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import multer from 'multer';
|
||||
import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3';
|
||||
import { createServer as createViteServer } from 'vite';
|
||||
import { loadDb, saveDb } from './src/db/db';
|
||||
import { AsaasService } from './src/services/asaas';
|
||||
|
|
@ -15,6 +17,60 @@ const PORT = 3000;
|
|||
|
||||
app.use(express.json());
|
||||
|
||||
// --- MINIO / S3 CONFIGURATION ---
|
||||
const s3 = new S3Client({
|
||||
region: 'us-east-1',
|
||||
endpoint: 'http://minio-microtecflix:9000',
|
||||
credentials: {
|
||||
accessKeyId: process.env.MINIO_ROOT_USER || 'microtecflix_admin',
|
||||
secretAccessKey: process.env.MINIO_ROOT_PASSWORD || 'MicrotecFlixS3SecurePass2026!'
|
||||
},
|
||||
forcePathStyle: true
|
||||
});
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
async function initMinio() {
|
||||
const bucketName = 'microtecflix';
|
||||
try {
|
||||
await s3.send(new HeadBucketCommand({ Bucket: bucketName }));
|
||||
console.log('✅ MinIO bucket already exists.');
|
||||
} catch (err: any) {
|
||||
if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404 || err.message.includes('404')) {
|
||||
console.log('⚠️ Creating MinIO bucket...');
|
||||
try {
|
||||
await s3.send(new CreateBucketCommand({ Bucket: bucketName }));
|
||||
|
||||
const policy = {
|
||||
Version: "2012-10-17",
|
||||
Statement: [
|
||||
{
|
||||
Sid: "PublicReadGetObject",
|
||||
Effect: "Allow",
|
||||
Principal: "*",
|
||||
Action: "s3:GetObject",
|
||||
Resource: `arn:aws:s3:::${bucketName}/*`
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await s3.send(new PutBucketPolicyCommand({
|
||||
Bucket: bucketName,
|
||||
Policy: JSON.stringify(policy)
|
||||
}));
|
||||
console.log('✅ MinIO bucket created and set to public read.');
|
||||
} catch (createErr) {
|
||||
console.error('❌ Failed to create or configure MinIO bucket:', createErr);
|
||||
}
|
||||
} else {
|
||||
console.error('❌ Error checking MinIO bucket:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Start initialization async, don't block server start
|
||||
initMinio();
|
||||
|
||||
|
||||
// --- JWT AUTH MIDDLEWARE ---
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
|
|
@ -121,6 +177,7 @@ app.post('/api/auth/register', async (req: Request, res: Response) => {
|
|||
cpf: newUser.cpf,
|
||||
whatsapp: newUser.whatsapp,
|
||||
birthDate: newUser.birthDate,
|
||||
avatarUrl: newUser.avatarUrl,
|
||||
createdAt: newUser.createdAt
|
||||
}
|
||||
});
|
||||
|
|
@ -170,6 +227,7 @@ app.post('/api/auth/login', (req: Request, res: Response) => {
|
|||
cpf: user.cpf,
|
||||
whatsapp: user.whatsapp,
|
||||
birthDate: user.birthDate,
|
||||
avatarUrl: user.avatarUrl,
|
||||
createdAt: user.createdAt
|
||||
}
|
||||
});
|
||||
|
|
@ -200,10 +258,68 @@ app.get('/api/auth/me', authenticateToken, (req: AuthenticatedRequest, res: Resp
|
|||
cpf: user.cpf,
|
||||
whatsapp: user.whatsapp,
|
||||
birthDate: user.birthDate,
|
||||
avatarUrl: user.avatarUrl,
|
||||
createdAt: user.createdAt
|
||||
});
|
||||
});
|
||||
|
||||
// 3.1. Profile: Update Data
|
||||
app.put('/api/users/profile', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
||||
const { name, whatsapp, birthDate } = req.body;
|
||||
const userId = req.user!.id;
|
||||
const db = loadDb();
|
||||
|
||||
const user = db.users.find(u => u.id === userId);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'Usuário não encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (name) user.name = name;
|
||||
if (whatsapp !== undefined) user.whatsapp = whatsapp;
|
||||
if (birthDate !== undefined) user.birthDate = birthDate;
|
||||
|
||||
saveDb(db);
|
||||
|
||||
res.json({ success: true, user });
|
||||
});
|
||||
|
||||
// 3.2. Profile: Avatar Upload
|
||||
app.post('/api/users/avatar', authenticateToken, upload.single('avatar'), async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.user!.id;
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Nenhum arquivo enviado' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = req.file.originalname.split('.').pop();
|
||||
const filename = `avatars/${userId}_${Date.now()}.${ext}`;
|
||||
|
||||
try {
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: 'microtecflix',
|
||||
Key: filename,
|
||||
Body: req.file.buffer,
|
||||
ContentType: req.file.mimetype
|
||||
}));
|
||||
|
||||
// URL Pública configurada via Traefik
|
||||
const avatarUrl = `https://s3-estudo.microtecinformaticacurso.com.br/microtecflix/${filename}`;
|
||||
|
||||
const db = loadDb();
|
||||
const user = db.users.find(u => u.id === userId);
|
||||
if (user) {
|
||||
user.avatarUrl = avatarUrl;
|
||||
saveDb(db);
|
||||
}
|
||||
|
||||
res.json({ success: true, avatarUrl });
|
||||
} catch (err: any) {
|
||||
console.error('Erro no upload da foto:', err);
|
||||
res.status(500).json({ error: 'Erro ao salvar foto no MinIO' });
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Courses List (With Progress)
|
||||
app.get('/api/courses', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
||||
const db = loadDb();
|
||||
|
|
|
|||
|
|
@ -269,6 +269,7 @@ export default function App() {
|
|||
<ProfileView
|
||||
user={user as any}
|
||||
onBack={() => setView('home')}
|
||||
onUpdateUser={(updated) => setUser(updated)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
import React from 'react';
|
||||
import { User as UserIcon, Shield, CreditCard, Mail, Phone, Calendar, ArrowLeft } from 'lucide-react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { User as UserIcon, Shield, CreditCard, Mail, Phone, Calendar, ArrowLeft, Camera, Edit2, Save, X, Loader2 } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
|
||||
interface ProfileViewProps {
|
||||
user: User;
|
||||
onBack: () => void;
|
||||
onUpdateUser?: (updated: User) => void;
|
||||
}
|
||||
|
||||
export default function ProfileView({ user, onBack }: ProfileViewProps) {
|
||||
export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [name, setName] = useState(user.name);
|
||||
const [whatsapp, setWhatsapp] = useState(user.whatsapp || '');
|
||||
const [birthDate, setBirthDate] = useState(user.birthDate || '');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'ACTIVE': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/50';
|
||||
|
|
@ -28,23 +38,132 @@ export default function ProfileView({ user, onBack }: ProfileViewProps) {
|
|||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center space-x-2 text-zinc-400 hover:text-white transition-colors text-sm font-bold"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Voltar</span>
|
||||
</button>
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch('/api/users/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify({ name, whatsapp, birthDate })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && onUpdateUser) {
|
||||
onUpdateUser(data.user);
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
alert(data.error || 'Erro ao atualizar perfil');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Erro de conexão ao salvar perfil');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
<div className="glass-card rounded-2xl overflow-hidden shadow-2xl border border-white/10 p-8">
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch('/api/users/avatar', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token && { Authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && onUpdateUser) {
|
||||
onUpdateUser({ ...user, avatarUrl: data.avatarUrl });
|
||||
} else {
|
||||
alert(data.error || 'Erro ao fazer upload da foto');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Erro de conexão ao enviar foto');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto pb-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center space-x-2 text-zinc-400 hover:text-white transition-colors text-sm font-bold"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Voltar</span>
|
||||
</button>
|
||||
|
||||
{!isEditing ? (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center space-x-2 glass-btn-secondary px-4 py-2 rounded-lg text-sm font-bold text-white hover:text-[#E50914] transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
<span>Editar Perfil</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setName(user.name);
|
||||
setWhatsapp(user.whatsapp || '');
|
||||
setBirthDate(user.birthDate || '');
|
||||
}}
|
||||
className="flex items-center space-x-1 px-4 py-2 rounded-lg text-sm font-bold text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<span>Cancelar</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="flex items-center space-x-1 glass-btn-primary px-4 py-2 rounded-lg text-sm font-bold text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
<span>{isSaving ? 'Salvando...' : 'Salvar Alterações'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass-card rounded-2xl overflow-hidden shadow-2xl border border-white/10 p-8 relative">
|
||||
<div className="flex flex-col md:flex-row items-start gap-8">
|
||||
|
||||
{/* Avatar Area */}
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="w-32 h-32 bg-zinc-800 rounded-full flex items-center justify-center border-4 border-[#E50914]/20">
|
||||
<UserIcon className="w-16 h-16 text-zinc-400" />
|
||||
<div className="relative group cursor-pointer" onClick={() => fileInputRef.current?.click()}>
|
||||
<div className="w-32 h-32 bg-zinc-800 rounded-full flex items-center justify-center border-4 border-[#E50914]/20 overflow-hidden">
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-8 h-8 text-[#E50914] animate-spin" />
|
||||
) : user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<UserIcon className="w-16 h-16 text-zinc-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/60 rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Camera className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={`px-3 py-1 rounded-full text-xs font-bold border uppercase tracking-wider ${getStatusColor(user.subscriptionStatus)}`}>
|
||||
{getStatusLabel(user.subscriptionStatus)}
|
||||
|
|
@ -54,45 +173,82 @@ export default function ProfileView({ user, onBack }: ProfileViewProps) {
|
|||
{/* User Info */}
|
||||
<div className="flex-1 space-y-6 w-full">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">{user.name}</h2>
|
||||
<p className="text-zinc-400 text-sm">{user.role === 'admin' ? 'Administrador do Sistema' : 'Aluno'}</p>
|
||||
{isEditing ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] uppercase font-bold text-zinc-500">Nome Completo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full glass-input rounded-lg p-2 text-xl font-bold text-white focus:outline-none focus:border-[#E50914]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold text-white">{user.name}</h2>
|
||||
<p className="text-zinc-400 text-sm">{user.role === 'admin' ? 'Administrador do Sistema' : 'Aluno'}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex items-center space-x-3">
|
||||
<Mail className="w-5 h-5 text-zinc-500" />
|
||||
<div>
|
||||
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center">
|
||||
<div className="flex items-center space-x-3 mb-1">
|
||||
<Mail className="w-4 h-4 text-zinc-500" />
|
||||
<p className="text-[10px] uppercase font-bold text-zinc-500">Email</p>
|
||||
<p className="text-sm font-medium text-white">{user.email}</p>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-white opacity-60 cursor-not-allowed pl-7">{user.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex items-center space-x-3">
|
||||
<Phone className="w-5 h-5 text-zinc-500" />
|
||||
<div>
|
||||
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center">
|
||||
<div className="flex items-center space-x-3 mb-1">
|
||||
<Phone className="w-4 h-4 text-zinc-500" />
|
||||
<p className="text-[10px] uppercase font-bold text-zinc-500">WhatsApp</p>
|
||||
<p className="text-sm font-medium text-white">{user.whatsapp || 'Não informado'}</p>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="pl-7">
|
||||
<input
|
||||
type="text"
|
||||
value={whatsapp}
|
||||
onChange={(e) => setWhatsapp(e.target.value)}
|
||||
placeholder="(11) 99999-9999"
|
||||
className="w-full bg-black/50 border border-white/10 rounded-md p-1.5 text-sm text-white focus:outline-none focus:border-[#E50914]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-white pl-7">{user.whatsapp || 'Não informado'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex items-center space-x-3">
|
||||
<Calendar className="w-5 h-5 text-zinc-500" />
|
||||
<div>
|
||||
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center">
|
||||
<div className="flex items-center space-x-3 mb-1">
|
||||
<Calendar className="w-4 h-4 text-zinc-500" />
|
||||
<p className="text-[10px] uppercase font-bold text-zinc-500">Data de Nascimento</p>
|
||||
<p className="text-sm font-medium text-white">
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="pl-7">
|
||||
<input
|
||||
type="date"
|
||||
value={birthDate}
|
||||
onChange={(e) => setBirthDate(e.target.value)}
|
||||
className="w-full bg-black/50 border border-white/10 rounded-md p-1.5 text-sm text-white focus:outline-none focus:border-[#E50914] [color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-white pl-7">
|
||||
{user.birthDate ? new Date(user.birthDate).toLocaleDateString('pt-BR') : 'Não informado'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex items-center space-x-3">
|
||||
<CreditCard className="w-5 h-5 text-zinc-500" />
|
||||
<div>
|
||||
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center">
|
||||
<div className="flex items-center space-x-3 mb-1">
|
||||
<CreditCard className="w-4 h-4 text-zinc-500" />
|
||||
<p className="text-[10px] uppercase font-bold text-zinc-500">Membro desde</p>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{user.createdAt ? new Date(user.createdAt).toLocaleDateString('pt-BR') : 'Não disponível'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-white pl-7">
|
||||
{user.createdAt ? new Date(user.createdAt).toLocaleDateString('pt-BR') : 'Não disponível'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export interface User {
|
|||
cpf?: string;
|
||||
birthDate?: string;
|
||||
whatsapp?: string;
|
||||
avatarUrl?: string;
|
||||
trialEndsAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue