From abaf0097c52a712621d41b02d1ad635052534a81 Mon Sep 17 00:00:00 2001 From: marcoitaloesp-ai Date: Wed, 17 Dec 2025 10:40:20 +0000 Subject: [PATCH] feat(profile): perfil completo para SaaS v1.48.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 31 ++ VERSION | 2 +- .../Http/Controllers/Api/AuthController.php | 63 +++- backend/app/Models/User.php | 34 ++ ...3338_add_profile_fields_to_users_table.php | 57 ++++ frontend/src/i18n/locales/en.json | 18 +- frontend/src/i18n/locales/es.json | 20 +- frontend/src/i18n/locales/pt-BR.json | 20 +- frontend/src/pages/Profile.jsx | 309 ++++++++++++++++-- 9 files changed, 503 insertions(+), 51 deletions(-) create mode 100644 backend/database/migrations/2025_12_17_103338_add_profile_fields_to_users_table.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7a07c..16b21d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 21998d3..9db5ea1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.47.0 +1.48.0 diff --git a/backend/app/Http/Controllers/Api/AuthController.php b/backend/app/Http/Controllers/Api/AuthController.php index b9f81bb..917ff80 100644 --- a/backend/app/Http/Controllers/Api/AuthController.php +++ b/backend/app/Http/Controllers/Api/AuthController.php @@ -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); diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 460cd87..48459cc 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -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 */ diff --git a/backend/database/migrations/2025_12_17_103338_add_profile_fields_to_users_table.php b/backend/database/migrations/2025_12_17_103338_add_profile_fields_to_users_table.php new file mode 100644 index 0000000..0e22858 --- /dev/null +++ b/backend/database/migrations/2025_12_17_103338_add_profile_fields_to_users_table.php @@ -0,0 +1,57 @@ +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', + ]); + }); + } +}; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f59c323..9f3dc2d 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 6681a72..e4abf08 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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", diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index b580134..d9c423a 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -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", diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index ce6ce97..989fb70 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -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() {
- {/* Nome */} -
- - handleProfileChange('name', e.target.value)} - required - /> - {profileErrors.name && ( -
{profileErrors.name[0]}
- )} + {/* Nome e Sobrenome */} +
+
+ + handleProfileChange('first_name', e.target.value)} + placeholder={t('profile.firstNamePlaceholder')} + required + /> + {profileErrors.first_name && ( +
{profileErrors.first_name[0]}
+ )} +
+
+ + handleProfileChange('last_name', e.target.value)} + placeholder={t('profile.lastNamePlaceholder')} + required + /> + {profileErrors.last_name && ( +
{profileErrors.last_name[0]}
+ )} +
{/* Email */} @@ -198,17 +307,147 @@ export default function Profile() { - handleProfileChange('email', e.target.value)} - required - /> - {profileErrors.email && ( -
{profileErrors.email[0]}
- )} +
+ + + + handleProfileChange('email', e.target.value)} + required + /> + {profileErrors.email && ( +
{profileErrors.email[0]}
+ )} +
+
+ + {/* Telefone */} +
+ +
+ + handleProfileChange('phone', e.target.value.replace(/\D/g, ''))} + placeholder="600 123 456" + required + /> + {profileErrors.phone && ( +
{profileErrors.phone[0]}
+ )} +
+
+ + {/* Preferências de Comunicação */} +
+ +
+
+
+ handleProfileChange('accept_whatsapp', e.target.checked)} + /> + +
+
+ handleProfileChange('accept_emails', e.target.checked)} + /> + +
+
+
+
{t('profile.communicationNote')}
+
+ + {/* País e Timezone */} +
+
+ + +
+
+ + +
+
+ + {/* Idioma */} +
+ +
{/* Botão Salvar Perfil */}