v1.53.0: Fix admin middleware, deploy cnxifly pages from server

This commit is contained in:
marcoitaloesp-ai 2025-12-17 17:42:46 +00:00 committed by GitHub
parent 3a336eb692
commit c99bca9404
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 3077 additions and 17 deletions

View File

@ -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/). 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 ## [1.51.0] - 2025-12-17
### Added ### Added

View File

@ -1 +1 @@
1.51.0 1.53.0

View File

@ -30,6 +30,9 @@ public function index(Request $request)
$this->setPrimaryCurrency(); $this->setPrimaryCurrency();
$this->loadExchangeRates(); $this->loadExchangeRates();
// Verificar si hay datos suficientes para análisis
$dataStatus = $this->checkDataSufficiency();
// Obtener datos base // Obtener datos base
$financialSummary = $this->getFinancialSummary(); $financialSummary = $this->getFinancialSummary();
$cashFlowAnalysis = $this->analyzeCashFlow(); $cashFlowAnalysis = $this->analyzeCashFlow();
@ -77,6 +80,9 @@ public function index(Request $request)
'last_updated' => now()->toIso8601String(), 'last_updated' => now()->toIso8601String(),
'currency' => $this->primaryCurrency, 'currency' => $this->primaryCurrency,
// Estado de datos
'data_status' => $dataStatus,
// Resumen financiero // Resumen financiero
'summary' => [ 'summary' => [
'total_assets' => $financialSummary['total_assets'], '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 * Establecer moneda principal del usuario
*/ */

View 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',
];
}
}

View File

@ -6,8 +6,10 @@
use App\Models\User; use App\Models\User;
use App\Models\Plan; use App\Models\Plan;
use App\Models\Subscription; use App\Models\Subscription;
use App\Mail\WelcomeNewUser;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Carbon\Carbon; use Carbon\Carbon;
@ -22,15 +24,17 @@ public function store(Request $request)
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email', 'email' => 'required|email|unique:users,email',
'password' => 'sometimes|string|min:8', 'password' => 'nullable|string|min:8',
'language' => 'sometimes|string|in:es,pt-BR,en', 'language' => 'sometimes|string|in:es,pt-BR,en',
'currency' => 'sometimes|string|size:3', 'currency' => 'sometimes|string|size:3',
'user_type' => 'sometimes|string|in:free,pro,admin', 'user_type' => 'sometimes|string|in:free,pro,admin',
'send_welcome_email' => 'sometimes|boolean',
]); ]);
// Generate random password if not provided // Generate random password if not provided or empty
$password = $validated['password'] ?? bin2hex(random_bytes(8)); $password = !empty($validated['password']) ? $validated['password'] : bin2hex(random_bytes(8));
$userType = $validated['user_type'] ?? 'free'; $userType = $validated['user_type'] ?? 'free';
$sendWelcomeEmail = $validated['send_welcome_email'] ?? true;
$user = User::create([ $user = User::create([
'name' => $validated['name'], 'name' => $validated['name'],
@ -54,7 +58,7 @@ public function store(Request $request)
'plan_id' => $proPlan->id, 'plan_id' => $proPlan->id,
'status' => Subscription::STATUS_ACTIVE, 'status' => Subscription::STATUS_ACTIVE,
'current_period_start' => now(), '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_subscription_id' => 'ADMIN_GRANTED_' . strtoupper(bin2hex(random_bytes(8))),
'paypal_status' => 'ACTIVE', 'paypal_status' => 'ACTIVE',
'price_paid' => 0, '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([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Usuario creado correctamente', 'message' => 'Usuario creado correctamente',
@ -78,10 +93,12 @@ public function store(Request $request)
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, 'email' => $user->email,
'is_admin' => $user->is_admin, 'is_admin' => $user->is_admin,
'language' => $user->language,
], ],
'user_type' => $userType, 'user_type' => $userType,
'subscription' => $subscriptionInfo, 'subscription' => $subscriptionInfo,
'temporary_password' => isset($validated['password']) ? null : $password, 'temporary_password' => isset($validated['password']) ? null : $password,
'welcome_email_sent' => $emailSent,
], ],
], 201); ], 201);
} }
@ -122,6 +139,9 @@ public function index(Request $request)
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, 'email' => $user->email,
'language' => $user->language,
'currency' => $user->currency,
'is_admin' => (bool) $user->is_admin,
'created_at' => $user->created_at, 'created_at' => $user->created_at,
'last_login_at' => $user->last_login_at, 'last_login_at' => $user->last_login_at,
'email_verified_at' => $user->email_verified_at, 'email_verified_at' => $user->email_verified_at,
@ -207,11 +227,20 @@ public function update(Request $request, $id)
{ {
$user = User::findOrFail($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([ $validated = $request->validate([
'name' => 'sometimes|string|max:255', 'name' => 'sometimes|string|max:255',
'email' => 'sometimes|email|unique:users,email,' . $id, 'email' => 'sometimes|email|unique:users,email,' . $id,
'language' => 'sometimes|string|in:es,pt-BR,en', 'language' => 'sometimes|string|in:es,pt-BR,en',
'currency' => 'sometimes|string|size:3', 'currency' => 'sometimes|string|size:3',
'is_admin' => 'sometimes|boolean',
]); ]);
$user->update($validated); $user->update($validated);
@ -219,7 +248,14 @@ public function update(Request $request, $id)
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Usuario actualizado correctamente', '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 * Get summary statistics
*/ */

View 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',
],
);
}
}

View 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,
]
);
}
}

View File

@ -31,8 +31,11 @@ class User extends Authenticatable
'country', 'country',
'timezone', 'timezone',
'locale', 'locale',
'language',
'currency',
'password', 'password',
'is_admin', 'is_admin',
'email_verified_at',
]; ];
/** /**

View File

@ -54,6 +54,18 @@
'url' => env('APP_URL', 'http://localhost'), '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 | Application Timezone

View File

@ -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');
}
};

View File

@ -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']);
});
}
};

View File

@ -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

View 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 -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 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>

View File

@ -25,6 +25,7 @@
use App\Http\Controllers\Api\PlanController; use App\Http\Controllers\Api\PlanController;
use App\Http\Controllers\Api\SubscriptionController; use App\Http\Controllers\Api\SubscriptionController;
use App\Http\Controllers\Api\UserManagementController; use App\Http\Controllers\Api\UserManagementController;
use App\Http\Controllers\Api\SiteSettingsController;
// Public routes with rate limiting // Public routes with rate limiting
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register'); Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
@ -236,6 +237,7 @@
Route::get('admin/users/{id}', [UserManagementController::class, 'show']); Route::get('admin/users/{id}', [UserManagementController::class, 'show']);
Route::put('admin/users/{id}', [UserManagementController::class, 'update']); Route::put('admin/users/{id}', [UserManagementController::class, 'update']);
Route::post('admin/users/{id}/reset-password', [UserManagementController::class, 'resetPassword']); 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']); Route::delete('admin/users/{id}', [UserManagementController::class, 'destroy']);
// Configurações de Negócio (Markup) // Configurações de Negócio (Markup)
@ -323,5 +325,17 @@
Route::get('preferences', [UserPreferenceController::class, 'index']); Route::get('preferences', [UserPreferenceController::class, 'index']);
Route::put('preferences', [UserPreferenceController::class, 'update']); Route::put('preferences', [UserPreferenceController::class, 'update']);
Route::post('preferences/test-notification', [UserPreferenceController::class, 'testNotification']); 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
View 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

View File

@ -1,5 +1,5 @@
// WebMoney Service Worker - PWA Support // 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_STATIC = `${CACHE_VERSION}-static`;
const CACHE_DYNAMIC = `${CACHE_VERSION}-dynamic`; const CACHE_DYNAMIC = `${CACHE_VERSION}-dynamic`;
const CACHE_IMMUTABLE = `${CACHE_VERSION}-immutable`; const CACHE_IMMUTABLE = `${CACHE_VERSION}-immutable`;

View File

@ -26,6 +26,7 @@ import Profile from './pages/Profile';
import Pricing from './pages/Pricing'; import Pricing from './pages/Pricing';
import Billing from './pages/Billing'; import Billing from './pages/Billing';
import Users from './pages/Users'; import Users from './pages/Users';
import SiteSettings from './pages/SiteSettings';
function App() { function App() {
return ( return (
@ -232,6 +233,16 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/site-settings"
element={
<ProtectedRoute>
<Layout>
<SiteSettings />
</Layout>
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" />} /> <Route path="/" element={<Navigate to="/dashboard" />} />
</Routes> </Routes>
<CookieConsent /> <CookieConsent />

View File

@ -120,8 +120,11 @@ const Layout = ({ children }) => {
{ path: '/profile', icon: 'bi-person-circle', label: t('nav.profile') }, { path: '/profile', icon: 'bi-person-circle', label: t('nav.profile') },
{ path: '/billing', icon: 'bi-credit-card', label: t('nav.billing') }, { path: '/billing', icon: 'bi-credit-card', label: t('nav.billing') },
{ path: '/pricing', icon: 'bi-tags-fill', label: t('nav.pricing') }, { path: '/pricing', icon: 'bi-tags-fill', label: t('nav.pricing') },
// Admin only: User management // Admin only: User management and Site Settings
...(isAdmin ? [{ path: '/users', icon: 'bi-people', label: t('nav.users') }] : []), ...(isAdmin ? [
{ path: '/users', icon: 'bi-people', label: t('nav.users') },
{ path: '/site-settings', icon: 'bi-globe', label: t('nav.siteSettings', 'Sitio Web') },
] : []),
] ]
}, },
]; ];

View File

@ -15,7 +15,9 @@ export const ToastProvider = ({ children }) => {
const addToast = useCallback((message, type = 'info', duration = 5000) => { const addToast = useCallback((message, type = 'info', duration = 5000) => {
const id = Date.now(); 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]); setToasts((prev) => [...prev, toast]);
@ -48,8 +50,11 @@ export const ToastProvider = ({ children }) => {
return addToast(message, 'info', duration); return addToast(message, 'info', duration);
}, [addToast]); }, [addToast]);
// Alias for backward compatibility
const showToast = addToast;
return ( return (
<ToastContext.Provider value={{ addToast, removeToast, success, error, warning, info }}> <ToastContext.Provider value={{ addToast, showToast, removeToast, success, error, warning, info }}>
{children} {children}
<ToastContainer toasts={toasts} removeToast={removeToast} /> <ToastContainer toasts={toasts} removeToast={removeToast} />
</ToastContext.Provider> </ToastContext.Provider>

View File

@ -228,6 +228,26 @@ const FinancialHealth = () => {
}], }],
} : null; } : 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 ( return (
<div className="financial-health-container"> <div className="financial-health-container">
{/* Header */} {/* Header */}
@ -250,6 +270,46 @@ const FinancialHealth = () => {
</div> </div>
</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 */} {/* Tabs */}
<ul className="nav nav-pills mb-4 gap-2"> <ul className="nav nav-pills mb-4 gap-2">
{tabs.map(tab => ( {tabs.map(tab => (

View 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>
);
}

View File

@ -25,10 +25,25 @@ function Users() {
// Create user modal states // Create user modal states
const [showCreateModal, setShowCreateModal] = useState(false); 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 [createdUser, setCreatedUser] = useState(null);
const [creating, setCreating] = useState(false); 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(() => { useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768); const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
@ -159,10 +174,97 @@ function Users() {
const resetCreateModal = () => { const resetCreateModal = () => {
setShowCreateModal(false); 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); 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 getUserTypeLabel = (type) => {
const labels = { const labels = {
free: 'Free', free: 'Free',
@ -390,6 +492,13 @@ function Users() {
{formatDate(user.created_at)} {formatDate(user.created_at)}
</td> </td>
<td className="align-middle text-end"> <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 <button
onClick={() => { onClick={() => {
setSelectedUser(user); setSelectedUser(user);
@ -559,11 +668,27 @@ function Users() {
<h5 className="text-white mb-1">¡Usuario Creado!</h5> <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 mb-0">{createdUser.user.name}</p>
<p className="text-slate-400 small">{createdUser.user.email}</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">
{getUserTypeLabel(createdUser.user_type)} <span className={`badge ${getUserTypeBadge(createdUser.user_type)}`}>
</span> {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> </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 && ( {createdUser.temporary_password && (
<div className="bg-secondary bg-opacity-25 rounded p-3 mb-3"> <div className="bg-secondary bg-opacity-25 rounded p-3 mb-3">
<p className="text-slate-400 small mb-2">Contraseña temporal:</p> <p className="text-slate-400 small mb-2">Contraseña temporal:</p>
@ -585,7 +710,7 @@ function Users() {
{createdUser.user_type === 'free' && 'Este usuario tiene acceso Free con los límites del plan gratuito.'} {createdUser.user_type === 'free' && 'Este usuario tiene acceso Free con los límites del plan gratuito.'}
</div> </div>
{createdUser.temporary_password && ( {createdUser.temporary_password && !createdUser.welcome_email_sent && (
<p className="text-slate-400 small"> <p className="text-slate-400 small">
<i className="bi bi-exclamation-triangle me-1 text-warning"></i> <i className="bi bi-exclamation-triangle me-1 text-warning"></i>
Copia esta contraseña y envíala al usuario de forma segura. Copia esta contraseña y envíala al usuario de forma segura.
@ -727,6 +852,65 @@ function Users() {
</div> </div>
</div> </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>
<div className="modal-footer border-secondary"> <div className="modal-footer border-secondary">
@ -762,6 +946,229 @@ function Users() {
</div> </div>
</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> </div>
); );
} }

1027
landing/index.html Normal file

File diff suppressed because it is too large Load Diff

190
landing/maintenance.html Normal file
View 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>