feat(profile): perfil completo para SaaS v1.48.0

- Expandir tabela users com campos para SaaS
- Adicionar: first_name, last_name, phone_country_code, phone
- Adicionar: accept_whatsapp, accept_emails, country, timezone, locale
- User Model: accessors fullName e fullPhone
- Profile.jsx: formulário completo com DDI, checkboxes, seletores
- Traduções i18n em ES, PT-BR, EN
- Fase 1 do roadmap SaaS concluída
This commit is contained in:
marcoitaloesp-ai 2025-12-17 10:40:20 +00:00 committed by GitHub
parent 8121a35c8b
commit abaf0097c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 503 additions and 51 deletions

View File

@ -5,6 +5,37 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
## [1.48.0] - 2025-12-17
### Added
- 🌍 **Perfil Completo para SaaS** - Expansão da página de perfil para suportar modelo de assinaturas
- **Dados pessoais expandidos**: nome, sobrenome, email
- **Telefone internacional**: código do país selecionável (+34, +55, +1, etc.) com bandeiras
- **Preferências de comunicação**:
- Checkbox "Aceitar notificações por WhatsApp"
- Checkbox "Aceitar emails promocionais"
- **Localização**: país, timezone e idioma selecionáveis
- **Alteração de senha**: seção separada com validação
### Technical Details
- **Backend**:
- Nova migration: `add_profile_fields_to_users_table`
- Campos adicionados: `first_name`, `last_name`, `phone_country_code`, `phone`, `accept_whatsapp`, `accept_emails`, `avatar`, `country`, `timezone`, `locale`
- User Model: accessors `getFullNameAttribute()`, `getFullPhoneAttribute()`
- AuthController: `me()` e `updateProfile()` atualizados
- **Frontend**:
- Componente `Profile.jsx` redesenhado com formulário completo
- Listas de códigos de país, países e timezones
- Traduções em ES, PT-BR, EN para todos os novos campos
### Roadmap SaaS
Esta release é a **Fase 1** da transformação para SaaS:
1. ✅ **Fase 1**: Perfil completo do usuário
2. ⏳ **Fase 2**: Tabelas de assinaturas (plans, subscriptions, invoices)
3. ⏳ **Fase 3**: Integração PayPal Subscriptions
4. ⏳ **Fase 4**: Página de Billing e gestão de assinatura
## [1.47.0] - 2025-12-17
### Added

View File

@ -1 +1 @@
1.47.0
1.48.0

View File

@ -166,7 +166,19 @@ public function me(Request $request): JsonResponse
'user' => [
'id' => $user->id,
'name' => $user->name,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'full_name' => $user->full_name,
'email' => $user->email,
'phone_country_code' => $user->phone_country_code,
'phone' => $user->phone,
'full_phone' => $user->full_phone,
'accept_whatsapp' => $user->accept_whatsapp,
'accept_emails' => $user->accept_emails,
'avatar' => $user->avatar,
'country' => $user->country,
'timezone' => $user->timezone,
'locale' => $user->locale,
]
]
], 200);
@ -181,7 +193,7 @@ public function me(Request $request): JsonResponse
}
/**
* Update user profile (name, email, password)
* Update user profile (all profile fields including password)
*/
public function updateProfile(Request $request): JsonResponse
{
@ -189,15 +201,25 @@ public function updateProfile(Request $request): JsonResponse
$user = $request->user();
$rules = [
'name' => 'sometimes|required|string|max:255',
'first_name' => 'sometimes|required|string|max:255',
'last_name' => 'sometimes|required|string|max:255',
'email' => 'sometimes|required|string|email|max:255|unique:users,email,' . $user->id,
'phone_country_code' => 'nullable|string|max:5',
'phone' => 'nullable|string|max:20',
'accept_whatsapp' => 'nullable|boolean',
'accept_emails' => 'nullable|boolean',
'country' => 'nullable|string|size:2',
'timezone' => 'nullable|string|max:50',
'locale' => 'nullable|string|max:5',
'current_password' => 'required_with:new_password',
'new_password' => 'nullable|string|min:8|confirmed',
];
$messages = [
'name.required' => 'O nome é obrigatório',
'name.max' => 'O nome deve ter no máximo 255 caracteres',
'first_name.required' => 'O nome é obrigatório',
'first_name.max' => 'O nome deve ter no máximo 255 caracteres',
'last_name.required' => 'O sobrenome é obrigatório',
'last_name.max' => 'O sobrenome deve ter no máximo 255 caracteres',
'email.required' => 'O email é obrigatório',
'email.email' => 'O email deve ser válido',
'email.unique' => 'Este email já está em uso',
@ -228,14 +250,23 @@ public function updateProfile(Request $request): JsonResponse
$user->password = Hash::make($request->new_password);
}
// Atualizar nome se fornecido
if ($request->has('name')) {
$user->name = $request->name;
// Atualizar campos do perfil
$profileFields = [
'first_name', 'last_name', 'email',
'phone_country_code', 'phone',
'accept_whatsapp', 'accept_emails',
'country', 'timezone', 'locale'
];
foreach ($profileFields as $field) {
if ($request->has($field)) {
$user->$field = $request->$field;
}
}
// Atualizar email se fornecido
if ($request->has('email')) {
$user->email = $request->email;
// Atualizar name baseado em first_name e last_name
if ($request->has('first_name') || $request->has('last_name')) {
$user->name = trim(($user->first_name ?? '') . ' ' . ($user->last_name ?? ''));
}
$user->save();
@ -247,7 +278,19 @@ public function updateProfile(Request $request): JsonResponse
'user' => [
'id' => $user->id,
'name' => $user->name,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'full_name' => $user->full_name,
'email' => $user->email,
'phone_country_code' => $user->phone_country_code,
'phone' => $user->phone,
'full_phone' => $user->full_phone,
'accept_whatsapp' => $user->accept_whatsapp,
'accept_emails' => $user->accept_emails,
'avatar' => $user->avatar,
'country' => $user->country,
'timezone' => $user->timezone,
'locale' => $user->locale,
]
]
], 200);

View File

@ -20,7 +20,17 @@ class User extends Authenticatable
*/
protected $fillable = [
'name',
'first_name',
'last_name',
'email',
'phone_country_code',
'phone',
'accept_whatsapp',
'accept_emails',
'avatar',
'country',
'timezone',
'locale',
'password',
'is_admin',
];
@ -46,9 +56,33 @@ protected function casts(): array
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_admin' => 'boolean',
'accept_whatsapp' => 'boolean',
'accept_emails' => 'boolean',
];
}
/**
* Get full name attribute
*/
public function getFullNameAttribute(): string
{
if ($this->first_name && $this->last_name) {
return "{$this->first_name} {$this->last_name}";
}
return $this->name ?? '';
}
/**
* Get full phone with country code
*/
public function getFullPhoneAttribute(): ?string
{
if ($this->phone_country_code && $this->phone) {
return "{$this->phone_country_code} {$this->phone}";
}
return $this->phone;
}
/**
* Verifica se o usuário é administrador
*/

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Separar nome em first_name e last_name
$table->string('first_name')->nullable()->after('name');
$table->string('last_name')->nullable()->after('first_name');
// Telefone com código do país
$table->string('phone_country_code', 5)->nullable()->after('email'); // +34, +55, +1
$table->string('phone', 20)->nullable()->after('phone_country_code');
// Preferências de comunicação
$table->boolean('accept_whatsapp')->default(false)->after('phone');
$table->boolean('accept_emails')->default(true)->after('accept_whatsapp');
// Avatar (URL ou path)
$table->string('avatar')->nullable()->after('accept_emails');
// Localização para billing futuro
$table->string('country', 2)->nullable()->after('avatar'); // ISO 3166-1 alpha-2 (ES, BR, US)
$table->string('timezone')->nullable()->after('country');
$table->string('locale', 5)->nullable()->after('timezone'); // es, pt-BR, en
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'first_name',
'last_name',
'phone_country_code',
'phone',
'accept_whatsapp',
'accept_emails',
'avatar',
'country',
'timezone',
'locale',
]);
});
}
};

View File

@ -2008,10 +2008,26 @@
},
"profile": {
"title": "My Profile",
"subtitle": "Manage your personal data and password",
"subtitle": "Manage your personal data and preferences",
"personalData": "Personal Data",
"firstName": "First Name",
"firstNamePlaceholder": "Your first name",
"firstNameRequired": "First name is required",
"lastName": "Last Name",
"lastNamePlaceholder": "Your last name",
"lastNameRequired": "Last name is required",
"name": "Name",
"email": "Email",
"phone": "Phone",
"phoneRequired": "Phone number is required",
"communicationPreferences": "Communication Preferences",
"acceptWhatsapp": "I agree to receive WhatsApp notifications",
"acceptEmails": "I agree to receive promotional emails and updates",
"communicationNote": "You can change these preferences at any time",
"country": "Country",
"selectCountry": "Select your country",
"timezone": "Timezone",
"language": "Language",
"saveProfile": "Save Profile",
"changePassword": "Change Password",
"currentPassword": "Current Password",

View File

@ -1996,10 +1996,26 @@
},
"profile": {
"title": "Mi Perfil",
"subtitle": "Gestiona tus datos personales y contraseña",
"subtitle": "Gestiona tus datos personales y preferencias",
"personalData": "Datos Personales",
"firstName": "Nombre",
"firstNamePlaceholder": "Tu nombre",
"firstNameRequired": "El nombre es obligatorio",
"lastName": "Apellidos",
"lastNamePlaceholder": "Tus apellidos",
"lastNameRequired": "Los apellidos son obligatorios",
"name": "Nombre",
"email": "Email",
"email": "Correo electrónico",
"phone": "Teléfono",
"phoneRequired": "El teléfono es obligatorio",
"communicationPreferences": "Preferencias de comunicación",
"acceptWhatsapp": "Acepto recibir notificaciones por WhatsApp",
"acceptEmails": "Acepto recibir correos promocionales y novedades",
"communicationNote": "Puedes cambiar estas preferencias en cualquier momento",
"country": "País",
"selectCountry": "Selecciona tu país",
"timezone": "Zona horaria",
"language": "Idioma",
"saveProfile": "Guardar Perfil",
"changePassword": "Cambiar Contraseña",
"currentPassword": "Contraseña Actual",

View File

@ -2014,10 +2014,26 @@
},
"profile": {
"title": "Meu Perfil",
"subtitle": "Gerencie seus dados pessoais e senha",
"subtitle": "Gerencie seus dados pessoais e preferências",
"personalData": "Dados Pessoais",
"firstName": "Nome",
"firstNamePlaceholder": "Seu nome",
"firstNameRequired": "O nome é obrigatório",
"lastName": "Sobrenome",
"lastNamePlaceholder": "Seu sobrenome",
"lastNameRequired": "O sobrenome é obrigatório",
"name": "Nome",
"email": "Email",
"email": "E-mail",
"phone": "Telefone",
"phoneRequired": "O telefone é obrigatório",
"communicationPreferences": "Preferências de comunicação",
"acceptWhatsapp": "Aceito receber notificações por WhatsApp",
"acceptEmails": "Aceito receber e-mails promocionais e novidades",
"communicationNote": "Você pode alterar essas preferências a qualquer momento",
"country": "País",
"selectCountry": "Selecione seu país",
"timezone": "Fuso horário",
"language": "Idioma",
"saveProfile": "Salvar Perfil",
"changePassword": "Alterar Senha",
"currentPassword": "Senha Atual",

View File

@ -3,8 +3,65 @@ import { useTranslation } from 'react-i18next';
import { useToast } from '../components/Toast';
import { profileService } from '../services/api';
// Lista de países com código de telefone (foco: ES, BR, US)
const COUNTRY_CODES = [
{ code: '+34', country: 'ES', flag: '🇪🇸', name: 'España' },
{ code: '+55', country: 'BR', flag: '🇧🇷', name: 'Brasil' },
{ code: '+1', country: 'US', flag: '🇺🇸', name: 'United States' },
{ code: '+44', country: 'GB', flag: '🇬🇧', name: 'United Kingdom' },
{ code: '+33', country: 'FR', flag: '🇫🇷', name: 'France' },
{ code: '+49', country: 'DE', flag: '🇩🇪', name: 'Germany' },
{ code: '+39', country: 'IT', flag: '🇮🇹', name: 'Italy' },
{ code: '+351', country: 'PT', flag: '🇵🇹', name: 'Portugal' },
{ code: '+52', country: 'MX', flag: '🇲🇽', name: 'México' },
{ code: '+54', country: 'AR', flag: '🇦🇷', name: 'Argentina' },
{ code: '+56', country: 'CL', flag: '🇨🇱', name: 'Chile' },
{ code: '+57', country: 'CO', flag: '🇨🇴', name: 'Colombia' },
{ code: '+58', country: 'VE', flag: '🇻🇪', name: 'Venezuela' },
{ code: '+81', country: 'JP', flag: '🇯🇵', name: 'Japan' },
{ code: '+86', country: 'CN', flag: '🇨🇳', name: 'China' },
];
// Lista de países para seleção
const COUNTRIES = [
{ code: 'ES', name: 'España', flag: '🇪🇸' },
{ code: 'BR', name: 'Brasil', flag: '🇧🇷' },
{ code: 'US', name: 'United States', flag: '🇺🇸' },
{ code: 'GB', name: 'United Kingdom', flag: '🇬🇧' },
{ code: 'FR', name: 'France', flag: '🇫🇷' },
{ code: 'DE', name: 'Germany', flag: '🇩🇪' },
{ code: 'IT', name: 'Italy', flag: '🇮🇹' },
{ code: 'PT', name: 'Portugal', flag: '🇵🇹' },
{ code: 'MX', name: 'México', flag: '🇲🇽' },
{ code: 'AR', name: 'Argentina', flag: '🇦🇷' },
{ code: 'CL', name: 'Chile', flag: '🇨🇱' },
{ code: 'CO', name: 'Colombia', flag: '🇨🇴' },
{ code: 'VE', name: 'Venezuela', flag: '🇻🇪' },
{ code: 'JP', name: 'Japan', flag: '🇯🇵' },
{ code: 'CN', name: 'China', flag: '🇨🇳' },
];
// Lista de timezones comuns
const TIMEZONES = [
{ value: 'Europe/Madrid', label: 'Madrid (CET/CEST)' },
{ value: 'Europe/Lisbon', label: 'Lisboa (WET/WEST)' },
{ value: 'Europe/London', label: 'London (GMT/BST)' },
{ value: 'Europe/Paris', label: 'Paris (CET/CEST)' },
{ value: 'Europe/Berlin', label: 'Berlin (CET/CEST)' },
{ value: 'America/Sao_Paulo', label: 'São Paulo (BRT)' },
{ value: 'America/New_York', label: 'New York (EST/EDT)' },
{ value: 'America/Los_Angeles', label: 'Los Angeles (PST/PDT)' },
{ value: 'America/Chicago', label: 'Chicago (CST/CDT)' },
{ value: 'America/Mexico_City', label: 'Ciudad de México (CST)' },
{ value: 'America/Buenos_Aires', label: 'Buenos Aires (ART)' },
{ value: 'America/Santiago', label: 'Santiago (CLT)' },
{ value: 'America/Bogota', label: 'Bogotá (COT)' },
{ value: 'Asia/Tokyo', label: 'Tokyo (JST)' },
{ value: 'Asia/Shanghai', label: 'Shanghai (CST)' },
];
export default function Profile() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { showToast } = useToast();
const [loading, setLoading] = useState(true);
@ -13,8 +70,16 @@ export default function Profile() {
// Dados do perfil
const [profile, setProfile] = useState({
name: '',
first_name: '',
last_name: '',
email: '',
phone_country_code: '+34',
phone: '',
accept_whatsapp: false,
accept_emails: true,
country: 'ES',
timezone: 'Europe/Madrid',
locale: 'es',
});
// Dados de alteração de senha
@ -37,9 +102,18 @@ export default function Profile() {
setLoading(true);
const response = await profileService.get();
if (response.success) {
const user = response.data.user;
setProfile({
name: response.data.user.name || '',
email: response.data.user.email || '',
first_name: user.first_name || '',
last_name: user.last_name || '',
email: user.email || '',
phone_country_code: user.phone_country_code || '+34',
phone: user.phone || '',
accept_whatsapp: user.accept_whatsapp || false,
accept_emails: user.accept_emails !== false, // default true
country: user.country || 'ES',
timezone: user.timezone || 'Europe/Madrid',
locale: user.locale || i18n.language || 'es',
});
}
} catch (error) {
@ -76,15 +150,30 @@ export default function Profile() {
e.preventDefault();
setProfileErrors({});
// Validação básica
if (!profile.first_name.trim()) {
setProfileErrors({ first_name: [t('profile.firstNameRequired')] });
return;
}
if (!profile.last_name.trim()) {
setProfileErrors({ last_name: [t('profile.lastNameRequired')] });
return;
}
if (!profile.phone.trim()) {
setProfileErrors({ phone: [t('profile.phoneRequired')] });
return;
}
try {
setSavingProfile(true);
const response = await profileService.update({
name: profile.name,
email: profile.email,
});
const response = await profileService.update(profile);
if (response.success) {
showToast(t('profile.saveSuccess'), 'success');
// Atualizar idioma se mudou
if (profile.locale && profile.locale !== i18n.language) {
i18n.changeLanguage(profile.locale);
}
}
} catch (error) {
console.error('Error saving profile:', error);
@ -175,22 +264,42 @@ export default function Profile() {
</div>
<div className="card-body">
<form onSubmit={handleSaveProfile}>
{/* Nome */}
<div className="mb-3">
<label htmlFor="name" className="form-label">
{t('profile.name')} <span className="text-danger">*</span>
</label>
<input
type="text"
className={`form-control ${profileErrors.name ? 'is-invalid' : ''}`}
id="name"
value={profile.name}
onChange={(e) => handleProfileChange('name', e.target.value)}
required
/>
{profileErrors.name && (
<div className="invalid-feedback">{profileErrors.name[0]}</div>
)}
{/* Nome e Sobrenome */}
<div className="row">
<div className="col-md-6 mb-3">
<label htmlFor="first_name" className="form-label">
{t('profile.firstName')} <span className="text-danger">*</span>
</label>
<input
type="text"
className={`form-control ${profileErrors.first_name ? 'is-invalid' : ''}`}
id="first_name"
value={profile.first_name}
onChange={(e) => handleProfileChange('first_name', e.target.value)}
placeholder={t('profile.firstNamePlaceholder')}
required
/>
{profileErrors.first_name && (
<div className="invalid-feedback">{profileErrors.first_name[0]}</div>
)}
</div>
<div className="col-md-6 mb-3">
<label htmlFor="last_name" className="form-label">
{t('profile.lastName')} <span className="text-danger">*</span>
</label>
<input
type="text"
className={`form-control ${profileErrors.last_name ? 'is-invalid' : ''}`}
id="last_name"
value={profile.last_name}
onChange={(e) => handleProfileChange('last_name', e.target.value)}
placeholder={t('profile.lastNamePlaceholder')}
required
/>
{profileErrors.last_name && (
<div className="invalid-feedback">{profileErrors.last_name[0]}</div>
)}
</div>
</div>
{/* Email */}
@ -198,17 +307,147 @@ export default function Profile() {
<label htmlFor="email" className="form-label">
{t('profile.email')} <span className="text-danger">*</span>
</label>
<input
type="email"
className={`form-control ${profileErrors.email ? 'is-invalid' : ''}`}
id="email"
value={profile.email}
onChange={(e) => handleProfileChange('email', e.target.value)}
required
/>
{profileErrors.email && (
<div className="invalid-feedback">{profileErrors.email[0]}</div>
)}
<div className="input-group">
<span className="input-group-text">
<i className="bi bi-envelope"></i>
</span>
<input
type="email"
className={`form-control ${profileErrors.email ? 'is-invalid' : ''}`}
id="email"
value={profile.email}
onChange={(e) => handleProfileChange('email', e.target.value)}
required
/>
{profileErrors.email && (
<div className="invalid-feedback">{profileErrors.email[0]}</div>
)}
</div>
</div>
{/* Telefone */}
<div className="mb-3">
<label htmlFor="phone" className="form-label">
{t('profile.phone')} <span className="text-danger">*</span>
</label>
<div className="input-group">
<select
className="form-select"
style={{ maxWidth: '140px' }}
value={profile.phone_country_code}
onChange={(e) => handleProfileChange('phone_country_code', e.target.value)}
>
{COUNTRY_CODES.map(c => (
<option key={c.code} value={c.code}>
{c.flag} {c.code}
</option>
))}
</select>
<input
type="tel"
className={`form-control ${profileErrors.phone ? 'is-invalid' : ''}`}
id="phone"
value={profile.phone}
onChange={(e) => handleProfileChange('phone', e.target.value.replace(/\D/g, ''))}
placeholder="600 123 456"
required
/>
{profileErrors.phone && (
<div className="invalid-feedback">{profileErrors.phone[0]}</div>
)}
</div>
</div>
{/* Preferências de Comunicação */}
<div className="mb-4">
<label className="form-label">{t('profile.communicationPreferences')}</label>
<div className="card bg-dark">
<div className="card-body py-2">
<div className="form-check mb-2">
<input
className="form-check-input"
type="checkbox"
id="accept_whatsapp"
checked={profile.accept_whatsapp}
onChange={(e) => handleProfileChange('accept_whatsapp', e.target.checked)}
/>
<label className="form-check-label" htmlFor="accept_whatsapp">
<i className="bi bi-whatsapp text-success me-2"></i>
{t('profile.acceptWhatsapp')}
</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="accept_emails"
checked={profile.accept_emails}
onChange={(e) => handleProfileChange('accept_emails', e.target.checked)}
/>
<label className="form-check-label" htmlFor="accept_emails">
<i className="bi bi-envelope text-info me-2"></i>
{t('profile.acceptEmails')}
</label>
</div>
</div>
</div>
<div className="form-text">{t('profile.communicationNote')}</div>
</div>
{/* País e Timezone */}
<div className="row">
<div className="col-md-6 mb-3">
<label htmlFor="country" className="form-label">
{t('profile.country')}
</label>
<select
className="form-select"
id="country"
value={profile.country}
onChange={(e) => handleProfileChange('country', e.target.value)}
>
<option value="">{t('profile.selectCountry')}</option>
{COUNTRIES.map(c => (
<option key={c.code} value={c.code}>
{c.flag} {c.name}
</option>
))}
</select>
</div>
<div className="col-md-6 mb-3">
<label htmlFor="timezone" className="form-label">
{t('profile.timezone')}
</label>
<select
className="form-select"
id="timezone"
value={profile.timezone}
onChange={(e) => handleProfileChange('timezone', e.target.value)}
>
{TIMEZONES.map(tz => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))}
</select>
</div>
</div>
{/* Idioma */}
<div className="mb-4">
<label htmlFor="locale" className="form-label">
{t('profile.language')}
</label>
<select
className="form-select"
id="locale"
value={profile.locale}
onChange={(e) => handleProfileChange('locale', e.target.value)}
>
<option value="es">🇪🇸 Español</option>
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
<option value="en">🇺🇸 English</option>
</select>
</div>
{/* Botão Salvar Perfil */}