webmoney/frontend/src/pages/Profile.jsx
marco 806bfc1a89 feat: Adicionar aviso de IVA em todos os preços
�� IVA NÃO INCLUÍDO - Avisos adicionados

📍 LOCAIS ATUALIZADOS:

1. Pricing Page (/pricing)
   - Aviso "+ IVA" abaixo do preço principal
   - Texto destacado em amarelo

2. Profile Page (/profile)
   - Seção de assinatura com "(IVA não incluído)"

3. Billing Page (/billing)
   - Plano atual com aviso de IVA

🌍 i18n - 3 Idiomas:

pt-BR:
- plusVat: "+ IVA"
- vatNotIncluded: "(IVA não incluído)"
- billedAnnually: "...€{{price}} + IVA"

ES:
- plusVat: "+ IVA"
- vatNotIncluded: "(IVA no incluido)"
- billedAnnually: "...€{{price}} + IVA"

EN:
- plusVat: "+ VAT"
- vatNotIncluded: "(VAT not included)"
- billedAnnually: "...€{{price}} + VAT"

🎨 VISUAL:
- Texto em amarelo (text-warning)
- Destaque com fw-semibold
- Posicionamento consistente

 Cliente agora vê claramente que IVA será adicionado
2025-12-19 17:23:34 +01:00

757 lines
31 KiB
JavaScript
Executable File

import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from '../components/Toast';
import { profileService, subscriptionService } from '../services/api';
import FactoryResetWizard from '../components/FactoryResetWizard';
import ImportBackupModal from '../components/ImportBackupModal';
import CancellationWizard from '../components/CancellationWizard';
// 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, i18n } = useTranslation();
const { showToast } = useToast();
const [loading, setLoading] = useState(true);
const [savingProfile, setSavingProfile] = useState(false);
const [savingPassword, setSavingPassword] = useState(false);
// Dados do perfil
const [profile, setProfile] = useState({
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
const [passwordData, setPasswordData] = useState({
current_password: '',
new_password: '',
new_password_confirmation: '',
});
// Erros de validação
const [profileErrors, setProfileErrors] = useState({});
const [passwordErrors, setPasswordErrors] = useState({});
// Modais
const [showFactoryReset, setShowFactoryReset] = useState(false);
const [showImportBackup, setShowImportBackup] = useState(false);
const [showCancellation, setShowCancellation] = useState(false);
// Subscription data
const [subscriptionData, setSubscriptionData] = useState(null);
useEffect(() => {
loadProfile();
loadSubscription();
}, []);
const loadProfile = async () => {
try {
setLoading(true);
const response = await profileService.get();
if (response.success) {
const user = response.data.user;
setProfile({
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) {
console.error('Error loading profile:', error);
showToast(t('profile.loadError'), 'error');
} finally {
setLoading(false);
}
};
const loadSubscription = async () => {
try {
const response = await subscriptionService.getStatus();
if (response.success) {
setSubscriptionData(response.data);
}
} catch (error) {
console.error('Error loading subscription:', error);
}
};
const handleProfileChange = (field, value) => {
setProfile(prev => ({
...prev,
[field]: value,
}));
// Limpar erro do campo
if (profileErrors[field]) {
setProfileErrors(prev => ({ ...prev, [field]: null }));
}
};
const handlePasswordChange = (field, value) => {
setPasswordData(prev => ({
...prev,
[field]: value,
}));
// Limpar erro do campo
if (passwordErrors[field]) {
setPasswordErrors(prev => ({ ...prev, [field]: null }));
}
};
const handleSaveProfile = async (e) => {
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(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);
if (error.response?.data?.errors) {
setProfileErrors(error.response.data.errors);
}
showToast(error.response?.data?.message || t('profile.saveError'), 'error');
} finally {
setSavingProfile(false);
}
};
const handleChangePassword = async (e) => {
e.preventDefault();
setPasswordErrors({});
// Validação básica no frontend
if (passwordData.new_password !== passwordData.new_password_confirmation) {
setPasswordErrors({ new_password_confirmation: [t('profile.passwordMismatch')] });
return;
}
if (passwordData.new_password.length < 8) {
setPasswordErrors({ new_password: [t('profile.passwordTooShort')] });
return;
}
try {
setSavingPassword(true);
const response = await profileService.update({
current_password: passwordData.current_password,
new_password: passwordData.new_password,
new_password_confirmation: passwordData.new_password_confirmation,
});
if (response.success) {
showToast(t('profile.passwordChanged'), 'success');
// Limpar campos de senha
setPasswordData({
current_password: '',
new_password: '',
new_password_confirmation: '',
});
}
} catch (error) {
console.error('Error changing password:', error);
if (error.response?.data?.errors) {
setPasswordErrors(error.response.data.errors);
}
showToast(error.response?.data?.message || t('profile.passwordError'), 'error');
} finally {
setSavingPassword(false);
}
};
if (loading) {
return (
<div className="container-fluid py-4">
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">{t('common.loading')}</span>
</div>
</div>
</div>
);
}
return (
<div className="container-fluid py-4">
<div className="row justify-content-center">
<div className="col-lg-8 col-xl-6">
{/* Header */}
<div className="d-flex align-items-center mb-4">
<i className="bi bi-person-circle fs-2 text-primary me-3"></i>
<div>
<h2 className="mb-0">{t('profile.title')}</h2>
<p className="text-muted mb-0">{t('profile.subtitle')}</p>
</div>
</div>
{/* Dados Pessoais */}
<div className="card mb-4">
<div className="card-header">
<h5 className="mb-0">
<i className="bi bi-person me-2"></i>
{t('profile.personalData')}
</h5>
</div>
<div className="card-body">
<form onSubmit={handleSaveProfile}>
{/* 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 */}
<div className="mb-3">
<label htmlFor="email" className="form-label">
{t('profile.email')} <span className="text-danger">*</span>
</label>
<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 */}
<div className="d-flex justify-content-end">
<button
type="submit"
className="btn btn-primary"
disabled={savingProfile}
>
{savingProfile ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
{t('common.saving')}
</>
) : (
<>
<i className="bi bi-check-lg me-2"></i>
{t('profile.saveProfile')}
</>
)}
</button>
</div>
</form>
</div>
</div>
{/* Alterar Senha */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">
<i className="bi bi-shield-lock me-2"></i>
{t('profile.changePassword')}
</h5>
</div>
<div className="card-body">
<form onSubmit={handleChangePassword}>
{/* Senha Atual */}
<div className="mb-3">
<label htmlFor="current_password" className="form-label">
{t('profile.currentPassword')} <span className="text-danger">*</span>
</label>
<input
type="password"
className={`form-control ${passwordErrors.current_password ? 'is-invalid' : ''}`}
id="current_password"
value={passwordData.current_password}
onChange={(e) => handlePasswordChange('current_password', e.target.value)}
required
autoComplete="current-password"
/>
{passwordErrors.current_password && (
<div className="invalid-feedback">{passwordErrors.current_password[0]}</div>
)}
</div>
{/* Nova Senha */}
<div className="mb-3">
<label htmlFor="new_password" className="form-label">
{t('profile.newPassword')} <span className="text-danger">*</span>
</label>
<input
type="password"
className={`form-control ${passwordErrors.new_password ? 'is-invalid' : ''}`}
id="new_password"
value={passwordData.new_password}
onChange={(e) => handlePasswordChange('new_password', e.target.value)}
required
minLength={8}
autoComplete="new-password"
/>
{passwordErrors.new_password && (
<div className="invalid-feedback">{passwordErrors.new_password[0]}</div>
)}
<div className="form-text">{t('profile.passwordHint')}</div>
</div>
{/* Confirmar Nova Senha */}
<div className="mb-3">
<label htmlFor="new_password_confirmation" className="form-label">
{t('profile.confirmPassword')} <span className="text-danger">*</span>
</label>
<input
type="password"
className={`form-control ${passwordErrors.new_password_confirmation ? 'is-invalid' : ''}`}
id="new_password_confirmation"
value={passwordData.new_password_confirmation}
onChange={(e) => handlePasswordChange('new_password_confirmation', e.target.value)}
required
minLength={8}
autoComplete="new-password"
/>
{passwordErrors.new_password_confirmation && (
<div className="invalid-feedback">{passwordErrors.new_password_confirmation[0]}</div>
)}
</div>
{/* Botão Alterar Senha */}
<div className="d-flex justify-content-end">
<button
type="submit"
className="btn btn-warning"
disabled={savingPassword}
>
{savingPassword ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
{t('common.saving')}
</>
) : (
<>
<i className="bi bi-key me-2"></i>
{t('profile.changePassword')}
</>
)}
</button>
</div>
</form>
</div>
</div>
{/* Seção: Assinatura */}
{subscriptionData?.has_subscription && subscriptionData?.plan && !subscriptionData?.plan?.is_free && (
<div className="col-12 mt-4">
<div className="card shadow-sm">
<div className="card-header bg-primary text-white">
<h5 className="mb-0">
<i className="bi bi-credit-card me-2"></i>
{t('profile.subscription') || 'Minha Assinatura'}
</h5>
</div>
<div className="card-body">
<div className="row align-items-center">
<div className="col-md-8">
<div className="d-flex align-items-start mb-3">
<i className="bi bi-star-fill text-warning fs-3 me-3"></i>
<div>
<h6 className="fw-bold mb-1">
{subscriptionData.plan.name}
<span className="badge bg-success ms-2">{t('common.active') || 'Ativo'}</span>
</h6>
<p className="text-muted mb-1" style={{ fontSize: '14px' }}>
{subscriptionData.plan.formatted_price} / {subscriptionData.plan.billing_period === 'monthly' ? t('common.month') || 'mês' : t('common.year') || 'ano'}
<span className="text-warning fw-semibold ms-2">({t('pricing.vatNotIncluded') || '+ IVA'})</span>
</p>
{subscriptionData.subscription?.current_period_end && (
<p className="text-muted mb-0" style={{ fontSize: '13px' }}>
<i className="bi bi-calendar-event me-1"></i>
{t('profile.renewsOn') || 'Renovação em'}: {new Date(subscriptionData.subscription.current_period_end).toLocaleDateString()}
</p>
)}
</div>
</div>
<div className="d-flex gap-2 flex-wrap">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => window.open('https://www.paypal.com/myaccount/autopay/', '_blank')}
>
<i className="bi bi-paypal me-1"></i>
{t('profile.managePayPal') || 'Gerenciar no PayPal'}
</button>
</div>
</div>
<div className="col-md-4 text-md-end mt-3 mt-md-0">
<button
className="btn btn-outline-danger w-100"
onClick={() => setShowCancellation(true)}
>
<i className="bi bi-x-circle me-2"></i>
{t('profile.cancelSubscription') || 'Cancelar Assinatura'}
</button>
<small className="text-muted d-block mt-2">
{t('profile.cancelAnytime') || 'Cancele a qualquer momento'}
</small>
</div>
</div>
{subscriptionData?.guarantee?.within_period && (
<div className="alert alert-info mt-3 mb-0">
<small>
<i className="bi bi-shield-check me-2"></i>
<strong>{t('profile.guaranteePeriod') || 'Período de Garantia:'}</strong> Você tem {subscriptionData.guarantee.days_remaining} dia(s) restantes para cancelar com reembolso total (garantia de 7 dias).
</small>
</div>
)}
</div>
</div>
</div>
)}
{/* Seção: Backup e Factory Reset */}
<div className="col-12 mt-4">
<div className="card shadow-sm">
<div className="card-header bg-danger text-white">
<h5 className="mb-0">
<i className="bi bi-shield-exclamation me-2"></i>
{t('profile.dataManagement') || 'Gerenciamento de Dados'}
</h5>
</div>
<div className="card-body">
<div className="row">
{/* Importar Backup */}
<div className="col-md-6 mb-3 mb-md-0">
<div className="d-flex align-items-start">
<i className="bi bi-cloud-upload text-primary fs-3 me-3"></i>
<div className="flex-grow-1">
<h6 className="fw-bold">{t('profile.importBackup') || 'Importar Backup'}</h6>
<p className="text-muted small mb-2">
{t('profile.importBackupDesc') ||
'Restaure dados de um backup anteriormente exportado. Os dados serão adicionados à sua conta.'}
</p>
<button
className="btn btn-sm btn-outline-primary"
onClick={() => setShowImportBackup(true)}
>
<i className="bi bi-upload me-1"></i>
{t('profile.importBackupBtn') || 'Importar Agora'}
</button>
</div>
</div>
</div>
{/* Factory Reset */}
<div className="col-md-6">
<div className="d-flex align-items-start">
<i className="bi bi-exclamation-triangle text-danger fs-3 me-3"></i>
<div className="flex-grow-1">
<h6 className="fw-bold text-danger">
{t('profile.factoryReset') || 'Factory Reset'}
</h6>
<p className="text-muted small mb-2">
{t('profile.factoryResetDesc') ||
'ATENÇÃO: Deleta permanentemente TODOS os seus dados. Esta ação não pode ser desfeita.'}
</p>
<button
className="btn btn-sm btn-outline-danger"
onClick={() => setShowFactoryReset(true)}
>
<i className="bi bi-trash me-1"></i>
{t('profile.factoryResetBtn') || 'Iniciar Factory Reset'}
</button>
</div>
</div>
</div>
</div>
<div className="alert alert-warning mt-3 mb-0">
<small>
<i className="bi bi-info-circle me-2"></i>
{t('profile.dataManagementWarning') ||
'Antes de fazer Factory Reset, recomendamos criar um backup dos seus dados para recuperá-los posteriormente se necessário.'}
</small>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Modais */}
{showFactoryReset && (
<FactoryResetWizard onClose={() => setShowFactoryReset(false)} />
)}
{showImportBackup && (
<ImportBackupModal
onClose={() => setShowImportBackup(false)}
onSuccess={() => {
setShowImportBackup(false);
// Recarregar dados se necessário
window.location.reload();
}}
/>
)}
{showCancellation && subscriptionData?.subscription && (
<CancellationWizard
onClose={() => setShowCancellation(false)}
subscription={subscriptionData.subscription}
/>
)}
</div>
);
}