v1.53.0: Fix admin middleware, deploy cnxifly pages from server
This commit is contained in:
parent
3a336eb692
commit
c99bca9404
65
CHANGELOG.md
65
CHANGELOG.md
@ -5,6 +5,71 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
|
||||
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
|
||||
|
||||
|
||||
## [1.53.0] - 2025-12-17
|
||||
|
||||
### Fixed
|
||||
- 🔧 **Correção Middleware Admin** - Alterado de `admin` para `admin.only` nas rotas site-settings
|
||||
- 🔧 **Correção Deploy cnxifly.com** - Controller atualizado para usar arquivos do servidor
|
||||
- Páginas armazenadas em `/var/www/cnxifly-pages/`
|
||||
- Deploy direto via `File::copy()` ao invés de instruções manuais
|
||||
|
||||
### Added
|
||||
- 📁 **Diretório de páginas no servidor** (`/var/www/cnxifly-pages/`)
|
||||
- `index.html` - Página institucional live
|
||||
- `maintenance.html` - Página de manutenção
|
||||
- Permite trocar páginas via painel admin
|
||||
|
||||
### Technical Details
|
||||
- Middleware correto: `admin.only` (registrado em bootstrap/app.php)
|
||||
- SiteSettingsController agora copia arquivos diretamente no servidor
|
||||
- Permissões ajustadas para www-data no cnxifly-pages
|
||||
|
||||
---
|
||||
|
||||
## [1.52.0] - 2025-12-17
|
||||
|
||||
### Added
|
||||
- 🌐 **Landing Page Institucional ConneXiFly** - Página completa para cnxifly.com
|
||||
- **Produtos destacados**:
|
||||
- WebMoney: Gestão financeira inteligente (Disponível)
|
||||
- EZPool: Software de mantenimiento de piscinas (Próximamente)
|
||||
- **Seções**:
|
||||
- Hero com animações e gradientes
|
||||
- Cards de produtos com preços
|
||||
- Features do ConneXiFly
|
||||
- CTA para registro
|
||||
- Footer completo
|
||||
- **Modal de notificação** para EZPool (pré-registro)
|
||||
- **Design responsivo** e dark theme consistente
|
||||
|
||||
- 🔧 **Página de Manutenção** alternativa (`landing/maintenance.html`)
|
||||
- Design simples para modo manutenção
|
||||
- Links para produtos disponíveis
|
||||
|
||||
- ⚙️ **Painel Admin - Configurações do Site**
|
||||
- **Backend**:
|
||||
- `SiteSetting` model para configurações persistentes
|
||||
- `SiteSettingsController` para gerenciar cnxifly.com
|
||||
- Rotas admin para toggle de modo (live/maintenance)
|
||||
- Migration para tabela `site_settings`
|
||||
|
||||
- **Frontend**:
|
||||
- Nova página `/site-settings` (admin only)
|
||||
- Toggle entre modo Live e Manutenção
|
||||
- Preview links para todos os sites
|
||||
- Instruções de deploy
|
||||
|
||||
- 📝 **Script de Deploy Landing** (`deploy-landing.sh`)
|
||||
- Deploy automático para cnxifly.com
|
||||
- Suporte a modos: live, maintenance
|
||||
|
||||
### Technical Details
|
||||
- Arquivos criados em `/landing/` (index.html, maintenance.html)
|
||||
- Bootstrap 5.3 + Bootstrap Icons para a landing
|
||||
- Integração com menu do WebMoney admin
|
||||
|
||||
---
|
||||
|
||||
## [1.51.0] - 2025-12-17
|
||||
|
||||
### Added
|
||||
|
||||
@ -30,6 +30,9 @@ public function index(Request $request)
|
||||
$this->setPrimaryCurrency();
|
||||
$this->loadExchangeRates();
|
||||
|
||||
// Verificar si hay datos suficientes para análisis
|
||||
$dataStatus = $this->checkDataSufficiency();
|
||||
|
||||
// Obtener datos base
|
||||
$financialSummary = $this->getFinancialSummary();
|
||||
$cashFlowAnalysis = $this->analyzeCashFlow();
|
||||
@ -77,6 +80,9 @@ public function index(Request $request)
|
||||
'last_updated' => now()->toIso8601String(),
|
||||
'currency' => $this->primaryCurrency,
|
||||
|
||||
// Estado de datos
|
||||
'data_status' => $dataStatus,
|
||||
|
||||
// Resumen financiero
|
||||
'summary' => [
|
||||
'total_assets' => $financialSummary['total_assets'],
|
||||
@ -118,6 +124,56 @@ public function index(Request $request)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si hay datos suficientes para análisis
|
||||
*/
|
||||
private function checkDataSufficiency()
|
||||
{
|
||||
$accountsCount = Account::where('user_id', $this->userId)->count();
|
||||
$transactionsCount = Transaction::where('user_id', $this->userId)->count();
|
||||
$categoriesCount = Category::where('user_id', $this->userId)->count();
|
||||
|
||||
// Transacciones de los últimos 30 días
|
||||
$recentTransactions = Transaction::where('user_id', $this->userId)
|
||||
->where('effective_date', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
// Determinar nivel de suficiencia
|
||||
$hasSufficientData = $accountsCount >= 1 && $transactionsCount >= 10;
|
||||
$hasMinimalData = $accountsCount >= 1 || $transactionsCount >= 1;
|
||||
|
||||
// Mensaje apropiado
|
||||
$message = null;
|
||||
$level = 'sufficient';
|
||||
|
||||
if ($accountsCount === 0 && $transactionsCount === 0) {
|
||||
$level = 'no_data';
|
||||
$message = 'No hay datos registrados. Añade cuentas y transacciones para comenzar el análisis.';
|
||||
} elseif ($accountsCount === 0) {
|
||||
$level = 'insufficient';
|
||||
$message = 'Añade al menos una cuenta bancaria para un análisis más preciso.';
|
||||
} elseif ($transactionsCount < 10) {
|
||||
$level = 'limited';
|
||||
$message = 'Hay pocos datos para un análisis completo. Registra más transacciones para mejorar la precisión.';
|
||||
} elseif ($recentTransactions === 0) {
|
||||
$level = 'outdated';
|
||||
$message = 'No hay transacciones recientes. Los datos pueden estar desactualizados.';
|
||||
}
|
||||
|
||||
return [
|
||||
'has_sufficient_data' => $hasSufficientData,
|
||||
'has_minimal_data' => $hasMinimalData,
|
||||
'level' => $level,
|
||||
'message' => $message,
|
||||
'counts' => [
|
||||
'accounts' => $accountsCount,
|
||||
'transactions' => $transactionsCount,
|
||||
'categories' => $categoriesCount,
|
||||
'recent_transactions' => $recentTransactions,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Establecer moneda principal del usuario
|
||||
*/
|
||||
|
||||
192
backend/app/Http/Controllers/Api/SiteSettingsController.php
Normal file
192
backend/app/Http/Controllers/Api/SiteSettingsController.php
Normal file
@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SiteSettingsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get all site settings
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$settings = SiteSetting::all()->mapWithKeys(function ($setting) {
|
||||
return [$setting->key => [
|
||||
'value' => $setting->value['value'] ?? $setting->value,
|
||||
'description' => $setting->description,
|
||||
'updated_at' => $setting->updated_at,
|
||||
]];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'settings' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific setting
|
||||
*/
|
||||
public function show(string $key)
|
||||
{
|
||||
$setting = SiteSetting::where('key', $key)->first();
|
||||
|
||||
if (!$setting) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Configuración no encontrada',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'key' => $setting->key,
|
||||
'value' => $setting->value['value'] ?? $setting->value,
|
||||
'description' => $setting->description,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a setting
|
||||
*/
|
||||
public function update(Request $request, string $key)
|
||||
{
|
||||
$request->validate([
|
||||
'value' => 'required',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$setting = SiteSetting::setValue(
|
||||
$key,
|
||||
$request->value,
|
||||
$request->description
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Configuración actualizada',
|
||||
'setting' => [
|
||||
'key' => $setting->key,
|
||||
'value' => $setting->value['value'] ?? $setting->value,
|
||||
'description' => $setting->description,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle cnxifly.com page mode
|
||||
*/
|
||||
public function toggleCnxiflyPage(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'mode' => 'required|in:live,maintenance,construction',
|
||||
]);
|
||||
|
||||
$mode = $request->mode;
|
||||
|
||||
// Update database setting
|
||||
SiteSetting::setValue('cnxifly_page_type', $mode, 'Tipo de página cnxifly.com');
|
||||
SiteSetting::setValue('cnxifly_maintenance_mode', $mode !== 'live', 'Modo de mantenimiento');
|
||||
|
||||
// Get paths
|
||||
$serverPath = '/var/www/cnxifly';
|
||||
$localLandingPath = base_path('../landing');
|
||||
|
||||
// Log the action
|
||||
Log::info("CnxiFly page mode changed to: {$mode}");
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "Modo de página cambiado a: {$mode}",
|
||||
'current_mode' => $mode,
|
||||
'instructions' => $this->getInstructions($mode),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cnxifly page status
|
||||
*/
|
||||
public function getCnxiflyStatus()
|
||||
{
|
||||
$mode = SiteSetting::getValue('cnxifly_page_type', 'live');
|
||||
$maintenanceMode = SiteSetting::getValue('cnxifly_maintenance_mode', false);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'mode' => $mode,
|
||||
'maintenance_mode' => $maintenanceMode,
|
||||
'modes_available' => [
|
||||
'live' => 'Página institucional completa',
|
||||
'maintenance' => 'Página de mantenimiento',
|
||||
'construction' => 'Página en construcción',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy cnxifly landing page to server
|
||||
*/
|
||||
public function deployCnxiflyPage(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'mode' => 'required|in:live,maintenance',
|
||||
]);
|
||||
|
||||
$mode = $request->mode;
|
||||
|
||||
// Source files are in /var/www/cnxifly-pages/ on the server
|
||||
$sourceDir = '/var/www/cnxifly-pages';
|
||||
$targetDir = '/var/www/cnxifly';
|
||||
$sourceFile = $mode === 'live' ? 'index.html' : 'maintenance.html';
|
||||
$sourcePath = "{$sourceDir}/{$sourceFile}";
|
||||
$targetPath = "{$targetDir}/index.html";
|
||||
|
||||
// Check if source file exists
|
||||
if (!File::exists($sourcePath)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "Archivo de origen no encontrado: {$sourcePath}. Ejecute deploy-landing.sh primero.",
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Copy the file
|
||||
try {
|
||||
File::copy($sourcePath, $targetPath);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "Error al copiar archivo: " . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
// Update setting
|
||||
SiteSetting::setValue('cnxifly_page_type', $mode);
|
||||
SiteSetting::setValue('cnxifly_maintenance_mode', $mode === 'maintenance');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "Página cnxifly.com actualizada a modo: {$mode}",
|
||||
'current_mode' => $mode,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getInstructions(string $mode): array
|
||||
{
|
||||
$sourceFile = match($mode) {
|
||||
'live' => 'index.html',
|
||||
'maintenance' => 'maintenance.html',
|
||||
'construction' => 'maintenance.html',
|
||||
};
|
||||
|
||||
return [
|
||||
'description' => "Para aplicar el modo '{$mode}', copie el archivo {$sourceFile} al servidor",
|
||||
'source_file' => $sourceFile,
|
||||
'target_path' => '/var/www/cnxifly/index.html',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,10 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Subscription;
|
||||
use App\Mail\WelcomeNewUser;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Carbon\Carbon;
|
||||
|
||||
@ -22,15 +24,17 @@ public function store(Request $request)
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'password' => 'sometimes|string|min:8',
|
||||
'password' => 'nullable|string|min:8',
|
||||
'language' => 'sometimes|string|in:es,pt-BR,en',
|
||||
'currency' => 'sometimes|string|size:3',
|
||||
'user_type' => 'sometimes|string|in:free,pro,admin',
|
||||
'send_welcome_email' => 'sometimes|boolean',
|
||||
]);
|
||||
|
||||
// Generate random password if not provided
|
||||
$password = $validated['password'] ?? bin2hex(random_bytes(8));
|
||||
// Generate random password if not provided or empty
|
||||
$password = !empty($validated['password']) ? $validated['password'] : bin2hex(random_bytes(8));
|
||||
$userType = $validated['user_type'] ?? 'free';
|
||||
$sendWelcomeEmail = $validated['send_welcome_email'] ?? true;
|
||||
|
||||
$user = User::create([
|
||||
'name' => $validated['name'],
|
||||
@ -54,7 +58,7 @@ public function store(Request $request)
|
||||
'plan_id' => $proPlan->id,
|
||||
'status' => Subscription::STATUS_ACTIVE,
|
||||
'current_period_start' => now(),
|
||||
'current_period_end' => now()->addYears(100), // "Lifetime" subscription
|
||||
'current_period_end' => Carbon::create(2037, 12, 31, 23, 59, 59), // "Lifetime" subscription (max timestamp)
|
||||
'paypal_subscription_id' => 'ADMIN_GRANTED_' . strtoupper(bin2hex(random_bytes(8))),
|
||||
'paypal_status' => 'ACTIVE',
|
||||
'price_paid' => 0,
|
||||
@ -69,6 +73,17 @@ public function store(Request $request)
|
||||
}
|
||||
}
|
||||
|
||||
// Send welcome email with temporary password
|
||||
$emailSent = false;
|
||||
if ($sendWelcomeEmail) {
|
||||
try {
|
||||
Mail::to($user->email)->send(new WelcomeNewUser($user, $password));
|
||||
$emailSent = true;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to send welcome email: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Usuario creado correctamente',
|
||||
@ -78,10 +93,12 @@ public function store(Request $request)
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'is_admin' => $user->is_admin,
|
||||
'language' => $user->language,
|
||||
],
|
||||
'user_type' => $userType,
|
||||
'subscription' => $subscriptionInfo,
|
||||
'temporary_password' => isset($validated['password']) ? null : $password,
|
||||
'welcome_email_sent' => $emailSent,
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
@ -122,6 +139,9 @@ public function index(Request $request)
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'language' => $user->language,
|
||||
'currency' => $user->currency,
|
||||
'is_admin' => (bool) $user->is_admin,
|
||||
'created_at' => $user->created_at,
|
||||
'last_login_at' => $user->last_login_at,
|
||||
'email_verified_at' => $user->email_verified_at,
|
||||
@ -207,11 +227,20 @@ public function update(Request $request, $id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
// Don't allow changing main admin's admin status
|
||||
if ($user->email === 'marco@cnxifly.com' && $request->has('is_admin') && !$request->is_admin) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No se puede remover permisos del administrador principal',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|string|max:255',
|
||||
'email' => 'sometimes|email|unique:users,email,' . $id,
|
||||
'language' => 'sometimes|string|in:es,pt-BR,en',
|
||||
'currency' => 'sometimes|string|size:3',
|
||||
'is_admin' => 'sometimes|boolean',
|
||||
]);
|
||||
|
||||
$user->update($validated);
|
||||
@ -219,7 +248,14 @@ public function update(Request $request, $id)
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Usuario actualizado correctamente',
|
||||
'data' => $user,
|
||||
'data' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'language' => $user->language,
|
||||
'currency' => $user->currency,
|
||||
'is_admin' => $user->is_admin,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@ -268,6 +304,90 @@ public function destroy($id)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user subscription plan
|
||||
*/
|
||||
public function changePlan(Request $request, $id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'plan' => 'required|string|in:free,pro',
|
||||
]);
|
||||
|
||||
// If changing to free, cancel any existing subscription
|
||||
if ($validated['plan'] === 'free') {
|
||||
$subscription = $user->subscription;
|
||||
if ($subscription) {
|
||||
$subscription->update([
|
||||
'status' => Subscription::STATUS_CANCELED,
|
||||
'canceled_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Usuario cambiado a plan Free',
|
||||
'data' => [
|
||||
'plan' => 'free',
|
||||
'subscription' => null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// If changing to pro, create or reactivate subscription
|
||||
if ($validated['plan'] === 'pro') {
|
||||
$proPlan = Plan::where('slug', 'pro-annual')->first();
|
||||
|
||||
if (!$proPlan) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Plan Pro no encontrado en el sistema',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Check if user has existing subscription
|
||||
$subscription = $user->subscription;
|
||||
|
||||
if ($subscription) {
|
||||
// Reactivate existing subscription
|
||||
$subscription->update([
|
||||
'status' => Subscription::STATUS_ACTIVE,
|
||||
'canceled_at' => null,
|
||||
'current_period_start' => now(),
|
||||
'current_period_end' => Carbon::create(2037, 12, 31, 23, 59, 59),
|
||||
]);
|
||||
} else {
|
||||
// Create new subscription
|
||||
$subscription = Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $proPlan->id,
|
||||
'status' => Subscription::STATUS_ACTIVE,
|
||||
'current_period_start' => now(),
|
||||
'current_period_end' => Carbon::create(2037, 12, 31, 23, 59, 59),
|
||||
'paypal_subscription_id' => 'ADMIN_GRANTED_' . strtoupper(bin2hex(random_bytes(8))),
|
||||
'paypal_status' => 'ACTIVE',
|
||||
'price_paid' => 0,
|
||||
'currency' => 'EUR',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Usuario cambiado a plan Pro',
|
||||
'data' => [
|
||||
'plan' => 'pro',
|
||||
'subscription' => [
|
||||
'id' => $subscription->id,
|
||||
'plan_name' => $proPlan->name,
|
||||
'status' => $subscription->status,
|
||||
'current_period_end' => $subscription->current_period_end,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics
|
||||
*/
|
||||
|
||||
77
backend/app/Mail/WelcomeNewUser.php
Normal file
77
backend/app/Mail/WelcomeNewUser.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Mail\Mailables\Headers;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class WelcomeNewUser extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public User $user;
|
||||
public string $temporaryPassword;
|
||||
public string $language;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(User $user, string $temporaryPassword)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->temporaryPassword = $temporaryPassword;
|
||||
$this->language = $user->language ?? 'es';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message headers.
|
||||
*/
|
||||
public function headers(): Headers
|
||||
{
|
||||
return new Headers(
|
||||
text: [
|
||||
'X-Priority' => '3',
|
||||
'X-Mailer' => 'WebMoney Mailer',
|
||||
'List-Unsubscribe' => '<mailto:unsubscribe@cnxifly.com>',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subjects = [
|
||||
'es' => 'WebMoney - Credenciales de acceso a tu cuenta',
|
||||
'pt-BR' => 'WebMoney - Credenciais de acesso à sua conta',
|
||||
'en' => 'WebMoney - Your account access credentials',
|
||||
];
|
||||
|
||||
return new Envelope(
|
||||
subject: $subjects[$this->language] ?? $subjects['es'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.welcome-new-user',
|
||||
text: 'emails.welcome-new-user-text',
|
||||
with: [
|
||||
'user' => $this->user,
|
||||
'temporaryPassword' => $this->temporaryPassword,
|
||||
'language' => $this->language,
|
||||
'loginUrl' => config('app.frontend_url', 'https://webmoney.cnxifly.com') . '/login',
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
52
backend/app/Models/SiteSetting.php
Normal file
52
backend/app/Models/SiteSetting.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SiteSetting extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'value',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'value' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a setting value by key
|
||||
*/
|
||||
public static function getValue(string $key, $default = null)
|
||||
{
|
||||
$setting = static::where('key', $key)->first();
|
||||
|
||||
if (!$setting) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
// If value is a simple string stored as JSON array, extract it
|
||||
$value = $setting->value;
|
||||
if (is_array($value) && isset($value['value'])) {
|
||||
return $value['value'];
|
||||
}
|
||||
|
||||
return $value ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a setting value by key
|
||||
*/
|
||||
public static function setValue(string $key, $value, string $description = null): self
|
||||
{
|
||||
return static::updateOrCreate(
|
||||
['key' => $key],
|
||||
[
|
||||
'value' => is_array($value) ? $value : ['value' => $value],
|
||||
'description' => $description,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -31,8 +31,11 @@ class User extends Authenticatable
|
||||
'country',
|
||||
'timezone',
|
||||
'locale',
|
||||
'language',
|
||||
'currency',
|
||||
'password',
|
||||
'is_admin',
|
||||
'email_verified_at',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -54,6 +54,18 @@
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Frontend URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used for generating links to the frontend application,
|
||||
| such as password reset links and email verification links.
|
||||
|
|
||||
*/
|
||||
|
||||
'frontend_url' => env('FRONTEND_URL', 'https://webmoney.cnxifly.com'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('site_settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('key')->unique();
|
||||
$table->json('value')->nullable();
|
||||
$table->string('description')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// Insert default settings
|
||||
\App\Models\SiteSetting::setValue('cnxifly_maintenance_mode', false, 'Modo de mantenimiento para cnxifly.com');
|
||||
\App\Models\SiteSetting::setValue('cnxifly_page_type', 'live', 'Tipo de página: live, maintenance, construction');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('site_settings');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
<?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) {
|
||||
$table->string('language', 10)->default('es')->after('locale');
|
||||
$table->string('currency', 3)->default('EUR')->after('language');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn(['language', 'currency']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,66 @@
|
||||
@if($language === 'pt-BR')
|
||||
{{-- PORTUGUÊS --}}
|
||||
WebMoney - Credenciais de Acesso
|
||||
================================
|
||||
|
||||
Olá, {{ $user->name }}!
|
||||
|
||||
Sua conta WebMoney foi criada com sucesso.
|
||||
|
||||
SUAS CREDENCIAIS DE ACESSO:
|
||||
---------------------------
|
||||
Email: {{ $user->email }}
|
||||
Senha: {{ $temporaryPassword }}
|
||||
|
||||
IMPORTANTE: Por motivos de segurança, recomendamos que altere sua senha após o primeiro login.
|
||||
|
||||
Acesse sua conta em: {{ $loginUrl }}
|
||||
|
||||
---
|
||||
Equipe WebMoney
|
||||
https://webmoney.cnxifly.com
|
||||
|
||||
@elseif($language === 'en')
|
||||
{{-- ENGLISH --}}
|
||||
WebMoney - Access Credentials
|
||||
=============================
|
||||
|
||||
Hello, {{ $user->name }}!
|
||||
|
||||
Your WebMoney account has been successfully created.
|
||||
|
||||
YOUR ACCESS CREDENTIALS:
|
||||
------------------------
|
||||
Email: {{ $user->email }}
|
||||
Password: {{ $temporaryPassword }}
|
||||
|
||||
IMPORTANT: For security reasons, we recommend changing your password after your first login.
|
||||
|
||||
Access your account at: {{ $loginUrl }}
|
||||
|
||||
---
|
||||
WebMoney Team
|
||||
https://webmoney.cnxifly.com
|
||||
|
||||
@else
|
||||
{{-- ESPAÑOL --}}
|
||||
WebMoney - Credenciales de Acceso
|
||||
=================================
|
||||
|
||||
Hola, {{ $user->name }}!
|
||||
|
||||
Tu cuenta de WebMoney ha sido creada exitosamente.
|
||||
|
||||
TUS CREDENCIALES DE ACCESO:
|
||||
---------------------------
|
||||
Email: {{ $user->email }}
|
||||
Contraseña: {{ $temporaryPassword }}
|
||||
|
||||
IMPORTANTE: Por motivos de seguridad, te recomendamos cambiar tu contraseña después del primer inicio de sesión.
|
||||
|
||||
Accede a tu cuenta en: {{ $loginUrl }}
|
||||
|
||||
---
|
||||
Equipo WebMoney
|
||||
https://webmoney.cnxifly.com
|
||||
@endif
|
||||
294
backend/resources/views/emails/welcome-new-user.blade.php
Normal file
294
backend/resources/views/emails/welcome-new-user.blade.php
Normal file
@ -0,0 +1,294 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ $language }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>
|
||||
@if($language === 'pt-BR')
|
||||
Bem-vindo ao WebMoney
|
||||
@elseif($language === 'en')
|
||||
Welcome to WebMoney
|
||||
@else
|
||||
Bienvenido a WebMoney
|
||||
@endif
|
||||
</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #3b82f6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.logo span {
|
||||
color: #22c55e;
|
||||
}
|
||||
h1 {
|
||||
color: #1e293b;
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.credentials-box {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin: 25px 0;
|
||||
color: #fff;
|
||||
}
|
||||
.credentials-box h3 {
|
||||
color: #22c55e;
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 16px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.credential-item {
|
||||
margin: 12px 0;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.credential-label {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.credential-value {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
.warning-box {
|
||||
background-color: #fef3c7;
|
||||
border-left: 4px solid #f59e0b;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
.warning-box p {
|
||||
margin: 0;
|
||||
color: #92400e;
|
||||
font-size: 14px;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.button:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
}
|
||||
.features {
|
||||
background-color: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 25px 0;
|
||||
}
|
||||
.features h3 {
|
||||
color: #1e293b;
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.features ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #64748b;
|
||||
}
|
||||
.features li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
.footer a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">Web<span>Money</span></div>
|
||||
</div>
|
||||
|
||||
@if($language === 'pt-BR')
|
||||
{{-- PORTUGUÊS --}}
|
||||
<h1>Olá, {{ $user->name }}! 👋</h1>
|
||||
|
||||
<p>Sua conta WebMoney foi criada com sucesso. Estamos muito felizes em tê-lo conosco!</p>
|
||||
|
||||
<p>Abaixo estão suas credenciais de acesso:</p>
|
||||
|
||||
<div class="credentials-box">
|
||||
<h3>🔐 Suas Credenciais</h3>
|
||||
<div class="credential-item">
|
||||
<div class="credential-label">Email</div>
|
||||
<div class="credential-value">{{ $user->email }}</div>
|
||||
</div>
|
||||
<div class="credential-item">
|
||||
<div class="credential-label">Senha Temporária</div>
|
||||
<div class="credential-value">{{ $temporaryPassword }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="warning-box">
|
||||
<p>⚠️ <strong>Importante:</strong> Recomendamos que você altere sua senha após o primeiro login por motivos de segurança.</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="{{ $loginUrl }}" class="button">Acessar Minha Conta</a>
|
||||
</div>
|
||||
|
||||
<div class="features">
|
||||
<h3>🚀 O que você pode fazer com o WebMoney:</h3>
|
||||
<ul>
|
||||
<li>Gerenciar todas suas contas bancárias em um só lugar</li>
|
||||
<li>Categorizar receitas e despesas automaticamente</li>
|
||||
<li>Criar orçamentos e acompanhar seus gastos</li>
|
||||
<li>Visualizar relatórios e gráficos detalhados</li>
|
||||
<li>Definir metas financeiras e alcançá-las</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>Se você tiver alguma dúvida ou precisar de ajuda, não hesite em nos contatar.</p>
|
||||
|
||||
<p>Atenciosamente,<br><strong>Equipe WebMoney</strong></p>
|
||||
|
||||
@elseif($language === 'en')
|
||||
{{-- ENGLISH --}}
|
||||
<h1>Hello, {{ $user->name }}! 👋</h1>
|
||||
|
||||
<p>Your WebMoney account has been successfully created. We're thrilled to have you with us!</p>
|
||||
|
||||
<p>Below are your login credentials:</p>
|
||||
|
||||
<div class="credentials-box">
|
||||
<h3>🔐 Your Credentials</h3>
|
||||
<div class="credential-item">
|
||||
<div class="credential-label">Email</div>
|
||||
<div class="credential-value">{{ $user->email }}</div>
|
||||
</div>
|
||||
<div class="credential-item">
|
||||
<div class="credential-label">Temporary Password</div>
|
||||
<div class="credential-value">{{ $temporaryPassword }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="warning-box">
|
||||
<p>⚠️ <strong>Important:</strong> We recommend changing your password after your first login for security purposes.</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="{{ $loginUrl }}" class="button">Access My Account</a>
|
||||
</div>
|
||||
|
||||
<div class="features">
|
||||
<h3>🚀 What you can do with WebMoney:</h3>
|
||||
<ul>
|
||||
<li>Manage all your bank accounts in one place</li>
|
||||
<li>Automatically categorize income and expenses</li>
|
||||
<li>Create budgets and track your spending</li>
|
||||
<li>View detailed reports and charts</li>
|
||||
<li>Set financial goals and achieve them</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>If you have any questions or need help, don't hesitate to contact us.</p>
|
||||
|
||||
<p>Best regards,<br><strong>The WebMoney Team</strong></p>
|
||||
|
||||
@else
|
||||
{{-- ESPAÑOL (default) --}}
|
||||
<h1>¡Hola, {{ $user->name }}! 👋</h1>
|
||||
|
||||
<p>Tu cuenta de WebMoney ha sido creada exitosamente. ¡Estamos muy contentos de tenerte con nosotros!</p>
|
||||
|
||||
<p>A continuación encontrarás tus credenciales de acceso:</p>
|
||||
|
||||
<div class="credentials-box">
|
||||
<h3>🔐 Tus Credenciales</h3>
|
||||
<div class="credential-item">
|
||||
<div class="credential-label">Email</div>
|
||||
<div class="credential-value">{{ $user->email }}</div>
|
||||
</div>
|
||||
<div class="credential-item">
|
||||
<div class="credential-label">Contraseña Temporal</div>
|
||||
<div class="credential-value">{{ $temporaryPassword }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="warning-box">
|
||||
<p>⚠️ <strong>Importante:</strong> Te recomendamos cambiar tu contraseña después de tu primer inicio de sesión por motivos de seguridad.</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="{{ $loginUrl }}" class="button">Acceder a Mi Cuenta</a>
|
||||
</div>
|
||||
|
||||
<div class="features">
|
||||
<h3>🚀 Lo que puedes hacer con WebMoney:</h3>
|
||||
<ul>
|
||||
<li>Gestionar todas tus cuentas bancarias en un solo lugar</li>
|
||||
<li>Categorizar ingresos y gastos automáticamente</li>
|
||||
<li>Crear presupuestos y hacer seguimiento de tus gastos</li>
|
||||
<li>Ver informes y gráficos detallados</li>
|
||||
<li>Establecer metas financieras y alcanzarlas</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>Si tienes alguna pregunta o necesitas ayuda, no dudes en contactarnos.</p>
|
||||
|
||||
<p>Saludos cordiales,<br><strong>El Equipo de WebMoney</strong></p>
|
||||
@endif
|
||||
|
||||
<div class="footer">
|
||||
<p>© {{ date('Y') }} WebMoney.
|
||||
@if($language === 'pt-BR')
|
||||
Todos os direitos reservados.
|
||||
@elseif($language === 'en')
|
||||
All rights reserved.
|
||||
@else
|
||||
Todos los derechos reservados.
|
||||
@endif
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{ $loginUrl }}">{{ $loginUrl }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -25,6 +25,7 @@
|
||||
use App\Http\Controllers\Api\PlanController;
|
||||
use App\Http\Controllers\Api\SubscriptionController;
|
||||
use App\Http\Controllers\Api\UserManagementController;
|
||||
use App\Http\Controllers\Api\SiteSettingsController;
|
||||
|
||||
// Public routes with rate limiting
|
||||
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
|
||||
@ -236,6 +237,7 @@
|
||||
Route::get('admin/users/{id}', [UserManagementController::class, 'show']);
|
||||
Route::put('admin/users/{id}', [UserManagementController::class, 'update']);
|
||||
Route::post('admin/users/{id}/reset-password', [UserManagementController::class, 'resetPassword']);
|
||||
Route::post('admin/users/{id}/change-plan', [UserManagementController::class, 'changePlan']);
|
||||
Route::delete('admin/users/{id}', [UserManagementController::class, 'destroy']);
|
||||
|
||||
// Configurações de Negócio (Markup)
|
||||
@ -323,5 +325,17 @@
|
||||
Route::get('preferences', [UserPreferenceController::class, 'index']);
|
||||
Route::put('preferences', [UserPreferenceController::class, 'update']);
|
||||
Route::post('preferences/test-notification', [UserPreferenceController::class, 'testNotification']);
|
||||
|
||||
// ============================================
|
||||
// Site Settings (Admin only - Configurações do Site)
|
||||
// ============================================
|
||||
Route::middleware('admin.only')->prefix('admin/site-settings')->group(function () {
|
||||
Route::get('/', [SiteSettingsController::class, 'index']);
|
||||
Route::get('/cnxifly/status', [SiteSettingsController::class, 'getCnxiflyStatus']);
|
||||
Route::post('/cnxifly/toggle', [SiteSettingsController::class, 'toggleCnxiflyPage']);
|
||||
Route::post('/cnxifly/deploy', [SiteSettingsController::class, 'deployCnxiflyPage']);
|
||||
Route::get('/{key}', [SiteSettingsController::class, 'show']);
|
||||
Route::put('/{key}', [SiteSettingsController::class, 'update']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
39
deploy-landing.sh
Executable file
39
deploy-landing.sh
Executable file
@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Deploy landing page to cnxifly.com
|
||||
# Usage: ./deploy-landing.sh [live|maintenance]
|
||||
|
||||
MODE=${1:-live}
|
||||
SERVER="root@213.165.93.60"
|
||||
PASSWORD="Master9354"
|
||||
DEST="/var/www/cnxifly"
|
||||
|
||||
echo "🚀 Deploying cnxifly.com landing page in mode: $MODE"
|
||||
|
||||
if [ "$MODE" = "live" ]; then
|
||||
SOURCE="landing/index.html"
|
||||
elif [ "$MODE" = "maintenance" ]; then
|
||||
SOURCE="landing/maintenance.html"
|
||||
else
|
||||
echo "❌ Invalid mode. Use: live or maintenance"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if source file exists
|
||||
if [ ! -f "$SOURCE" ]; then
|
||||
echo "❌ Source file not found: $SOURCE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📁 Copying $SOURCE to server..."
|
||||
|
||||
# Deploy the file
|
||||
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no "$SOURCE" "$SERVER:$DEST/index.html"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Landing page deployed successfully!"
|
||||
echo "🌐 Visit: https://cnxifly.com"
|
||||
else
|
||||
echo "❌ Deploy failed!"
|
||||
exit 1
|
||||
fi
|
||||
@ -1,5 +1,5 @@
|
||||
// WebMoney Service Worker - PWA Support
|
||||
const CACHE_VERSION = 'webmoney-v1.39.0';
|
||||
const CACHE_VERSION = 'webmoney-v1.40.0';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_DYNAMIC = `${CACHE_VERSION}-dynamic`;
|
||||
const CACHE_IMMUTABLE = `${CACHE_VERSION}-immutable`;
|
||||
|
||||
@ -26,6 +26,7 @@ import Profile from './pages/Profile';
|
||||
import Pricing from './pages/Pricing';
|
||||
import Billing from './pages/Billing';
|
||||
import Users from './pages/Users';
|
||||
import SiteSettings from './pages/SiteSettings';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -232,6 +233,16 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/site-settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SiteSettings />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||
</Routes>
|
||||
<CookieConsent />
|
||||
|
||||
@ -120,8 +120,11 @@ const Layout = ({ children }) => {
|
||||
{ path: '/profile', icon: 'bi-person-circle', label: t('nav.profile') },
|
||||
{ path: '/billing', icon: 'bi-credit-card', label: t('nav.billing') },
|
||||
{ path: '/pricing', icon: 'bi-tags-fill', label: t('nav.pricing') },
|
||||
// Admin only: User management
|
||||
...(isAdmin ? [{ path: '/users', icon: 'bi-people', label: t('nav.users') }] : []),
|
||||
// Admin only: User management and Site Settings
|
||||
...(isAdmin ? [
|
||||
{ path: '/users', icon: 'bi-people', label: t('nav.users') },
|
||||
{ path: '/site-settings', icon: 'bi-globe', label: t('nav.siteSettings', 'Sitio Web') },
|
||||
] : []),
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
@ -15,7 +15,9 @@ export const ToastProvider = ({ children }) => {
|
||||
|
||||
const addToast = useCallback((message, type = 'info', duration = 5000) => {
|
||||
const id = Date.now();
|
||||
const toast = { id, message, type, duration };
|
||||
// Map 'error' to 'danger' for backward compatibility
|
||||
const mappedType = type === 'error' ? 'danger' : type;
|
||||
const toast = { id, message, type: mappedType, duration };
|
||||
|
||||
setToasts((prev) => [...prev, toast]);
|
||||
|
||||
@ -48,8 +50,11 @@ export const ToastProvider = ({ children }) => {
|
||||
return addToast(message, 'info', duration);
|
||||
}, [addToast]);
|
||||
|
||||
// Alias for backward compatibility
|
||||
const showToast = addToast;
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ addToast, removeToast, success, error, warning, info }}>
|
||||
<ToastContext.Provider value={{ addToast, showToast, removeToast, success, error, warning, info }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
||||
</ToastContext.Provider>
|
||||
|
||||
@ -228,6 +228,26 @@ const FinancialHealth = () => {
|
||||
}],
|
||||
} : null;
|
||||
|
||||
// Data sufficiency status
|
||||
const dataStatus = data.data_status;
|
||||
const showDataWarning = dataStatus && !dataStatus.has_sufficient_data;
|
||||
|
||||
// Helper para obtener ícono y cor do alerta baseado no nível
|
||||
const getDataWarningStyle = (level) => {
|
||||
switch (level) {
|
||||
case 'no_data':
|
||||
return { icon: 'bi-database-x', color: 'danger', bg: 'rgba(239, 68, 68, 0.1)' };
|
||||
case 'insufficient':
|
||||
return { icon: 'bi-exclamation-triangle', color: 'warning', bg: 'rgba(245, 158, 11, 0.1)' };
|
||||
case 'limited':
|
||||
return { icon: 'bi-info-circle', color: 'info', bg: 'rgba(59, 130, 246, 0.1)' };
|
||||
case 'outdated':
|
||||
return { icon: 'bi-clock-history', color: 'secondary', bg: 'rgba(100, 116, 139, 0.1)' };
|
||||
default:
|
||||
return { icon: 'bi-info-circle', color: 'info', bg: 'rgba(59, 130, 246, 0.1)' };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="financial-health-container">
|
||||
{/* Header */}
|
||||
@ -250,6 +270,46 @@ const FinancialHealth = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Sufficiency Warning */}
|
||||
{showDataWarning && (
|
||||
<div
|
||||
className={`alert alert-${getDataWarningStyle(dataStatus.level).color} d-flex align-items-start mb-4`}
|
||||
style={{
|
||||
background: getDataWarningStyle(dataStatus.level).bg,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<i className={`bi ${getDataWarningStyle(dataStatus.level).icon} me-3 fs-4`}></i>
|
||||
<div className="flex-grow-1">
|
||||
<h6 className="alert-heading mb-1">
|
||||
{dataStatus.level === 'no_data' && t('financialHealth.dataWarning.noData', 'Datos Insuficientes')}
|
||||
{dataStatus.level === 'insufficient' && t('financialHealth.dataWarning.insufficient', 'Análisis Limitado')}
|
||||
{dataStatus.level === 'limited' && t('financialHealth.dataWarning.limited', 'Pocos Datos')}
|
||||
{dataStatus.level === 'outdated' && t('financialHealth.dataWarning.outdated', 'Datos Desactualizados')}
|
||||
</h6>
|
||||
<p className="mb-2 small">{dataStatus.message}</p>
|
||||
<div className="d-flex gap-3 small">
|
||||
<span>
|
||||
<i className="bi bi-wallet2 me-1"></i>
|
||||
{dataStatus.counts?.accounts || 0} {t('financialHealth.dataWarning.accounts', 'cuentas')}
|
||||
</span>
|
||||
<span>
|
||||
<i className="bi bi-arrow-left-right me-1"></i>
|
||||
{dataStatus.counts?.transactions || 0} {t('financialHealth.dataWarning.transactions', 'transacciones')}
|
||||
</span>
|
||||
<span>
|
||||
<i className="bi bi-calendar-check me-1"></i>
|
||||
{dataStatus.counts?.recent_transactions || 0} {t('financialHealth.dataWarning.recentTransactions', 'últimos 30 días')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/accounts" className="btn btn-sm btn-outline-light ms-3">
|
||||
<i className="bi bi-plus-lg me-1"></i>
|
||||
{t('financialHealth.dataWarning.addData', 'Añadir Datos')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<ul className="nav nav-pills mb-4 gap-2">
|
||||
{tabs.map(tab => (
|
||||
|
||||
310
frontend/src/pages/SiteSettings.jsx
Normal file
310
frontend/src/pages/SiteSettings.jsx
Normal file
@ -0,0 +1,310 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useToast } from '../components/Toast';
|
||||
|
||||
export default function SiteSettings() {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const [status, setStatus] = useState({
|
||||
mode: 'live',
|
||||
maintenance_mode: false,
|
||||
modes_available: {
|
||||
live: 'Página institucional completa',
|
||||
maintenance: 'Página de mantenimiento',
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, []);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/site-settings/cnxifly/status');
|
||||
setStatus(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching status:', error);
|
||||
toast.error('Error al obtener el estado del sitio');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleMode = async (newMode) => {
|
||||
try {
|
||||
setDeploying(true);
|
||||
const response = await api.post('/admin/site-settings/cnxifly/toggle', { mode: newMode });
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success(response.data.message);
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
mode: newMode,
|
||||
maintenance_mode: newMode !== 'live'
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling mode:', error);
|
||||
toast.error('Error al cambiar el modo');
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async (mode) => {
|
||||
if (!confirm(`¿Está seguro de desplegar la página en modo "${mode}"? Esto actualizará cnxifly.com`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeploying(true);
|
||||
const response = await api.post('/admin/site-settings/cnxifly/deploy', { mode });
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success(`Configuración actualizada. Use el comando de deploy para aplicar los cambios.`);
|
||||
|
||||
// Log deploy instructions to console for copying
|
||||
console.log('Deploy command:', response.data.deploy_instructions?.command);
|
||||
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
mode: mode,
|
||||
maintenance_mode: mode === 'maintenance'
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deploying:', error);
|
||||
toast.error('Error al desplegar la página');
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getModeColor = (mode, isActive) => {
|
||||
if (!isActive) return 'bg-gray-700 border-gray-600 hover:border-gray-500';
|
||||
|
||||
switch (mode) {
|
||||
case 'live':
|
||||
return 'bg-green-900/30 border-green-500 ring-2 ring-green-500/30';
|
||||
case 'maintenance':
|
||||
return 'bg-yellow-900/30 border-yellow-500 ring-2 ring-yellow-500/30';
|
||||
case 'construction':
|
||||
return 'bg-orange-900/30 border-orange-500 ring-2 ring-orange-500/30';
|
||||
default:
|
||||
return 'bg-gray-700 border-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getModeTextColor = (mode) => {
|
||||
switch (mode) {
|
||||
case 'live':
|
||||
return 'text-green-400';
|
||||
case 'maintenance':
|
||||
return 'text-yellow-400';
|
||||
case 'construction':
|
||||
return 'text-orange-400';
|
||||
default:
|
||||
return 'text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<i className="bi bi-arrow-repeat text-4xl text-blue-500 animate-spin"></i>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<i className="bi bi-globe text-3xl text-blue-400"></i>
|
||||
Configuración del Sitio cnxifly.com
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Controle el estado de la página institucional de ConneXiFly
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
className="btn btn-secondary flex items-center gap-2"
|
||||
disabled={loading}
|
||||
>
|
||||
<i className={`bi bi-arrow-repeat ${loading ? 'animate-spin' : ''}`}></i>
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current Status */}
|
||||
<div className="bg-gray-800 rounded-xl p-6 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Estado Actual</h2>
|
||||
<p className="text-gray-400 text-sm">El modo seleccionado determina qué página se muestra</p>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 px-4 py-2 rounded-full ${getModeColor(status.mode, true)}`}>
|
||||
<i className={`bi ${status.mode === 'live' ? 'bi-rocket-takeoff' : 'bi-tools'}`}></i>
|
||||
<span className={`font-medium capitalize ${getModeTextColor(status.mode)}`}>
|
||||
{status.mode}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Live Mode */}
|
||||
<div
|
||||
className={`p-5 rounded-xl border-2 cursor-pointer transition-all ${getModeColor('live', status.mode === 'live')}`}
|
||||
onClick={() => !deploying && handleToggleMode('live')}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-3 rounded-lg ${status.mode === 'live' ? 'bg-green-500/20' : 'bg-gray-600'}`}>
|
||||
<i className={`bi bi-rocket-takeoff text-2xl ${status.mode === 'live' ? 'text-green-400' : 'text-gray-400'}`}></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Página en Vivo</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Página institucional completa con todos los productos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status.mode === 'live' && (
|
||||
<i className="bi bi-check-circle-fill text-green-400 text-xl"></i>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
Muestra: WebMoney, EZPool, precios, registro y contacto
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Mode */}
|
||||
<div
|
||||
className={`p-5 rounded-xl border-2 cursor-pointer transition-all ${getModeColor('maintenance', status.mode === 'maintenance')}`}
|
||||
onClick={() => !deploying && handleToggleMode('maintenance')}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-3 rounded-lg ${status.mode === 'maintenance' ? 'bg-yellow-500/20' : 'bg-gray-600'}`}>
|
||||
<i className={`bi bi-tools text-2xl ${status.mode === 'maintenance' ? 'text-yellow-400' : 'text-gray-400'}`}></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">En Mantenimiento</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Página simple informando mantenimiento
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status.mode === 'maintenance' && (
|
||||
<i className="bi bi-check-circle-fill text-yellow-400 text-xl"></i>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
Muestra: Mensaje de mantenimiento con links a los productos
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deploy Section */}
|
||||
<div className="bg-gray-800 rounded-xl p-6 border border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<i className="bi bi-server text-2xl text-blue-400"></i>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Despliegue</h2>
|
||||
<p className="text-gray-400 text-sm">Aplique los cambios al servidor de producción</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900/50 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<i className="bi bi-exclamation-triangle text-yellow-400 text-xl flex-shrink-0 mt-0.5"></i>
|
||||
<div className="text-sm text-gray-300">
|
||||
<p className="font-medium text-yellow-400 mb-1">Importante</p>
|
||||
<p>
|
||||
Los cambios se guardan en la configuración, pero para aplicarlos al servidor debe ejecutar
|
||||
el comando de deploy. La página actual en cnxifly.com no cambiará hasta que se despliegue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleDeploy('live')}
|
||||
disabled={deploying}
|
||||
className="flex-1 py-3 px-4 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deploying ? (
|
||||
<i className="bi bi-arrow-repeat animate-spin"></i>
|
||||
) : (
|
||||
<i className="bi bi-rocket-takeoff"></i>
|
||||
)}
|
||||
Desplegar Página Completa
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDeploy('maintenance')}
|
||||
disabled={deploying}
|
||||
className="flex-1 py-3 px-4 bg-yellow-600 hover:bg-yellow-700 text-white font-medium rounded-lg flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deploying ? (
|
||||
<i className="bi bi-arrow-repeat animate-spin"></i>
|
||||
) : (
|
||||
<i className="bi bi-tools"></i>
|
||||
)}
|
||||
Desplegar Mantenimiento
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Links */}
|
||||
<div className="bg-gray-800 rounded-xl p-6 border border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Enlaces de Vista Previa</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<a
|
||||
href="https://cnxifly.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<i className="bi bi-globe text-blue-400 text-xl"></i>
|
||||
<div>
|
||||
<p className="font-medium text-white">cnxifly.com</p>
|
||||
<p className="text-sm text-gray-400">Página actual en producción</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="https://webmoney.cnxifly.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<i className="bi bi-wallet2 text-green-400 text-xl"></i>
|
||||
<div>
|
||||
<p className="font-medium text-white">webmoney.cnxifly.com</p>
|
||||
<p className="text-sm text-gray-400">Aplicación WebMoney</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="https://ezpool.cnxifly.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<i className="bi bi-droplet-half text-cyan-400 text-xl"></i>
|
||||
<div>
|
||||
<p className="font-medium text-white">ezpool.cnxifly.com</p>
|
||||
<p className="text-sm text-gray-400">Aplicación EZPool (En breve)</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -25,10 +25,25 @@ function Users() {
|
||||
|
||||
// Create user modal states
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({ name: '', email: '', password: '', user_type: 'free' });
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
user_type: 'free',
|
||||
language: 'es',
|
||||
currency: 'EUR',
|
||||
send_welcome_email: true
|
||||
});
|
||||
const [createdUser, setCreatedUser] = useState(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Edit user modal states
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editForm, setEditForm] = useState({ name: '', email: '', language: 'es', currency: 'EUR', is_admin: false });
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
const [changingPlan, setChangingPlan] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', handleResize);
|
||||
@ -159,10 +174,97 @@ function Users() {
|
||||
|
||||
const resetCreateModal = () => {
|
||||
setShowCreateModal(false);
|
||||
setCreateForm({ name: '', email: '', password: '', user_type: 'free' });
|
||||
setCreateForm({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
user_type: 'free',
|
||||
language: 'es',
|
||||
currency: 'EUR',
|
||||
send_welcome_email: true
|
||||
});
|
||||
setCreatedUser(null);
|
||||
};
|
||||
|
||||
const openEditModal = (user) => {
|
||||
setEditingUser(user);
|
||||
setEditForm({
|
||||
name: user.name || '',
|
||||
email: user.email || '',
|
||||
language: user.language || 'es',
|
||||
currency: user.currency || 'EUR',
|
||||
is_admin: user.is_admin || false,
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const resetEditModal = () => {
|
||||
setShowEditModal(false);
|
||||
setEditForm({ name: '', email: '', language: 'es', currency: 'EUR', is_admin: false });
|
||||
setEditingUser(null);
|
||||
};
|
||||
|
||||
const handleEditUser = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!editingUser) return;
|
||||
|
||||
setEditing(true);
|
||||
try {
|
||||
const response = await api.put(`/admin/users/${editingUser.id}`, editForm);
|
||||
if (response.data.success) {
|
||||
showToast('Usuario actualizado correctamente', 'success');
|
||||
resetEditModal();
|
||||
fetchUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || 'Error al actualizar usuario';
|
||||
showToast(message, 'error');
|
||||
} finally {
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePlan = async (newPlan) => {
|
||||
if (!editingUser) return;
|
||||
|
||||
setChangingPlan(true);
|
||||
try {
|
||||
const response = await api.post(`/admin/users/${editingUser.id}/change-plan`, { plan: newPlan });
|
||||
if (response.data.success) {
|
||||
showToast(response.data.message, 'success');
|
||||
// Update local state to reflect the change
|
||||
setEditingUser(prev => ({
|
||||
...prev,
|
||||
subscription: response.data.data.subscription ? {
|
||||
...response.data.data.subscription,
|
||||
plan_name: 'Pro Annual'
|
||||
} : null
|
||||
}));
|
||||
fetchUsers();
|
||||
fetchSummary();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || 'Error al cambiar plan';
|
||||
showToast(message, 'error');
|
||||
} finally {
|
||||
setChangingPlan(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAdmin = async (user) => {
|
||||
try {
|
||||
const response = await api.put(`/admin/users/${user.id}`, {
|
||||
is_admin: !user.is_admin,
|
||||
});
|
||||
if (response.data.success) {
|
||||
showToast(user.is_admin ? 'Permisos de admin removidos' : 'Permisos de admin concedidos', 'success');
|
||||
fetchUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error al cambiar permisos', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const getUserTypeLabel = (type) => {
|
||||
const labels = {
|
||||
free: 'Free',
|
||||
@ -390,6 +492,13 @@ function Users() {
|
||||
{formatDate(user.created_at)}
|
||||
</td>
|
||||
<td className="align-middle text-end">
|
||||
<button
|
||||
onClick={() => openEditModal(user)}
|
||||
className="btn btn-sm btn-outline-info me-1"
|
||||
title="Editar usuario"
|
||||
>
|
||||
<i className="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
@ -559,10 +668,26 @@ function Users() {
|
||||
<h5 className="text-white mb-1">¡Usuario Creado!</h5>
|
||||
<p className="text-slate-400 mb-0">{createdUser.user.name}</p>
|
||||
<p className="text-slate-400 small">{createdUser.user.email}</p>
|
||||
<span className={`badge ${getUserTypeBadge(createdUser.user_type)} mt-2`}>
|
||||
<div className="d-flex justify-content-center gap-2 mt-2">
|
||||
<span className={`badge ${getUserTypeBadge(createdUser.user_type)}`}>
|
||||
{getUserTypeLabel(createdUser.user_type)}
|
||||
</span>
|
||||
<span className="badge bg-secondary">
|
||||
{createdUser.user.language === 'es' ? '🇪🇸 ES' : createdUser.user.language === 'pt-BR' ? '🇧🇷 PT' : '🇺🇸 EN'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Status */}
|
||||
{createdUser.welcome_email_sent !== undefined && (
|
||||
<div className={`alert ${createdUser.welcome_email_sent ? 'alert-success bg-success' : 'alert-warning bg-warning'} bg-opacity-10 border-${createdUser.welcome_email_sent ? 'success' : 'warning'} text-${createdUser.welcome_email_sent ? 'success' : 'warning'} small mb-3`}>
|
||||
<i className={`bi ${createdUser.welcome_email_sent ? 'bi-envelope-check' : 'bi-envelope-x'} me-2`}></i>
|
||||
{createdUser.welcome_email_sent
|
||||
? `Email de bienvenida enviado a ${createdUser.user.email}`
|
||||
: 'No se pudo enviar el email de bienvenida'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdUser.temporary_password && (
|
||||
<div className="bg-secondary bg-opacity-25 rounded p-3 mb-3">
|
||||
@ -585,7 +710,7 @@ function Users() {
|
||||
{createdUser.user_type === 'free' && 'Este usuario tiene acceso Free con los límites del plan gratuito.'}
|
||||
</div>
|
||||
|
||||
{createdUser.temporary_password && (
|
||||
{createdUser.temporary_password && !createdUser.welcome_email_sent && (
|
||||
<p className="text-slate-400 small">
|
||||
<i className="bi bi-exclamation-triangle me-1 text-warning"></i>
|
||||
Copia esta contraseña y envíala al usuario de forma segura.
|
||||
@ -727,6 +852,65 @@ function Users() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language and Currency Row */}
|
||||
<div className="row">
|
||||
<div className="col-md-6 mb-3">
|
||||
<label className="form-label text-slate-400">
|
||||
<i className="bi bi-translate me-1"></i>
|
||||
Idioma
|
||||
</label>
|
||||
<select
|
||||
className="form-select bg-dark border-secondary text-white"
|
||||
value={createForm.language}
|
||||
onChange={(e) => setCreateForm(prev => ({ ...prev, language: e.target.value }))}
|
||||
>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
|
||||
<option value="en">🇺🇸 English</option>
|
||||
</select>
|
||||
<small className="text-slate-500">
|
||||
El email de bienvenida se enviará en este idioma.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6 mb-3">
|
||||
<label className="form-label text-slate-400">
|
||||
<i className="bi bi-currency-exchange me-1"></i>
|
||||
Moneda
|
||||
</label>
|
||||
<select
|
||||
className="form-select bg-dark border-secondary text-white"
|
||||
value={createForm.currency}
|
||||
onChange={(e) => setCreateForm(prev => ({ ...prev, currency: e.target.value }))}
|
||||
>
|
||||
<option value="EUR">€ EUR - Euro</option>
|
||||
<option value="USD">$ USD - Dólar</option>
|
||||
<option value="BRL">R$ BRL - Real</option>
|
||||
<option value="GBP">£ GBP - Libra</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Welcome Email Toggle */}
|
||||
<div className="mb-3">
|
||||
<div className="form-check form-switch">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="sendWelcomeEmail"
|
||||
checked={createForm.send_welcome_email}
|
||||
onChange={(e) => setCreateForm(prev => ({ ...prev, send_welcome_email: e.target.checked }))}
|
||||
/>
|
||||
<label className="form-check-label text-white" htmlFor="sendWelcomeEmail">
|
||||
<i className="bi bi-envelope me-2 text-primary"></i>
|
||||
Enviar email de bienvenida
|
||||
</label>
|
||||
</div>
|
||||
<small className="text-slate-500 d-block mt-1 ms-4">
|
||||
El usuario recibirá un email con sus credenciales de acceso y un enlace para iniciar sesión.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer border-secondary">
|
||||
@ -762,6 +946,229 @@ function Users() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit User Modal */}
|
||||
{showEditModal && editingUser && (
|
||||
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div className="modal-content bg-dark text-white border-secondary">
|
||||
<div className="modal-header border-secondary">
|
||||
<h5 className="modal-title">
|
||||
<i className="bi bi-pencil me-2 text-info"></i>
|
||||
Editar Usuario
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white"
|
||||
onClick={resetEditModal}
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleEditUser}>
|
||||
<div className="modal-body">
|
||||
<div className="row">
|
||||
<div className="col-md-6 mb-3">
|
||||
<label className="form-label text-slate-400">Nombre *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control bg-dark border-secondary text-white"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
required
|
||||
placeholder="Nombre completo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6 mb-3">
|
||||
<label className="form-label text-slate-400">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control bg-dark border-secondary text-white"
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, email: e.target.value }))}
|
||||
required
|
||||
placeholder="email@ejemplo.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-md-6 mb-3">
|
||||
<label className="form-label text-slate-400">Idioma</label>
|
||||
<select
|
||||
className="form-select bg-dark border-secondary text-white"
|
||||
value={editForm.language}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, language: e.target.value }))}
|
||||
>
|
||||
<option value="es">Español</option>
|
||||
<option value="pt-BR">Português (Brasil)</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6 mb-3">
|
||||
<label className="form-label text-slate-400">Moneda</label>
|
||||
<select
|
||||
className="form-select bg-dark border-secondary text-white"
|
||||
value={editForm.currency}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, currency: e.target.value }))}
|
||||
>
|
||||
<option value="EUR">EUR - Euro</option>
|
||||
<option value="USD">USD - Dólar</option>
|
||||
<option value="BRL">BRL - Real</option>
|
||||
<option value="GBP">GBP - Libra</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Info Card */}
|
||||
<div className="card bg-secondary bg-opacity-25 border-secondary mb-3">
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
<div className="col-md-6">
|
||||
<h6 className="text-slate-400 mb-2">
|
||||
<i className="bi bi-info-circle me-2"></i>
|
||||
Información
|
||||
</h6>
|
||||
<p className="mb-1 small">
|
||||
<span className="text-slate-400">ID:</span>
|
||||
<span className="text-white ms-2">{editingUser.id}</span>
|
||||
</p>
|
||||
<p className="mb-1 small">
|
||||
<span className="text-slate-400">Registrado:</span>
|
||||
<span className="text-white ms-2">{formatDate(editingUser.created_at)}</span>
|
||||
</p>
|
||||
<p className="mb-0 small">
|
||||
<span className="text-slate-400">Plan actual:</span>
|
||||
<span className="ms-2">{getStatusBadge(editingUser.subscription)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<h6 className="text-slate-400 mb-2">
|
||||
<i className="bi bi-bar-chart me-2"></i>
|
||||
Uso
|
||||
</h6>
|
||||
<p className="mb-1 small">
|
||||
<i className="bi bi-wallet2 text-slate-400 me-2"></i>
|
||||
<span className="text-white">{editingUser.usage?.accounts || 0}</span> cuentas
|
||||
</p>
|
||||
<p className="mb-1 small">
|
||||
<i className="bi bi-tags text-slate-400 me-2"></i>
|
||||
<span className="text-white">{editingUser.usage?.categories || 0}</span> categorías
|
||||
</p>
|
||||
<p className="mb-0 small">
|
||||
<i className="bi bi-arrow-left-right text-slate-400 me-2"></i>
|
||||
<span className="text-white">{editingUser.usage?.transactions || 0}</span> transacciones
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change Plan Section */}
|
||||
<div className="card bg-secondary bg-opacity-25 border-secondary mb-3">
|
||||
<div className="card-body">
|
||||
<h6 className="text-slate-400 mb-3">
|
||||
<i className="bi bi-credit-card me-2"></i>
|
||||
Cambiar Plan de Suscripción
|
||||
</h6>
|
||||
<div className="d-flex gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${!editingUser.subscription || editingUser.subscription.status === 'canceled' ? 'btn-secondary' : 'btn-outline-secondary'}`}
|
||||
onClick={() => handleChangePlan('free')}
|
||||
disabled={changingPlan || !editingUser.subscription || editingUser.subscription.status === 'canceled'}
|
||||
>
|
||||
{changingPlan ? (
|
||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||
) : (
|
||||
<i className="bi bi-person me-2"></i>
|
||||
)}
|
||||
Free
|
||||
{(!editingUser.subscription || editingUser.subscription.status === 'canceled') && (
|
||||
<i className="bi bi-check-lg ms-2 text-success"></i>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${editingUser.subscription && editingUser.subscription.status === 'active' ? 'btn-success' : 'btn-outline-success'}`}
|
||||
onClick={() => handleChangePlan('pro')}
|
||||
disabled={changingPlan || (editingUser.subscription && editingUser.subscription.status === 'active')}
|
||||
>
|
||||
{changingPlan ? (
|
||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||
) : (
|
||||
<i className="bi bi-star me-2"></i>
|
||||
)}
|
||||
Pro
|
||||
{editingUser.subscription && editingUser.subscription.status === 'active' && (
|
||||
<i className="bi bi-check-lg ms-2"></i>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-slate-500 small mt-2">
|
||||
{editingUser.subscription && editingUser.subscription.status === 'active' ? (
|
||||
<>Plan Pro activo hasta: {formatDate(editingUser.subscription.current_period_end)}</>
|
||||
) : (
|
||||
<>El usuario está en plan Free. Clic en "Pro" para activar suscripción Pro.</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Toggle - Only show if not the main admin */}
|
||||
{editingUser.email !== 'marco@cnxifly.com' && (
|
||||
<div className="form-check form-switch mb-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="isAdminSwitch"
|
||||
checked={editForm.is_admin}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, is_admin: e.target.checked }))}
|
||||
/>
|
||||
<label className="form-check-label text-white" htmlFor="isAdminSwitch">
|
||||
<i className="bi bi-shield-check me-2 text-danger"></i>
|
||||
Permisos de Administrador
|
||||
</label>
|
||||
<div className="text-slate-500 small mt-1">
|
||||
Los administradores tienen acceso completo al sistema y gestión de usuarios.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer border-secondary">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={resetEditModal}
|
||||
disabled={editing}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-info"
|
||||
disabled={editing || !editForm.name || !editForm.email}
|
||||
>
|
||||
{editing ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||
Guardando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-check-lg me-2"></i>
|
||||
Guardar Cambios
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
1027
landing/index.html
Normal file
1027
landing/index.html
Normal file
File diff suppressed because it is too large
Load Diff
190
landing/maintenance.html
Normal file
190
landing/maintenance.html
Normal file
@ -0,0 +1,190 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ConneXiFly - En Mantenimiento</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3b82f6;
|
||||
--secondary: #10b981;
|
||||
--dark: #0f172a;
|
||||
--dark-lighter: #1e293b;
|
||||
--text: #f1f5f9;
|
||||
--text-muted: #94a3b8;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: radial-gradient(ellipse at 20% 20%, rgba(59, 130, 246, 0.15), transparent 50%),
|
||||
radial-gradient(ellipse at 80% 80%, rgba(16, 185, 129, 0.1), transparent 50%),
|
||||
var(--dark);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.logo i {
|
||||
font-size: 2.5rem;
|
||||
color: var(--primary);
|
||||
-webkit-text-fill-color: var(--primary);
|
||||
}
|
||||
|
||||
.maintenance-icon {
|
||||
font-size: 5rem;
|
||||
margin-bottom: 2rem;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.8; }
|
||||
}
|
||||
|
||||
.maintenance-icon i {
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.products-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
margin-top: 3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.product-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--dark-lighter);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.product-link:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 10px 30px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.product-link i {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.product-link.webmoney i {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.product-link.ezpool i {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.product-link span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contact {
|
||||
margin-top: 3rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.contact a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.contact a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo-container">
|
||||
<div class="logo">
|
||||
<i class="bi bi-lightning-charge-fill"></i>
|
||||
ConneXiFly
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="maintenance-icon">
|
||||
<i class="bi bi-gear-wide-connected"></i>
|
||||
</div>
|
||||
|
||||
<h1>Estamos en Mantenimiento</h1>
|
||||
<p class="subtitle">
|
||||
Estamos trabajando para traerte una mejor experiencia.
|
||||
Volveremos muy pronto con novedades increíbles.
|
||||
</p>
|
||||
|
||||
<div class="products-preview">
|
||||
<a href="https://webmoney.cnxifly.com" class="product-link webmoney">
|
||||
<i class="bi bi-wallet2"></i>
|
||||
<span>WebMoney</span>
|
||||
</a>
|
||||
<a href="https://ezpool.cnxifly.com" class="product-link ezpool">
|
||||
<i class="bi bi-droplet-half"></i>
|
||||
<span>EZPool</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="contact">
|
||||
<p>¿Necesitas ayuda? <a href="mailto:admin@cnxifly.com">admin@cnxifly.com</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user