first commit
This commit is contained in:
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
backend/data/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
!.env.example
|
||||||
|
!**/.env.example
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Runtime uploads / user-generated content
|
||||||
|
backend/uploads/
|
||||||
|
|
||||||
|
# Tooling
|
||||||
|
.turbo/
|
||||||
|
.cursor/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.pem
|
||||||
209
README.md
Normal file
209
README.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# Spanglish - Language Exchange Event Platform
|
||||||
|
|
||||||
|
A full-stack web application for organizing and managing language exchange events in Asunción, Paraguay.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Public Website
|
||||||
|
- **Homepage** - Hero section with next event, about section, newsletter signup
|
||||||
|
- **Events** - Browse upcoming and past events, view event details
|
||||||
|
- **Booking** - Book tickets for events with payment options
|
||||||
|
- **Community** - WhatsApp and social media links, community guidelines
|
||||||
|
- **Contact** - Contact form for inquiries
|
||||||
|
- **Multi-language** - English and Spanish support with easy extensibility
|
||||||
|
|
||||||
|
### Admin Dashboard (`/admin`)
|
||||||
|
- **Dashboard** - Overview stats, alerts, quick actions
|
||||||
|
- **Events** - Create, edit, publish, and manage events
|
||||||
|
- **Tickets** - View bookings, check-in attendees, manage ticket status
|
||||||
|
- **Users** - Manage user accounts and roles
|
||||||
|
- **Payments** - View and confirm payments, process refunds
|
||||||
|
- **Messages** - View and respond to contact form submissions
|
||||||
|
|
||||||
|
### API
|
||||||
|
- **API Docs** - Swagger UI at `/api-docs`
|
||||||
|
- **OpenAPI Spec** - JSON specification at `/openapi.json`
|
||||||
|
- Full REST API with JWT authentication
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Runtime**: Node.js with TypeScript
|
||||||
|
- **Framework**: Hono (fast, lightweight)
|
||||||
|
- **Database**: SQLite (default) or PostgreSQL
|
||||||
|
- **ORM**: Drizzle ORM
|
||||||
|
- **Authentication**: JWT with bcrypt password hashing
|
||||||
|
- **API Documentation**: OpenAPI 3.0 / Swagger UI
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Framework**: Next.js 14 (App Router)
|
||||||
|
- **Styling**: Tailwind CSS
|
||||||
|
- **Icons**: Heroicons
|
||||||
|
- **State Management**: React Context
|
||||||
|
- **HTTP Client**: SWR / Fetch API
|
||||||
|
- **Notifications**: React Hot Toast
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Node.js 18+
|
||||||
|
- npm or yarn
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd Spanglish
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set up environment variables:
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
# Edit backend/.env with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Initialize the database:
|
||||||
|
```bash
|
||||||
|
npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Start the development servers:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at:
|
||||||
|
- Frontend: http://localhost:3002
|
||||||
|
- Backend API: http://localhost:3001
|
||||||
|
- API Docs: http://localhost:3001/api-docs
|
||||||
|
|
||||||
|
### First User = Admin
|
||||||
|
|
||||||
|
The first user to register becomes the admin. Register at `/register` to create the admin account.
|
||||||
|
|
||||||
|
## Database Configuration
|
||||||
|
|
||||||
|
### SQLite (Default)
|
||||||
|
No additional configuration needed. Database file is created at `backend/data/spanglish.db`.
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
Update `backend/.env`:
|
||||||
|
```env
|
||||||
|
DB_TYPE=postgres
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/spanglish
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Spanglish/
|
||||||
|
├── backend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── db/ # Database schema and migrations
|
||||||
|
│ │ ├── lib/ # Utilities (auth, helpers)
|
||||||
|
│ │ ├── routes/ # API routes
|
||||||
|
│ │ └── index.ts # Server entry point
|
||||||
|
│ └── drizzle.config.ts
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── app/ # Next.js pages
|
||||||
|
│ │ │ ├── (public)/ # Public pages
|
||||||
|
│ │ │ └── admin/ # Admin dashboard
|
||||||
|
│ │ ├── components/ # React components
|
||||||
|
│ │ ├── context/ # React contexts
|
||||||
|
│ │ ├── i18n/ # Internationalization
|
||||||
|
│ │ └── lib/ # API client, utilities
|
||||||
|
│ └── tailwind.config.js
|
||||||
|
└── package.json # Monorepo workspace config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Languages
|
||||||
|
|
||||||
|
The i18n system is designed for easy extensibility:
|
||||||
|
|
||||||
|
1. Create a new locale file at `frontend/src/i18n/locales/{code}.json`
|
||||||
|
2. Copy structure from `en.json` and translate
|
||||||
|
3. Update `frontend/src/i18n/index.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import pt from './locales/pt.json';
|
||||||
|
|
||||||
|
export type Locale = 'en' | 'es' | 'pt';
|
||||||
|
|
||||||
|
export const locales: Record<Locale, typeof en> = {
|
||||||
|
en,
|
||||||
|
es,
|
||||||
|
pt, // Add new locale
|
||||||
|
};
|
||||||
|
|
||||||
|
export const localeNames: Record<Locale, string> = {
|
||||||
|
en: 'English',
|
||||||
|
es: 'Español',
|
||||||
|
pt: 'Português', // Add display name
|
||||||
|
};
|
||||||
|
|
||||||
|
export const localeFlags: Record<Locale, string> = {
|
||||||
|
en: '🇺🇸',
|
||||||
|
es: '🇪🇸',
|
||||||
|
pt: '🇧🇷', // Add flag
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /api/auth/register` - Register new user
|
||||||
|
- `POST /api/auth/login` - Login
|
||||||
|
- `GET /api/auth/me` - Get current user
|
||||||
|
|
||||||
|
### Events
|
||||||
|
- `GET /api/events` - List events
|
||||||
|
- `GET /api/events/:id` - Get event
|
||||||
|
- `POST /api/events` - Create event (admin)
|
||||||
|
- `PUT /api/events/:id` - Update event (admin)
|
||||||
|
- `DELETE /api/events/:id` - Delete event (admin)
|
||||||
|
|
||||||
|
### Tickets
|
||||||
|
- `POST /api/tickets` - Book ticket
|
||||||
|
- `GET /api/tickets/:id` - Get ticket
|
||||||
|
- `POST /api/tickets/:id/checkin` - Check in (staff)
|
||||||
|
|
||||||
|
### Users
|
||||||
|
- `GET /api/users` - List users (admin)
|
||||||
|
- `PUT /api/users/:id` - Update user
|
||||||
|
|
||||||
|
### Payments
|
||||||
|
- `GET /api/payments` - List payments (admin)
|
||||||
|
- `PUT /api/payments/:id` - Update payment status
|
||||||
|
- `POST /api/payments/:id/refund` - Process refund
|
||||||
|
|
||||||
|
### Contacts
|
||||||
|
- `POST /api/contacts` - Submit contact form
|
||||||
|
- `POST /api/contacts/subscribe` - Subscribe to newsletter
|
||||||
|
|
||||||
|
## User Roles
|
||||||
|
|
||||||
|
- **admin** - Full access to all features
|
||||||
|
- **organizer** - Manage events, tickets, view payments
|
||||||
|
- **staff** - Check-in attendees, view ticket info
|
||||||
|
- **marketing** - Access to subscriber lists, analytics
|
||||||
|
- **user** - Public user, can book events
|
||||||
|
|
||||||
|
## Brand Colors
|
||||||
|
|
||||||
|
- Primary Yellow: `#FFD84D`
|
||||||
|
- Primary Dark: `#111111`
|
||||||
|
- Primary White: `#FFFFFF`
|
||||||
|
- Secondary Gray: `#F5F5F5`
|
||||||
|
- Accent Blue: `#2F80ED`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
417
about/FUNCTIONAL_SPEC.md
Normal file
417
about/FUNCTIONAL_SPEC.md
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
# Spanglish Website – Functional Specification
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document defines the functional behavior of the Spanglish website and admin system.
|
||||||
|
|
||||||
|
It specifies what the system must do, how users interact with it, and how core workflows operate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. User Roles
|
||||||
|
|
||||||
|
### 2.1 Public User
|
||||||
|
|
||||||
|
* Can browse the website
|
||||||
|
* Can view events
|
||||||
|
* Can book tickets
|
||||||
|
* Can join community channels
|
||||||
|
* Can contact organizers
|
||||||
|
|
||||||
|
### 2.2 Admin
|
||||||
|
|
||||||
|
* Full system access
|
||||||
|
* Manages events
|
||||||
|
* Manages users
|
||||||
|
* Manages payments
|
||||||
|
* Manages communications
|
||||||
|
* Manages settings
|
||||||
|
|
||||||
|
### 2.3 Organizer
|
||||||
|
|
||||||
|
* Manages assigned events
|
||||||
|
* Views attendees
|
||||||
|
* Sends event emails
|
||||||
|
* Manages check-in
|
||||||
|
|
||||||
|
### 2.4 Staff
|
||||||
|
|
||||||
|
* Access to check-in system
|
||||||
|
* Limited attendee viewing
|
||||||
|
|
||||||
|
### 2.5 Marketing
|
||||||
|
|
||||||
|
* Access to email tools
|
||||||
|
* Access to analytics
|
||||||
|
* Access to media library
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Public Website Features
|
||||||
|
|
||||||
|
### 3.1 Homepage
|
||||||
|
|
||||||
|
The homepage must display:
|
||||||
|
|
||||||
|
* Hero section with next event
|
||||||
|
* Primary call-to-action button
|
||||||
|
* Brief description of Spanglish
|
||||||
|
* Photo gallery
|
||||||
|
* Community links
|
||||||
|
* Email signup form
|
||||||
|
* Contact information
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
* Automatically shows the next upcoming event
|
||||||
|
* Updates dynamically when events change
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Events Listing Page
|
||||||
|
|
||||||
|
The events page must display:
|
||||||
|
|
||||||
|
* List of upcoming events
|
||||||
|
* List of past events
|
||||||
|
* Basic filtering by date
|
||||||
|
|
||||||
|
Each event card shows:
|
||||||
|
|
||||||
|
* Title
|
||||||
|
* Date
|
||||||
|
* Location
|
||||||
|
* Status
|
||||||
|
* Booking button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Event Detail Page
|
||||||
|
|
||||||
|
Each event page must include:
|
||||||
|
|
||||||
|
* Event title
|
||||||
|
* Description
|
||||||
|
* Date and time
|
||||||
|
* Location with map
|
||||||
|
* Price
|
||||||
|
* Available seats
|
||||||
|
* Booking button
|
||||||
|
* FAQ section
|
||||||
|
* Social sharing buttons
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
* Booking disabled when sold out
|
||||||
|
* Status shown when cancelled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 Community Page
|
||||||
|
|
||||||
|
The community page must include:
|
||||||
|
|
||||||
|
* WhatsApp invite links
|
||||||
|
* Instagram link
|
||||||
|
* Participation guidelines
|
||||||
|
* Volunteer information
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 Contact Page
|
||||||
|
|
||||||
|
The contact page must include:
|
||||||
|
|
||||||
|
* Contact form
|
||||||
|
* Email address
|
||||||
|
* Social media links
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
* Form submissions stored in database
|
||||||
|
* Admin receives notification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Booking System
|
||||||
|
|
||||||
|
### 4.1 Booking Flow
|
||||||
|
|
||||||
|
1. User clicks "Join" on event page
|
||||||
|
2. Booking form is displayed
|
||||||
|
3. User enters personal details
|
||||||
|
4. User selects payment method
|
||||||
|
5. Payment is processed or recorded
|
||||||
|
6. Ticket is generated
|
||||||
|
7. Confirmation is sent
|
||||||
|
8. User appears in admin system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Booking Form
|
||||||
|
|
||||||
|
The booking form collects:
|
||||||
|
|
||||||
|
* Full name
|
||||||
|
* Email address
|
||||||
|
* Phone number
|
||||||
|
* Language preference (optional)
|
||||||
|
* Payment method
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
* Email format check
|
||||||
|
* Required field validation
|
||||||
|
* Duplicate booking prevention
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Ticket Generation
|
||||||
|
|
||||||
|
After successful booking:
|
||||||
|
|
||||||
|
* Unique ticket ID is generated
|
||||||
|
* Ticket linked to user and event
|
||||||
|
* QR code created (optional)
|
||||||
|
* Ticket stored in database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Payment Management
|
||||||
|
|
||||||
|
### 5.1 Supported Payment Methods
|
||||||
|
|
||||||
|
Initial support:
|
||||||
|
|
||||||
|
* Stripe / MercadoPago
|
||||||
|
* Bank transfer
|
||||||
|
* Cash (manual entry)
|
||||||
|
|
||||||
|
Future support:
|
||||||
|
|
||||||
|
* Lightning
|
||||||
|
* Cashu
|
||||||
|
* Nostr payments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Payment Workflow
|
||||||
|
|
||||||
|
* Payment initiated during booking
|
||||||
|
* Payment status tracked
|
||||||
|
* Manual confirmation supported
|
||||||
|
* Refunds supported
|
||||||
|
|
||||||
|
Statuses:
|
||||||
|
|
||||||
|
* Pending
|
||||||
|
* Paid
|
||||||
|
* Refunded
|
||||||
|
* Failed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Admin Dashboard
|
||||||
|
|
||||||
|
### 6.1 Dashboard Overview
|
||||||
|
|
||||||
|
The main dashboard displays:
|
||||||
|
|
||||||
|
* Upcoming events
|
||||||
|
* Ticket sales
|
||||||
|
* Revenue summary
|
||||||
|
* Pending payments
|
||||||
|
* Alerts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.2 Event Management Module
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
* Create event
|
||||||
|
* Edit event
|
||||||
|
* Publish event
|
||||||
|
* Archive event
|
||||||
|
* Duplicate event
|
||||||
|
|
||||||
|
Event fields:
|
||||||
|
|
||||||
|
* Title
|
||||||
|
* Description
|
||||||
|
* Date
|
||||||
|
* Time
|
||||||
|
* Location
|
||||||
|
* Price
|
||||||
|
* Capacity
|
||||||
|
* Banner image
|
||||||
|
* Status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.3 Ticket Management Module
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
* View attendee list
|
||||||
|
* Search attendees
|
||||||
|
* Edit attendee info
|
||||||
|
* Manual ticket creation
|
||||||
|
* Cancel tickets
|
||||||
|
* Export CSV
|
||||||
|
* Check-in system
|
||||||
|
|
||||||
|
Check-in:
|
||||||
|
|
||||||
|
* Manual toggle
|
||||||
|
* QR scanning (optional)
|
||||||
|
* Timestamp logging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.4 Payment Management Module
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
* View transactions
|
||||||
|
* Manual payment entry
|
||||||
|
* Refund processing
|
||||||
|
* Revenue reports
|
||||||
|
* Export financial data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.5 Email & Messaging Module
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
* Email templates
|
||||||
|
* Mass mailing
|
||||||
|
* Automated reminders
|
||||||
|
* Event notifications
|
||||||
|
* Contact segmentation
|
||||||
|
|
||||||
|
Automated emails:
|
||||||
|
|
||||||
|
* Booking confirmation
|
||||||
|
* Event reminder
|
||||||
|
* Cancellation notice
|
||||||
|
* Post-event follow-up
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.6 Community Management Module
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
* User profiles
|
||||||
|
* Attendance history
|
||||||
|
* Notes system
|
||||||
|
* Tagging
|
||||||
|
* Segmentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.7 Media Management Module
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
* Upload images
|
||||||
|
* Organize galleries
|
||||||
|
* Assign images to events
|
||||||
|
* Homepage gallery management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.8 Role & Permission Management
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
* Create roles
|
||||||
|
* Assign permissions
|
||||||
|
* Assign users to roles
|
||||||
|
* Audit access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Reporting & Analytics
|
||||||
|
|
||||||
|
The system must provide:
|
||||||
|
|
||||||
|
* Event attendance reports
|
||||||
|
* Revenue reports
|
||||||
|
* User growth reports
|
||||||
|
* Conversion metrics
|
||||||
|
* Email engagement statistics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Error Handling
|
||||||
|
|
||||||
|
The system must handle:
|
||||||
|
|
||||||
|
* Failed payments
|
||||||
|
* Overbooking attempts
|
||||||
|
* Network failures
|
||||||
|
* Invalid input
|
||||||
|
* Unauthorized access
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
* Display user-friendly messages
|
||||||
|
* Log technical errors
|
||||||
|
* Notify admins when critical
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Notifications
|
||||||
|
|
||||||
|
System notifications include:
|
||||||
|
|
||||||
|
* New booking alerts
|
||||||
|
* Payment failures
|
||||||
|
* Low capacity warnings
|
||||||
|
* System errors
|
||||||
|
|
||||||
|
Delivery channels:
|
||||||
|
|
||||||
|
* Email
|
||||||
|
* Admin dashboard alerts
|
||||||
|
n
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Audit Logging
|
||||||
|
|
||||||
|
The system must record:
|
||||||
|
|
||||||
|
* Admin actions
|
||||||
|
* Payment changes
|
||||||
|
* Event modifications
|
||||||
|
* User updates
|
||||||
|
|
||||||
|
Logs must include:
|
||||||
|
|
||||||
|
* Timestamp
|
||||||
|
* User ID
|
||||||
|
* Action type
|
||||||
|
* Target record
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Accessibility
|
||||||
|
|
||||||
|
The public website must:
|
||||||
|
|
||||||
|
* Support mobile devices
|
||||||
|
* Provide readable fonts
|
||||||
|
* Support screen readers
|
||||||
|
* Maintain color contrast
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Summary
|
||||||
|
|
||||||
|
This functional specification defines the operational behavior of the Spanglish platform.
|
||||||
|
|
||||||
|
All implemented features must comply with this document to ensure consistency, reliability, and scalability.
|
||||||
339
about/TECH_SPEC.md
Normal file
339
about/TECH_SPEC.md
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
# Spanglish Website – Technical Specification
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document defines the technical architecture, technology stack, and implementation guidelines for the Spanglish website and admin system.
|
||||||
|
|
||||||
|
It serves as the reference for developers responsible for building, deploying, and maintaining the platform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. System Architecture
|
||||||
|
|
||||||
|
### 2.1 High-Level Architecture
|
||||||
|
|
||||||
|
The system follows a client-server architecture:
|
||||||
|
|
||||||
|
Browser (Public/Admin)
|
||||||
|
→ Frontend Application
|
||||||
|
→ Backend API
|
||||||
|
→ Database
|
||||||
|
→ External Services
|
||||||
|
|
||||||
|
External services include payment providers and email delivery systems.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Component Overview
|
||||||
|
|
||||||
|
* Frontend: Public website and admin interface
|
||||||
|
* Backend: REST API and business logic
|
||||||
|
* Database: Central data storage
|
||||||
|
* Payment Services: External processors
|
||||||
|
* Email Service: Transactional and bulk email
|
||||||
|
* Media Storage: Image and file storage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Technology Stack
|
||||||
|
|
||||||
|
### 3.1 Frontend
|
||||||
|
|
||||||
|
* Framework: Next.js (React)
|
||||||
|
* Styling: Tailwind CSS
|
||||||
|
* State Management: React Context / Query
|
||||||
|
* Build Tool: Vite / Next Build
|
||||||
|
* Image Optimization: Next Image
|
||||||
|
|
||||||
|
### 3.2 Backend
|
||||||
|
|
||||||
|
* Framework: FastAPI (Python)
|
||||||
|
* API Style: REST
|
||||||
|
* Authentication: JWT
|
||||||
|
* ORM: SQLAlchemy
|
||||||
|
* Validation: Pydantic
|
||||||
|
|
||||||
|
### 3.3 Database
|
||||||
|
|
||||||
|
* System: PostgreSQL
|
||||||
|
* Migration Tool: Alembic
|
||||||
|
* Backup: Automated daily backups
|
||||||
|
|
||||||
|
### 3.4 Infrastructure
|
||||||
|
|
||||||
|
* Hosting: VPS (Linux)
|
||||||
|
* Reverse Proxy: Nginx
|
||||||
|
* SSL: Let’s Encrypt
|
||||||
|
* CDN: Optional (Cloudflare)
|
||||||
|
* Containerization: Docker
|
||||||
|
|
||||||
|
### 3.5 External Services
|
||||||
|
|
||||||
|
* Payments: Stripe / MercadoPago
|
||||||
|
* Email: Resend / Postmark / Mailgun
|
||||||
|
* Analytics: Plausible / GA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Database Design
|
||||||
|
|
||||||
|
### 4.1 Core Tables
|
||||||
|
|
||||||
|
#### users
|
||||||
|
|
||||||
|
* id (UUID)
|
||||||
|
* name
|
||||||
|
* email
|
||||||
|
* phone
|
||||||
|
* role
|
||||||
|
* created_at
|
||||||
|
* updated_at
|
||||||
|
|
||||||
|
#### events
|
||||||
|
|
||||||
|
* id (UUID)
|
||||||
|
* title
|
||||||
|
* description
|
||||||
|
* start_datetime
|
||||||
|
* end_datetime
|
||||||
|
* location
|
||||||
|
* price
|
||||||
|
* capacity
|
||||||
|
* status
|
||||||
|
* banner_url
|
||||||
|
* created_at
|
||||||
|
|
||||||
|
#### tickets
|
||||||
|
|
||||||
|
* id (UUID)
|
||||||
|
* user_id
|
||||||
|
* event_id
|
||||||
|
* status
|
||||||
|
* checkin_at
|
||||||
|
* created_at
|
||||||
|
|
||||||
|
#### payments
|
||||||
|
|
||||||
|
* id (UUID)
|
||||||
|
* ticket_id
|
||||||
|
* provider
|
||||||
|
* amount
|
||||||
|
* currency
|
||||||
|
* status
|
||||||
|
* reference
|
||||||
|
* created_at
|
||||||
|
|
||||||
|
#### emails
|
||||||
|
|
||||||
|
* id (UUID)
|
||||||
|
* user_id
|
||||||
|
* subject
|
||||||
|
* body
|
||||||
|
* status
|
||||||
|
* sent_at
|
||||||
|
|
||||||
|
#### media
|
||||||
|
|
||||||
|
* id (UUID)
|
||||||
|
* file_url
|
||||||
|
* type
|
||||||
|
* related_id
|
||||||
|
* created_at
|
||||||
|
|
||||||
|
#### audit_logs
|
||||||
|
|
||||||
|
* id (UUID)
|
||||||
|
* user_id
|
||||||
|
* action
|
||||||
|
* target
|
||||||
|
* timestamp
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API Design
|
||||||
|
|
||||||
|
### 5.1 Authentication
|
||||||
|
|
||||||
|
POST /api/auth/login
|
||||||
|
POST /api/auth/refresh
|
||||||
|
POST /api/auth/logout
|
||||||
|
|
||||||
|
JWT tokens are used for session management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Event Endpoints
|
||||||
|
|
||||||
|
GET /api/events
|
||||||
|
GET /api/events/{id}
|
||||||
|
POST /api/events
|
||||||
|
PUT /api/events/{id}
|
||||||
|
DELETE /api/events/{id}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Ticket Endpoints
|
||||||
|
|
||||||
|
POST /api/tickets
|
||||||
|
GET /api/tickets/{id}
|
||||||
|
GET /api/events/{id}/tickets
|
||||||
|
PUT /api/tickets/{id}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4 Payment Endpoints
|
||||||
|
|
||||||
|
POST /api/payments/initiate
|
||||||
|
POST /api/payments/webhook
|
||||||
|
GET /api/payments/{id}
|
||||||
|
POST /api/payments/refund
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.5 User & Community Endpoints
|
||||||
|
|
||||||
|
GET /api/users
|
||||||
|
GET /api/users/{id}
|
||||||
|
PUT /api/users/{id}
|
||||||
|
GET /api/users/{id}/history
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.6 Media Endpoints
|
||||||
|
|
||||||
|
POST /api/media/upload
|
||||||
|
GET /api/media/{id}
|
||||||
|
DELETE /api/media/{id}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Authentication & Authorization
|
||||||
|
|
||||||
|
* JWT-based authentication
|
||||||
|
* Refresh tokens
|
||||||
|
* Role-based access control
|
||||||
|
* Password hashing (bcrypt/argon2)
|
||||||
|
* Optional OAuth/Nostr integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Security
|
||||||
|
|
||||||
|
### 7.1 Application Security
|
||||||
|
|
||||||
|
* Input validation
|
||||||
|
* CSRF protection
|
||||||
|
* CORS policies
|
||||||
|
* Rate limiting
|
||||||
|
* SQL injection prevention
|
||||||
|
|
||||||
|
### 7.2 Infrastructure Security
|
||||||
|
|
||||||
|
* Firewall rules
|
||||||
|
* Fail2ban
|
||||||
|
* Encrypted backups
|
||||||
|
* Secure secrets storage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Deployment
|
||||||
|
|
||||||
|
### 8.1 Environment Structure
|
||||||
|
|
||||||
|
* Development
|
||||||
|
* Staging
|
||||||
|
* Production
|
||||||
|
|
||||||
|
Each environment uses separate databases and credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.2 Deployment Process
|
||||||
|
|
||||||
|
1. Build frontend
|
||||||
|
2. Build backend container
|
||||||
|
3. Run database migrations
|
||||||
|
4. Deploy containers
|
||||||
|
5. Reload Nginx
|
||||||
|
6. Verify health checks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.3 CI/CD (Optional)
|
||||||
|
|
||||||
|
* GitHub Actions
|
||||||
|
* Automated testing
|
||||||
|
* Automated deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Monitoring & Logging
|
||||||
|
|
||||||
|
* Application logs
|
||||||
|
* Error tracking
|
||||||
|
* Performance monitoring
|
||||||
|
* Uptime monitoring
|
||||||
|
|
||||||
|
Recommended tools:
|
||||||
|
|
||||||
|
* Sentry
|
||||||
|
* Prometheus
|
||||||
|
* Grafana
|
||||||
|
* Uptime Kuma
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Backup & Recovery
|
||||||
|
|
||||||
|
* Daily database backups
|
||||||
|
* Weekly full backups
|
||||||
|
* Offsite storage
|
||||||
|
* Restore testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Performance Optimization
|
||||||
|
|
||||||
|
* Database indexing
|
||||||
|
* Query optimization
|
||||||
|
* CDN caching
|
||||||
|
* Image compression
|
||||||
|
* Lazy loading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Development Guidelines
|
||||||
|
|
||||||
|
* Follow PEP8 (Backend)
|
||||||
|
* Use type hints
|
||||||
|
* Write unit tests
|
||||||
|
* Document endpoints
|
||||||
|
* Use environment variables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Versioning & Updates
|
||||||
|
|
||||||
|
* Semantic versioning
|
||||||
|
* Backward-compatible APIs
|
||||||
|
* Migration scripts
|
||||||
|
* Change logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Future Extensions
|
||||||
|
|
||||||
|
* Mobile application
|
||||||
|
* Membership system
|
||||||
|
* Lightning integration
|
||||||
|
* Cashu payments
|
||||||
|
* Nostr identity
|
||||||
|
* Multi-city deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Summary
|
||||||
|
|
||||||
|
This technical specification defines the architecture and implementation standards for the Spanglish platform.
|
||||||
|
|
||||||
|
All development must follow this document to ensure security, maintainability, and scalability.
|
||||||
368
about/booking_fow.md
Normal file
368
about/booking_fow.md
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
# Spanglish Website – Booking & Payment Flow
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document defines the complete booking and payment flow for Spanglish events.
|
||||||
|
|
||||||
|
It ensures a fast, reliable, and user-friendly process for participants and a clear management workflow for organizers.
|
||||||
|
|
||||||
|
The system supports the following payment methods:
|
||||||
|
|
||||||
|
* TPago / Bancard (Credit & Debit Cards)
|
||||||
|
* Bitcoin Lightning (BTCPay)
|
||||||
|
* Cash at Event
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core Objectives
|
||||||
|
|
||||||
|
The booking system must:
|
||||||
|
|
||||||
|
* Allow users to reserve a seat in under 60 seconds
|
||||||
|
* Minimize drop-off during payment
|
||||||
|
* Provide clear confirmation
|
||||||
|
* Support mobile-first usage
|
||||||
|
* Reduce manual coordination
|
||||||
|
* Integrate cleanly with the admin dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. High-Level Booking Flow
|
||||||
|
|
||||||
|
1. User visits an event page
|
||||||
|
2. User clicks "Join Event"
|
||||||
|
3. Booking page opens
|
||||||
|
4. User enters personal details
|
||||||
|
5. User selects payment method
|
||||||
|
6. Payment or reservation is processed
|
||||||
|
7. Ticket is generated
|
||||||
|
8. Confirmation is sent
|
||||||
|
9. User appears in admin dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Booking Page Structure
|
||||||
|
|
||||||
|
The booking page is available at:
|
||||||
|
|
||||||
|
/book/{event-id}
|
||||||
|
|
||||||
|
It consists of a single vertical flow optimized for mobile devices.
|
||||||
|
|
||||||
|
### 4.1 Event Summary Section
|
||||||
|
|
||||||
|
Displayed at the top and always visible:
|
||||||
|
|
||||||
|
* Event title
|
||||||
|
* Date and time
|
||||||
|
* Location
|
||||||
|
* Remaining seats
|
||||||
|
* Price
|
||||||
|
|
||||||
|
This section helps users verify they selected the correct event.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 User Information Section
|
||||||
|
|
||||||
|
The booking form collects:
|
||||||
|
|
||||||
|
* Full name (required)
|
||||||
|
* Email address (required)
|
||||||
|
* Phone / WhatsApp number (required)
|
||||||
|
* Preferred language (optional)
|
||||||
|
|
||||||
|
Validation rules:
|
||||||
|
|
||||||
|
* All required fields must be filled
|
||||||
|
* Email format validation
|
||||||
|
* Phone number format validation
|
||||||
|
* Duplicate booking prevention
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Payment Selection Section
|
||||||
|
|
||||||
|
Users choose one of the following payment methods using selectable cards.
|
||||||
|
|
||||||
|
Each option is presented as a large, tap-friendly card with icon and description.
|
||||||
|
|
||||||
|
Available options:
|
||||||
|
|
||||||
|
1. TPago / Bancard
|
||||||
|
2. Bitcoin Lightning
|
||||||
|
3. Cash at Event
|
||||||
|
|
||||||
|
Only one option may be selected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Payment Methods
|
||||||
|
|
||||||
|
### 5.1 TPago / Bancard (Credit & Debit Card)
|
||||||
|
|
||||||
|
#### User Flow
|
||||||
|
|
||||||
|
1. User selects "TPago / Bancard"
|
||||||
|
2. User clicks "Pay with Card"
|
||||||
|
3. User is redirected to Bancard checkout
|
||||||
|
4. User completes payment
|
||||||
|
5. User is redirected back to Spanglish
|
||||||
|
6. Payment is verified via webhook
|
||||||
|
7. Ticket is confirmed
|
||||||
|
|
||||||
|
#### System Behavior
|
||||||
|
|
||||||
|
* Payment intent is created before redirect
|
||||||
|
* Payment reference is stored
|
||||||
|
* Webhook validation is mandatory
|
||||||
|
* Only verified payments mark tickets as paid
|
||||||
|
|
||||||
|
Status: Paid
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Bitcoin Lightning (BTCPay)
|
||||||
|
|
||||||
|
#### User Flow
|
||||||
|
|
||||||
|
1. User selects "Bitcoin Lightning"
|
||||||
|
2. Invoice is generated
|
||||||
|
3. QR code and invoice string are displayed
|
||||||
|
4. User pays with Lightning wallet
|
||||||
|
5. Payment is detected via webhook
|
||||||
|
6. Ticket is confirmed automatically
|
||||||
|
|
||||||
|
#### Display Requirements
|
||||||
|
|
||||||
|
* QR code
|
||||||
|
* Amount in sats
|
||||||
|
* Expiry countdown
|
||||||
|
* Copy invoice button
|
||||||
|
|
||||||
|
#### System Behavior
|
||||||
|
|
||||||
|
* Invoice created via BTCPay API
|
||||||
|
* Webhook confirmation required
|
||||||
|
* Automatic ticket confirmation
|
||||||
|
|
||||||
|
Status: Paid
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Cash at Event
|
||||||
|
|
||||||
|
#### User Flow
|
||||||
|
|
||||||
|
1. User selects "Cash at Event"
|
||||||
|
2. User confirms reservation
|
||||||
|
3. Booking is created
|
||||||
|
4. Ticket is generated
|
||||||
|
5. User receives confirmation
|
||||||
|
6. User pays in cash at the event entrance
|
||||||
|
|
||||||
|
#### System Behavior
|
||||||
|
|
||||||
|
* Booking is stored as unpaid
|
||||||
|
* Seat is reserved immediately
|
||||||
|
* No automatic expiration is applied
|
||||||
|
* Admin marks payment as received manually
|
||||||
|
|
||||||
|
Status: Pending (until marked Paid by staff)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Ticket Generation
|
||||||
|
|
||||||
|
After successful booking:
|
||||||
|
|
||||||
|
* A unique ticket ID is generated
|
||||||
|
* Ticket is linked to user and event
|
||||||
|
* Ticket status is assigned
|
||||||
|
* QR code may be generated
|
||||||
|
* Ticket is stored permanently
|
||||||
|
|
||||||
|
Ticket statuses:
|
||||||
|
|
||||||
|
* Pending
|
||||||
|
* Paid
|
||||||
|
* Cancelled
|
||||||
|
* Refunded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Confirmation & Notifications
|
||||||
|
|
||||||
|
### 7.1 Booking Confirmation
|
||||||
|
|
||||||
|
Sent immediately after booking.
|
||||||
|
|
||||||
|
Channels:
|
||||||
|
|
||||||
|
* Email
|
||||||
|
* Optional WhatsApp integration
|
||||||
|
|
||||||
|
Content includes:
|
||||||
|
|
||||||
|
* Event details
|
||||||
|
* Payment method
|
||||||
|
* Ticket ID
|
||||||
|
* Check-in instructions
|
||||||
|
n
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.2 Payment Confirmation
|
||||||
|
|
||||||
|
Sent when payment is completed (Card / Lightning).
|
||||||
|
|
||||||
|
Includes:
|
||||||
|
|
||||||
|
* Payment receipt
|
||||||
|
* Ticket confirmation
|
||||||
|
* Calendar link
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.3 Event Reminder
|
||||||
|
|
||||||
|
Sent automatically before the event.
|
||||||
|
|
||||||
|
Includes:
|
||||||
|
|
||||||
|
* Date and time
|
||||||
|
* Location map
|
||||||
|
* Payment reminder for cash users
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Admin Management Flow
|
||||||
|
|
||||||
|
### 8.1 Admin View
|
||||||
|
|
||||||
|
Admins can view all bookings with:
|
||||||
|
|
||||||
|
* Name
|
||||||
|
* Event
|
||||||
|
* Payment method
|
||||||
|
* Status
|
||||||
|
* Contact info
|
||||||
|
* Check-in status
|
||||||
|
|
||||||
|
Color coding:
|
||||||
|
|
||||||
|
* Green: Paid
|
||||||
|
* Yellow: Pending
|
||||||
|
* Red: Cancelled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.2 Manual Payment Processing
|
||||||
|
|
||||||
|
For cash payments:
|
||||||
|
|
||||||
|
* Admin locates ticket
|
||||||
|
* Marks as Paid
|
||||||
|
* Timestamp is recorded
|
||||||
|
* Receipt may be attached
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.3 Refund Handling
|
||||||
|
|
||||||
|
Admins may:
|
||||||
|
|
||||||
|
* Mark tickets as Refunded
|
||||||
|
* Trigger payment provider refunds
|
||||||
|
* Send notification to user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Capacity Management
|
||||||
|
|
||||||
|
* Each event has a fixed capacity
|
||||||
|
* Seats are reserved immediately on booking
|
||||||
|
* Overbooking is prevented
|
||||||
|
* Sold-out events disable booking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Anti-Abuse Measures
|
||||||
|
|
||||||
|
The system must implement:
|
||||||
|
|
||||||
|
* Duplicate booking detection
|
||||||
|
* Rate limiting on bookings
|
||||||
|
* Email verification (optional)
|
||||||
|
* Admin override tools
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Error Handling
|
||||||
|
|
||||||
|
The system must handle:
|
||||||
|
|
||||||
|
* Failed payments
|
||||||
|
* Interrupted redirects
|
||||||
|
* Invoice expiration
|
||||||
|
* Network issues
|
||||||
|
* Invalid input
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
* Clear user-facing messages
|
||||||
|
* Automatic retry options
|
||||||
|
* Error logging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Audit Logging
|
||||||
|
|
||||||
|
All booking-related actions must be logged:
|
||||||
|
|
||||||
|
* Ticket creation
|
||||||
|
* Payment updates
|
||||||
|
* Manual status changes
|
||||||
|
* Refunds
|
||||||
|
* Cancellations
|
||||||
|
|
||||||
|
Each log entry includes:
|
||||||
|
|
||||||
|
* User ID
|
||||||
|
* Admin ID (if applicable)
|
||||||
|
* Timestamp
|
||||||
|
* Action type
|
||||||
|
n
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Security Requirements
|
||||||
|
|
||||||
|
* Secure payment redirects
|
||||||
|
* Webhook signature verification
|
||||||
|
* Encrypted data storage
|
||||||
|
* PCI compliance via provider
|
||||||
|
* No sensitive card data stored
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Future Extensions
|
||||||
|
|
||||||
|
The booking system is designed to support:
|
||||||
|
|
||||||
|
* Subscriptions
|
||||||
|
* Membership passes
|
||||||
|
* Promo codes
|
||||||
|
* Group bookings
|
||||||
|
* Lightning Address payments
|
||||||
|
* Cashu payments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Summary
|
||||||
|
|
||||||
|
This booking and payment flow is designed to be fast, reliable, and scalable.
|
||||||
|
|
||||||
|
It supports local payment methods, Bitcoin Lightning, and cash while maintaining professional standards.
|
||||||
|
|
||||||
|
All implementations must follow this document to ensure consistency and trust.
|
||||||
441
about/brand.md
Normal file
441
about/brand.md
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
# Spanglish Website – Brand & Design Guide
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document defines the visual identity, branding rules, and design direction for the Spanglish website.
|
||||||
|
|
||||||
|
It ensures consistent appearance across the public website, admin dashboard, emails, and marketing materials.
|
||||||
|
|
||||||
|
All designers and developers must follow this guide.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Brand Identity
|
||||||
|
|
||||||
|
### 2.1 Brand Personality
|
||||||
|
|
||||||
|
Spanglish is:
|
||||||
|
|
||||||
|
* Friendly
|
||||||
|
* International
|
||||||
|
* Social
|
||||||
|
* Modern
|
||||||
|
* Trustworthy
|
||||||
|
* Community-driven
|
||||||
|
* Inclusive
|
||||||
|
|
||||||
|
The visual identity must reflect openness, warmth, and professionalism.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Brand Values
|
||||||
|
|
||||||
|
* Accessibility
|
||||||
|
* Simplicity
|
||||||
|
* Human connection
|
||||||
|
* Cultural exchange
|
||||||
|
* Reliability
|
||||||
|
* Growth
|
||||||
|
|
||||||
|
These values guide all design decisions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Visual Direction
|
||||||
|
|
||||||
|
### 3.1 Overall Look
|
||||||
|
|
||||||
|
The website must feel:
|
||||||
|
|
||||||
|
* Clean
|
||||||
|
* Modern
|
||||||
|
* Light
|
||||||
|
* Welcoming
|
||||||
|
* Organized
|
||||||
|
|
||||||
|
The design is inspired by:
|
||||||
|
|
||||||
|
* Café culture
|
||||||
|
* International meetups
|
||||||
|
* Modern SaaS landing pages
|
||||||
|
* Community platforms
|
||||||
|
|
||||||
|
The hero section uses strong contrast, large typography, and real photography.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Layout Philosophy
|
||||||
|
|
||||||
|
* Mobile-first
|
||||||
|
* One-column dominant layout
|
||||||
|
* Large sections with breathing room
|
||||||
|
* Consistent spacing
|
||||||
|
* Clear visual hierarchy
|
||||||
|
|
||||||
|
Avoid clutter and unnecessary elements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Color System
|
||||||
|
|
||||||
|
### 4.1 Primary Colors
|
||||||
|
|
||||||
|
Primary Yellow (Brand Anchor)
|
||||||
|
|
||||||
|
* Hex: #FFD84D
|
||||||
|
* Usage: CTAs, highlights, logo accents
|
||||||
|
|
||||||
|
Primary Dark (Text & Contrast)
|
||||||
|
|
||||||
|
* Hex: #111111
|
||||||
|
* Usage: Headings, main text, navigation
|
||||||
|
|
||||||
|
Primary White (Background)
|
||||||
|
|
||||||
|
* Hex: #FFFFFF
|
||||||
|
* Usage: Main backgrounds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Secondary Colors
|
||||||
|
|
||||||
|
Soft Gray (Background Sections)
|
||||||
|
|
||||||
|
* Hex: #F5F5F5
|
||||||
|
|
||||||
|
Light Gray (Borders)
|
||||||
|
|
||||||
|
* Hex: #E5E5E5
|
||||||
|
|
||||||
|
Accent Blue (Links)
|
||||||
|
|
||||||
|
* Hex: #2F80ED
|
||||||
|
|
||||||
|
Warm Brown (Coffee Accent)
|
||||||
|
|
||||||
|
* Hex: #6B4A2B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Color Usage Rules
|
||||||
|
|
||||||
|
* Yellow is used sparingly
|
||||||
|
* White is dominant
|
||||||
|
* Dark is used for contrast
|
||||||
|
* Avoid heavy gradients
|
||||||
|
* Avoid neon colors
|
||||||
|
* Avoid dark backgrounds on public pages
|
||||||
|
|
||||||
|
Admin dashboard may use darker neutral tones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Typography
|
||||||
|
|
||||||
|
### 5.1 Primary Font
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
* Inter
|
||||||
|
* Poppins
|
||||||
|
* Montserrat
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
* Headings: Semi-bold / Bold
|
||||||
|
* Body: Regular
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Body Font
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
* Inter
|
||||||
|
* Roboto
|
||||||
|
* System UI
|
||||||
|
|
||||||
|
Line height: 1.5 – 1.7
|
||||||
|
|
||||||
|
Avoid decorative fonts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Font Hierarchy
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
H1: 48–56px
|
||||||
|
H2: 36–40px
|
||||||
|
H3: 24–28px
|
||||||
|
Body: 16–18px
|
||||||
|
Small: 14px
|
||||||
|
|
||||||
|
Responsive scaling required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Logo Usage
|
||||||
|
|
||||||
|
### 6.1 Logo Style
|
||||||
|
|
||||||
|
* Simple
|
||||||
|
* Flat
|
||||||
|
* Works in monochrome
|
||||||
|
* Works on light backgrounds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.2 Logo Placement
|
||||||
|
|
||||||
|
* Top-left in navigation
|
||||||
|
* Footer
|
||||||
|
* Emails
|
||||||
|
* Tickets
|
||||||
|
* Posters
|
||||||
|
|
||||||
|
Minimum clear space: Logo height x 0.5
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Photography Style
|
||||||
|
|
||||||
|
### 7.1 Photo Characteristics
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
* Real participants
|
||||||
|
* Natural lighting
|
||||||
|
* Smiling faces
|
||||||
|
* Conversation scenes
|
||||||
|
* Coffee and table shots
|
||||||
|
* Multicultural groups
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
* Stock photos
|
||||||
|
* Posed portraits
|
||||||
|
* Empty rooms
|
||||||
|
* Artificial lighting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.2 Image Treatment
|
||||||
|
|
||||||
|
* Slight warm tone
|
||||||
|
* Balanced contrast
|
||||||
|
* No heavy filters
|
||||||
|
* Consistent cropping
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Iconography
|
||||||
|
|
||||||
|
### 8.1 Style
|
||||||
|
|
||||||
|
* Flat
|
||||||
|
* Rounded
|
||||||
|
* Line-based
|
||||||
|
* Minimal detail
|
||||||
|
|
||||||
|
Recommended sets:
|
||||||
|
|
||||||
|
* Heroicons
|
||||||
|
* Lucide
|
||||||
|
* Feather
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.2 Usage
|
||||||
|
|
||||||
|
Icons support content.
|
||||||
|
They must not replace text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. UI Components
|
||||||
|
|
||||||
|
### 9.1 Buttons
|
||||||
|
|
||||||
|
Primary Button:
|
||||||
|
|
||||||
|
* Background: #FFD84D
|
||||||
|
* Text: #111111
|
||||||
|
* Border-radius: 10–14px
|
||||||
|
* Padding: 12px 24px
|
||||||
|
|
||||||
|
Secondary Button:
|
||||||
|
|
||||||
|
* Border: 1px solid #111111
|
||||||
|
* Background: Transparent
|
||||||
|
|
||||||
|
Hover:
|
||||||
|
|
||||||
|
* Slight darkening
|
||||||
|
* Soft shadow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.2 Cards
|
||||||
|
|
||||||
|
* White background
|
||||||
|
* Rounded corners (16–20px)
|
||||||
|
* Soft shadow
|
||||||
|
* Internal padding
|
||||||
|
|
||||||
|
Used for:
|
||||||
|
|
||||||
|
* Events
|
||||||
|
* Testimonials
|
||||||
|
* Stats
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.3 Forms
|
||||||
|
|
||||||
|
* Large input fields
|
||||||
|
* Clear labels
|
||||||
|
* Rounded corners
|
||||||
|
* Inline validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Hero Section Design
|
||||||
|
|
||||||
|
### 10.1 Structure
|
||||||
|
|
||||||
|
Left Side:
|
||||||
|
|
||||||
|
* Main headline
|
||||||
|
* Subheading
|
||||||
|
* CTA button
|
||||||
|
* Event info
|
||||||
|
|
||||||
|
Right Side:
|
||||||
|
|
||||||
|
* Photo collage
|
||||||
|
* Group images
|
||||||
|
* Rounded corners
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10.2 Hero Content Style
|
||||||
|
|
||||||
|
Headline:
|
||||||
|
|
||||||
|
* Bold
|
||||||
|
* Clear
|
||||||
|
* Positive
|
||||||
|
|
||||||
|
Example:
|
||||||
|
"Practice English & Spanish in Asunción"
|
||||||
|
|
||||||
|
Subheadline:
|
||||||
|
"Meet people. Learn languages. Have fun."
|
||||||
|
|
||||||
|
CTA:
|
||||||
|
"Join Next Event"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10.3 Background
|
||||||
|
|
||||||
|
* White or very light gray
|
||||||
|
* Yellow accents
|
||||||
|
* Subtle shapes
|
||||||
|
|
||||||
|
Avoid full-color backgrounds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Navigation Design
|
||||||
|
|
||||||
|
* Sticky top navigation
|
||||||
|
* White background
|
||||||
|
* Subtle shadow
|
||||||
|
* Clear links
|
||||||
|
|
||||||
|
Menu items:
|
||||||
|
|
||||||
|
* Home
|
||||||
|
* Events
|
||||||
|
* Community
|
||||||
|
* Contact
|
||||||
|
* Join
|
||||||
|
|
||||||
|
CTA highlighted in yellow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Footer Design
|
||||||
|
|
||||||
|
* Light gray background
|
||||||
|
* Minimal links
|
||||||
|
* Social icons
|
||||||
|
* Copyright info
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Admin Dashboard Style
|
||||||
|
|
||||||
|
### 13.1 Visual Tone
|
||||||
|
|
||||||
|
* Neutral
|
||||||
|
* Productivity-focused
|
||||||
|
* Low distraction
|
||||||
|
|
||||||
|
### 13.2 Colors
|
||||||
|
|
||||||
|
* White / Gray backgrounds
|
||||||
|
* Blue accents
|
||||||
|
* Minimal yellow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Email Design
|
||||||
|
|
||||||
|
* White background
|
||||||
|
* Logo header
|
||||||
|
* Clear typography
|
||||||
|
* Single CTA
|
||||||
|
* Mobile-friendly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Accessibility
|
||||||
|
|
||||||
|
* Minimum contrast ratio 4.5:1
|
||||||
|
* Large tap targets
|
||||||
|
* Keyboard navigation
|
||||||
|
* Alt text for images
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Consistency Rules
|
||||||
|
|
||||||
|
* Reuse components
|
||||||
|
* Follow spacing system
|
||||||
|
* Use defined colors only
|
||||||
|
* No custom fonts without approval
|
||||||
|
* No random color usage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Future Branding Extensions
|
||||||
|
|
||||||
|
* City-specific color accents
|
||||||
|
* Event themes
|
||||||
|
* Seasonal variations
|
||||||
|
* Partner branding
|
||||||
|
|
||||||
|
All extensions must respect core identity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Summary
|
||||||
|
|
||||||
|
This brand and design guide defines how Spanglish should look and feel.
|
||||||
|
|
||||||
|
It ensures a consistent, modern, and welcoming experience across all touchpoints.
|
||||||
|
|
||||||
|
All visual implementations must comply with this document.
|
||||||
299
about/email_spec.md
Normal file
299
about/email_spec.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# Spanglish Website – Email System Specification
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document defines all email communications sent by the Spanglish platform.
|
||||||
|
|
||||||
|
The goal is to provide clear, reliable communication without unnecessary automation or spam.
|
||||||
|
|
||||||
|
All emails must support event management, trust, and community growth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core Principles
|
||||||
|
|
||||||
|
* Bookings are only confirmed after payment
|
||||||
|
* No automatic reminders based on cron jobs
|
||||||
|
* No automatic cash reminders
|
||||||
|
* Follow-up emails are sent manually
|
||||||
|
* Event-specific communication must be possible
|
||||||
|
* Emails must be intentional and relevant
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Booking Confirmation Policy
|
||||||
|
|
||||||
|
### 3.1 Payment Requirement
|
||||||
|
|
||||||
|
A booking is considered "Confirmed" only when:
|
||||||
|
|
||||||
|
* Card payment is verified, or
|
||||||
|
* Lightning payment is verified, or
|
||||||
|
* Cash payment is marked as received by staff
|
||||||
|
|
||||||
|
Unpaid cash reservations are not considered confirmed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Confirmation Email
|
||||||
|
|
||||||
|
Sent only after payment is confirmed.
|
||||||
|
|
||||||
|
Trigger:
|
||||||
|
|
||||||
|
* Payment webhook (Card / Lightning)
|
||||||
|
* Manual admin confirmation (Cash)
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
* Official confirmation
|
||||||
|
* Proof of participation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Supported Email Types
|
||||||
|
|
||||||
|
### 4.1 Booking Confirmation (Automatic)
|
||||||
|
|
||||||
|
Sent when payment is confirmed.
|
||||||
|
|
||||||
|
Trigger:
|
||||||
|
|
||||||
|
* Payment verification
|
||||||
|
* Manual confirmation
|
||||||
|
|
||||||
|
Subject Example:
|
||||||
|
Your Spanglish ticket is confirmed 🎉
|
||||||
|
|
||||||
|
Content includes:
|
||||||
|
|
||||||
|
* Event name
|
||||||
|
* Date and time
|
||||||
|
* Location and map
|
||||||
|
* Payment method
|
||||||
|
* Ticket ID
|
||||||
|
* Contact info
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Payment Receipt (Automatic)
|
||||||
|
|
||||||
|
Sent for Card and Lightning payments.
|
||||||
|
|
||||||
|
Trigger:
|
||||||
|
|
||||||
|
* Payment provider webhook
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
* Financial confirmation
|
||||||
|
* Transparency
|
||||||
|
|
||||||
|
Content includes:
|
||||||
|
|
||||||
|
* Amount
|
||||||
|
* Method
|
||||||
|
* Reference ID
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Event Update / Custom Message (Manual)
|
||||||
|
|
||||||
|
Sent manually by admin per event.
|
||||||
|
|
||||||
|
Trigger:
|
||||||
|
|
||||||
|
* Admin action
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
* Inform attendees about changes
|
||||||
|
* Share important information
|
||||||
|
* Handle exceptional situations
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
* Location change
|
||||||
|
* Time change
|
||||||
|
* Special instructions
|
||||||
|
* Delays
|
||||||
|
* Guest speakers
|
||||||
|
|
||||||
|
Admin must be able to:
|
||||||
|
|
||||||
|
* Write custom subject
|
||||||
|
* Write custom body
|
||||||
|
* Select target event
|
||||||
|
* Preview before sending
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.4 Post-Event Follow-Up (Manual)
|
||||||
|
|
||||||
|
Sent manually after the event.
|
||||||
|
|
||||||
|
Trigger:
|
||||||
|
|
||||||
|
* Admin action
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
* Thank attendees
|
||||||
|
* Share photos
|
||||||
|
* Promote next event
|
||||||
|
* Build loyalty
|
||||||
|
|
||||||
|
This email is never automated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Disabled / Unsupported Emails
|
||||||
|
|
||||||
|
The system must NOT send:
|
||||||
|
|
||||||
|
* Automatic cash payment reminders
|
||||||
|
* Automated event reminders
|
||||||
|
* Cron-based scheduled emails
|
||||||
|
* Repeated promotional spam
|
||||||
|
|
||||||
|
All non-critical communication is manual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Admin Email Interface
|
||||||
|
|
||||||
|
### 6.1 Email Center
|
||||||
|
|
||||||
|
The admin dashboard must include an Email Center with:
|
||||||
|
|
||||||
|
* List of past emails
|
||||||
|
* Draft editor
|
||||||
|
* Template library
|
||||||
|
* Recipient selector
|
||||||
|
* Preview mode
|
||||||
|
* Send confirmation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.2 Recipient Selection
|
||||||
|
|
||||||
|
Admins must be able to target:
|
||||||
|
|
||||||
|
* All attendees of an event
|
||||||
|
* Only confirmed attendees
|
||||||
|
* Only unpaid attendees
|
||||||
|
* Custom subsets
|
||||||
|
|
||||||
|
Selection must be explicit before sending.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Templates
|
||||||
|
|
||||||
|
### 7.1 Default Templates
|
||||||
|
|
||||||
|
System-provided templates:
|
||||||
|
|
||||||
|
* Booking confirmation
|
||||||
|
* Payment receipt
|
||||||
|
* Event update
|
||||||
|
* Follow-up
|
||||||
|
|
||||||
|
Templates are editable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.2 Custom Templates
|
||||||
|
|
||||||
|
Admins may create custom templates per event.
|
||||||
|
|
||||||
|
Templates support:
|
||||||
|
|
||||||
|
* Variables (name, event, date)
|
||||||
|
* Preview rendering
|
||||||
|
* Versioning
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Sending Logic
|
||||||
|
|
||||||
|
### 8.1 Manual Sending
|
||||||
|
|
||||||
|
Most emails are sent manually from the admin panel.
|
||||||
|
|
||||||
|
The system must require:
|
||||||
|
|
||||||
|
* Explicit confirmation
|
||||||
|
* Recipient preview
|
||||||
|
* Final approval
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.2 Automatic Sending
|
||||||
|
|
||||||
|
Only the following emails may be automatic:
|
||||||
|
|
||||||
|
* Booking confirmation (after payment)
|
||||||
|
* Payment receipt
|
||||||
|
|
||||||
|
No other automatic emails are allowed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Logging & Auditing
|
||||||
|
|
||||||
|
All email activity must be logged:
|
||||||
|
|
||||||
|
* Sender (admin)
|
||||||
|
* Recipients
|
||||||
|
* Subject
|
||||||
|
* Timestamp
|
||||||
|
* Event reference
|
||||||
|
* Delivery status
|
||||||
|
|
||||||
|
Logs are visible to admins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Deliverability Requirements
|
||||||
|
|
||||||
|
* Use verified sender domain
|
||||||
|
* DKIM and SPF enabled
|
||||||
|
* Bounce handling
|
||||||
|
* Unsubscribe support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Design Rules
|
||||||
|
|
||||||
|
All emails must follow brand guidelines:
|
||||||
|
|
||||||
|
* White background
|
||||||
|
* Logo header
|
||||||
|
* Clean typography
|
||||||
|
* Mobile-friendly layout
|
||||||
|
* One main CTA
|
||||||
|
* No clutter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Future Extensions
|
||||||
|
|
||||||
|
Possible future additions:
|
||||||
|
|
||||||
|
* WhatsApp integration
|
||||||
|
* SMS notifications
|
||||||
|
* Smart segmentation
|
||||||
|
* Automated campaigns (opt-in only)
|
||||||
|
|
||||||
|
All future automation requires explicit approval.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Summary
|
||||||
|
|
||||||
|
The Spanglish email system prioritizes clarity, trust, and human control.
|
||||||
|
|
||||||
|
Only critical confirmations are automated.
|
||||||
|
|
||||||
|
All community and relationship communication remains manual and intentional.
|
||||||
59199
about/lnbits_api.json
Normal file
59199
about/lnbits_api.json
Normal file
File diff suppressed because it is too large
Load Diff
264
about/overview.md
Normal file
264
about/overview.md
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
# Spanglish Website – Overview
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
The Spanglish website is the official digital platform for organizing, promoting, and managing Spanglish language exchange events in Asunción, Paraguay.
|
||||||
|
|
||||||
|
Its primary purpose is to:
|
||||||
|
|
||||||
|
* Promote monthly language exchange events
|
||||||
|
* Convert visitors into attendees
|
||||||
|
* Centralize event operations
|
||||||
|
* Reduce manual work for organizers
|
||||||
|
* Build and retain a long-term community
|
||||||
|
* Support future expansion
|
||||||
|
|
||||||
|
The website is designed to be the main reference point for all Spanglish activities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Product Vision
|
||||||
|
|
||||||
|
Spanglish aims to be the leading language exchange community in Paraguay and later in other cities.
|
||||||
|
|
||||||
|
The website should:
|
||||||
|
|
||||||
|
* Feel welcoming and modern
|
||||||
|
* Be easy to use for first-time visitors
|
||||||
|
* Be powerful for internal management
|
||||||
|
* Scale with community growth
|
||||||
|
|
||||||
|
Long-term, the system should support multiple cities, memberships, and learning resources without major redesign.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Target Users
|
||||||
|
|
||||||
|
### 3.1 Public Users (Participants)
|
||||||
|
|
||||||
|
* Spanish speakers learning English
|
||||||
|
* English speakers learning Spanish
|
||||||
|
* Students and professionals
|
||||||
|
* Expats and travelers
|
||||||
|
* Newcomers discovering Spanglish
|
||||||
|
* Returning community members
|
||||||
|
|
||||||
|
### 3.2 Internal Users (Staff)
|
||||||
|
|
||||||
|
* Admin (Owner)
|
||||||
|
* Organizers
|
||||||
|
* Check-in staff
|
||||||
|
* Marketing staff
|
||||||
|
* Support staff
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Core Goals
|
||||||
|
|
||||||
|
The website is built around the following core goals:
|
||||||
|
|
||||||
|
1. Increase event attendance
|
||||||
|
2. Improve operational efficiency
|
||||||
|
3. Reduce manual coordination
|
||||||
|
4. Improve communication
|
||||||
|
5. Build community loyalty
|
||||||
|
6. Enable data-driven decisions
|
||||||
|
|
||||||
|
All features must directly or indirectly support these goals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. System Scope
|
||||||
|
|
||||||
|
The system consists of two main components:
|
||||||
|
|
||||||
|
1. Public Website
|
||||||
|
2. Admin Dashboard
|
||||||
|
|
||||||
|
Both components are connected through a shared backend and database.
|
||||||
|
|
||||||
|
### 5.1 Public Website
|
||||||
|
|
||||||
|
The public website is responsible for:
|
||||||
|
|
||||||
|
* Presenting Spanglish
|
||||||
|
* Displaying events
|
||||||
|
* Handling bookings
|
||||||
|
* Promoting community channels
|
||||||
|
* Collecting contact information
|
||||||
|
|
||||||
|
### 5.2 Admin Dashboard
|
||||||
|
|
||||||
|
The admin dashboard is responsible for:
|
||||||
|
|
||||||
|
* Managing events
|
||||||
|
* Managing attendees
|
||||||
|
* Managing payments
|
||||||
|
* Sending communications
|
||||||
|
* Managing media
|
||||||
|
* Monitoring performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Core Principles
|
||||||
|
|
||||||
|
All development and design decisions must follow these principles:
|
||||||
|
|
||||||
|
### 6.1 Simplicity
|
||||||
|
|
||||||
|
* Avoid unnecessary complexity
|
||||||
|
* Prioritize ease of use
|
||||||
|
* Minimize user friction
|
||||||
|
|
||||||
|
### 6.2 Reliability
|
||||||
|
|
||||||
|
* Stable operation
|
||||||
|
* Clear error handling
|
||||||
|
* Data integrity
|
||||||
|
|
||||||
|
### 6.3 Performance
|
||||||
|
|
||||||
|
* Fast loading pages
|
||||||
|
* Responsive UI
|
||||||
|
* Optimized assets
|
||||||
|
|
||||||
|
### 6.4 Maintainability
|
||||||
|
|
||||||
|
* Clean code structure
|
||||||
|
* Clear documentation
|
||||||
|
* Modular design
|
||||||
|
|
||||||
|
### 6.5 Scalability
|
||||||
|
|
||||||
|
* Support growing user base
|
||||||
|
* Support additional locations
|
||||||
|
* Support new business models
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Brand and Experience
|
||||||
|
|
||||||
|
### 7.1 Brand Personality
|
||||||
|
|
||||||
|
Spanglish is:
|
||||||
|
|
||||||
|
* Friendly
|
||||||
|
* International
|
||||||
|
* Social
|
||||||
|
* Organized
|
||||||
|
* Trustworthy
|
||||||
|
* Community-driven
|
||||||
|
|
||||||
|
The website should reflect these values in design and tone.
|
||||||
|
|
||||||
|
### 7.2 User Experience Goals
|
||||||
|
|
||||||
|
Visitors should feel:
|
||||||
|
|
||||||
|
* Welcome
|
||||||
|
* Comfortable
|
||||||
|
* Confident
|
||||||
|
* Motivated to join
|
||||||
|
|
||||||
|
Admins should feel:
|
||||||
|
|
||||||
|
* In control
|
||||||
|
* Efficient
|
||||||
|
* Informed
|
||||||
|
* Supported
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Community Strategy
|
||||||
|
|
||||||
|
The website supports community building by:
|
||||||
|
|
||||||
|
* Promoting WhatsApp and social groups
|
||||||
|
* Collecting email addresses
|
||||||
|
* Tracking attendance history
|
||||||
|
* Supporting loyalty programs
|
||||||
|
* Enabling direct communication
|
||||||
|
|
||||||
|
The platform is designed to strengthen long-term relationships, not only facilitate one-time events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Business Model
|
||||||
|
|
||||||
|
The primary revenue source is event ticket sales.
|
||||||
|
|
||||||
|
Secondary and future revenue sources may include:
|
||||||
|
|
||||||
|
* Membership subscriptions
|
||||||
|
* Partner sponsorships
|
||||||
|
* Premium events
|
||||||
|
* Workshops
|
||||||
|
* Digital learning materials
|
||||||
|
|
||||||
|
The system must be flexible enough to support these models.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Data Strategy
|
||||||
|
|
||||||
|
The system collects and manages:
|
||||||
|
|
||||||
|
* User profiles
|
||||||
|
* Event data
|
||||||
|
* Ticket records
|
||||||
|
* Payment records
|
||||||
|
* Communication logs
|
||||||
|
* Media assets
|
||||||
|
|
||||||
|
All data must be handled securely and responsibly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Growth Vision
|
||||||
|
|
||||||
|
Future expansion includes:
|
||||||
|
|
||||||
|
* Multiple cities
|
||||||
|
* Multi-language support
|
||||||
|
* Mobile app
|
||||||
|
* Learning platform
|
||||||
|
* Certification system
|
||||||
|
* Nostr and Lightning integration
|
||||||
|
|
||||||
|
The initial architecture must allow these extensions without major rewrites.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Success Metrics
|
||||||
|
|
||||||
|
The project will be evaluated using:
|
||||||
|
|
||||||
|
* Event attendance growth
|
||||||
|
* Conversion rate
|
||||||
|
* Repeat participation rate
|
||||||
|
* Revenue per event
|
||||||
|
* Community size
|
||||||
|
* Operational efficiency
|
||||||
|
|
||||||
|
These metrics guide future improvements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Constraints
|
||||||
|
|
||||||
|
The system must:
|
||||||
|
|
||||||
|
* Run on existing VPS infrastructure
|
||||||
|
* Be maintainable by a small team
|
||||||
|
* Minimize operational overhead
|
||||||
|
* Avoid unnecessary dependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Summary
|
||||||
|
|
||||||
|
The Spanglish website is a professional event management and community platform designed to support sustainable growth.
|
||||||
|
|
||||||
|
It combines a simple, welcoming public interface with a powerful internal management system.
|
||||||
|
|
||||||
|
All development must prioritize usability, reliability, and long-term scalability.
|
||||||
366
about/payment_options.md
Normal file
366
about/payment_options.md
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
# Spanglish Website – Payment Options Management
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document defines how payment methods are configured, displayed, and managed in the Spanglish platform.
|
||||||
|
|
||||||
|
It introduces a centralized Payment Options system that can be configured globally and overridden per event.
|
||||||
|
|
||||||
|
The goal is to provide flexibility, transparency, and administrative control over all payment flows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core Principles
|
||||||
|
|
||||||
|
* Payment options are centrally managed in the admin panel
|
||||||
|
* Each event can override default payment settings
|
||||||
|
* Most non-instant payments require manual admin approval
|
||||||
|
* Only approved payments trigger confirmation emails
|
||||||
|
* Users must clearly understand how to pay
|
||||||
|
* The system must support future payment methods
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Payment Options Admin Tab
|
||||||
|
|
||||||
|
### 3.1 Location
|
||||||
|
|
||||||
|
The admin dashboard must include a dedicated tab:
|
||||||
|
|
||||||
|
/Admin → Payment Options
|
||||||
|
|
||||||
|
This section controls global payment settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Global Payment Configuration
|
||||||
|
|
||||||
|
Admins can configure default payment options used for all events unless overridden.
|
||||||
|
|
||||||
|
Global settings include:
|
||||||
|
|
||||||
|
* TPago default link
|
||||||
|
* Bank transfer details
|
||||||
|
* Bitcoin/Lightning configuration (LNbits)
|
||||||
|
* Cash policy
|
||||||
|
* Enabled / disabled status per method
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Event-Level Overrides
|
||||||
|
|
||||||
|
Each event includes a "Payment Settings" section.
|
||||||
|
|
||||||
|
Admins may override:
|
||||||
|
|
||||||
|
* TPago link
|
||||||
|
* Bank account details
|
||||||
|
* Enabled payment methods
|
||||||
|
* Custom instructions
|
||||||
|
|
||||||
|
If no override is set, global defaults apply.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Supported Payment Methods
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.1 Paraguayan Bank Transfer
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
Allows users to pay via local bank transfer.
|
||||||
|
|
||||||
|
Payment details are displayed to the user.
|
||||||
|
|
||||||
|
### Displayed Information
|
||||||
|
|
||||||
|
* Bank name
|
||||||
|
* Account holder
|
||||||
|
* Account number
|
||||||
|
* Alias
|
||||||
|
* Phone number
|
||||||
|
* Additional notes (optional)
|
||||||
|
|
||||||
|
### User Flow
|
||||||
|
|
||||||
|
1. User selects "Bank Transfer"
|
||||||
|
2. Bank details are displayed
|
||||||
|
3. User makes the transfer externally
|
||||||
|
4. User clicks "I Have Paid"
|
||||||
|
5. Booking status becomes "Pending Approval"
|
||||||
|
6. Admin verifies payment
|
||||||
|
7. Admin marks payment as Paid
|
||||||
|
8. Confirmation email is sent
|
||||||
|
|
||||||
|
### System Behavior
|
||||||
|
|
||||||
|
* No automatic confirmation
|
||||||
|
* No booking confirmation until approved
|
||||||
|
* Payment proof upload (optional future feature)
|
||||||
|
|
||||||
|
Status Flow:
|
||||||
|
Pending → Paid → Confirmed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.2 International Cards (TPago Link)
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
International card payments are handled via a TPago payment link.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
[https://www.tpago.com.py/links?alias=PXEOI9](https://www.tpago.com.py/links?alias=PXEOI9)...
|
||||||
|
|
||||||
|
### Displayed Information
|
||||||
|
|
||||||
|
* Payment provider name
|
||||||
|
* External payment link
|
||||||
|
* Instructions
|
||||||
|
|
||||||
|
### User Flow
|
||||||
|
|
||||||
|
1. User selects "International Card"
|
||||||
|
2. TPago link opens in new tab
|
||||||
|
3. User completes payment
|
||||||
|
4. User returns to booking page
|
||||||
|
5. User clicks "I Have Paid"
|
||||||
|
6. Booking status becomes "Pending Approval"
|
||||||
|
7. Admin verifies payment
|
||||||
|
8. Admin marks payment as Paid
|
||||||
|
9. Confirmation email is sent
|
||||||
|
|
||||||
|
### System Behavior
|
||||||
|
|
||||||
|
* Payment verification is manual
|
||||||
|
* No automatic webhook integration
|
||||||
|
* External link must be configurable
|
||||||
|
|
||||||
|
Status Flow:
|
||||||
|
Pending → Paid → Confirmed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.3 Bitcoin / Lightning (LNbits)
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
Lightning payments are handled via LNbits integration.
|
||||||
|
|
||||||
|
This method supports instant confirmation.
|
||||||
|
|
||||||
|
### User Flow
|
||||||
|
|
||||||
|
1. User selects "Bitcoin / Lightning"
|
||||||
|
2. Invoice is generated
|
||||||
|
3. QR code is displayed
|
||||||
|
4. User pays
|
||||||
|
5. Payment is detected automatically
|
||||||
|
6. Ticket is confirmed
|
||||||
|
7. Confirmation email is sent
|
||||||
|
|
||||||
|
### System Behavior
|
||||||
|
|
||||||
|
* Fully automated
|
||||||
|
* Webhook-based confirmation
|
||||||
|
* No admin intervention required
|
||||||
|
|
||||||
|
Status Flow:
|
||||||
|
Paid → Confirmed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.4 Cash at the Door
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
Users pay in cash when arriving at the event.
|
||||||
|
|
||||||
|
### User Flow
|
||||||
|
|
||||||
|
1. User selects "Cash at the Door"
|
||||||
|
2. Booking is created
|
||||||
|
3. Status is set to Pending
|
||||||
|
4. User attends event
|
||||||
|
5. Staff receives payment
|
||||||
|
6. Admin marks as Paid
|
||||||
|
7. Confirmation is sent
|
||||||
|
|
||||||
|
### System Behavior
|
||||||
|
|
||||||
|
* No automatic confirmation
|
||||||
|
* Manual approval required
|
||||||
|
* Visible in admin dashboard
|
||||||
|
|
||||||
|
Status Flow:
|
||||||
|
Pending → Paid → Confirmed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Booking Page Presentation
|
||||||
|
|
||||||
|
Payment methods are displayed as selectable cards.
|
||||||
|
|
||||||
|
Each card includes:
|
||||||
|
|
||||||
|
* Icon
|
||||||
|
* Title
|
||||||
|
* Short description
|
||||||
|
* Processing speed (Instant / Manual)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
[ Bank Transfer ] (Manual)
|
||||||
|
[ International Card ] (Manual)
|
||||||
|
[ Bitcoin / Lightning ] (Instant)
|
||||||
|
[ Cash at Door ] (Manual)
|
||||||
|
|
||||||
|
Selected card expands with instructions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. "I Have Paid" Confirmation Button
|
||||||
|
|
||||||
|
For manual payment methods, the booking page must display:
|
||||||
|
|
||||||
|
Button: "I Have Paid"
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
* Marks booking as "Pending Approval"
|
||||||
|
* Stores timestamp
|
||||||
|
* Notifies admin
|
||||||
|
* Disables duplicate clicks
|
||||||
|
|
||||||
|
No confirmation email is sent at this stage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Admin Payment Verification Panel
|
||||||
|
|
||||||
|
### 7.1 Payment Review Queue
|
||||||
|
|
||||||
|
Admins have access to:
|
||||||
|
|
||||||
|
/Admin → Payments → Pending Approval
|
||||||
|
|
||||||
|
This list shows:
|
||||||
|
|
||||||
|
* User name
|
||||||
|
* Event
|
||||||
|
* Payment method
|
||||||
|
* Amount
|
||||||
|
* Date
|
||||||
|
* Reference (if provided)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.2 Approval Actions
|
||||||
|
|
||||||
|
For each pending payment:
|
||||||
|
|
||||||
|
* Approve (mark as Paid)
|
||||||
|
* Reject (mark as Failed)
|
||||||
|
* Add internal note
|
||||||
|
|
||||||
|
Approval triggers:
|
||||||
|
|
||||||
|
* Ticket confirmation
|
||||||
|
* Confirmation email
|
||||||
|
* Audit log entry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Payment Status Model
|
||||||
|
|
||||||
|
All bookings follow this status lifecycle:
|
||||||
|
|
||||||
|
Created → Pending → Paid → Confirmed
|
||||||
|
↓
|
||||||
|
Failed / Cancelled
|
||||||
|
|
||||||
|
Only "Confirmed" bookings are considered valid attendees.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Data Storage
|
||||||
|
|
||||||
|
Each payment record stores:
|
||||||
|
|
||||||
|
* Payment ID
|
||||||
|
* Booking ID
|
||||||
|
* Method
|
||||||
|
* Amount
|
||||||
|
* Currency
|
||||||
|
* Reference
|
||||||
|
* Status
|
||||||
|
* Admin approver
|
||||||
|
* Approval timestamp
|
||||||
|
* Notes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Extensibility for Future Methods
|
||||||
|
|
||||||
|
The payment system must be modular.
|
||||||
|
|
||||||
|
New payment methods can be added by defining:
|
||||||
|
|
||||||
|
* Name
|
||||||
|
* Type (Instant / Manual)
|
||||||
|
* Configuration schema
|
||||||
|
* UI template
|
||||||
|
* Validation rules
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
* PayPal
|
||||||
|
* PIX
|
||||||
|
* Cashu
|
||||||
|
* Local wallets
|
||||||
|
* QR bank payments
|
||||||
|
|
||||||
|
No core refactor should be required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Security Requirements
|
||||||
|
|
||||||
|
* Secure storage of payment data
|
||||||
|
* Access control on payment approval
|
||||||
|
* Audit logging
|
||||||
|
* Protection against duplicate approvals
|
||||||
|
* No storage of sensitive card data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Reporting
|
||||||
|
|
||||||
|
The admin dashboard must provide:
|
||||||
|
|
||||||
|
* Revenue per payment method
|
||||||
|
* Pending approvals
|
||||||
|
* Failed payments
|
||||||
|
* Approval turnaround time
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. User Experience Rules
|
||||||
|
|
||||||
|
* Instructions must be clear
|
||||||
|
* Links must open correctly
|
||||||
|
* Manual steps must be visible
|
||||||
|
* No misleading "confirmed" messages
|
||||||
|
* Payment state always visible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Summary
|
||||||
|
|
||||||
|
This payment options system centralizes configuration and control while supporting both instant and manual payment methods.
|
||||||
|
|
||||||
|
It enables local Paraguayan payments, international cards, Bitcoin Lightning, and cash handling within one unified workflow.
|
||||||
|
|
||||||
|
All implementations must follow this specification to ensure reliability and scalability.
|
||||||
203
about/ruc_format.md
Normal file
203
about/ruc_format.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
RUC Form Integration
|
||||||
|
1. Overview
|
||||||
|
|
||||||
|
This document defines the implementation of a RUC (Registro Único del Contribuyente) input form for the application.
|
||||||
|
|
||||||
|
The goal is to:
|
||||||
|
|
||||||
|
Collect valid Paraguayan RUC numbers
|
||||||
|
|
||||||
|
Reduce user input errors
|
||||||
|
|
||||||
|
Improve user experience
|
||||||
|
|
||||||
|
Ensure basic legal and invoicing compliance
|
||||||
|
|
||||||
|
The RUC field will be used for:
|
||||||
|
|
||||||
|
Invoicing
|
||||||
|
|
||||||
|
Payments
|
||||||
|
|
||||||
|
Registrations
|
||||||
|
|
||||||
|
Business verification
|
||||||
|
|
||||||
|
Tax-related records
|
||||||
|
|
||||||
|
2. RUC Format Specification
|
||||||
|
|
||||||
|
A valid RUC has the following structure:
|
||||||
|
|
||||||
|
6 to 8 digits + "-" + 1 check digit
|
||||||
|
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
4521876-5
|
||||||
|
80012345-6
|
||||||
|
12345678-9
|
||||||
|
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
Only numbers and one hyphen
|
||||||
|
|
||||||
|
The hyphen is always before the last digit
|
||||||
|
|
||||||
|
No spaces or letters allowed
|
||||||
|
|
||||||
|
Maximum length: 10 characters (including "-")
|
||||||
|
|
||||||
|
3. User Interface Requirements
|
||||||
|
3.1 Input Field
|
||||||
|
|
||||||
|
The RUC input field must:
|
||||||
|
|
||||||
|
Accept only numeric input
|
||||||
|
|
||||||
|
Auto-insert the hyphen
|
||||||
|
|
||||||
|
Show placeholder example
|
||||||
|
|
||||||
|
Display validation errors
|
||||||
|
|
||||||
|
Example UI:
|
||||||
|
|
||||||
|
[ RUC: 12345678-9 ]
|
||||||
|
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
|
||||||
|
Required field (when invoicing enabled)
|
||||||
|
|
||||||
|
Mobile-friendly
|
||||||
|
|
||||||
|
Copy-paste safe
|
||||||
|
|
||||||
|
Accessible (label + aria support)
|
||||||
|
|
||||||
|
3.2 Placeholder Text
|
||||||
|
|
||||||
|
Default placeholder:
|
||||||
|
|
||||||
|
12345678-9
|
||||||
|
|
||||||
|
|
||||||
|
Optional localized versions:
|
||||||
|
|
||||||
|
Spanish:
|
||||||
|
|
||||||
|
Ej: 12345678-9
|
||||||
|
|
||||||
|
|
||||||
|
English:
|
||||||
|
|
||||||
|
Example: 12345678-9
|
||||||
|
|
||||||
|
4. Client-Side Validation
|
||||||
|
4.1 Format Validation
|
||||||
|
|
||||||
|
The frontend must verify:
|
||||||
|
|
||||||
|
Pattern: /^[0-9]{6,8}-[0-9]{1}$/
|
||||||
|
|
||||||
|
Exactly one hyphen
|
||||||
|
|
||||||
|
Correct digit count
|
||||||
|
|
||||||
|
Invalid examples:
|
||||||
|
|
||||||
|
1234-5
|
||||||
|
123456789
|
||||||
|
ABC123-4
|
||||||
|
|
||||||
|
4.2 Check Digit Validation
|
||||||
|
|
||||||
|
The verification digit must be validated using modulo 11.
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Detect mistyped RUC numbers
|
||||||
|
|
||||||
|
Prevent fake entries
|
||||||
|
|
||||||
|
Improve data quality
|
||||||
|
|
||||||
|
Validation occurs:
|
||||||
|
|
||||||
|
On input blur
|
||||||
|
|
||||||
|
On form submit
|
||||||
|
|
||||||
|
Before backend submission
|
||||||
|
|
||||||
|
4.3 Auto-Formatting
|
||||||
|
|
||||||
|
The system must:
|
||||||
|
|
||||||
|
Remove non-numeric characters
|
||||||
|
|
||||||
|
Automatically insert "-"
|
||||||
|
|
||||||
|
Limit input to 9 digits
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
User types:
|
||||||
|
|
||||||
|
45218765
|
||||||
|
|
||||||
|
|
||||||
|
System shows:
|
||||||
|
|
||||||
|
4521876-5
|
||||||
|
|
||||||
|
|
||||||
|
This improves usability and reduces errors.
|
||||||
|
|
||||||
|
5. Error Handling
|
||||||
|
5.1 Error Messages
|
||||||
|
|
||||||
|
When validation fails, show:
|
||||||
|
|
||||||
|
Format error:
|
||||||
|
|
||||||
|
Formato inválido. Ej: 12345678-9
|
||||||
|
|
||||||
|
|
||||||
|
Check digit error:
|
||||||
|
|
||||||
|
RUC inválido. Verifique el número.
|
||||||
|
|
||||||
|
|
||||||
|
Empty field:
|
||||||
|
|
||||||
|
Este campo es obligatorio.
|
||||||
|
|
||||||
|
|
||||||
|
Messages should be:
|
||||||
|
|
||||||
|
Clear
|
||||||
|
|
||||||
|
Short
|
||||||
|
|
||||||
|
Non-technical
|
||||||
|
|
||||||
|
Localized
|
||||||
|
|
||||||
|
5.2 Visual Feedback
|
||||||
|
|
||||||
|
Invalid input must:
|
||||||
|
|
||||||
|
Highlight input in red
|
||||||
|
|
||||||
|
Show error text below field
|
||||||
|
|
||||||
|
Disable submit if blocking
|
||||||
|
|
||||||
|
Valid input must:
|
||||||
|
|
||||||
|
Show neutral or success state
|
||||||
|
|
||||||
|
Remove error messages
|
||||||
430
about/user_dashboard.md
Normal file
430
about/user_dashboard.md
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
# Spanglish Website – User Accounts & Dashboard Specification
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document defines the complete user account system, authentication methods, identity management, and user dashboard features for the Spanglish platform.
|
||||||
|
|
||||||
|
It ensures that all bookings are linked to user accounts while keeping the booking process frictionless.
|
||||||
|
|
||||||
|
The system supports both casual users and long-term community members.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core Principles
|
||||||
|
|
||||||
|
* Every booking is linked to a user account
|
||||||
|
* Account creation must not block booking
|
||||||
|
* Authentication must be flexible
|
||||||
|
* Email is the primary identifier
|
||||||
|
* Users own their data and history
|
||||||
|
* Financial transparency is mandatory
|
||||||
|
* Administrative users can manage and resolve conflicts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. User Account Model
|
||||||
|
|
||||||
|
### 3.1 Progressive Accounts
|
||||||
|
|
||||||
|
Spanglish uses a progressive account system.
|
||||||
|
|
||||||
|
Users are created automatically during booking using their email address.
|
||||||
|
|
||||||
|
These accounts are initially marked as unclaimed and can be activated later.
|
||||||
|
|
||||||
|
This allows first-time users to book without registration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 User States
|
||||||
|
|
||||||
|
Each user account has one of the following states:
|
||||||
|
|
||||||
|
* Unclaimed: Created during booking, no login configured
|
||||||
|
* Claimed: User has activated login
|
||||||
|
* Suspended: Disabled by admin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 User Identity Fields
|
||||||
|
|
||||||
|
Each user record contains:
|
||||||
|
|
||||||
|
* id (UUID)
|
||||||
|
* name
|
||||||
|
* email (unique)
|
||||||
|
* phone
|
||||||
|
* password_hash (nullable)
|
||||||
|
* google_id (nullable)
|
||||||
|
* is_claimed (boolean)
|
||||||
|
* ruc_number (nullable)
|
||||||
|
* created_at
|
||||||
|
* updated_at
|
||||||
|
|
||||||
|
Email is the primary identity key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Authentication Methods
|
||||||
|
|
||||||
|
### 4.1 Password Authentication
|
||||||
|
|
||||||
|
Users may optionally set a password.
|
||||||
|
|
||||||
|
Passwords are never required for booking.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
* Minimum 10 characters
|
||||||
|
* Argon2 hashing
|
||||||
|
* Reset via email
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Google Login (OAuth)
|
||||||
|
|
||||||
|
Users may authenticate using Google.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
* Email is verified by Google
|
||||||
|
* Existing accounts are matched by email
|
||||||
|
* Google ID is linked on first login
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Email Code / Magic Link
|
||||||
|
|
||||||
|
Users may log in using one-time codes or magic links.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
* Code expires in 10 minutes
|
||||||
|
* One-time use only
|
||||||
|
* Sent to registered email
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Account Claiming Flow
|
||||||
|
|
||||||
|
When a user books without logging in:
|
||||||
|
|
||||||
|
1. System creates unclaimed account
|
||||||
|
2. Confirmation email includes "Claim Account" link
|
||||||
|
3. User sets password or links Google
|
||||||
|
4. Account becomes claimed
|
||||||
|
|
||||||
|
This process is optional but encouraged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Booking to User Linking
|
||||||
|
|
||||||
|
During booking:
|
||||||
|
|
||||||
|
* Email is mandatory
|
||||||
|
* System searches for existing user
|
||||||
|
* If found, booking is linked
|
||||||
|
* If not found, new user is created
|
||||||
|
|
||||||
|
All tickets are linked to user_id.
|
||||||
|
|
||||||
|
No orphan records are allowed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Multiple Ticket Management
|
||||||
|
|
||||||
|
### 7.1 Single User, Multiple Events
|
||||||
|
|
||||||
|
Users may book unlimited events.
|
||||||
|
|
||||||
|
Each booking creates a separate ticket record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.2 Group Bookings
|
||||||
|
|
||||||
|
Users may purchase multiple tickets per booking.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
* One user_id owns all tickets
|
||||||
|
* Guest names are optional
|
||||||
|
* Tickets may be reassigned later
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. RUC & Invoice Management
|
||||||
|
|
||||||
|
### 8.1 RUC Collection
|
||||||
|
|
||||||
|
During booking, users may optionally provide:
|
||||||
|
|
||||||
|
* Paraguayan RUC number
|
||||||
|
* Legal name (optional)
|
||||||
|
|
||||||
|
This data is stored in the user profile and booking record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.2 Invoice Generation
|
||||||
|
|
||||||
|
When a booking is marked as Paid:
|
||||||
|
|
||||||
|
* System generates a fiscal invoice
|
||||||
|
* Invoice includes RUC data
|
||||||
|
* Invoice is stored as PDF
|
||||||
|
* Invoice is linked to payment record
|
||||||
|
|
||||||
|
Invoices are immutable after generation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.3 Invoice Access
|
||||||
|
|
||||||
|
From the dashboard, users may:
|
||||||
|
|
||||||
|
* View invoices
|
||||||
|
* Download PDF invoices
|
||||||
|
* Access invoice history
|
||||||
|
|
||||||
|
Only confirmed and paid bookings generate invoices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. User Dashboard
|
||||||
|
|
||||||
|
### 9.1 Access
|
||||||
|
|
||||||
|
Dashboard URL:
|
||||||
|
|
||||||
|
/dashboard
|
||||||
|
|
||||||
|
Authentication required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.2 Dashboard Sections
|
||||||
|
|
||||||
|
#### 9.2.1 Welcome Panel
|
||||||
|
|
||||||
|
Displays:
|
||||||
|
|
||||||
|
* User name
|
||||||
|
* Account status
|
||||||
|
* Membership duration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 9.2.2 Next Event Panel
|
||||||
|
|
||||||
|
Displays:
|
||||||
|
|
||||||
|
* Upcoming event
|
||||||
|
* Date and time
|
||||||
|
* Location
|
||||||
|
* Payment status
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
|
||||||
|
* View ticket
|
||||||
|
* Open map
|
||||||
|
* Add to calendar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 9.2.3 My Tickets
|
||||||
|
|
||||||
|
Displays all tickets:
|
||||||
|
|
||||||
|
* Event name
|
||||||
|
* Date
|
||||||
|
* Payment method
|
||||||
|
* Status
|
||||||
|
* Invoice link (if available)
|
||||||
|
|
||||||
|
Tickets link to detail view.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 9.2.4 Payments & Invoices
|
||||||
|
|
||||||
|
Displays:
|
||||||
|
|
||||||
|
* Payment history
|
||||||
|
* Pending approvals
|
||||||
|
* Downloadable invoices
|
||||||
|
* Payment references
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 9.2.5 Community Access
|
||||||
|
|
||||||
|
Displays:
|
||||||
|
|
||||||
|
* WhatsApp group link
|
||||||
|
* Instagram link
|
||||||
|
* Website link
|
||||||
|
|
||||||
|
Links are managed by admin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 9.2.6 Profile Settings
|
||||||
|
|
||||||
|
Users may edit:
|
||||||
|
|
||||||
|
* Name
|
||||||
|
* Email
|
||||||
|
* Phone
|
||||||
|
* Preferred language
|
||||||
|
* RUC number
|
||||||
|
|
||||||
|
Email changes require verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 9.2.7 Security Settings
|
||||||
|
|
||||||
|
Users may:
|
||||||
|
|
||||||
|
* Set or change password
|
||||||
|
* Link / unlink Google account
|
||||||
|
* View active sessions
|
||||||
|
* Logout all sessions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 9.2.8 Support Panel
|
||||||
|
|
||||||
|
Includes:
|
||||||
|
|
||||||
|
* Contact links
|
||||||
|
* FAQ
|
||||||
|
* Support form
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Ticket Detail View
|
||||||
|
|
||||||
|
Each ticket page includes:
|
||||||
|
|
||||||
|
* Ticket ID
|
||||||
|
* QR code (optional)
|
||||||
|
* Event details
|
||||||
|
* Payment status
|
||||||
|
* Invoice download
|
||||||
|
* Check-in status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Admin User Management
|
||||||
|
|
||||||
|
Admins may:
|
||||||
|
|
||||||
|
* View all users
|
||||||
|
* Edit profiles
|
||||||
|
* Reset authentication
|
||||||
|
* Merge duplicate accounts
|
||||||
|
* Suspend users
|
||||||
|
* Edit RUC data
|
||||||
|
|
||||||
|
All actions are logged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Data Protection & Privacy
|
||||||
|
|
||||||
|
* GDPR-style data rights
|
||||||
|
* Data export on request
|
||||||
|
* Account deletion support
|
||||||
|
* Secure storage
|
||||||
|
* Encrypted credentials
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Notifications
|
||||||
|
|
||||||
|
Users receive system notifications for:
|
||||||
|
|
||||||
|
* Booking confirmation
|
||||||
|
* Payment approval
|
||||||
|
* Invoice generation
|
||||||
|
* Account security changes
|
||||||
|
|
||||||
|
Notifications are delivered via email.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Database Tables (Core)
|
||||||
|
|
||||||
|
### users
|
||||||
|
|
||||||
|
* id
|
||||||
|
* name
|
||||||
|
* email
|
||||||
|
* phone
|
||||||
|
* password_hash
|
||||||
|
* google_id
|
||||||
|
* is_claimed
|
||||||
|
* ruc_number
|
||||||
|
* created_at
|
||||||
|
|
||||||
|
### tickets
|
||||||
|
|
||||||
|
* id
|
||||||
|
* user_id
|
||||||
|
* event_id
|
||||||
|
* status
|
||||||
|
* created_at
|
||||||
|
|
||||||
|
### payments
|
||||||
|
|
||||||
|
* id
|
||||||
|
* ticket_id
|
||||||
|
* status
|
||||||
|
* amount
|
||||||
|
* reference
|
||||||
|
|
||||||
|
### invoices
|
||||||
|
|
||||||
|
* id
|
||||||
|
* payment_id
|
||||||
|
* pdf_url
|
||||||
|
* ruc_number
|
||||||
|
* created_at
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Security Requirements
|
||||||
|
|
||||||
|
* Argon2 password hashing
|
||||||
|
* OAuth verification
|
||||||
|
* Rate-limited login
|
||||||
|
* Brute-force protection
|
||||||
|
* Secure cookies
|
||||||
|
* HTTPS enforced
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Future Extensions
|
||||||
|
|
||||||
|
Planned enhancements:
|
||||||
|
|
||||||
|
* Loyalty tiers
|
||||||
|
* Membership subscriptions
|
||||||
|
* Family accounts
|
||||||
|
* Corporate accounts
|
||||||
|
* Tax reporting exports
|
||||||
|
* Mobile app support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Summary
|
||||||
|
|
||||||
|
The Spanglish user system provides frictionless onboarding, strong identity management, and full financial transparency.
|
||||||
|
|
||||||
|
It supports both casual participants and professional users who require invoices and account history.
|
||||||
|
|
||||||
|
All implementations must follow this specification.
|
||||||
60
backend/.env.example
Normal file
60
backend/.env.example
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Database Configuration
|
||||||
|
# Use 'sqlite' or 'postgres'
|
||||||
|
DB_TYPE=sqlite
|
||||||
|
|
||||||
|
# For SQLite (relative or absolute path)
|
||||||
|
DATABASE_URL=./data/spanglish.db
|
||||||
|
|
||||||
|
# For PostgreSQL
|
||||||
|
# DATABASE_URL=postgresql://user:password@localhost:5432/spanglish
|
||||||
|
|
||||||
|
# JWT Secret (change in production!)
|
||||||
|
JWT_SECRET=your-super-secret-key-change-in-production
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
PORT=3001
|
||||||
|
API_URL=http://localhost:3001
|
||||||
|
FRONTEND_URL=http://localhost:3002
|
||||||
|
|
||||||
|
# Payment Providers (optional)
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
MERCADOPAGO_ACCESS_TOKEN=
|
||||||
|
|
||||||
|
# LNbits Configuration (for Bitcoin Lightning payments)
|
||||||
|
# Get these from your LNbits instance
|
||||||
|
# URL should be like: https://lnbits.yourdomain.com or https://legend.lnbits.com
|
||||||
|
LNBITS_URL=
|
||||||
|
# Invoice/Read key from your LNbits wallet
|
||||||
|
LNBITS_API_KEY=
|
||||||
|
# Optional: webhook secret for additional verification
|
||||||
|
LNBITS_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# Email Service
|
||||||
|
# Provider options: resend, smtp, console (console = log only, no actual email)
|
||||||
|
EMAIL_PROVIDER=console
|
||||||
|
EMAIL_FROM=
|
||||||
|
EMAIL_FROM_NAME=
|
||||||
|
|
||||||
|
# Resend Configuration (if EMAIL_PROVIDER=resend)
|
||||||
|
# Get your API key from https://resend.com
|
||||||
|
EMAIL_API_KEY=
|
||||||
|
# Alternative env name also supported:
|
||||||
|
# RESEND_API_KEY=
|
||||||
|
|
||||||
|
# SMTP Configuration (if EMAIL_PROVIDER=smtp)
|
||||||
|
# Works with any SMTP server: Gmail, SendGrid, Mailgun, Amazon SES, etc.
|
||||||
|
SMTP_HOST=mail.spango.lat
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
# Set to true for port 465 (implicit TLS), false for port 587 (STARTTLS)
|
||||||
|
SMTP_SECURE=true
|
||||||
|
# Set to false to allow self-signed certificates (not recommended for production)
|
||||||
|
SMTP_TLS_REJECT_UNAUTHORIZED=true
|
||||||
|
|
||||||
|
# Common SMTP examples:
|
||||||
|
# Gmail: SMTP_HOST=smtp.gmail.com, SMTP_PORT=587, use App Password for SMTP_PASS
|
||||||
|
# SendGrid: SMTP_HOST=smtp.sendgrid.net, SMTP_PORT=587, SMTP_USER=apikey, SMTP_PASS=your_api_key
|
||||||
|
# Mailgun: SMTP_HOST=smtp.mailgun.org, SMTP_PORT=587
|
||||||
|
# Amazon SES: SMTP_HOST=email-smtp.us-east-1.amazonaws.com, SMTP_PORT=587
|
||||||
16
backend/drizzle.config.ts
Normal file
16
backend/drizzle.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/db/schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dialect: dbType === 'postgres' ? 'postgresql' : 'sqlite',
|
||||||
|
dbCredentials: dbType === 'postgres'
|
||||||
|
? {
|
||||||
|
url: process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
url: process.env.DATABASE_URL || './data/spanglish.db',
|
||||||
|
},
|
||||||
|
});
|
||||||
39
backend/package.json
Normal file
39
backend/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
|
"db:studio": "drizzle-kit studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.11.4",
|
||||||
|
"@hono/swagger-ui": "^0.4.0",
|
||||||
|
"@hono/zod-openapi": "^0.14.4",
|
||||||
|
"argon2": "^0.44.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"better-sqlite3": "^11.0.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"drizzle-orm": "^0.31.2",
|
||||||
|
"hono": "^4.4.7",
|
||||||
|
"jose": "^5.4.0",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
|
"nodemailer": "^7.0.13",
|
||||||
|
"pg": "^8.12.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/better-sqlite3": "^7.6.10",
|
||||||
|
"@types/node": "^20.14.9",
|
||||||
|
"@types/nodemailer": "^7.0.9",
|
||||||
|
"@types/pg": "^8.11.6",
|
||||||
|
"drizzle-kit": "^0.22.8",
|
||||||
|
"tsx": "^4.15.7",
|
||||||
|
"typescript": "^5.5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/src/db/index.ts
Normal file
33
backend/src/db/index.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import pg from 'pg';
|
||||||
|
import * as schema from './schema.js';
|
||||||
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||||
|
|
||||||
|
let db: ReturnType<typeof drizzleSqlite> | ReturnType<typeof drizzlePg>;
|
||||||
|
|
||||||
|
if (dbType === 'postgres') {
|
||||||
|
const pool = new pg.Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish',
|
||||||
|
});
|
||||||
|
db = drizzlePg(pool, { schema });
|
||||||
|
} else {
|
||||||
|
const dbPath = process.env.DATABASE_URL || './data/spanglish.db';
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
const dir = dirname(dbPath);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
sqlite.pragma('journal_mode = WAL');
|
||||||
|
db = drizzleSqlite(sqlite, { schema });
|
||||||
|
}
|
||||||
|
|
||||||
|
export { db };
|
||||||
|
export * from './schema.js';
|
||||||
624
backend/src/db/migrate.ts
Normal file
624
backend/src/db/migrate.ts
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
import { db } from './index.js';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log('Running migrations...');
|
||||||
|
|
||||||
|
if (dbType === 'sqlite') {
|
||||||
|
// SQLite migrations
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
phone TEXT,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
language_preference TEXT,
|
||||||
|
is_claimed INTEGER NOT NULL DEFAULT 1,
|
||||||
|
google_id TEXT,
|
||||||
|
ruc_number TEXT,
|
||||||
|
account_status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add new user columns if they don't exist (for existing databases)
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE users ADD COLUMN is_claimed INTEGER NOT NULL DEFAULT 1`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE users ADD COLUMN google_id TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ruc_number TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE users ADD COLUMN account_status TEXT NOT NULL DEFAULT 'active'`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Magic link tokens table
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS magic_link_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// User sessions table
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
last_active_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
title_es TEXT,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
description_es TEXT,
|
||||||
|
start_datetime TEXT NOT NULL,
|
||||||
|
end_datetime TEXT,
|
||||||
|
location TEXT NOT NULL,
|
||||||
|
location_url TEXT,
|
||||||
|
price REAL NOT NULL DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'PYG',
|
||||||
|
capacity INTEGER NOT NULL DEFAULT 50,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
banner_url TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
event_id TEXT NOT NULL REFERENCES events(id),
|
||||||
|
attendee_name TEXT NOT NULL,
|
||||||
|
attendee_email TEXT NOT NULL,
|
||||||
|
attendee_phone TEXT NOT NULL,
|
||||||
|
preferred_language TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
checkin_at TEXT,
|
||||||
|
qr_code TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add new columns if they don't exist (for existing databases)
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_name TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_email TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_phone TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN preferred_language TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN admin_note TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Migration: Add first/last name columns
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_first_name TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_last_name TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN attendee_ruc TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Migration: Copy data from attendee_name to attendee_first_name if attendee_first_name is empty
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`
|
||||||
|
UPDATE tickets
|
||||||
|
SET attendee_first_name = attendee_name
|
||||||
|
WHERE attendee_first_name IS NULL AND attendee_name IS NOT NULL
|
||||||
|
`);
|
||||||
|
} catch (e) { /* migration may have already run */ }
|
||||||
|
|
||||||
|
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
|
||||||
|
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
||||||
|
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
ticket_id TEXT NOT NULL REFERENCES tickets(id),
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'PYG',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
reference TEXT,
|
||||||
|
paid_at TEXT,
|
||||||
|
paid_by_admin_id TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add new columns if they don't exist (for existing databases)
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN paid_at TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN paid_by_admin_id TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN user_marked_paid_at TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN admin_note TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Invoices table
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
payment_id TEXT NOT NULL REFERENCES payments(id),
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
invoice_number TEXT NOT NULL UNIQUE,
|
||||||
|
ruc_number TEXT,
|
||||||
|
legal_name TEXT,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'PYG',
|
||||||
|
pdf_url TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'generated',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Payment options table
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS payment_options (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tpago_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tpago_link TEXT,
|
||||||
|
tpago_instructions TEXT,
|
||||||
|
tpago_instructions_es TEXT,
|
||||||
|
bank_transfer_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bank_name TEXT,
|
||||||
|
bank_account_holder TEXT,
|
||||||
|
bank_account_number TEXT,
|
||||||
|
bank_alias TEXT,
|
||||||
|
bank_phone TEXT,
|
||||||
|
bank_notes TEXT,
|
||||||
|
bank_notes_es TEXT,
|
||||||
|
lightning_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
cash_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
cash_instructions TEXT,
|
||||||
|
cash_instructions_es TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
updated_by TEXT REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Event payment overrides table
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS event_payment_overrides (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
event_id TEXT NOT NULL REFERENCES events(id),
|
||||||
|
tpago_enabled INTEGER,
|
||||||
|
tpago_link TEXT,
|
||||||
|
tpago_instructions TEXT,
|
||||||
|
tpago_instructions_es TEXT,
|
||||||
|
bank_transfer_enabled INTEGER,
|
||||||
|
bank_name TEXT,
|
||||||
|
bank_account_holder TEXT,
|
||||||
|
bank_account_number TEXT,
|
||||||
|
bank_alias TEXT,
|
||||||
|
bank_phone TEXT,
|
||||||
|
bank_notes TEXT,
|
||||||
|
bank_notes_es TEXT,
|
||||||
|
lightning_enabled INTEGER,
|
||||||
|
cash_enabled INTEGER,
|
||||||
|
cash_instructions TEXT,
|
||||||
|
cash_instructions_es TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'new',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS email_subscribers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS media (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
file_url TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
related_id TEXT,
|
||||||
|
related_type TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT REFERENCES users(id),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
details TEXT,
|
||||||
|
timestamp TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Email system tables
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS email_templates (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
subject_es TEXT,
|
||||||
|
body_html TEXT NOT NULL,
|
||||||
|
body_html_es TEXT,
|
||||||
|
body_text TEXT,
|
||||||
|
body_text_es TEXT,
|
||||||
|
description TEXT,
|
||||||
|
variables TEXT,
|
||||||
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS email_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
template_id TEXT REFERENCES email_templates(id),
|
||||||
|
event_id TEXT REFERENCES events(id),
|
||||||
|
recipient_email TEXT NOT NULL,
|
||||||
|
recipient_name TEXT,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
body_html TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
error_message TEXT,
|
||||||
|
sent_at TEXT,
|
||||||
|
sent_by TEXT REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS email_settings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
// PostgreSQL migrations
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password VARCHAR(255),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||||
|
language_preference VARCHAR(10),
|
||||||
|
is_claimed INTEGER NOT NULL DEFAULT 1,
|
||||||
|
google_id VARCHAR(255),
|
||||||
|
ruc_number VARCHAR(15),
|
||||||
|
account_status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add new user columns for existing PostgreSQL databases
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN is_claimed INTEGER NOT NULL DEFAULT 1`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN google_id VARCHAR(255)`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN ruc_number VARCHAR(15)`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN account_status VARCHAR(20) NOT NULL DEFAULT 'active'`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Magic link tokens table
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS magic_link_tokens (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
token VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
type VARCHAR(30) NOT NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
used_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// User sessions table
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
token VARCHAR(500) NOT NULL UNIQUE,
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
last_active_at TIMESTAMP NOT NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
title_es VARCHAR(255),
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
description_es TEXT,
|
||||||
|
start_datetime TIMESTAMP NOT NULL,
|
||||||
|
end_datetime TIMESTAMP,
|
||||||
|
location VARCHAR(500) NOT NULL,
|
||||||
|
location_url VARCHAR(500),
|
||||||
|
price DECIMAL(10, 2) NOT NULL DEFAULT 0,
|
||||||
|
currency VARCHAR(10) NOT NULL DEFAULT 'PYG',
|
||||||
|
capacity INTEGER NOT NULL DEFAULT 50,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft',
|
||||||
|
banner_url VARCHAR(500),
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
event_id UUID NOT NULL REFERENCES events(id),
|
||||||
|
attendee_first_name VARCHAR(255) NOT NULL,
|
||||||
|
attendee_last_name VARCHAR(255),
|
||||||
|
attendee_email VARCHAR(255),
|
||||||
|
attendee_phone VARCHAR(50),
|
||||||
|
attendee_ruc VARCHAR(15),
|
||||||
|
preferred_language VARCHAR(10),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
checkin_at TIMESTAMP,
|
||||||
|
qr_code VARCHAR(255),
|
||||||
|
admin_note TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add attendee_ruc column if it doesn't exist
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN attendee_ruc VARCHAR(15)`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
ticket_id UUID NOT NULL REFERENCES tickets(id),
|
||||||
|
provider VARCHAR(50) NOT NULL,
|
||||||
|
amount DECIMAL(10, 2) NOT NULL,
|
||||||
|
currency VARCHAR(10) NOT NULL DEFAULT 'PYG',
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
reference VARCHAR(255),
|
||||||
|
user_marked_paid_at TIMESTAMP,
|
||||||
|
paid_at TIMESTAMP,
|
||||||
|
paid_by_admin_id UUID,
|
||||||
|
admin_note TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Invoices table
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
payment_id UUID NOT NULL REFERENCES payments(id),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
invoice_number VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
ruc_number VARCHAR(15),
|
||||||
|
legal_name VARCHAR(255),
|
||||||
|
amount DECIMAL(10, 2) NOT NULL,
|
||||||
|
currency VARCHAR(10) NOT NULL DEFAULT 'PYG',
|
||||||
|
pdf_url VARCHAR(500),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'generated',
|
||||||
|
created_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS payment_options (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tpago_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tpago_link VARCHAR(500),
|
||||||
|
tpago_instructions TEXT,
|
||||||
|
tpago_instructions_es TEXT,
|
||||||
|
bank_transfer_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bank_name VARCHAR(255),
|
||||||
|
bank_account_holder VARCHAR(255),
|
||||||
|
bank_account_number VARCHAR(100),
|
||||||
|
bank_alias VARCHAR(100),
|
||||||
|
bank_phone VARCHAR(50),
|
||||||
|
bank_notes TEXT,
|
||||||
|
bank_notes_es TEXT,
|
||||||
|
lightning_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
cash_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
cash_instructions TEXT,
|
||||||
|
cash_instructions_es TEXT,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
updated_by UUID REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS event_payment_overrides (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
event_id UUID NOT NULL REFERENCES events(id),
|
||||||
|
tpago_enabled INTEGER,
|
||||||
|
tpago_link VARCHAR(500),
|
||||||
|
tpago_instructions TEXT,
|
||||||
|
tpago_instructions_es TEXT,
|
||||||
|
bank_transfer_enabled INTEGER,
|
||||||
|
bank_name VARCHAR(255),
|
||||||
|
bank_account_holder VARCHAR(255),
|
||||||
|
bank_account_number VARCHAR(100),
|
||||||
|
bank_alias VARCHAR(100),
|
||||||
|
bank_phone VARCHAR(50),
|
||||||
|
bank_notes TEXT,
|
||||||
|
bank_notes_es TEXT,
|
||||||
|
lightning_enabled INTEGER,
|
||||||
|
cash_enabled INTEGER,
|
||||||
|
cash_instructions TEXT,
|
||||||
|
cash_instructions_es TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'new',
|
||||||
|
created_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS email_subscribers (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(255),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS media (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
file_url VARCHAR(500) NOT NULL,
|
||||||
|
type VARCHAR(20) NOT NULL,
|
||||||
|
related_id UUID,
|
||||||
|
related_type VARCHAR(50),
|
||||||
|
created_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID REFERENCES users(id),
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
target VARCHAR(100),
|
||||||
|
target_id UUID,
|
||||||
|
details TEXT,
|
||||||
|
timestamp TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Email system tables
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS email_templates (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
subject VARCHAR(500) NOT NULL,
|
||||||
|
subject_es VARCHAR(500),
|
||||||
|
body_html TEXT NOT NULL,
|
||||||
|
body_html_es TEXT,
|
||||||
|
body_text TEXT,
|
||||||
|
body_text_es TEXT,
|
||||||
|
description TEXT,
|
||||||
|
variables TEXT,
|
||||||
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS email_logs (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
template_id UUID REFERENCES email_templates(id),
|
||||||
|
event_id UUID REFERENCES events(id),
|
||||||
|
recipient_email VARCHAR(255) NOT NULL,
|
||||||
|
recipient_name VARCHAR(255),
|
||||||
|
subject VARCHAR(500) NOT NULL,
|
||||||
|
body_html TEXT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
error_message TEXT,
|
||||||
|
sent_at TIMESTAMP,
|
||||||
|
sent_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS email_settings (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
key VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Migrations completed successfully!');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate().catch((err) => {
|
||||||
|
console.error('Migration failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
518
backend/src/db/schema.ts
Normal file
518
backend/src/db/schema.ts
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
|
||||||
|
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
// Type to determine which schema to use
|
||||||
|
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||||
|
|
||||||
|
// ==================== SQLite Schema ====================
|
||||||
|
export const sqliteUsers = sqliteTable('users', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
password: text('password'), // Nullable for unclaimed accounts
|
||||||
|
name: text('name').notNull(),
|
||||||
|
phone: text('phone'),
|
||||||
|
role: text('role', { enum: ['admin', 'organizer', 'staff', 'marketing', 'user'] }).notNull().default('user'),
|
||||||
|
languagePreference: text('language_preference'),
|
||||||
|
// New fields for progressive accounts and OAuth
|
||||||
|
isClaimed: integer('is_claimed', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
googleId: text('google_id'),
|
||||||
|
rucNumber: text('ruc_number'),
|
||||||
|
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Magic link tokens for passwordless login
|
||||||
|
export const sqliteMagicLinkTokens = sqliteTable('magic_link_tokens', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').notNull().references(() => sqliteUsers.id),
|
||||||
|
token: text('token').notNull().unique(),
|
||||||
|
type: text('type', { enum: ['login', 'reset_password', 'claim_account', 'email_verification'] }).notNull(),
|
||||||
|
expiresAt: text('expires_at').notNull(),
|
||||||
|
usedAt: text('used_at'),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// User sessions for session management
|
||||||
|
export const sqliteUserSessions = sqliteTable('user_sessions', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').notNull().references(() => sqliteUsers.id),
|
||||||
|
token: text('token').notNull().unique(),
|
||||||
|
userAgent: text('user_agent'),
|
||||||
|
ipAddress: text('ip_address'),
|
||||||
|
lastActiveAt: text('last_active_at').notNull(),
|
||||||
|
expiresAt: text('expires_at').notNull(),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invoices table
|
||||||
|
export const sqliteInvoices = sqliteTable('invoices', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
paymentId: text('payment_id').notNull().references(() => sqlitePayments.id),
|
||||||
|
userId: text('user_id').notNull().references(() => sqliteUsers.id),
|
||||||
|
invoiceNumber: text('invoice_number').notNull().unique(),
|
||||||
|
rucNumber: text('ruc_number'),
|
||||||
|
legalName: text('legal_name'),
|
||||||
|
amount: real('amount').notNull(),
|
||||||
|
currency: text('currency').notNull().default('PYG'),
|
||||||
|
pdfUrl: text('pdf_url'),
|
||||||
|
status: text('status', { enum: ['generated', 'voided'] }).notNull().default('generated'),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteEvents = sqliteTable('events', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
titleEs: text('title_es'),
|
||||||
|
description: text('description').notNull(),
|
||||||
|
descriptionEs: text('description_es'),
|
||||||
|
startDatetime: text('start_datetime').notNull(),
|
||||||
|
endDatetime: text('end_datetime'),
|
||||||
|
location: text('location').notNull(),
|
||||||
|
locationUrl: text('location_url'),
|
||||||
|
price: real('price').notNull().default(0),
|
||||||
|
currency: text('currency').notNull().default('PYG'),
|
||||||
|
capacity: integer('capacity').notNull().default(50),
|
||||||
|
status: text('status', { enum: ['draft', 'published', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
|
||||||
|
bannerUrl: text('banner_url'),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteTickets = sqliteTable('tickets', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').notNull().references(() => sqliteUsers.id),
|
||||||
|
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||||
|
attendeeFirstName: text('attendee_first_name').notNull(),
|
||||||
|
attendeeLastName: text('attendee_last_name'),
|
||||||
|
attendeeEmail: text('attendee_email'),
|
||||||
|
attendeePhone: text('attendee_phone'),
|
||||||
|
attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing
|
||||||
|
preferredLanguage: text('preferred_language'),
|
||||||
|
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] }).notNull().default('pending'),
|
||||||
|
checkinAt: text('checkin_at'),
|
||||||
|
qrCode: text('qr_code'),
|
||||||
|
adminNote: text('admin_note'),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqlitePayments = sqliteTable('payments', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
ticketId: text('ticket_id').notNull().references(() => sqliteTickets.id),
|
||||||
|
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
|
||||||
|
amount: real('amount').notNull(),
|
||||||
|
currency: text('currency').notNull().default('PYG'),
|
||||||
|
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] }).notNull().default('pending'),
|
||||||
|
reference: text('reference'),
|
||||||
|
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
|
||||||
|
paidAt: text('paid_at'),
|
||||||
|
paidByAdminId: text('paid_by_admin_id'),
|
||||||
|
adminNote: text('admin_note'), // Internal admin notes
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Payment Options Configuration Table (global settings)
|
||||||
|
export const sqlitePaymentOptions = sqliteTable('payment_options', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
// TPago configuration
|
||||||
|
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
tpagoLink: text('tpago_link'),
|
||||||
|
tpagoInstructions: text('tpago_instructions'),
|
||||||
|
tpagoInstructionsEs: text('tpago_instructions_es'),
|
||||||
|
// Bank Transfer configuration
|
||||||
|
bankTransferEnabled: integer('bank_transfer_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
bankName: text('bank_name'),
|
||||||
|
bankAccountHolder: text('bank_account_holder'),
|
||||||
|
bankAccountNumber: text('bank_account_number'),
|
||||||
|
bankAlias: text('bank_alias'),
|
||||||
|
bankPhone: text('bank_phone'),
|
||||||
|
bankNotes: text('bank_notes'),
|
||||||
|
bankNotesEs: text('bank_notes_es'),
|
||||||
|
// Lightning configuration
|
||||||
|
lightningEnabled: integer('lightning_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
// Cash configuration
|
||||||
|
cashEnabled: integer('cash_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
cashInstructions: text('cash_instructions'),
|
||||||
|
cashInstructionsEs: text('cash_instructions_es'),
|
||||||
|
// Metadata
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event-specific payment overrides
|
||||||
|
export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||||
|
// Override flags (null means use global)
|
||||||
|
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }),
|
||||||
|
tpagoLink: text('tpago_link'),
|
||||||
|
tpagoInstructions: text('tpago_instructions'),
|
||||||
|
tpagoInstructionsEs: text('tpago_instructions_es'),
|
||||||
|
bankTransferEnabled: integer('bank_transfer_enabled', { mode: 'boolean' }),
|
||||||
|
bankName: text('bank_name'),
|
||||||
|
bankAccountHolder: text('bank_account_holder'),
|
||||||
|
bankAccountNumber: text('bank_account_number'),
|
||||||
|
bankAlias: text('bank_alias'),
|
||||||
|
bankPhone: text('bank_phone'),
|
||||||
|
bankNotes: text('bank_notes'),
|
||||||
|
bankNotesEs: text('bank_notes_es'),
|
||||||
|
lightningEnabled: integer('lightning_enabled', { mode: 'boolean' }),
|
||||||
|
cashEnabled: integer('cash_enabled', { mode: 'boolean' }),
|
||||||
|
cashInstructions: text('cash_instructions'),
|
||||||
|
cashInstructionsEs: text('cash_instructions_es'),
|
||||||
|
// Metadata
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteContacts = sqliteTable('contacts', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
email: text('email').notNull(),
|
||||||
|
message: text('message').notNull(),
|
||||||
|
status: text('status', { enum: ['new', 'read', 'replied'] }).notNull().default('new'),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteEmailSubscribers = sqliteTable('email_subscribers', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
name: text('name'),
|
||||||
|
status: text('status', { enum: ['active', 'unsubscribed'] }).notNull().default('active'),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteMedia = sqliteTable('media', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
fileUrl: text('file_url').notNull(),
|
||||||
|
type: text('type', { enum: ['image', 'video', 'document'] }).notNull(),
|
||||||
|
relatedId: text('related_id'),
|
||||||
|
relatedType: text('related_type'),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteAuditLogs = sqliteTable('audit_logs', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').references(() => sqliteUsers.id),
|
||||||
|
action: text('action').notNull(),
|
||||||
|
target: text('target'),
|
||||||
|
targetId: text('target_id'),
|
||||||
|
details: text('details'),
|
||||||
|
timestamp: text('timestamp').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteEmailTemplates = sqliteTable('email_templates', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull().unique(),
|
||||||
|
slug: text('slug').notNull().unique(),
|
||||||
|
subject: text('subject').notNull(),
|
||||||
|
subjectEs: text('subject_es'),
|
||||||
|
bodyHtml: text('body_html').notNull(),
|
||||||
|
bodyHtmlEs: text('body_html_es'),
|
||||||
|
bodyText: text('body_text'),
|
||||||
|
bodyTextEs: text('body_text_es'),
|
||||||
|
description: text('description'),
|
||||||
|
variables: text('variables'), // JSON array of available variables
|
||||||
|
isSystem: integer('is_system', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteEmailLogs = sqliteTable('email_logs', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
templateId: text('template_id').references(() => sqliteEmailTemplates.id),
|
||||||
|
eventId: text('event_id').references(() => sqliteEvents.id),
|
||||||
|
recipientEmail: text('recipient_email').notNull(),
|
||||||
|
recipientName: text('recipient_name'),
|
||||||
|
subject: text('subject').notNull(),
|
||||||
|
bodyHtml: text('body_html'),
|
||||||
|
status: text('status', { enum: ['pending', 'sent', 'failed', 'bounced'] }).notNull().default('pending'),
|
||||||
|
errorMessage: text('error_message'),
|
||||||
|
sentAt: text('sent_at'),
|
||||||
|
sentBy: text('sent_by').references(() => sqliteUsers.id),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteEmailSettings = sqliteTable('email_settings', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
key: text('key').notNull().unique(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== PostgreSQL Schema ====================
|
||||||
|
export const pgUsers = pgTable('users', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||||
|
password: varchar('password', { length: 255 }), // Nullable for unclaimed accounts
|
||||||
|
name: varchar('name', { length: 255 }).notNull(),
|
||||||
|
phone: varchar('phone', { length: 50 }),
|
||||||
|
role: varchar('role', { length: 20 }).notNull().default('user'),
|
||||||
|
languagePreference: varchar('language_preference', { length: 10 }),
|
||||||
|
// New fields for progressive accounts and OAuth
|
||||||
|
isClaimed: pgInteger('is_claimed').notNull().default(1),
|
||||||
|
googleId: varchar('google_id', { length: 255 }),
|
||||||
|
rucNumber: varchar('ruc_number', { length: 15 }),
|
||||||
|
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Magic link tokens for passwordless login
|
||||||
|
export const pgMagicLinkTokens = pgTable('magic_link_tokens', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => pgUsers.id),
|
||||||
|
token: varchar('token', { length: 255 }).notNull().unique(),
|
||||||
|
type: varchar('type', { length: 30 }).notNull(),
|
||||||
|
expiresAt: timestamp('expires_at').notNull(),
|
||||||
|
usedAt: timestamp('used_at'),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// User sessions for session management
|
||||||
|
export const pgUserSessions = pgTable('user_sessions', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => pgUsers.id),
|
||||||
|
token: varchar('token', { length: 500 }).notNull().unique(),
|
||||||
|
userAgent: pgText('user_agent'),
|
||||||
|
ipAddress: varchar('ip_address', { length: 45 }),
|
||||||
|
lastActiveAt: timestamp('last_active_at').notNull(),
|
||||||
|
expiresAt: timestamp('expires_at').notNull(),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invoices table
|
||||||
|
export const pgInvoices = pgTable('invoices', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
paymentId: uuid('payment_id').notNull().references(() => pgPayments.id),
|
||||||
|
userId: uuid('user_id').notNull().references(() => pgUsers.id),
|
||||||
|
invoiceNumber: varchar('invoice_number', { length: 50 }).notNull().unique(),
|
||||||
|
rucNumber: varchar('ruc_number', { length: 15 }),
|
||||||
|
legalName: varchar('legal_name', { length: 255 }),
|
||||||
|
amount: decimal('amount', { precision: 10, scale: 2 }).notNull(),
|
||||||
|
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
|
||||||
|
pdfUrl: varchar('pdf_url', { length: 500 }),
|
||||||
|
status: varchar('status', { length: 20 }).notNull().default('generated'),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgEvents = pgTable('events', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
title: varchar('title', { length: 255 }).notNull(),
|
||||||
|
titleEs: varchar('title_es', { length: 255 }),
|
||||||
|
description: pgText('description').notNull(),
|
||||||
|
descriptionEs: pgText('description_es'),
|
||||||
|
startDatetime: timestamp('start_datetime').notNull(),
|
||||||
|
endDatetime: timestamp('end_datetime'),
|
||||||
|
location: varchar('location', { length: 500 }).notNull(),
|
||||||
|
locationUrl: varchar('location_url', { length: 500 }),
|
||||||
|
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||||
|
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
|
||||||
|
capacity: pgInteger('capacity').notNull().default(50),
|
||||||
|
status: varchar('status', { length: 20 }).notNull().default('draft'),
|
||||||
|
bannerUrl: varchar('banner_url', { length: 500 }),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgTickets = pgTable('tickets', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => pgUsers.id),
|
||||||
|
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||||
|
attendeeFirstName: varchar('attendee_first_name', { length: 255 }).notNull(),
|
||||||
|
attendeeLastName: varchar('attendee_last_name', { length: 255 }),
|
||||||
|
attendeeEmail: varchar('attendee_email', { length: 255 }),
|
||||||
|
attendeePhone: varchar('attendee_phone', { length: 50 }),
|
||||||
|
attendeeRuc: varchar('attendee_ruc', { length: 15 }), // Paraguayan tax ID for invoicing
|
||||||
|
preferredLanguage: varchar('preferred_language', { length: 10 }),
|
||||||
|
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
||||||
|
checkinAt: timestamp('checkin_at'),
|
||||||
|
qrCode: varchar('qr_code', { length: 255 }),
|
||||||
|
adminNote: pgText('admin_note'),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgPayments = pgTable('payments', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
ticketId: uuid('ticket_id').notNull().references(() => pgTickets.id),
|
||||||
|
provider: varchar('provider', { length: 50 }).notNull(),
|
||||||
|
amount: decimal('amount', { precision: 10, scale: 2 }).notNull(),
|
||||||
|
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
|
||||||
|
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
||||||
|
reference: varchar('reference', { length: 255 }),
|
||||||
|
userMarkedPaidAt: timestamp('user_marked_paid_at'),
|
||||||
|
paidAt: timestamp('paid_at'),
|
||||||
|
paidByAdminId: uuid('paid_by_admin_id'),
|
||||||
|
adminNote: pgText('admin_note'),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Payment Options Configuration Table (global settings)
|
||||||
|
export const pgPaymentOptions = pgTable('payment_options', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
tpagoEnabled: pgInteger('tpago_enabled').notNull().default(0),
|
||||||
|
tpagoLink: varchar('tpago_link', { length: 500 }),
|
||||||
|
tpagoInstructions: pgText('tpago_instructions'),
|
||||||
|
tpagoInstructionsEs: pgText('tpago_instructions_es'),
|
||||||
|
bankTransferEnabled: pgInteger('bank_transfer_enabled').notNull().default(0),
|
||||||
|
bankName: varchar('bank_name', { length: 255 }),
|
||||||
|
bankAccountHolder: varchar('bank_account_holder', { length: 255 }),
|
||||||
|
bankAccountNumber: varchar('bank_account_number', { length: 100 }),
|
||||||
|
bankAlias: varchar('bank_alias', { length: 100 }),
|
||||||
|
bankPhone: varchar('bank_phone', { length: 50 }),
|
||||||
|
bankNotes: pgText('bank_notes'),
|
||||||
|
bankNotesEs: pgText('bank_notes_es'),
|
||||||
|
lightningEnabled: pgInteger('lightning_enabled').notNull().default(1),
|
||||||
|
cashEnabled: pgInteger('cash_enabled').notNull().default(1),
|
||||||
|
cashInstructions: pgText('cash_instructions'),
|
||||||
|
cashInstructionsEs: pgText('cash_instructions_es'),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event-specific payment overrides
|
||||||
|
export const pgEventPaymentOverrides = pgTable('event_payment_overrides', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||||
|
tpagoEnabled: pgInteger('tpago_enabled'),
|
||||||
|
tpagoLink: varchar('tpago_link', { length: 500 }),
|
||||||
|
tpagoInstructions: pgText('tpago_instructions'),
|
||||||
|
tpagoInstructionsEs: pgText('tpago_instructions_es'),
|
||||||
|
bankTransferEnabled: pgInteger('bank_transfer_enabled'),
|
||||||
|
bankName: varchar('bank_name', { length: 255 }),
|
||||||
|
bankAccountHolder: varchar('bank_account_holder', { length: 255 }),
|
||||||
|
bankAccountNumber: varchar('bank_account_number', { length: 100 }),
|
||||||
|
bankAlias: varchar('bank_alias', { length: 100 }),
|
||||||
|
bankPhone: varchar('bank_phone', { length: 50 }),
|
||||||
|
bankNotes: pgText('bank_notes'),
|
||||||
|
bankNotesEs: pgText('bank_notes_es'),
|
||||||
|
lightningEnabled: pgInteger('lightning_enabled'),
|
||||||
|
cashEnabled: pgInteger('cash_enabled'),
|
||||||
|
cashInstructions: pgText('cash_instructions'),
|
||||||
|
cashInstructionsEs: pgText('cash_instructions_es'),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgContacts = pgTable('contacts', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
name: varchar('name', { length: 255 }).notNull(),
|
||||||
|
email: varchar('email', { length: 255 }).notNull(),
|
||||||
|
message: pgText('message').notNull(),
|
||||||
|
status: varchar('status', { length: 20 }).notNull().default('new'),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgEmailSubscribers = pgTable('email_subscribers', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||||
|
name: varchar('name', { length: 255 }),
|
||||||
|
status: varchar('status', { length: 20 }).notNull().default('active'),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgMedia = pgTable('media', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
fileUrl: varchar('file_url', { length: 500 }).notNull(),
|
||||||
|
type: varchar('type', { length: 20 }).notNull(),
|
||||||
|
relatedId: uuid('related_id'),
|
||||||
|
relatedType: varchar('related_type', { length: 50 }),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgAuditLogs = pgTable('audit_logs', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
userId: uuid('user_id').references(() => pgUsers.id),
|
||||||
|
action: varchar('action', { length: 100 }).notNull(),
|
||||||
|
target: varchar('target', { length: 100 }),
|
||||||
|
targetId: uuid('target_id'),
|
||||||
|
details: pgText('details'),
|
||||||
|
timestamp: timestamp('timestamp').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgEmailTemplates = pgTable('email_templates', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
name: varchar('name', { length: 255 }).notNull().unique(),
|
||||||
|
slug: varchar('slug', { length: 100 }).notNull().unique(),
|
||||||
|
subject: varchar('subject', { length: 500 }).notNull(),
|
||||||
|
subjectEs: varchar('subject_es', { length: 500 }),
|
||||||
|
bodyHtml: pgText('body_html').notNull(),
|
||||||
|
bodyHtmlEs: pgText('body_html_es'),
|
||||||
|
bodyText: pgText('body_text'),
|
||||||
|
bodyTextEs: pgText('body_text_es'),
|
||||||
|
description: pgText('description'),
|
||||||
|
variables: pgText('variables'), // JSON array of available variables
|
||||||
|
isSystem: pgInteger('is_system').notNull().default(0),
|
||||||
|
isActive: pgInteger('is_active').notNull().default(1),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgEmailLogs = pgTable('email_logs', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
templateId: uuid('template_id').references(() => pgEmailTemplates.id),
|
||||||
|
eventId: uuid('event_id').references(() => pgEvents.id),
|
||||||
|
recipientEmail: varchar('recipient_email', { length: 255 }).notNull(),
|
||||||
|
recipientName: varchar('recipient_name', { length: 255 }),
|
||||||
|
subject: varchar('subject', { length: 500 }).notNull(),
|
||||||
|
bodyHtml: pgText('body_html'),
|
||||||
|
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
||||||
|
errorMessage: pgText('error_message'),
|
||||||
|
sentAt: timestamp('sent_at'),
|
||||||
|
sentBy: uuid('sent_by').references(() => pgUsers.id),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgEmailSettings = pgTable('email_settings', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
key: varchar('key', { length: 100 }).notNull().unique(),
|
||||||
|
value: pgText('value').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export the appropriate schema based on DB_TYPE
|
||||||
|
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
|
||||||
|
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
|
||||||
|
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
|
||||||
|
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
|
||||||
|
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;
|
||||||
|
export const emailSubscribers = dbType === 'postgres' ? pgEmailSubscribers : sqliteEmailSubscribers;
|
||||||
|
export const media = dbType === 'postgres' ? pgMedia : sqliteMedia;
|
||||||
|
export const auditLogs = dbType === 'postgres' ? pgAuditLogs : sqliteAuditLogs;
|
||||||
|
export const emailTemplates = dbType === 'postgres' ? pgEmailTemplates : sqliteEmailTemplates;
|
||||||
|
export const emailLogs = dbType === 'postgres' ? pgEmailLogs : sqliteEmailLogs;
|
||||||
|
export const emailSettings = dbType === 'postgres' ? pgEmailSettings : sqliteEmailSettings;
|
||||||
|
export const paymentOptions = dbType === 'postgres' ? pgPaymentOptions : sqlitePaymentOptions;
|
||||||
|
export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverrides : sqliteEventPaymentOverrides;
|
||||||
|
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
|
||||||
|
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
|
||||||
|
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
|
||||||
|
|
||||||
|
// Type exports
|
||||||
|
export type User = typeof sqliteUsers.$inferSelect;
|
||||||
|
export type NewUser = typeof sqliteUsers.$inferInsert;
|
||||||
|
export type Event = typeof sqliteEvents.$inferSelect;
|
||||||
|
export type NewEvent = typeof sqliteEvents.$inferInsert;
|
||||||
|
export type Ticket = typeof sqliteTickets.$inferSelect;
|
||||||
|
export type NewTicket = typeof sqliteTickets.$inferInsert;
|
||||||
|
export type Payment = typeof sqlitePayments.$inferSelect;
|
||||||
|
export type NewPayment = typeof sqlitePayments.$inferInsert;
|
||||||
|
export type Contact = typeof sqliteContacts.$inferSelect;
|
||||||
|
export type NewContact = typeof sqliteContacts.$inferInsert;
|
||||||
|
export type EmailTemplate = typeof sqliteEmailTemplates.$inferSelect;
|
||||||
|
export type NewEmailTemplate = typeof sqliteEmailTemplates.$inferInsert;
|
||||||
|
export type EmailLog = typeof sqliteEmailLogs.$inferSelect;
|
||||||
|
export type NewEmailLog = typeof sqliteEmailLogs.$inferInsert;
|
||||||
|
export type PaymentOptions = typeof sqlitePaymentOptions.$inferSelect;
|
||||||
|
export type NewPaymentOptions = typeof sqlitePaymentOptions.$inferInsert;
|
||||||
|
export type EventPaymentOverride = typeof sqliteEventPaymentOverrides.$inferSelect;
|
||||||
|
export type NewEventPaymentOverride = typeof sqliteEventPaymentOverrides.$inferInsert;
|
||||||
|
export type MagicLinkToken = typeof sqliteMagicLinkTokens.$inferSelect;
|
||||||
|
export type NewMagicLinkToken = typeof sqliteMagicLinkTokens.$inferInsert;
|
||||||
|
export type UserSession = typeof sqliteUserSessions.$inferSelect;
|
||||||
|
export type NewUserSession = typeof sqliteUserSessions.$inferInsert;
|
||||||
|
export type Invoice = typeof sqliteInvoices.$inferSelect;
|
||||||
|
export type NewInvoice = typeof sqliteInvoices.$inferInsert;
|
||||||
1720
backend/src/index.ts
Normal file
1720
backend/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
248
backend/src/lib/auth.ts
Normal file
248
backend/src/lib/auth.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import * as jose from 'jose';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { Context } from 'hono';
|
||||||
|
import { db, users, magicLinkTokens, userSessions } from '../db/index.js';
|
||||||
|
import { eq, and, gt } from 'drizzle-orm';
|
||||||
|
import { generateId, getNow } from './utils.js';
|
||||||
|
|
||||||
|
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production');
|
||||||
|
const JWT_ISSUER = 'spanglish';
|
||||||
|
const JWT_AUDIENCE = 'spanglish-app';
|
||||||
|
|
||||||
|
export interface JWTPayload {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password hashing with Argon2 (spec requirement)
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return argon2.hash(password, {
|
||||||
|
type: argon2.argon2id,
|
||||||
|
memoryCost: 65536, // 64 MB
|
||||||
|
timeCost: 3,
|
||||||
|
parallelism: 4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
// Support both bcrypt (legacy) and argon2 hashes for migration
|
||||||
|
if (hash.startsWith('$argon2')) {
|
||||||
|
return argon2.verify(hash, password);
|
||||||
|
}
|
||||||
|
// Legacy bcrypt support
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate secure random token for magic links
|
||||||
|
export function generateSecureToken(): string {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create magic link token
|
||||||
|
export async function createMagicLinkToken(
|
||||||
|
userId: string,
|
||||||
|
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification',
|
||||||
|
expiresInMinutes: number = 10
|
||||||
|
): Promise<string> {
|
||||||
|
const token = generateSecureToken();
|
||||||
|
const now = getNow();
|
||||||
|
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
await (db as any).insert(magicLinkTokens).values({
|
||||||
|
id: generateId(),
|
||||||
|
userId,
|
||||||
|
token,
|
||||||
|
type,
|
||||||
|
expiresAt,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify and consume magic link token
|
||||||
|
export async function verifyMagicLinkToken(
|
||||||
|
token: string,
|
||||||
|
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification'
|
||||||
|
): Promise<{ valid: boolean; userId?: string; error?: string }> {
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
const tokenRecord = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(magicLinkTokens)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((magicLinkTokens as any).token, token),
|
||||||
|
eq((magicLinkTokens as any).type, type)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!tokenRecord) {
|
||||||
|
return { valid: false, error: 'Invalid token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenRecord.usedAt) {
|
||||||
|
return { valid: false, error: 'Token already used' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date(tokenRecord.expiresAt) < new Date()) {
|
||||||
|
return { valid: false, error: 'Token expired' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark token as used
|
||||||
|
await (db as any)
|
||||||
|
.update(magicLinkTokens)
|
||||||
|
.set({ usedAt: now })
|
||||||
|
.where(eq((magicLinkTokens as any).id, tokenRecord.id));
|
||||||
|
|
||||||
|
return { valid: true, userId: tokenRecord.userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user session
|
||||||
|
export async function createUserSession(
|
||||||
|
userId: string,
|
||||||
|
userAgent?: string,
|
||||||
|
ipAddress?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const sessionToken = generateSecureToken();
|
||||||
|
const now = getNow();
|
||||||
|
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days
|
||||||
|
|
||||||
|
await (db as any).insert(userSessions).values({
|
||||||
|
id: generateId(),
|
||||||
|
userId,
|
||||||
|
token: sessionToken,
|
||||||
|
userAgent: userAgent || null,
|
||||||
|
ipAddress: ipAddress || null,
|
||||||
|
lastActiveAt: now,
|
||||||
|
expiresAt,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's active sessions
|
||||||
|
export async function getUserSessions(userId: string) {
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
return (db as any)
|
||||||
|
.select()
|
||||||
|
.from(userSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((userSessions as any).userId, userId),
|
||||||
|
gt((userSessions as any).expiresAt, now)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate a specific session
|
||||||
|
export async function invalidateSession(sessionId: string, userId: string): Promise<boolean> {
|
||||||
|
const result = await (db as any)
|
||||||
|
.delete(userSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((userSessions as any).id, sessionId),
|
||||||
|
eq((userSessions as any).userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate all user sessions (logout everywhere)
|
||||||
|
export async function invalidateAllUserSessions(userId: string): Promise<void> {
|
||||||
|
await (db as any)
|
||||||
|
.delete(userSessions)
|
||||||
|
.where(eq((userSessions as any).userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password validation (min 10 characters per spec)
|
||||||
|
export function validatePassword(password: string): { valid: boolean; error?: string } {
|
||||||
|
if (password.length < 10) {
|
||||||
|
return { valid: false, error: 'Password must be at least 10 characters long' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createToken(userId: string, email: string, role: string): Promise<string> {
|
||||||
|
const token = await new jose.SignJWT({ sub: userId, email, role })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setIssuer(JWT_ISSUER)
|
||||||
|
.setAudience(JWT_AUDIENCE)
|
||||||
|
.setExpirationTime('7d')
|
||||||
|
.sign(JWT_SECRET);
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRefreshToken(userId: string): Promise<string> {
|
||||||
|
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setIssuer(JWT_ISSUER)
|
||||||
|
.setExpirationTime('30d')
|
||||||
|
.sign(JWT_SECRET);
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
|
||||||
|
issuer: JWT_ISSUER,
|
||||||
|
audience: JWT_AUDIENCE,
|
||||||
|
});
|
||||||
|
return payload as unknown as JWTPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthUser(c: Context) {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
const payload = await verifyToken(token);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await (db as any).select().from(users).where(eq((users as any).id, payload.sub)).get();
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth(roles?: string[]) {
|
||||||
|
return async (c: Context, next: () => Promise<void>) => {
|
||||||
|
const user = await getAuthUser(c);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roles && !roles.includes(user.role)) {
|
||||||
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set('user', user);
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isFirstUser(): Promise<boolean> {
|
||||||
|
const result = await (db as any).select().from(users).limit(1).all();
|
||||||
|
return !result || result.length === 0;
|
||||||
|
}
|
||||||
784
backend/src/lib/email.ts
Normal file
784
backend/src/lib/email.ts
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
// Email service for Spanglish platform
|
||||||
|
// Supports multiple email providers: Resend, SMTP (Nodemailer)
|
||||||
|
|
||||||
|
import { db, emailTemplates, emailLogs, events, tickets, payments, users } from '../db/index.js';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { getNow } from './utils.js';
|
||||||
|
import {
|
||||||
|
replaceTemplateVariables,
|
||||||
|
wrapInBaseTemplate,
|
||||||
|
defaultTemplates,
|
||||||
|
type DefaultTemplate
|
||||||
|
} from './emailTemplates.js';
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import type { Transporter } from 'nodemailer';
|
||||||
|
|
||||||
|
// ==================== Types ====================
|
||||||
|
|
||||||
|
interface SendEmailOptions {
|
||||||
|
to: string | string[];
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
text?: string;
|
||||||
|
replyTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendEmailResult {
|
||||||
|
success: boolean;
|
||||||
|
messageId?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailProvider = 'resend' | 'smtp' | 'console';
|
||||||
|
|
||||||
|
// ==================== Provider Configuration ====================
|
||||||
|
|
||||||
|
function getEmailProvider(): EmailProvider {
|
||||||
|
const provider = (process.env.EMAIL_PROVIDER || 'console').toLowerCase();
|
||||||
|
if (provider === 'resend' || provider === 'smtp' || provider === 'console') {
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
console.warn(`[Email] Unknown provider "${provider}", falling back to console`);
|
||||||
|
return 'console';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFromEmail(): string {
|
||||||
|
return process.env.EMAIL_FROM || 'noreply@spanglish.com';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFromName(): string {
|
||||||
|
return process.env.EMAIL_FROM_NAME || 'Spanglish';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SMTP Configuration ====================
|
||||||
|
|
||||||
|
interface SMTPConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
auth?: {
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSMTPConfig(): SMTPConfig | null {
|
||||||
|
const host = process.env.SMTP_HOST;
|
||||||
|
const port = parseInt(process.env.SMTP_PORT || '587');
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
|
const pass = process.env.SMTP_PASS;
|
||||||
|
const secure = process.env.SMTP_SECURE === 'true' || port === 465;
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: SMTPConfig = {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user && pass) {
|
||||||
|
config.auth = { user, pass };
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached SMTP transporter
|
||||||
|
let smtpTransporter: Transporter | null = null;
|
||||||
|
|
||||||
|
function getSMTPTransporter(): Transporter | null {
|
||||||
|
if (smtpTransporter) {
|
||||||
|
return smtpTransporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getSMTPConfig();
|
||||||
|
if (!config) {
|
||||||
|
console.error('[Email] SMTP configuration missing');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
smtpTransporter = nodemailer.createTransport({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
secure: config.secure,
|
||||||
|
auth: config.auth,
|
||||||
|
// Additional options for better deliverability
|
||||||
|
pool: true,
|
||||||
|
maxConnections: 5,
|
||||||
|
maxMessages: 100,
|
||||||
|
// TLS options
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify connection configuration
|
||||||
|
smtpTransporter.verify((error, success) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('[Email] SMTP connection verification failed:', error.message);
|
||||||
|
} else {
|
||||||
|
console.log('[Email] SMTP server is ready to send emails');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return smtpTransporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Email Providers ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email using Resend API
|
||||||
|
*/
|
||||||
|
async function sendWithResend(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||||
|
const apiKey = process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY;
|
||||||
|
const fromEmail = getFromEmail();
|
||||||
|
const fromName = getFromName();
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
console.error('[Email] Resend API key not configured');
|
||||||
|
return { success: false, error: 'Resend API key not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.resend.com/emails', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: `${fromName} <${fromEmail}>`,
|
||||||
|
to: Array.isArray(options.to) ? options.to : [options.to],
|
||||||
|
subject: options.subject,
|
||||||
|
html: options.html,
|
||||||
|
text: options.text,
|
||||||
|
reply_to: options.replyTo,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('[Email] Resend API error:', data);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: data.message || data.error || 'Failed to send email'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Email] Email sent via Resend:', data.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: data.id
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Email] Resend error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Failed to send email via Resend'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email using SMTP (Nodemailer)
|
||||||
|
*/
|
||||||
|
async function sendWithSMTP(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||||
|
const transporter = getSMTPTransporter();
|
||||||
|
|
||||||
|
if (!transporter) {
|
||||||
|
return { success: false, error: 'SMTP not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromEmail = getFromEmail();
|
||||||
|
const fromName = getFromName();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: `"${fromName}" <${fromEmail}>`,
|
||||||
|
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
|
||||||
|
replyTo: options.replyTo,
|
||||||
|
subject: options.subject,
|
||||||
|
html: options.html,
|
||||||
|
text: options.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Email] Email sent via SMTP:', info.messageId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: info.messageId
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Email] SMTP error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Failed to send email via SMTP'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Console logger for development/testing (no actual email sent)
|
||||||
|
*/
|
||||||
|
async function sendWithConsole(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||||
|
const to = Array.isArray(options.to) ? options.to.join(', ') : options.to;
|
||||||
|
|
||||||
|
console.log('\n========================================');
|
||||||
|
console.log('[Email] Console Mode - Email Preview');
|
||||||
|
console.log('========================================');
|
||||||
|
console.log(`To: ${to}`);
|
||||||
|
console.log(`Subject: ${options.subject}`);
|
||||||
|
console.log(`Reply-To: ${options.replyTo || 'N/A'}`);
|
||||||
|
console.log('----------------------------------------');
|
||||||
|
console.log('HTML Body (truncated):');
|
||||||
|
console.log(options.html?.substring(0, 500) + '...');
|
||||||
|
console.log('========================================\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: `console-${Date.now()}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main send function that routes to the appropriate provider
|
||||||
|
*/
|
||||||
|
async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||||
|
const provider = getEmailProvider();
|
||||||
|
|
||||||
|
console.log(`[Email] Sending email via ${provider} to ${Array.isArray(options.to) ? options.to.join(', ') : options.to}`);
|
||||||
|
|
||||||
|
switch (provider) {
|
||||||
|
case 'resend':
|
||||||
|
return sendWithResend(options);
|
||||||
|
case 'smtp':
|
||||||
|
return sendWithSMTP(options);
|
||||||
|
case 'console':
|
||||||
|
default:
|
||||||
|
return sendWithConsole(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Email Service ====================
|
||||||
|
|
||||||
|
export const emailService = {
|
||||||
|
/**
|
||||||
|
* Get current email provider info
|
||||||
|
*/
|
||||||
|
getProviderInfo(): { provider: EmailProvider; configured: boolean } {
|
||||||
|
const provider = getEmailProvider();
|
||||||
|
let configured = false;
|
||||||
|
|
||||||
|
switch (provider) {
|
||||||
|
case 'resend':
|
||||||
|
configured = !!(process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY);
|
||||||
|
break;
|
||||||
|
case 'smtp':
|
||||||
|
configured = !!process.env.SMTP_HOST;
|
||||||
|
break;
|
||||||
|
case 'console':
|
||||||
|
configured = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { provider, configured };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test email configuration by sending a test email
|
||||||
|
*/
|
||||||
|
async testConnection(to: string): Promise<SendEmailResult> {
|
||||||
|
const { provider, configured } = this.getProviderInfo();
|
||||||
|
|
||||||
|
if (!configured) {
|
||||||
|
return { success: false, error: `Email provider "${provider}" is not configured` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendEmail({
|
||||||
|
to,
|
||||||
|
subject: 'Spanglish - Email Test',
|
||||||
|
html: `
|
||||||
|
<h2>Email Configuration Test</h2>
|
||||||
|
<p>This is a test email from your Spanglish platform.</p>
|
||||||
|
<p><strong>Provider:</strong> ${provider}</p>
|
||||||
|
<p><strong>Timestamp:</strong> ${new Date().toISOString()}</p>
|
||||||
|
<p>If you received this email, your email configuration is working correctly!</p>
|
||||||
|
`,
|
||||||
|
text: `Email Configuration Test\n\nProvider: ${provider}\nTimestamp: ${new Date().toISOString()}\n\nIf you received this email, your email configuration is working correctly!`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get common variables for all emails
|
||||||
|
*/
|
||||||
|
getCommonVariables(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
siteName: 'Spanglish',
|
||||||
|
siteUrl: process.env.FRONTEND_URL || 'https://spanglish.com',
|
||||||
|
currentYear: new Date().getFullYear().toString(),
|
||||||
|
supportEmail: process.env.EMAIL_FROM || 'hello@spanglish.com',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date for emails
|
||||||
|
*/
|
||||||
|
formatDate(dateStr: string, locale: string = 'en'): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time for emails
|
||||||
|
*/
|
||||||
|
formatTime(dateStr: string, locale: string = 'en'): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format currency
|
||||||
|
*/
|
||||||
|
formatCurrency(amount: number, currency: string = 'PYG'): string {
|
||||||
|
if (currency === 'PYG') {
|
||||||
|
return `${amount.toLocaleString('es-PY')} PYG`;
|
||||||
|
}
|
||||||
|
return `$${amount.toFixed(2)} ${currency}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a template by slug
|
||||||
|
*/
|
||||||
|
async getTemplate(slug: string): Promise<any | null> {
|
||||||
|
const template = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(emailTemplates)
|
||||||
|
.where(eq((emailTemplates as any).slug, slug))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return template || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed default templates if they don't exist
|
||||||
|
*/
|
||||||
|
async seedDefaultTemplates(): Promise<void> {
|
||||||
|
console.log('[Email] Checking for default templates...');
|
||||||
|
|
||||||
|
for (const template of defaultTemplates) {
|
||||||
|
const existing = await this.getTemplate(template.slug);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
console.log(`[Email] Creating template: ${template.name}`);
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
await (db as any).insert(emailTemplates).values({
|
||||||
|
id: nanoid(),
|
||||||
|
name: template.name,
|
||||||
|
slug: template.slug,
|
||||||
|
subject: template.subject,
|
||||||
|
subjectEs: template.subjectEs,
|
||||||
|
bodyHtml: template.bodyHtml,
|
||||||
|
bodyHtmlEs: template.bodyHtmlEs,
|
||||||
|
bodyText: template.bodyText,
|
||||||
|
bodyTextEs: template.bodyTextEs,
|
||||||
|
description: template.description,
|
||||||
|
variables: JSON.stringify(template.variables),
|
||||||
|
isSystem: template.isSystem ? 1 : 0,
|
||||||
|
isActive: 1,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Email] Default templates check complete');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an email using a template
|
||||||
|
*/
|
||||||
|
async sendTemplateEmail(params: {
|
||||||
|
templateSlug: string;
|
||||||
|
to: string;
|
||||||
|
toName?: string;
|
||||||
|
variables: Record<string, any>;
|
||||||
|
locale?: string;
|
||||||
|
eventId?: string;
|
||||||
|
sentBy?: string;
|
||||||
|
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
||||||
|
const { templateSlug, to, toName, variables, locale = 'en', eventId, sentBy } = params;
|
||||||
|
|
||||||
|
// Get template
|
||||||
|
const template = await this.getTemplate(templateSlug);
|
||||||
|
if (!template) {
|
||||||
|
return { success: false, error: `Template "${templateSlug}" not found` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build variables
|
||||||
|
const allVariables = {
|
||||||
|
...this.getCommonVariables(),
|
||||||
|
lang: locale,
|
||||||
|
...variables,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get localized content
|
||||||
|
const subject = locale === 'es' && template.subjectEs
|
||||||
|
? template.subjectEs
|
||||||
|
: template.subject;
|
||||||
|
const bodyHtml = locale === 'es' && template.bodyHtmlEs
|
||||||
|
? template.bodyHtmlEs
|
||||||
|
: template.bodyHtml;
|
||||||
|
const bodyText = locale === 'es' && template.bodyTextEs
|
||||||
|
? template.bodyTextEs
|
||||||
|
: template.bodyText;
|
||||||
|
|
||||||
|
// Replace variables
|
||||||
|
const finalSubject = replaceTemplateVariables(subject, allVariables);
|
||||||
|
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables);
|
||||||
|
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
|
||||||
|
const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined;
|
||||||
|
|
||||||
|
// Create log entry
|
||||||
|
const logId = nanoid();
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
await (db as any).insert(emailLogs).values({
|
||||||
|
id: logId,
|
||||||
|
templateId: template.id,
|
||||||
|
eventId: eventId || null,
|
||||||
|
recipientEmail: to,
|
||||||
|
recipientName: toName || null,
|
||||||
|
subject: finalSubject,
|
||||||
|
bodyHtml: finalBodyHtml,
|
||||||
|
status: 'pending',
|
||||||
|
sentBy: sentBy || null,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
const result = await sendEmail({
|
||||||
|
to,
|
||||||
|
subject: finalSubject,
|
||||||
|
html: finalBodyHtml,
|
||||||
|
text: finalBodyText,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update log with result
|
||||||
|
if (result.success) {
|
||||||
|
await (db as any)
|
||||||
|
.update(emailLogs)
|
||||||
|
.set({
|
||||||
|
status: 'sent',
|
||||||
|
sentAt: getNow(),
|
||||||
|
})
|
||||||
|
.where(eq((emailLogs as any).id, logId));
|
||||||
|
} else {
|
||||||
|
await (db as any)
|
||||||
|
.update(emailLogs)
|
||||||
|
.set({
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: result.error,
|
||||||
|
})
|
||||||
|
.where(eq((emailLogs as any).id, logId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
logId,
|
||||||
|
error: result.error
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send booking confirmation email
|
||||||
|
*/
|
||||||
|
async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
// Get ticket with event info
|
||||||
|
const ticket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).id, ticketId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return { success: false, error: 'Ticket not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return { success: false, error: 'Event not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = ticket.preferredLanguage || 'en';
|
||||||
|
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||||
|
|
||||||
|
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||||
|
return this.sendTemplateEmail({
|
||||||
|
templateSlug: 'booking-confirmation',
|
||||||
|
to: ticket.attendeeEmail,
|
||||||
|
toName: attendeeFullName,
|
||||||
|
locale,
|
||||||
|
eventId: event.id,
|
||||||
|
variables: {
|
||||||
|
attendeeName: attendeeFullName,
|
||||||
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
|
ticketId: ticket.id,
|
||||||
|
qrCode: ticket.qrCode || '',
|
||||||
|
eventTitle,
|
||||||
|
eventDate: this.formatDate(event.startDatetime, locale),
|
||||||
|
eventTime: this.formatTime(event.startDatetime, locale),
|
||||||
|
eventLocation: event.location,
|
||||||
|
eventLocationUrl: event.locationUrl || '',
|
||||||
|
eventPrice: this.formatCurrency(event.price, event.currency),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send payment receipt email
|
||||||
|
*/
|
||||||
|
async sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
// Get payment with ticket and event info
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).id, paymentId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
return { success: false, error: 'Payment not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return { success: false, error: 'Ticket not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return { success: false, error: 'Event not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = ticket.preferredLanguage || 'en';
|
||||||
|
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||||
|
|
||||||
|
const paymentMethodNames: Record<string, Record<string, string>> = {
|
||||||
|
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash' },
|
||||||
|
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||||
|
return this.sendTemplateEmail({
|
||||||
|
templateSlug: 'payment-receipt',
|
||||||
|
to: ticket.attendeeEmail,
|
||||||
|
toName: receiptFullName,
|
||||||
|
locale,
|
||||||
|
eventId: event.id,
|
||||||
|
variables: {
|
||||||
|
attendeeName: receiptFullName,
|
||||||
|
ticketId: ticket.id,
|
||||||
|
eventTitle,
|
||||||
|
eventDate: this.formatDate(event.startDatetime, locale),
|
||||||
|
paymentAmount: this.formatCurrency(payment.amount, payment.currency),
|
||||||
|
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
|
||||||
|
paymentReference: payment.reference || payment.id,
|
||||||
|
paymentDate: this.formatDate(payment.paidAt || payment.createdAt, locale),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send custom email to event attendees
|
||||||
|
*/
|
||||||
|
async sendToEventAttendees(params: {
|
||||||
|
eventId: string;
|
||||||
|
templateSlug: string;
|
||||||
|
customVariables?: Record<string, any>;
|
||||||
|
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||||
|
sentBy: string;
|
||||||
|
}): Promise<{ success: boolean; sentCount: number; failedCount: number; errors: string[] }> {
|
||||||
|
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
|
||||||
|
|
||||||
|
// Get event
|
||||||
|
const event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return { success: false, sentCount: 0, failedCount: 0, errors: ['Event not found'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tickets based on filter
|
||||||
|
let ticketQuery = (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).eventId, eventId));
|
||||||
|
|
||||||
|
if (recipientFilter !== 'all') {
|
||||||
|
ticketQuery = ticketQuery.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, eventId),
|
||||||
|
eq((tickets as any).status, recipientFilter)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventTickets = await ticketQuery.all();
|
||||||
|
|
||||||
|
if (eventTickets.length === 0) {
|
||||||
|
return { success: true, sentCount: 0, failedCount: 0, errors: ['No recipients found'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let sentCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Send to each attendee
|
||||||
|
for (const ticket of eventTickets) {
|
||||||
|
const locale = ticket.preferredLanguage || 'en';
|
||||||
|
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||||
|
|
||||||
|
const bulkFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||||
|
const result = await this.sendTemplateEmail({
|
||||||
|
templateSlug,
|
||||||
|
to: ticket.attendeeEmail,
|
||||||
|
toName: bulkFullName,
|
||||||
|
locale,
|
||||||
|
eventId: event.id,
|
||||||
|
sentBy,
|
||||||
|
variables: {
|
||||||
|
attendeeName: bulkFullName,
|
||||||
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
|
ticketId: ticket.id,
|
||||||
|
eventTitle,
|
||||||
|
eventDate: this.formatDate(event.startDatetime, locale),
|
||||||
|
eventTime: this.formatTime(event.startDatetime, locale),
|
||||||
|
eventLocation: event.location,
|
||||||
|
eventLocationUrl: event.locationUrl || '',
|
||||||
|
...customVariables,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
sentCount++;
|
||||||
|
} else {
|
||||||
|
failedCount++;
|
||||||
|
errors.push(`Failed to send to ${ticket.attendeeEmail}: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: failedCount === 0,
|
||||||
|
sentCount,
|
||||||
|
failedCount,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a custom email (not from template)
|
||||||
|
*/
|
||||||
|
async sendCustomEmail(params: {
|
||||||
|
to: string;
|
||||||
|
toName?: string;
|
||||||
|
subject: string;
|
||||||
|
bodyHtml: string;
|
||||||
|
bodyText?: string;
|
||||||
|
eventId?: string;
|
||||||
|
sentBy: string;
|
||||||
|
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
||||||
|
const { to, toName, subject, bodyHtml, bodyText, eventId, sentBy } = params;
|
||||||
|
|
||||||
|
const allVariables = {
|
||||||
|
...this.getCommonVariables(),
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables);
|
||||||
|
|
||||||
|
// Create log entry
|
||||||
|
const logId = nanoid();
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
await (db as any).insert(emailLogs).values({
|
||||||
|
id: logId,
|
||||||
|
templateId: null,
|
||||||
|
eventId: eventId || null,
|
||||||
|
recipientEmail: to,
|
||||||
|
recipientName: toName || null,
|
||||||
|
subject,
|
||||||
|
bodyHtml: finalBodyHtml,
|
||||||
|
status: 'pending',
|
||||||
|
sentBy,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
const result = await sendEmail({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html: finalBodyHtml,
|
||||||
|
text: bodyText,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update log
|
||||||
|
if (result.success) {
|
||||||
|
await (db as any)
|
||||||
|
.update(emailLogs)
|
||||||
|
.set({
|
||||||
|
status: 'sent',
|
||||||
|
sentAt: getNow(),
|
||||||
|
})
|
||||||
|
.where(eq((emailLogs as any).id, logId));
|
||||||
|
} else {
|
||||||
|
await (db as any)
|
||||||
|
.update(emailLogs)
|
||||||
|
.set({
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: result.error,
|
||||||
|
})
|
||||||
|
.where(eq((emailLogs as any).id, logId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
logId,
|
||||||
|
error: result.error
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export the main sendEmail function for direct use
|
||||||
|
export { sendEmail };
|
||||||
|
|
||||||
|
export default emailService;
|
||||||
675
backend/src/lib/emailTemplates.ts
Normal file
675
backend/src/lib/emailTemplates.ts
Normal file
@@ -0,0 +1,675 @@
|
|||||||
|
// Email templates for Spanglish platform
|
||||||
|
// These are the default templates that get seeded into the database
|
||||||
|
|
||||||
|
export interface EmailVariable {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
example: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DefaultTemplate {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
subject: string;
|
||||||
|
subjectEs: string;
|
||||||
|
bodyHtml: string;
|
||||||
|
bodyHtmlEs: string;
|
||||||
|
bodyText: string;
|
||||||
|
bodyTextEs: string;
|
||||||
|
description: string;
|
||||||
|
variables: EmailVariable[];
|
||||||
|
isSystem: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common variables available in all templates
|
||||||
|
export const commonVariables: EmailVariable[] = [
|
||||||
|
{ name: 'siteName', description: 'Website name', example: 'Spanglish' },
|
||||||
|
{ name: 'siteUrl', description: 'Website URL', example: 'https://spanglish.com' },
|
||||||
|
{ name: 'currentYear', description: 'Current year', example: '2026' },
|
||||||
|
{ name: 'supportEmail', description: 'Support email address', example: 'hello@spanglish.com' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Booking-specific variables
|
||||||
|
export const bookingVariables: EmailVariable[] = [
|
||||||
|
{ name: 'attendeeName', description: 'Attendee full name', example: 'John Doe' },
|
||||||
|
{ name: 'attendeeEmail', description: 'Attendee email', example: 'john@example.com' },
|
||||||
|
{ name: 'ticketId', description: 'Unique ticket ID', example: 'TKT-ABC123' },
|
||||||
|
{ name: 'qrCode', description: 'QR code for check-in', example: 'data:image/png;base64,...' },
|
||||||
|
{ name: 'eventTitle', description: 'Event title', example: 'Spanglish Night - January Edition' },
|
||||||
|
{ name: 'eventDate', description: 'Event date formatted', example: 'January 28, 2026' },
|
||||||
|
{ name: 'eventTime', description: 'Event time', example: '7:00 PM' },
|
||||||
|
{ name: 'eventLocation', description: 'Event location', example: 'Casa Cultural, Asunción' },
|
||||||
|
{ name: 'eventLocationUrl', description: 'Google Maps link', example: 'https://maps.google.com/...' },
|
||||||
|
{ name: 'eventPrice', description: 'Event price with currency', example: '50,000 PYG' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Payment-specific variables
|
||||||
|
export const paymentVariables: EmailVariable[] = [
|
||||||
|
{ name: 'paymentAmount', description: 'Payment amount with currency', example: '50,000 PYG' },
|
||||||
|
{ name: 'paymentMethod', description: 'Payment method used', example: 'Lightning' },
|
||||||
|
{ name: 'paymentReference', description: 'Payment reference ID', example: 'PAY-XYZ789' },
|
||||||
|
{ name: 'paymentDate', description: 'Payment date', example: 'January 28, 2026' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Base HTML wrapper for all emails
|
||||||
|
export const baseEmailWrapper = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{lang}}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{subject}}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.header h1 span {
|
||||||
|
color: #f4d03f;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
.content h2 {
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.event-card {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.event-card h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.event-detail {
|
||||||
|
display: flex;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.event-detail strong {
|
||||||
|
min-width: 80px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.ticket-box {
|
||||||
|
background-color: #f4d03f;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.ticket-box p {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.ticket-id {
|
||||||
|
font-size: 20px;
|
||||||
|
font-family: monospace;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #f4d03f;
|
||||||
|
color: #1a1a1a;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
background-color: #e6c230;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.qr-code {
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.qr-code img {
|
||||||
|
max-width: 150px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background-color: #eee;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
background-color: #fff9e6;
|
||||||
|
border-left: 4px solid #f4d03f;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.content {
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Span<span>glish</span></h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
{{content}}
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>{{siteName}} - Language Exchange Community in Asunción</p>
|
||||||
|
<p><a href="{{siteUrl}}">{{siteUrl}}</a></p>
|
||||||
|
<p>Questions? Contact us at <a href="mailto:{{supportEmail}}">{{supportEmail}}</a></p>
|
||||||
|
<p>© {{currentYear}} {{siteName}}. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Default templates
|
||||||
|
export const defaultTemplates: DefaultTemplate[] = [
|
||||||
|
{
|
||||||
|
name: 'Booking Confirmation',
|
||||||
|
slug: 'booking-confirmation',
|
||||||
|
subject: 'Your Spanglish ticket is confirmed 🎉',
|
||||||
|
subjectEs: 'Tu entrada de Spanglish está confirmada 🎉',
|
||||||
|
bodyHtml: `
|
||||||
|
<h2>Your Booking is Confirmed!</h2>
|
||||||
|
<p>Hi {{attendeeName}},</p>
|
||||||
|
<p>Great news! Your spot for <strong>{{eventTitle}}</strong> has been confirmed. We can't wait to see you there!</p>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>📅 Event Details</h3>
|
||||||
|
<div class="event-detail"><strong>Event:</strong> {{eventTitle}}</div>
|
||||||
|
<div class="event-detail"><strong>Date:</strong> {{eventDate}}</div>
|
||||||
|
<div class="event-detail"><strong>Time:</strong> {{eventTime}}</div>
|
||||||
|
<div class="event-detail"><strong>Location:</strong> {{eventLocation}}</div>
|
||||||
|
{{#if eventLocationUrl}}
|
||||||
|
<p><a href="{{eventLocationUrl}}" class="btn">📍 View on Map</a></p>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ticket-box">
|
||||||
|
<p>Your Ticket ID</p>
|
||||||
|
<p class="ticket-id">{{ticketId}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if qrCode}}
|
||||||
|
<div class="qr-code">
|
||||||
|
<p><strong>Show this QR code at check-in:</strong></p>
|
||||||
|
<img src="{{qrCode}}" alt="Check-in QR Code" />
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>💡 Important:</strong> Please arrive 10-15 minutes early for check-in. Bring your ticket ID or show this email.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>See you at Spanglish!</p>
|
||||||
|
<p>The Spanglish Team</p>
|
||||||
|
`,
|
||||||
|
bodyHtmlEs: `
|
||||||
|
<h2>¡Tu Reserva está Confirmada!</h2>
|
||||||
|
<p>Hola {{attendeeName}},</p>
|
||||||
|
<p>¡Excelentes noticias! Tu lugar para <strong>{{eventTitle}}</strong> ha sido confirmado. ¡No podemos esperar a verte ahí!</p>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>📅 Detalles del Evento</h3>
|
||||||
|
<div class="event-detail"><strong>Evento:</strong> {{eventTitle}}</div>
|
||||||
|
<div class="event-detail"><strong>Fecha:</strong> {{eventDate}}</div>
|
||||||
|
<div class="event-detail"><strong>Hora:</strong> {{eventTime}}</div>
|
||||||
|
<div class="event-detail"><strong>Ubicación:</strong> {{eventLocation}}</div>
|
||||||
|
{{#if eventLocationUrl}}
|
||||||
|
<p><a href="{{eventLocationUrl}}" class="btn">📍 Ver en el Mapa</a></p>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ticket-box">
|
||||||
|
<p>Tu ID de Ticket</p>
|
||||||
|
<p class="ticket-id">{{ticketId}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if qrCode}}
|
||||||
|
<div class="qr-code">
|
||||||
|
<p><strong>Muestra este código QR en el check-in:</strong></p>
|
||||||
|
<img src="{{qrCode}}" alt="Código QR de Check-in" />
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>💡 Importante:</strong> Por favor llega 10-15 minutos antes para el check-in. Trae tu ID de ticket o muestra este email.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>¡Nos vemos en Spanglish!</p>
|
||||||
|
<p>El Equipo de Spanglish</p>
|
||||||
|
`,
|
||||||
|
bodyText: `Your Booking is Confirmed!
|
||||||
|
|
||||||
|
Hi {{attendeeName}},
|
||||||
|
|
||||||
|
Great news! Your spot for {{eventTitle}} has been confirmed.
|
||||||
|
|
||||||
|
Event Details:
|
||||||
|
- Event: {{eventTitle}}
|
||||||
|
- Date: {{eventDate}}
|
||||||
|
- Time: {{eventTime}}
|
||||||
|
- Location: {{eventLocation}}
|
||||||
|
|
||||||
|
Your Ticket ID: {{ticketId}}
|
||||||
|
|
||||||
|
Important: Please arrive 10-15 minutes early for check-in. Bring your ticket ID or show this email.
|
||||||
|
|
||||||
|
See you at Spanglish!
|
||||||
|
The Spanglish Team`,
|
||||||
|
bodyTextEs: `¡Tu Reserva está Confirmada!
|
||||||
|
|
||||||
|
Hola {{attendeeName}},
|
||||||
|
|
||||||
|
¡Excelentes noticias! Tu lugar para {{eventTitle}} ha sido confirmado.
|
||||||
|
|
||||||
|
Detalles del Evento:
|
||||||
|
- Evento: {{eventTitle}}
|
||||||
|
- Fecha: {{eventDate}}
|
||||||
|
- Hora: {{eventTime}}
|
||||||
|
- Ubicación: {{eventLocation}}
|
||||||
|
|
||||||
|
Tu ID de Ticket: {{ticketId}}
|
||||||
|
|
||||||
|
Importante: Por favor llega 10-15 minutos antes para el check-in. Trae tu ID de ticket o muestra este email.
|
||||||
|
|
||||||
|
¡Nos vemos en Spanglish!
|
||||||
|
El Equipo de Spanglish`,
|
||||||
|
description: 'Sent automatically when a booking is confirmed after payment',
|
||||||
|
variables: [...commonVariables, ...bookingVariables],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Payment Receipt',
|
||||||
|
slug: 'payment-receipt',
|
||||||
|
subject: 'Payment Receipt - Spanglish',
|
||||||
|
subjectEs: 'Recibo de Pago - Spanglish',
|
||||||
|
bodyHtml: `
|
||||||
|
<h2>Payment Received</h2>
|
||||||
|
<p>Hi {{attendeeName}},</p>
|
||||||
|
<p>Thank you for your payment! Here's your receipt for your records.</p>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>💳 Payment Details</h3>
|
||||||
|
<div class="event-detail"><strong>Amount:</strong> {{paymentAmount}}</div>
|
||||||
|
<div class="event-detail"><strong>Method:</strong> {{paymentMethod}}</div>
|
||||||
|
<div class="event-detail"><strong>Reference:</strong> {{paymentReference}}</div>
|
||||||
|
<div class="event-detail"><strong>Date:</strong> {{paymentDate}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>📅 Event</h3>
|
||||||
|
<div class="event-detail"><strong>Event:</strong> {{eventTitle}}</div>
|
||||||
|
<div class="event-detail"><strong>Date:</strong> {{eventDate}}</div>
|
||||||
|
<div class="event-detail"><strong>Ticket ID:</strong> {{ticketId}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Keep this email as your payment confirmation.</p>
|
||||||
|
<p>The Spanglish Team</p>
|
||||||
|
`,
|
||||||
|
bodyHtmlEs: `
|
||||||
|
<h2>Pago Recibido</h2>
|
||||||
|
<p>Hola {{attendeeName}},</p>
|
||||||
|
<p>¡Gracias por tu pago! Aquí está tu recibo para tus registros.</p>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>💳 Detalles del Pago</h3>
|
||||||
|
<div class="event-detail"><strong>Monto:</strong> {{paymentAmount}}</div>
|
||||||
|
<div class="event-detail"><strong>Método:</strong> {{paymentMethod}}</div>
|
||||||
|
<div class="event-detail"><strong>Referencia:</strong> {{paymentReference}}</div>
|
||||||
|
<div class="event-detail"><strong>Fecha:</strong> {{paymentDate}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>📅 Evento</h3>
|
||||||
|
<div class="event-detail"><strong>Evento:</strong> {{eventTitle}}</div>
|
||||||
|
<div class="event-detail"><strong>Fecha:</strong> {{eventDate}}</div>
|
||||||
|
<div class="event-detail"><strong>ID de Ticket:</strong> {{ticketId}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Guarda este email como tu confirmación de pago.</p>
|
||||||
|
<p>El Equipo de Spanglish</p>
|
||||||
|
`,
|
||||||
|
bodyText: `Payment Received
|
||||||
|
|
||||||
|
Hi {{attendeeName}},
|
||||||
|
|
||||||
|
Thank you for your payment! Here's your receipt:
|
||||||
|
|
||||||
|
Payment Details:
|
||||||
|
- Amount: {{paymentAmount}}
|
||||||
|
- Method: {{paymentMethod}}
|
||||||
|
- Reference: {{paymentReference}}
|
||||||
|
- Date: {{paymentDate}}
|
||||||
|
|
||||||
|
Event: {{eventTitle}}
|
||||||
|
Date: {{eventDate}}
|
||||||
|
Ticket ID: {{ticketId}}
|
||||||
|
|
||||||
|
Keep this email as your payment confirmation.
|
||||||
|
|
||||||
|
The Spanglish Team`,
|
||||||
|
bodyTextEs: `Pago Recibido
|
||||||
|
|
||||||
|
Hola {{attendeeName}},
|
||||||
|
|
||||||
|
¡Gracias por tu pago! Aquí está tu recibo:
|
||||||
|
|
||||||
|
Detalles del Pago:
|
||||||
|
- Monto: {{paymentAmount}}
|
||||||
|
- Método: {{paymentMethod}}
|
||||||
|
- Referencia: {{paymentReference}}
|
||||||
|
- Fecha: {{paymentDate}}
|
||||||
|
|
||||||
|
Evento: {{eventTitle}}
|
||||||
|
Fecha: {{eventDate}}
|
||||||
|
ID de Ticket: {{ticketId}}
|
||||||
|
|
||||||
|
Guarda este email como tu confirmación de pago.
|
||||||
|
|
||||||
|
El Equipo de Spanglish`,
|
||||||
|
description: 'Sent automatically after payment is processed',
|
||||||
|
variables: [...commonVariables, ...bookingVariables, ...paymentVariables],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Event Update',
|
||||||
|
slug: 'event-update',
|
||||||
|
subject: 'Important Update: {{eventTitle}}',
|
||||||
|
subjectEs: 'Actualización Importante: {{eventTitle}}',
|
||||||
|
bodyHtml: `
|
||||||
|
<h2>Important Event Update</h2>
|
||||||
|
<p>Hi {{attendeeName}},</p>
|
||||||
|
<p>We have an important update regarding <strong>{{eventTitle}}</strong>.</p>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>📢 Message</h3>
|
||||||
|
<p>{{customMessage}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>📅 Event Details</h3>
|
||||||
|
<div class="event-detail"><strong>Event:</strong> {{eventTitle}}</div>
|
||||||
|
<div class="event-detail"><strong>Date:</strong> {{eventDate}}</div>
|
||||||
|
<div class="event-detail"><strong>Time:</strong> {{eventTime}}</div>
|
||||||
|
<div class="event-detail"><strong>Location:</strong> {{eventLocation}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If you have any questions, please don't hesitate to contact us.</p>
|
||||||
|
<p>The Spanglish Team</p>
|
||||||
|
`,
|
||||||
|
bodyHtmlEs: `
|
||||||
|
<h2>Actualización Importante del Evento</h2>
|
||||||
|
<p>Hola {{attendeeName}},</p>
|
||||||
|
<p>Tenemos una actualización importante sobre <strong>{{eventTitle}}</strong>.</p>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>📢 Mensaje</h3>
|
||||||
|
<p>{{customMessage}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>📅 Detalles del Evento</h3>
|
||||||
|
<div class="event-detail"><strong>Evento:</strong> {{eventTitle}}</div>
|
||||||
|
<div class="event-detail"><strong>Fecha:</strong> {{eventDate}}</div>
|
||||||
|
<div class="event-detail"><strong>Hora:</strong> {{eventTime}}</div>
|
||||||
|
<div class="event-detail"><strong>Ubicación:</strong> {{eventLocation}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Si tienes alguna pregunta, no dudes en contactarnos.</p>
|
||||||
|
<p>El Equipo de Spanglish</p>
|
||||||
|
`,
|
||||||
|
bodyText: `Important Event Update
|
||||||
|
|
||||||
|
Hi {{attendeeName}},
|
||||||
|
|
||||||
|
We have an important update regarding {{eventTitle}}.
|
||||||
|
|
||||||
|
Message:
|
||||||
|
{{customMessage}}
|
||||||
|
|
||||||
|
Event Details:
|
||||||
|
- Event: {{eventTitle}}
|
||||||
|
- Date: {{eventDate}}
|
||||||
|
- Time: {{eventTime}}
|
||||||
|
- Location: {{eventLocation}}
|
||||||
|
|
||||||
|
If you have any questions, please don't hesitate to contact us.
|
||||||
|
|
||||||
|
The Spanglish Team`,
|
||||||
|
bodyTextEs: `Actualización Importante del Evento
|
||||||
|
|
||||||
|
Hola {{attendeeName}},
|
||||||
|
|
||||||
|
Tenemos una actualización importante sobre {{eventTitle}}.
|
||||||
|
|
||||||
|
Mensaje:
|
||||||
|
{{customMessage}}
|
||||||
|
|
||||||
|
Detalles del Evento:
|
||||||
|
- Evento: {{eventTitle}}
|
||||||
|
- Fecha: {{eventDate}}
|
||||||
|
- Hora: {{eventTime}}
|
||||||
|
- Ubicación: {{eventLocation}}
|
||||||
|
|
||||||
|
Si tienes alguna pregunta, no dudes en contactarnos.
|
||||||
|
|
||||||
|
El Equipo de Spanglish`,
|
||||||
|
description: 'Template for sending event updates to attendees (sent manually)',
|
||||||
|
variables: [
|
||||||
|
...commonVariables,
|
||||||
|
...bookingVariables,
|
||||||
|
{ name: 'customMessage', description: 'Custom message from admin', example: 'The venue has changed...' }
|
||||||
|
],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Post-Event Follow-Up',
|
||||||
|
slug: 'post-event-followup',
|
||||||
|
subject: 'Thanks for joining {{eventTitle}}! 🙏',
|
||||||
|
subjectEs: '¡Gracias por asistir a {{eventTitle}}! 🙏',
|
||||||
|
bodyHtml: `
|
||||||
|
<h2>Thank You for Joining Us!</h2>
|
||||||
|
<p>Hi {{attendeeName}},</p>
|
||||||
|
<p>Thank you so much for being part of <strong>{{eventTitle}}</strong>! We hope you had a great time practicing languages and meeting new people.</p>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>💬 Share Your Experience</h3>
|
||||||
|
<p>{{customMessage}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if nextEventTitle}}
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>📅 Next Event</h3>
|
||||||
|
<div class="event-detail"><strong>Event:</strong> {{nextEventTitle}}</div>
|
||||||
|
<div class="event-detail"><strong>Date:</strong> {{nextEventDate}}</div>
|
||||||
|
<p style="text-align: center; margin-top: 16px;">
|
||||||
|
<a href="{{nextEventUrl}}" class="btn">Reserve Your Spot</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<p>Follow us on social media for updates and photos from the event!</p>
|
||||||
|
<p>See you at the next Spanglish!</p>
|
||||||
|
<p>The Spanglish Team</p>
|
||||||
|
`,
|
||||||
|
bodyHtmlEs: `
|
||||||
|
<h2>¡Gracias por Unirte!</h2>
|
||||||
|
<p>Hola {{attendeeName}},</p>
|
||||||
|
<p>¡Muchas gracias por ser parte de <strong>{{eventTitle}}</strong>! Esperamos que hayas pasado un gran momento practicando idiomas y conociendo gente nueva.</p>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>💬 Comparte tu Experiencia</h3>
|
||||||
|
<p>{{customMessage}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if nextEventTitle}}
|
||||||
|
<div class="event-card">
|
||||||
|
<h3>📅 Próximo Evento</h3>
|
||||||
|
<div class="event-detail"><strong>Evento:</strong> {{nextEventTitle}}</div>
|
||||||
|
<div class="event-detail"><strong>Fecha:</strong> {{nextEventDate}}</div>
|
||||||
|
<p style="text-align: center; margin-top: 16px;">
|
||||||
|
<a href="{{nextEventUrl}}" class="btn">Reserva tu Lugar</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<p>¡Síguenos en redes sociales para actualizaciones y fotos del evento!</p>
|
||||||
|
<p>¡Nos vemos en el próximo Spanglish!</p>
|
||||||
|
<p>El Equipo de Spanglish</p>
|
||||||
|
`,
|
||||||
|
bodyText: `Thank You for Joining Us!
|
||||||
|
|
||||||
|
Hi {{attendeeName}},
|
||||||
|
|
||||||
|
Thank you so much for being part of {{eventTitle}}! We hope you had a great time.
|
||||||
|
|
||||||
|
{{customMessage}}
|
||||||
|
|
||||||
|
Follow us on social media for updates and photos from the event!
|
||||||
|
|
||||||
|
See you at the next Spanglish!
|
||||||
|
The Spanglish Team`,
|
||||||
|
bodyTextEs: `¡Gracias por Unirte!
|
||||||
|
|
||||||
|
Hola {{attendeeName}},
|
||||||
|
|
||||||
|
¡Muchas gracias por ser parte de {{eventTitle}}! Esperamos que hayas pasado un gran momento.
|
||||||
|
|
||||||
|
{{customMessage}}
|
||||||
|
|
||||||
|
¡Síguenos en redes sociales para actualizaciones y fotos del evento!
|
||||||
|
|
||||||
|
¡Nos vemos en el próximo Spanglish!
|
||||||
|
El Equipo de Spanglish`,
|
||||||
|
description: 'Template for post-event follow-up emails (sent manually)',
|
||||||
|
variables: [
|
||||||
|
...commonVariables,
|
||||||
|
...bookingVariables,
|
||||||
|
{ name: 'customMessage', description: 'Custom message from admin', example: 'We would love to hear your feedback!' },
|
||||||
|
{ name: 'nextEventTitle', description: 'Next event title (optional)', example: 'Spanglish Night - February' },
|
||||||
|
{ name: 'nextEventDate', description: 'Next event date (optional)', example: 'February 25, 2026' },
|
||||||
|
{ name: 'nextEventUrl', description: 'Next event booking URL (optional)', example: 'https://spanglish.com/book/...' },
|
||||||
|
],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Custom Email',
|
||||||
|
slug: 'custom-email',
|
||||||
|
subject: '{{customSubject}}',
|
||||||
|
subjectEs: '{{customSubject}}',
|
||||||
|
bodyHtml: `
|
||||||
|
<h2>{{customTitle}}</h2>
|
||||||
|
<p>Hi {{attendeeName}},</p>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
{{customMessage}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>The Spanglish Team</p>
|
||||||
|
`,
|
||||||
|
bodyHtmlEs: `
|
||||||
|
<h2>{{customTitle}}</h2>
|
||||||
|
<p>Hola {{attendeeName}},</p>
|
||||||
|
|
||||||
|
<div class="event-card">
|
||||||
|
{{customMessage}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>El Equipo de Spanglish</p>
|
||||||
|
`,
|
||||||
|
bodyText: `{{customTitle}}
|
||||||
|
|
||||||
|
Hi {{attendeeName}},
|
||||||
|
|
||||||
|
{{customMessage}}
|
||||||
|
|
||||||
|
The Spanglish Team`,
|
||||||
|
bodyTextEs: `{{customTitle}}
|
||||||
|
|
||||||
|
Hola {{attendeeName}},
|
||||||
|
|
||||||
|
{{customMessage}}
|
||||||
|
|
||||||
|
El Equipo de Spanglish`,
|
||||||
|
description: 'Blank template for fully custom emails',
|
||||||
|
variables: [
|
||||||
|
...commonVariables,
|
||||||
|
{ name: 'attendeeName', description: 'Recipient name', example: 'John Doe' },
|
||||||
|
{ name: 'customSubject', description: 'Email subject', example: 'Special Announcement' },
|
||||||
|
{ name: 'customTitle', description: 'Email title/heading', example: 'Special Announcement' },
|
||||||
|
{ name: 'customMessage', description: 'Email body content (supports HTML)', example: '<p>Your message here...</p>' },
|
||||||
|
],
|
||||||
|
isSystem: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper function to replace template variables
|
||||||
|
export function replaceTemplateVariables(template: string, variables: Record<string, any>): string {
|
||||||
|
let result = template;
|
||||||
|
|
||||||
|
// Handle conditional blocks {{#if variable}}...{{/if}}
|
||||||
|
const conditionalRegex = /\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g;
|
||||||
|
result = result.replace(conditionalRegex, (match, varName, content) => {
|
||||||
|
return variables[varName] ? content : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replace simple variables {{variable}}
|
||||||
|
const variableRegex = /\{\{(\w+)\}\}/g;
|
||||||
|
result = result.replace(variableRegex, (match, varName) => {
|
||||||
|
return variables[varName] !== undefined ? String(variables[varName]) : match;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to wrap content in the base template
|
||||||
|
export function wrapInBaseTemplate(content: string, variables: Record<string, any>): string {
|
||||||
|
const wrappedContent = baseEmailWrapper.replace('{{content}}', content);
|
||||||
|
return replaceTemplateVariables(wrappedContent, variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all available variables for a template by slug
|
||||||
|
export function getTemplateVariables(slug: string): EmailVariable[] {
|
||||||
|
const template = defaultTemplates.find(t => t.slug === slug);
|
||||||
|
return template?.variables || commonVariables;
|
||||||
|
}
|
||||||
212
backend/src/lib/lnbits.ts
Normal file
212
backend/src/lib/lnbits.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* LNbits API client for Lightning Network payments
|
||||||
|
*
|
||||||
|
* Uses LNbits API to create and manage Lightning invoices with webhook support
|
||||||
|
* for payment detection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Read environment variables dynamically to ensure dotenv has loaded
|
||||||
|
function getConfig() {
|
||||||
|
return {
|
||||||
|
url: process.env.LNBITS_URL || '',
|
||||||
|
apiKey: process.env.LNBITS_API_KEY || '', // Invoice/read key
|
||||||
|
webhookSecret: process.env.LNBITS_WEBHOOK_SECRET || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LNbitsInvoice {
|
||||||
|
paymentHash: string;
|
||||||
|
paymentRequest: string; // BOLT11 invoice string
|
||||||
|
checkingId: string;
|
||||||
|
amount: number; // Amount in satoshis (after conversion)
|
||||||
|
fiatAmount?: number; // Original fiat amount
|
||||||
|
fiatCurrency?: string; // Original fiat currency
|
||||||
|
memo: string;
|
||||||
|
expiry: string;
|
||||||
|
status: string;
|
||||||
|
extra?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInvoiceParams {
|
||||||
|
amount: number; // Amount in the specified unit
|
||||||
|
unit?: string; // Currency unit: 'sat', 'USD', 'PYG', etc. (default: 'sat')
|
||||||
|
memo: string;
|
||||||
|
webhookUrl?: string;
|
||||||
|
expiry?: number; // Expiry in seconds (default 3600 = 1 hour)
|
||||||
|
extra?: Record<string, any>; // Additional metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if LNbits is configured
|
||||||
|
*/
|
||||||
|
export function isLNbitsConfigured(): boolean {
|
||||||
|
const config = getConfig();
|
||||||
|
return !!(config.url && config.apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Lightning invoice using LNbits
|
||||||
|
* LNbits supports fiat currencies directly - it will convert to sats automatically
|
||||||
|
*/
|
||||||
|
export async function createInvoice(params: CreateInvoiceParams): Promise<LNbitsInvoice> {
|
||||||
|
const config = getConfig();
|
||||||
|
|
||||||
|
if (!config.url || !config.apiKey) {
|
||||||
|
throw new Error('LNbits is not configured. Please set LNBITS_URL and LNBITS_API_KEY.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiEndpoint = '/api/v1/payments';
|
||||||
|
|
||||||
|
// LNbits supports fiat currencies via the 'unit' parameter
|
||||||
|
// It will automatically convert to sats using its exchange rate provider
|
||||||
|
const payload: any = {
|
||||||
|
out: false, // false = create invoice for receiving payment
|
||||||
|
amount: params.amount,
|
||||||
|
unit: params.unit || 'sat', // Support fiat currencies like 'USD', 'PYG', etc.
|
||||||
|
memo: params.memo,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.webhookUrl) {
|
||||||
|
payload.webhook = params.webhookUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.expiry) {
|
||||||
|
payload.expiry = params.expiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.extra) {
|
||||||
|
payload.extra = params.extra;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Creating LNbits invoice:', {
|
||||||
|
url: `${config.url}${apiEndpoint}`,
|
||||||
|
amount: params.amount,
|
||||||
|
unit: payload.unit,
|
||||||
|
memo: params.memo,
|
||||||
|
webhook: params.webhookUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${config.url}${apiEndpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': config.apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('LNbits invoice creation failed:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
throw new Error(`Failed to create Lightning invoice: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// LNbits returns amount in millisatoshis for the actual invoice
|
||||||
|
// Convert to satoshis for display
|
||||||
|
const amountSats = data.amount ? Math.round(data.amount / 1000) : params.amount;
|
||||||
|
|
||||||
|
console.log('LNbits invoice created successfully:', {
|
||||||
|
paymentHash: data.payment_hash,
|
||||||
|
amountMsats: data.amount,
|
||||||
|
amountSats,
|
||||||
|
hasPaymentRequest: !!data.payment_request,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentHash: data.payment_hash,
|
||||||
|
paymentRequest: data.payment_request || data.bolt11,
|
||||||
|
checkingId: data.checking_id,
|
||||||
|
amount: amountSats, // Amount in satoshis
|
||||||
|
fiatAmount: params.unit && params.unit !== 'sat' ? params.amount : undefined,
|
||||||
|
fiatCurrency: params.unit && params.unit !== 'sat' ? params.unit : undefined,
|
||||||
|
memo: params.memo,
|
||||||
|
expiry: data.expiry || '',
|
||||||
|
status: data.status || 'pending',
|
||||||
|
extra: params.extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get invoice/payment status from LNbits
|
||||||
|
*/
|
||||||
|
export async function getPaymentStatus(paymentHash: string): Promise<{
|
||||||
|
paid: boolean;
|
||||||
|
status: string;
|
||||||
|
preimage?: string;
|
||||||
|
} | null> {
|
||||||
|
const config = getConfig();
|
||||||
|
|
||||||
|
if (!config.url || !config.apiKey) {
|
||||||
|
throw new Error('LNbits is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiEndpoint = `/api/v1/payments/${paymentHash}`;
|
||||||
|
|
||||||
|
const response = await fetch(`${config.url}${apiEndpoint}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': config.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw new Error('Failed to get payment status from LNbits');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// LNbits payment status: "pending", "complete", "failed"
|
||||||
|
// For incoming payments, "complete" means paid
|
||||||
|
const isPaid = data.status === 'complete' || data.paid === true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
paid: isPaid,
|
||||||
|
status: data.status,
|
||||||
|
preimage: data.preimage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify webhook payload from LNbits
|
||||||
|
* LNbits webhooks don't have signature verification by default,
|
||||||
|
* but we can verify the payment hash matches and check payment status
|
||||||
|
*/
|
||||||
|
export async function verifyWebhookPayment(paymentHash: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const status = await getPaymentStatus(paymentHash);
|
||||||
|
return status?.paid === true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying webhook payment:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment status types
|
||||||
|
*/
|
||||||
|
export type LNbitsPaymentStatus = 'pending' | 'complete' | 'failed' | 'expired';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if payment is complete
|
||||||
|
*/
|
||||||
|
export function isPaymentComplete(status: string): boolean {
|
||||||
|
return status === 'complete';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if payment is still pending
|
||||||
|
*/
|
||||||
|
export function isPaymentPending(status: string): boolean {
|
||||||
|
return status === 'pending';
|
||||||
|
}
|
||||||
37
backend/src/lib/utils.ts
Normal file
37
backend/src/lib/utils.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
export function generateId(): string {
|
||||||
|
return nanoid(21);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateTicketCode(): string {
|
||||||
|
return `TKT-${nanoid(8).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNow(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
|
||||||
|
return new Intl.NumberFormat('es-PY', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateAvailableSeats(capacity: number, bookedCount: number): number {
|
||||||
|
return Math.max(0, capacity - bookedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEventSoldOut(capacity: number, bookedCount: number): boolean {
|
||||||
|
return bookedCount >= capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
284
backend/src/routes/admin.ts
Normal file
284
backend/src/routes/admin.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { db, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
||||||
|
import { eq, and, gte, sql, desc } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '../lib/auth.js';
|
||||||
|
import { getNow } from '../lib/utils.js';
|
||||||
|
|
||||||
|
const adminRouter = new Hono();
|
||||||
|
|
||||||
|
// Dashboard overview stats (admin)
|
||||||
|
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
// Get upcoming events
|
||||||
|
const upcomingEvents = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((events as any).status, 'published'),
|
||||||
|
gte((events as any).startDatetime, now)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy((events as any).startDatetime)
|
||||||
|
.limit(5)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Get recent tickets
|
||||||
|
const recentTickets = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.orderBy(desc((tickets as any).createdAt))
|
||||||
|
.limit(10)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Get total stats
|
||||||
|
const totalUsers = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(users)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const totalEvents = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(events)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const totalTickets = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const confirmedTickets = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).status, 'confirmed'))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const pendingPayments = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).status, 'pending'))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const paidPayments = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).status, 'paid'))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + (p.amount || 0), 0);
|
||||||
|
|
||||||
|
const newContacts = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(contacts)
|
||||||
|
.where(eq((contacts as any).status, 'new'))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const totalSubscribers = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(emailSubscribers)
|
||||||
|
.where(eq((emailSubscribers as any).status, 'active'))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
dashboard: {
|
||||||
|
stats: {
|
||||||
|
totalUsers: totalUsers?.count || 0,
|
||||||
|
totalEvents: totalEvents?.count || 0,
|
||||||
|
totalTickets: totalTickets?.count || 0,
|
||||||
|
confirmedTickets: confirmedTickets?.count || 0,
|
||||||
|
pendingPayments: pendingPayments?.count || 0,
|
||||||
|
totalRevenue,
|
||||||
|
newContacts: newContacts?.count || 0,
|
||||||
|
totalSubscribers: totalSubscribers?.count || 0,
|
||||||
|
},
|
||||||
|
upcomingEvents,
|
||||||
|
recentTickets,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get analytics data (admin)
|
||||||
|
adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
||||||
|
// Get events with ticket counts
|
||||||
|
const allEvents = await (db as any).select().from(events).all();
|
||||||
|
|
||||||
|
const eventStats = await Promise.all(
|
||||||
|
allEvents.map(async (event: any) => {
|
||||||
|
const ticketCount = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).eventId, event.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const confirmedCount = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, event.id),
|
||||||
|
eq((tickets as any).status, 'confirmed')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const checkedInCount = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, event.id),
|
||||||
|
eq((tickets as any).status, 'checked_in')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
date: event.startDatetime,
|
||||||
|
capacity: event.capacity,
|
||||||
|
totalBookings: ticketCount?.count || 0,
|
||||||
|
confirmedBookings: confirmedCount?.count || 0,
|
||||||
|
checkedIn: checkedInCount?.count || 0,
|
||||||
|
revenue: (confirmedCount?.count || 0) * event.price,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
analytics: {
|
||||||
|
events: eventStats,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export data (admin)
|
||||||
|
adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
||||||
|
const eventId = c.req.query('eventId');
|
||||||
|
|
||||||
|
let query = (db as any).select().from(tickets);
|
||||||
|
|
||||||
|
if (eventId) {
|
||||||
|
query = query.where(eq((tickets as any).eventId, eventId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketList = await query.all();
|
||||||
|
|
||||||
|
// Get user and event details for each ticket
|
||||||
|
const enrichedTickets = await Promise.all(
|
||||||
|
ticketList.map(async (ticket: any) => {
|
||||||
|
const user = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq((users as any).id, ticket.userId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).ticketId, ticket.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ticketId: ticket.id,
|
||||||
|
ticketStatus: ticket.status,
|
||||||
|
qrCode: ticket.qrCode,
|
||||||
|
checkinAt: ticket.checkinAt,
|
||||||
|
userName: user?.name,
|
||||||
|
userEmail: user?.email,
|
||||||
|
userPhone: user?.phone,
|
||||||
|
eventTitle: event?.title,
|
||||||
|
eventDate: event?.startDatetime,
|
||||||
|
paymentStatus: payment?.status,
|
||||||
|
paymentAmount: payment?.amount,
|
||||||
|
createdAt: ticket.createdAt,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ tickets: enrichedTickets });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export financial data (admin)
|
||||||
|
adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
||||||
|
const startDate = c.req.query('startDate');
|
||||||
|
const endDate = c.req.query('endDate');
|
||||||
|
const eventId = c.req.query('eventId');
|
||||||
|
|
||||||
|
// Get all payments
|
||||||
|
let query = (db as any).select().from(payments);
|
||||||
|
|
||||||
|
const allPayments = await query.all();
|
||||||
|
|
||||||
|
// Enrich with event and ticket data
|
||||||
|
const enrichedPayments = await Promise.all(
|
||||||
|
allPayments.map(async (payment: any) => {
|
||||||
|
const ticket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!ticket) return null;
|
||||||
|
|
||||||
|
const event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if (eventId && ticket.eventId !== eventId) return null;
|
||||||
|
if (startDate && payment.createdAt < startDate) return null;
|
||||||
|
if (endDate && payment.createdAt > endDate) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentId: payment.id,
|
||||||
|
amount: payment.amount,
|
||||||
|
currency: payment.currency,
|
||||||
|
provider: payment.provider,
|
||||||
|
status: payment.status,
|
||||||
|
reference: payment.reference,
|
||||||
|
paidAt: payment.paidAt,
|
||||||
|
createdAt: payment.createdAt,
|
||||||
|
ticketId: ticket.id,
|
||||||
|
attendeeFirstName: ticket.attendeeFirstName,
|
||||||
|
attendeeLastName: ticket.attendeeLastName,
|
||||||
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
|
eventId: event?.id,
|
||||||
|
eventTitle: event?.title,
|
||||||
|
eventDate: event?.startDatetime,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredPayments = enrichedPayments.filter(p => p !== null);
|
||||||
|
|
||||||
|
// Calculate summary
|
||||||
|
const summary = {
|
||||||
|
totalPayments: filteredPayments.length,
|
||||||
|
totalPaid: filteredPayments.filter((p: any) => p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||||
|
totalPending: filteredPayments.filter((p: any) => p.status === 'pending').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||||
|
totalRefunded: filteredPayments.filter((p: any) => p.status === 'refunded').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||||
|
byProvider: {
|
||||||
|
bancard: filteredPayments.filter((p: any) => p.provider === 'bancard' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||||
|
lightning: filteredPayments.filter((p: any) => p.provider === 'lightning' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||||
|
cash: filteredPayments.filter((p: any) => p.provider === 'cash' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||||
|
},
|
||||||
|
paidCount: filteredPayments.filter((p: any) => p.status === 'paid').length,
|
||||||
|
pendingCount: filteredPayments.filter((p: any) => p.status === 'pending').length,
|
||||||
|
refundedCount: filteredPayments.filter((p: any) => p.status === 'refunded').length,
|
||||||
|
failedCount: filteredPayments.filter((p: any) => p.status === 'failed').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json({ payments: filteredPayments, summary });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default adminRouter;
|
||||||
652
backend/src/routes/auth.ts
Normal file
652
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db, users, magicLinkTokens, User } from '../db/index.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
createToken,
|
||||||
|
createRefreshToken,
|
||||||
|
isFirstUser,
|
||||||
|
getAuthUser,
|
||||||
|
validatePassword,
|
||||||
|
createMagicLinkToken,
|
||||||
|
verifyMagicLinkToken,
|
||||||
|
invalidateAllUserSessions,
|
||||||
|
requireAuth,
|
||||||
|
} from '../lib/auth.js';
|
||||||
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
|
import { sendEmail } from '../lib/email.js';
|
||||||
|
|
||||||
|
// User type that includes all fields (some added in schema updates)
|
||||||
|
type AuthUser = User & {
|
||||||
|
isClaimed: boolean;
|
||||||
|
googleId: string | null;
|
||||||
|
rucNumber: string | null;
|
||||||
|
accountStatus: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const auth = new Hono();
|
||||||
|
|
||||||
|
// Rate limiting store (in production, use Redis)
|
||||||
|
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
const MAX_LOGIN_ATTEMPTS = 5;
|
||||||
|
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
|
function checkRateLimit(email: string): { allowed: boolean; retryAfter?: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const attempts = loginAttempts.get(email);
|
||||||
|
|
||||||
|
if (!attempts) {
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now > attempts.resetAt) {
|
||||||
|
loginAttempts.delete(email);
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
|
||||||
|
return { allowed: false, retryAfter: Math.ceil((attempts.resetAt - now) / 1000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailedAttempt(email: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const attempts = loginAttempts.get(email) || { count: 0, resetAt: now + LOCKOUT_DURATION };
|
||||||
|
attempts.count++;
|
||||||
|
loginAttempts.set(email, attempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFailedAttempts(email: string): void {
|
||||||
|
loginAttempts.delete(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(10, 'Password must be at least 10 characters'),
|
||||||
|
name: z.string().min(2),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
languagePreference: z.enum(['en', 'es']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const magicLinkRequestSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const magicLinkVerifySchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordResetRequestSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordResetSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
password: z.string().min(10, 'Password must be at least 10 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const claimAccountSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
password: z.string().min(10, 'Password must be at least 10 characters').optional(),
|
||||||
|
googleId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const changePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string(),
|
||||||
|
newPassword: z.string().min(10, 'Password must be at least 10 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const googleAuthSchema = z.object({
|
||||||
|
credential: z.string(), // Google ID token
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register
|
||||||
|
auth.post('/register', zValidator('json', registerSchema), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
// Validate password strength
|
||||||
|
const passwordValidation = validatePassword(data.password);
|
||||||
|
if (!passwordValidation.valid) {
|
||||||
|
return c.json({ error: passwordValidation.error }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email exists
|
||||||
|
const existing = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get();
|
||||||
|
if (existing) {
|
||||||
|
// If user exists but is unclaimed, allow claiming
|
||||||
|
if (!existing.isClaimed || existing.accountStatus === 'unclaimed') {
|
||||||
|
return c.json({
|
||||||
|
error: 'Email already registered',
|
||||||
|
canClaim: true,
|
||||||
|
message: 'This email has an unclaimed account. Please check your email for the claim link or request a new one.'
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
return c.json({ error: 'Email already registered' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first user (becomes admin)
|
||||||
|
const firstUser = await isFirstUser();
|
||||||
|
|
||||||
|
const hashedPassword = await hashPassword(data.password);
|
||||||
|
const now = getNow();
|
||||||
|
const id = generateId();
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
id,
|
||||||
|
email: data.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
name: data.name,
|
||||||
|
phone: data.phone || null,
|
||||||
|
role: firstUser ? 'admin' : 'user',
|
||||||
|
languagePreference: data.languagePreference || null,
|
||||||
|
isClaimed: true,
|
||||||
|
googleId: null,
|
||||||
|
rucNumber: null,
|
||||||
|
accountStatus: 'active',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(users).values(newUser);
|
||||||
|
|
||||||
|
const token = await createToken(id, data.email, newUser.role);
|
||||||
|
const refreshToken = await createRefreshToken(id);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
user: {
|
||||||
|
id,
|
||||||
|
email: data.email,
|
||||||
|
name: data.name,
|
||||||
|
role: newUser.role,
|
||||||
|
isClaimed: true,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
message: firstUser ? 'Admin account created successfully' : 'Account created successfully',
|
||||||
|
}, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login with email/password
|
||||||
|
auth.post('/login', zValidator('json', loginSchema), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
// Check rate limit
|
||||||
|
const rateLimit = checkRateLimit(data.email);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return c.json({
|
||||||
|
error: 'Too many login attempts. Please try again later.',
|
||||||
|
retryAfter: rateLimit.retryAfter
|
||||||
|
}, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get();
|
||||||
|
if (!user) {
|
||||||
|
recordFailedAttempt(data.email);
|
||||||
|
return c.json({ error: 'Invalid credentials' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account is suspended
|
||||||
|
if (user.accountStatus === 'suspended') {
|
||||||
|
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has a password set
|
||||||
|
if (!user.password) {
|
||||||
|
return c.json({
|
||||||
|
error: 'No password set for this account',
|
||||||
|
needsClaim: !user.isClaimed,
|
||||||
|
message: user.isClaimed
|
||||||
|
? 'Please use Google login or request a password reset.'
|
||||||
|
: 'Please claim your account first.'
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPassword = await verifyPassword(data.password, user.password);
|
||||||
|
if (!validPassword) {
|
||||||
|
recordFailedAttempt(data.email);
|
||||||
|
return c.json({ error: 'Invalid credentials' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear failed attempts on successful login
|
||||||
|
clearFailedAttempts(data.email);
|
||||||
|
|
||||||
|
const token = await createToken(user.id, user.email, user.role);
|
||||||
|
const refreshToken = await createRefreshToken(user.id);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
isClaimed: user.isClaimed,
|
||||||
|
phone: user.phone,
|
||||||
|
rucNumber: user.rucNumber,
|
||||||
|
languagePreference: user.languagePreference,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request magic link login
|
||||||
|
auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), async (c) => {
|
||||||
|
const { email } = c.req.valid('json');
|
||||||
|
|
||||||
|
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Don't reveal if email exists
|
||||||
|
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.accountStatus === 'suspended') {
|
||||||
|
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create magic link token (expires in 10 minutes)
|
||||||
|
const token = await createMagicLinkToken(user.id, 'login', 10);
|
||||||
|
const magicLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/magic-link?token=${token}`;
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
try {
|
||||||
|
await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: 'Your Spanglish Login Link',
|
||||||
|
html: `
|
||||||
|
<h2>Login to Spanglish</h2>
|
||||||
|
<p>Click the link below to log in. This link expires in 10 minutes.</p>
|
||||||
|
<p><a href="${magicLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
|
||||||
|
<p>Or copy this link: ${magicLink}</p>
|
||||||
|
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send magic link email:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify magic link and login
|
||||||
|
auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async (c) => {
|
||||||
|
const { token } = c.req.valid('json');
|
||||||
|
|
||||||
|
const verification = await verifyMagicLinkToken(token, 'login');
|
||||||
|
|
||||||
|
if (!verification.valid) {
|
||||||
|
return c.json({ error: verification.error }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await (db as any).select().from(users).where(eq((users as any).id, verification.userId)).get();
|
||||||
|
|
||||||
|
if (!user || user.accountStatus === 'suspended') {
|
||||||
|
return c.json({ error: 'Invalid token' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authToken = await createToken(user.id, user.email, user.role);
|
||||||
|
const refreshToken = await createRefreshToken(user.id);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
isClaimed: user.isClaimed,
|
||||||
|
phone: user.phone,
|
||||||
|
rucNumber: user.rucNumber,
|
||||||
|
languagePreference: user.languagePreference,
|
||||||
|
},
|
||||||
|
token: authToken,
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request password reset
|
||||||
|
auth.post('/password-reset/request', zValidator('json', passwordResetRequestSchema), async (c) => {
|
||||||
|
const { email } = c.req.valid('json');
|
||||||
|
|
||||||
|
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Don't reveal if email exists
|
||||||
|
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.accountStatus === 'suspended') {
|
||||||
|
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create reset token (expires in 30 minutes)
|
||||||
|
const token = await createMagicLinkToken(user.id, 'reset_password', 30);
|
||||||
|
const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/reset-password?token=${token}`;
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
try {
|
||||||
|
await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: 'Reset Your Spanglish Password',
|
||||||
|
html: `
|
||||||
|
<h2>Reset Your Password</h2>
|
||||||
|
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
|
||||||
|
<p><a href="${resetLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
|
||||||
|
<p>Or copy this link: ${resetLink}</p>
|
||||||
|
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send password reset email:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset password
|
||||||
|
auth.post('/password-reset/confirm', zValidator('json', passwordResetSchema), async (c) => {
|
||||||
|
const { token, password } = c.req.valid('json');
|
||||||
|
|
||||||
|
// Validate password strength
|
||||||
|
const passwordValidation = validatePassword(password);
|
||||||
|
if (!passwordValidation.valid) {
|
||||||
|
return c.json({ error: passwordValidation.error }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = await verifyMagicLinkToken(token, 'reset_password');
|
||||||
|
|
||||||
|
if (!verification.valid) {
|
||||||
|
return c.json({ error: verification.error }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await hashPassword(password);
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
password: hashedPassword,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((users as any).id, verification.userId));
|
||||||
|
|
||||||
|
// Invalidate all existing sessions for security
|
||||||
|
await invalidateAllUserSessions(verification.userId!);
|
||||||
|
|
||||||
|
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Claim unclaimed account
|
||||||
|
auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), async (c) => {
|
||||||
|
const { email } = c.req.valid('json');
|
||||||
|
|
||||||
|
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.isClaimed && user.accountStatus !== 'unclaimed') {
|
||||||
|
return c.json({ error: 'Account is already claimed' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create claim token (expires in 24 hours)
|
||||||
|
const token = await createMagicLinkToken(user.id, 'claim_account', 24 * 60);
|
||||||
|
const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`;
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
try {
|
||||||
|
await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: 'Claim Your Spanglish Account',
|
||||||
|
html: `
|
||||||
|
<h2>Claim Your Account</h2>
|
||||||
|
<p>An account was created for you during booking. Click below to set up your login credentials.</p>
|
||||||
|
<p><a href="${claimLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
|
||||||
|
<p>Or copy this link: ${claimLink}</p>
|
||||||
|
<p>This link expires in 24 hours.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send claim account email:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Complete account claim
|
||||||
|
auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), async (c) => {
|
||||||
|
const { token, password, googleId } = c.req.valid('json');
|
||||||
|
|
||||||
|
if (!password && !googleId) {
|
||||||
|
return c.json({ error: 'Please provide either a password or link a Google account' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verification = await verifyMagicLinkToken(token, 'claim_account');
|
||||||
|
|
||||||
|
if (!verification.valid) {
|
||||||
|
return c.json({ error: verification.error }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
const updates: Record<string, any> = {
|
||||||
|
isClaimed: true,
|
||||||
|
accountStatus: 'active',
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
const passwordValidation = validatePassword(password);
|
||||||
|
if (!passwordValidation.valid) {
|
||||||
|
return c.json({ error: passwordValidation.error }, 400);
|
||||||
|
}
|
||||||
|
updates.password = await hashPassword(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (googleId) {
|
||||||
|
updates.googleId = googleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(users)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq((users as any).id, verification.userId));
|
||||||
|
|
||||||
|
const user = await (db as any).select().from(users).where(eq((users as any).id, verification.userId)).get();
|
||||||
|
|
||||||
|
const authToken = await createToken(user.id, user.email, user.role);
|
||||||
|
const refreshToken = await createRefreshToken(user.id);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
isClaimed: user.isClaimed,
|
||||||
|
phone: user.phone,
|
||||||
|
rucNumber: user.rucNumber,
|
||||||
|
languagePreference: user.languagePreference,
|
||||||
|
},
|
||||||
|
token: authToken,
|
||||||
|
refreshToken,
|
||||||
|
message: 'Account claimed successfully!',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Google OAuth login/register
|
||||||
|
auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
|
||||||
|
const { credential } = c.req.valid('json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify Google token
|
||||||
|
// In production, use Google's library to verify: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
|
||||||
|
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return c.json({ error: 'Invalid Google token' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const googleData = await response.json() as {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
email_verified: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (googleData.email_verified !== 'true') {
|
||||||
|
return c.json({ error: 'Google email not verified' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sub: googleId, email, name } = googleData;
|
||||||
|
|
||||||
|
// Check if user exists by email or google_id
|
||||||
|
let user = await (db as any).select().from(users).where(eq((users as any).email, email)).get();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Check by google_id
|
||||||
|
user = await (db as any).select().from(users).where(eq((users as any).googleId, googleId)).get();
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// User exists - link Google account if not already linked
|
||||||
|
if (user.accountStatus === 'suspended') {
|
||||||
|
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.googleId) {
|
||||||
|
await (db as any)
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
googleId,
|
||||||
|
isClaimed: true,
|
||||||
|
accountStatus: 'active',
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((users as any).id, user.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh user data
|
||||||
|
user = await (db as any).select().from(users).where(eq((users as any).id, user.id)).get();
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
const firstUser = await isFirstUser();
|
||||||
|
const id = generateId();
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
password: null,
|
||||||
|
name,
|
||||||
|
phone: null,
|
||||||
|
role: firstUser ? 'admin' : 'user',
|
||||||
|
languagePreference: null,
|
||||||
|
isClaimed: true,
|
||||||
|
googleId,
|
||||||
|
rucNumber: null,
|
||||||
|
accountStatus: 'active',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(users).values(newUser);
|
||||||
|
user = newUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authToken = await createToken(user.id, user.email, user.role);
|
||||||
|
const refreshToken = await createRefreshToken(user.id);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
isClaimed: user.isClaimed,
|
||||||
|
phone: user.phone,
|
||||||
|
rucNumber: user.rucNumber,
|
||||||
|
languagePreference: user.languagePreference,
|
||||||
|
},
|
||||||
|
token: authToken,
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google auth error:', error);
|
||||||
|
return c.json({ error: 'Failed to authenticate with Google' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
auth.get('/me', async (c) => {
|
||||||
|
const user = await getAuthUser(c);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
phone: user.phone,
|
||||||
|
isClaimed: user.isClaimed,
|
||||||
|
rucNumber: user.rucNumber,
|
||||||
|
languagePreference: user.languagePreference,
|
||||||
|
accountStatus: user.accountStatus,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change password (authenticated users)
|
||||||
|
auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSchema), async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
const { currentPassword, newPassword } = c.req.valid('json');
|
||||||
|
|
||||||
|
// Validate new password
|
||||||
|
const passwordValidation = validatePassword(newPassword);
|
||||||
|
if (!passwordValidation.valid) {
|
||||||
|
return c.json({ error: passwordValidation.error }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password if user has one
|
||||||
|
if (user.password) {
|
||||||
|
const validPassword = await verifyPassword(currentPassword, user.password);
|
||||||
|
if (!validPassword) {
|
||||||
|
return c.json({ error: 'Current password is incorrect' }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await hashPassword(newPassword);
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
password: hashedPassword,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((users as any).id, user.id));
|
||||||
|
|
||||||
|
return c.json({ message: 'Password changed successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout (client-side token removal, but we can log the action)
|
||||||
|
auth.post('/logout', async (c) => {
|
||||||
|
return c.json({ message: 'Logged out successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default auth;
|
||||||
193
backend/src/routes/contacts.ts
Normal file
193
backend/src/routes/contacts.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db, contacts, emailSubscribers } from '../db/index.js';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '../lib/auth.js';
|
||||||
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
|
|
||||||
|
const contactsRouter = new Hono();
|
||||||
|
|
||||||
|
const createContactSchema = z.object({
|
||||||
|
name: z.string().min(2),
|
||||||
|
email: z.string().email(),
|
||||||
|
message: z.string().min(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscribeSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateContactSchema = z.object({
|
||||||
|
status: z.enum(['new', 'read', 'replied']),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit contact form (public)
|
||||||
|
contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const now = getNow();
|
||||||
|
const id = generateId();
|
||||||
|
|
||||||
|
const newContact = {
|
||||||
|
id,
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
message: data.message,
|
||||||
|
status: 'new' as const,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(contacts).values(newContact);
|
||||||
|
|
||||||
|
return c.json({ message: 'Message sent successfully' }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to newsletter (public)
|
||||||
|
contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
// Check if already subscribed
|
||||||
|
const existing = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(emailSubscribers)
|
||||||
|
.where(eq((emailSubscribers as any).email, data.email))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === 'unsubscribed') {
|
||||||
|
// Resubscribe
|
||||||
|
await (db as any)
|
||||||
|
.update(emailSubscribers)
|
||||||
|
.set({ status: 'active' })
|
||||||
|
.where(eq((emailSubscribers as any).id, existing.id));
|
||||||
|
|
||||||
|
return c.json({ message: 'Successfully resubscribed' });
|
||||||
|
}
|
||||||
|
return c.json({ message: 'Already subscribed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
const id = generateId();
|
||||||
|
|
||||||
|
const newSubscriber = {
|
||||||
|
id,
|
||||||
|
email: data.email,
|
||||||
|
name: data.name || null,
|
||||||
|
status: 'active' as const,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(emailSubscribers).values(newSubscriber);
|
||||||
|
|
||||||
|
return c.json({ message: 'Successfully subscribed' }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unsubscribe from newsletter (public)
|
||||||
|
contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => {
|
||||||
|
const { email } = c.req.valid('json');
|
||||||
|
|
||||||
|
const existing = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(emailSubscribers)
|
||||||
|
.where(eq((emailSubscribers as any).email, email))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ error: 'Email not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(emailSubscribers)
|
||||||
|
.set({ status: 'unsubscribed' })
|
||||||
|
.where(eq((emailSubscribers as any).id, existing.id));
|
||||||
|
|
||||||
|
return c.json({ message: 'Successfully unsubscribed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all contacts (admin)
|
||||||
|
contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const status = c.req.query('status');
|
||||||
|
|
||||||
|
let query = (db as any).select().from(contacts);
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
query = query.where(eq((contacts as any).status, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query.orderBy(desc((contacts as any).createdAt)).all();
|
||||||
|
|
||||||
|
return c.json({ contacts: result });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single contact (admin)
|
||||||
|
contactsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const contact = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(contacts)
|
||||||
|
.where(eq((contacts as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
return c.json({ error: 'Contact not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ contact });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update contact status (admin)
|
||||||
|
contactsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateContactSchema), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
const existing = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(contacts)
|
||||||
|
.where(eq((contacts as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ error: 'Contact not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(contacts)
|
||||||
|
.set({ status: data.status })
|
||||||
|
.where(eq((contacts as any).id, id));
|
||||||
|
|
||||||
|
const updated = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(contacts)
|
||||||
|
.where(eq((contacts as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({ contact: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete contact (admin)
|
||||||
|
contactsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
await (db as any).delete(contacts).where(eq((contacts as any).id, id));
|
||||||
|
|
||||||
|
return c.json({ message: 'Contact deleted successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all subscribers (admin)
|
||||||
|
contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), async (c) => {
|
||||||
|
const status = c.req.query('status');
|
||||||
|
|
||||||
|
let query = (db as any).select().from(emailSubscribers);
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
query = query.where(eq((emailSubscribers as any).status, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query.orderBy(desc((emailSubscribers as any).createdAt)).all();
|
||||||
|
|
||||||
|
return c.json({ subscribers: result });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default contactsRouter;
|
||||||
576
backend/src/routes/dashboard.ts
Normal file
576
backend/src/routes/dashboard.ts
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db, users, tickets, payments, events, invoices, User } from '../db/index.js';
|
||||||
|
import { eq, desc, and, gt, sql } from 'drizzle-orm';
|
||||||
|
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.js';
|
||||||
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
|
|
||||||
|
// User type that includes all fields (some added in schema updates)
|
||||||
|
type AuthUser = User & {
|
||||||
|
isClaimed: boolean;
|
||||||
|
googleId: string | null;
|
||||||
|
rucNumber: string | null;
|
||||||
|
accountStatus: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dashboard = new Hono();
|
||||||
|
|
||||||
|
// Apply authentication to all routes
|
||||||
|
dashboard.use('*', requireAuth());
|
||||||
|
|
||||||
|
// ==================== Profile Routes ====================
|
||||||
|
|
||||||
|
const updateProfileSchema = z.object({
|
||||||
|
name: z.string().min(2).optional(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
languagePreference: z.enum(['en', 'es']).optional(),
|
||||||
|
rucNumber: z.string().max(15).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user profile
|
||||||
|
dashboard.get('/profile', async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
|
||||||
|
// Get membership duration
|
||||||
|
const createdDate = new Date(user.createdAt);
|
||||||
|
const now = new Date();
|
||||||
|
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
profile: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
phone: user.phone,
|
||||||
|
languagePreference: user.languagePreference,
|
||||||
|
rucNumber: user.rucNumber,
|
||||||
|
isClaimed: user.isClaimed,
|
||||||
|
accountStatus: user.accountStatus,
|
||||||
|
hasPassword: !!user.password,
|
||||||
|
hasGoogleLinked: !!user.googleId,
|
||||||
|
memberSince: user.createdAt,
|
||||||
|
membershipDays,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update profile
|
||||||
|
dashboard.put('/profile', zValidator('json', updateProfileSchema), async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
...data,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((users as any).id, user.id));
|
||||||
|
|
||||||
|
const updatedUser = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq((users as any).id, user.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
profile: {
|
||||||
|
id: updatedUser.id,
|
||||||
|
email: updatedUser.email,
|
||||||
|
name: updatedUser.name,
|
||||||
|
phone: updatedUser.phone,
|
||||||
|
languagePreference: updatedUser.languagePreference,
|
||||||
|
rucNumber: updatedUser.rucNumber,
|
||||||
|
},
|
||||||
|
message: 'Profile updated successfully',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Tickets Routes ====================
|
||||||
|
|
||||||
|
// Get user's tickets
|
||||||
|
dashboard.get('/tickets', async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
|
||||||
|
const userTickets = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).userId, user.id))
|
||||||
|
.orderBy(desc((tickets as any).createdAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Get event details for each ticket
|
||||||
|
const ticketsWithEvents = await Promise.all(
|
||||||
|
userTickets.map(async (ticket: any) => {
|
||||||
|
const event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).ticketId, ticket.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Check for invoice
|
||||||
|
let invoice = null;
|
||||||
|
if (payment && payment.status === 'paid') {
|
||||||
|
invoice = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq((invoices as any).paymentId, payment.id))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...ticket,
|
||||||
|
event: event ? {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
titleEs: event.titleEs,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
endDatetime: event.endDatetime,
|
||||||
|
location: event.location,
|
||||||
|
locationUrl: event.locationUrl,
|
||||||
|
price: event.price,
|
||||||
|
currency: event.currency,
|
||||||
|
status: event.status,
|
||||||
|
bannerUrl: event.bannerUrl,
|
||||||
|
} : null,
|
||||||
|
payment: payment ? {
|
||||||
|
id: payment.id,
|
||||||
|
provider: payment.provider,
|
||||||
|
amount: payment.amount,
|
||||||
|
currency: payment.currency,
|
||||||
|
status: payment.status,
|
||||||
|
paidAt: payment.paidAt,
|
||||||
|
} : null,
|
||||||
|
invoice: invoice ? {
|
||||||
|
id: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
pdfUrl: invoice.pdfUrl,
|
||||||
|
createdAt: invoice.createdAt,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ tickets: ticketsWithEvents });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single ticket detail
|
||||||
|
dashboard.get('/tickets/:id', async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
const ticketId = c.req.param('id');
|
||||||
|
|
||||||
|
const ticket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).id, ticketId),
|
||||||
|
eq((tickets as any).userId, user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return c.json({ error: 'Ticket not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).ticketId, ticket.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
let invoice = null;
|
||||||
|
if (payment && payment.status === 'paid') {
|
||||||
|
invoice = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq((invoices as any).paymentId, payment.id))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
ticket: {
|
||||||
|
...ticket,
|
||||||
|
event,
|
||||||
|
payment,
|
||||||
|
invoice,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Next Event Route ====================
|
||||||
|
|
||||||
|
// Get next upcoming event for user
|
||||||
|
dashboard.get('/next-event', async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
// Get user's tickets for upcoming events
|
||||||
|
const userTickets = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).userId, user.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (userTickets.length === 0) {
|
||||||
|
return c.json({ nextEvent: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the next upcoming event
|
||||||
|
let nextEvent = null;
|
||||||
|
let nextTicket = null;
|
||||||
|
let nextPayment = null;
|
||||||
|
|
||||||
|
for (const ticket of userTickets) {
|
||||||
|
if (ticket.status === 'cancelled') continue;
|
||||||
|
|
||||||
|
const event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!event) continue;
|
||||||
|
|
||||||
|
// Check if event is in the future
|
||||||
|
if (new Date(event.startDatetime) > new Date()) {
|
||||||
|
if (!nextEvent || new Date(event.startDatetime) < new Date(nextEvent.startDatetime)) {
|
||||||
|
nextEvent = event;
|
||||||
|
nextTicket = ticket;
|
||||||
|
nextPayment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).ticketId, ticket.id))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextEvent) {
|
||||||
|
return c.json({ nextEvent: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
nextEvent: {
|
||||||
|
event: nextEvent,
|
||||||
|
ticket: nextTicket,
|
||||||
|
payment: nextPayment,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Payments & Invoices Routes ====================
|
||||||
|
|
||||||
|
// Get payment history
|
||||||
|
dashboard.get('/payments', async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
|
||||||
|
// Get all user's tickets first
|
||||||
|
const userTickets = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).userId, user.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const ticketIds = userTickets.map((t: any) => t.id);
|
||||||
|
|
||||||
|
if (ticketIds.length === 0) {
|
||||||
|
return c.json({ payments: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all payments for user's tickets
|
||||||
|
const allPayments = [];
|
||||||
|
for (const ticketId of ticketIds) {
|
||||||
|
const ticketPayments = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).ticketId, ticketId))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
for (const payment of ticketPayments) {
|
||||||
|
const ticket = userTickets.find((t: any) => t.id === payment.ticketId);
|
||||||
|
const event = ticket
|
||||||
|
? await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
.get()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let invoice = null;
|
||||||
|
if (payment.status === 'paid') {
|
||||||
|
invoice = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq((invoices as any).paymentId, payment.id))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
allPayments.push({
|
||||||
|
...payment,
|
||||||
|
ticket: ticket ? {
|
||||||
|
id: ticket.id,
|
||||||
|
attendeeFirstName: ticket.attendeeFirstName,
|
||||||
|
attendeeLastName: ticket.attendeeLastName,
|
||||||
|
status: ticket.status,
|
||||||
|
} : null,
|
||||||
|
event: event ? {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
titleEs: event.titleEs,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
} : null,
|
||||||
|
invoice: invoice ? {
|
||||||
|
id: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
pdfUrl: invoice.pdfUrl,
|
||||||
|
} : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by createdAt desc
|
||||||
|
allPayments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
|
return c.json({ payments: allPayments });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get invoices
|
||||||
|
dashboard.get('/invoices', async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
|
||||||
|
const userInvoices = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq((invoices as any).userId, user.id))
|
||||||
|
.orderBy(desc((invoices as any).createdAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Get payment and event details for each invoice
|
||||||
|
const invoicesWithDetails = await Promise.all(
|
||||||
|
userInvoices.map(async (invoice: any) => {
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).id, invoice.paymentId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
let event = null;
|
||||||
|
if (payment) {
|
||||||
|
const ticket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (ticket) {
|
||||||
|
event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...invoice,
|
||||||
|
event: event ? {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
titleEs: event.titleEs,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ invoices: invoicesWithDetails });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Security Routes ====================
|
||||||
|
|
||||||
|
// Get active sessions
|
||||||
|
dashboard.get('/sessions', async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
|
||||||
|
const sessions = await getUserSessions(user.id);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
sessions: sessions.map((s: any) => ({
|
||||||
|
id: s.id,
|
||||||
|
userAgent: s.userAgent,
|
||||||
|
ipAddress: s.ipAddress,
|
||||||
|
lastActiveAt: s.lastActiveAt,
|
||||||
|
createdAt: s.createdAt,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revoke a specific session
|
||||||
|
dashboard.delete('/sessions/:id', async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
const sessionId = c.req.param('id');
|
||||||
|
|
||||||
|
await invalidateSession(sessionId, user.id);
|
||||||
|
|
||||||
|
return c.json({ message: 'Session revoked' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revoke all sessions (logout everywhere)
|
||||||
|
dashboard.post('/sessions/revoke-all', async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
|
||||||
|
await invalidateAllUserSessions(user.id);
|
||||||
|
|
||||||
|
return c.json({ message: 'All sessions revoked. Please log in again.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set password (for users without one)
|
||||||
|
const setPasswordSchema = z.object({
|
||||||
|
password: z.string().min(10, 'Password must be at least 10 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
const { password } = c.req.valid('json');
|
||||||
|
|
||||||
|
// Check if user already has a password
|
||||||
|
if (user.password) {
|
||||||
|
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordValidation = validatePassword(password);
|
||||||
|
if (!passwordValidation.valid) {
|
||||||
|
return c.json({ error: passwordValidation.error }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await hashPassword(password);
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
password: hashedPassword,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((users as any).id, user.id));
|
||||||
|
|
||||||
|
return c.json({ message: 'Password set successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unlink Google account (only if password is set)
|
||||||
|
dashboard.post('/unlink-google', async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
|
||||||
|
if (!user.googleId) {
|
||||||
|
return c.json({ error: 'Google account not linked' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.password) {
|
||||||
|
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
googleId: null,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((users as any).id, user.id));
|
||||||
|
|
||||||
|
return c.json({ message: 'Google account unlinked' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Dashboard Summary Route ====================
|
||||||
|
|
||||||
|
// Get dashboard summary (welcome panel data)
|
||||||
|
dashboard.get('/summary', async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Get membership duration
|
||||||
|
const createdDate = new Date(user.createdAt);
|
||||||
|
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
// Get ticket count
|
||||||
|
const userTickets = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).userId, user.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const totalTickets = userTickets.length;
|
||||||
|
const confirmedTickets = userTickets.filter((t: any) => t.status === 'confirmed' || t.status === 'checked_in').length;
|
||||||
|
const upcomingTickets = [];
|
||||||
|
|
||||||
|
for (const ticket of userTickets) {
|
||||||
|
if (ticket.status === 'cancelled') continue;
|
||||||
|
|
||||||
|
const event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (event && new Date(event.startDatetime) > now) {
|
||||||
|
upcomingTickets.push({ ticket, event });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending payments count
|
||||||
|
const ticketIds = userTickets.map((t: any) => t.id);
|
||||||
|
let pendingPayments = 0;
|
||||||
|
|
||||||
|
for (const ticketId of ticketIds) {
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((payments as any).ticketId, ticketId),
|
||||||
|
eq((payments as any).status, 'pending_approval')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (payment) pendingPayments++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
summary: {
|
||||||
|
user: {
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
accountStatus: user.accountStatus,
|
||||||
|
memberSince: user.createdAt,
|
||||||
|
membershipDays,
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
totalTickets,
|
||||||
|
confirmedTickets,
|
||||||
|
upcomingEvents: upcomingTickets.length,
|
||||||
|
pendingPayments,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default dashboard;
|
||||||
419
backend/src/routes/emails.ts
Normal file
419
backend/src/routes/emails.ts
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { db, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
|
||||||
|
import { eq, desc, and, sql } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '../lib/auth.js';
|
||||||
|
import { getNow } from '../lib/utils.js';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import emailService from '../lib/email.js';
|
||||||
|
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
|
||||||
|
|
||||||
|
const emailsRouter = new Hono();
|
||||||
|
|
||||||
|
// ==================== Template Routes ====================
|
||||||
|
|
||||||
|
// Get all email templates
|
||||||
|
emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const templates = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(emailTemplates)
|
||||||
|
.orderBy(desc((emailTemplates as any).createdAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Parse variables JSON for each template
|
||||||
|
const parsedTemplates = templates.map((t: any) => ({
|
||||||
|
...t,
|
||||||
|
variables: t.variables ? JSON.parse(t.variables) : [],
|
||||||
|
isSystem: Boolean(t.isSystem),
|
||||||
|
isActive: Boolean(t.isActive),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json({ templates: parsedTemplates });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single email template
|
||||||
|
emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const { id } = c.req.param();
|
||||||
|
|
||||||
|
const template = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(emailTemplates)
|
||||||
|
.where(eq((emailTemplates as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
return c.json({ error: 'Template not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
template: {
|
||||||
|
...template,
|
||||||
|
variables: template.variables ? JSON.parse(template.variables) : [],
|
||||||
|
isSystem: Boolean(template.isSystem),
|
||||||
|
isActive: Boolean(template.isActive),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new email template
|
||||||
|
emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { name, slug, subject, subjectEs, bodyHtml, bodyHtmlEs, bodyText, bodyTextEs, description, variables } = body;
|
||||||
|
|
||||||
|
if (!name || !slug || !subject || !bodyHtml) {
|
||||||
|
return c.json({ error: 'Name, slug, subject, and bodyHtml are required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if slug already exists
|
||||||
|
const existing = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(emailTemplates)
|
||||||
|
.where(eq((emailTemplates as any).slug, slug))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return c.json({ error: 'Template with this slug already exists' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
const template = {
|
||||||
|
id: nanoid(),
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
subject,
|
||||||
|
subjectEs: subjectEs || null,
|
||||||
|
bodyHtml,
|
||||||
|
bodyHtmlEs: bodyHtmlEs || null,
|
||||||
|
bodyText: bodyText || null,
|
||||||
|
bodyTextEs: bodyTextEs || null,
|
||||||
|
description: description || null,
|
||||||
|
variables: variables ? JSON.stringify(variables) : null,
|
||||||
|
isSystem: 0,
|
||||||
|
isActive: 1,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(emailTemplates).values(template);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
template: {
|
||||||
|
...template,
|
||||||
|
variables: variables || [],
|
||||||
|
isSystem: false,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
message: 'Template created successfully'
|
||||||
|
}, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update email template
|
||||||
|
emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
|
||||||
|
const { id } = c.req.param();
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
const existing = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(emailTemplates)
|
||||||
|
.where(eq((emailTemplates as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ error: 'Template not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = { updatedAt: getNow() };
|
||||||
|
|
||||||
|
// Only allow updating certain fields for system templates
|
||||||
|
const systemProtectedFields = ['slug', 'isSystem'];
|
||||||
|
|
||||||
|
const allowedFields = ['name', 'subject', 'subjectEs', 'bodyHtml', 'bodyHtmlEs', 'bodyText', 'bodyTextEs', 'description', 'variables', 'isActive'];
|
||||||
|
if (!existing.isSystem) {
|
||||||
|
allowedFields.push('slug');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of allowedFields) {
|
||||||
|
if (body[field] !== undefined) {
|
||||||
|
if (field === 'variables') {
|
||||||
|
updateData[field] = JSON.stringify(body[field]);
|
||||||
|
} else if (field === 'isActive') {
|
||||||
|
updateData[field] = body[field] ? 1 : 0;
|
||||||
|
} else {
|
||||||
|
updateData[field] = body[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(emailTemplates)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq((emailTemplates as any).id, id));
|
||||||
|
|
||||||
|
const updated = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(emailTemplates)
|
||||||
|
.where(eq((emailTemplates as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
template: {
|
||||||
|
...updated,
|
||||||
|
variables: updated.variables ? JSON.parse(updated.variables) : [],
|
||||||
|
isSystem: Boolean(updated.isSystem),
|
||||||
|
isActive: Boolean(updated.isActive),
|
||||||
|
},
|
||||||
|
message: 'Template updated successfully'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete email template (only non-system templates)
|
||||||
|
emailsRouter.delete('/templates/:id', requireAuth(['admin']), async (c) => {
|
||||||
|
const { id } = c.req.param();
|
||||||
|
|
||||||
|
const template = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(emailTemplates)
|
||||||
|
.where(eq((emailTemplates as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
return c.json({ error: 'Template not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.isSystem) {
|
||||||
|
return c.json({ error: 'Cannot delete system templates' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.delete(emailTemplates)
|
||||||
|
.where(eq((emailTemplates as any).id, id));
|
||||||
|
|
||||||
|
return c.json({ message: 'Template deleted successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get available template variables
|
||||||
|
emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const { slug } = c.req.param();
|
||||||
|
const variables = getTemplateVariables(slug);
|
||||||
|
return c.json({ variables });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Email Sending Routes ====================
|
||||||
|
|
||||||
|
// Send email using template to event attendees
|
||||||
|
emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const { eventId } = c.req.param();
|
||||||
|
const user = (c as any).get('user');
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { templateSlug, customVariables, recipientFilter } = body;
|
||||||
|
|
||||||
|
if (!templateSlug) {
|
||||||
|
return c.json({ error: 'Template slug is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await emailService.sendToEventAttendees({
|
||||||
|
eventId,
|
||||||
|
templateSlug,
|
||||||
|
customVariables,
|
||||||
|
recipientFilter: recipientFilter || 'confirmed',
|
||||||
|
sentBy: user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send custom email to specific recipients
|
||||||
|
emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const user = (c as any).get('user');
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { to, toName, subject, bodyHtml, bodyText, eventId } = body;
|
||||||
|
|
||||||
|
if (!to || !subject || !bodyHtml) {
|
||||||
|
return c.json({ error: 'Recipient (to), subject, and bodyHtml are required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await emailService.sendCustomEmail({
|
||||||
|
to,
|
||||||
|
toName,
|
||||||
|
subject,
|
||||||
|
bodyHtml,
|
||||||
|
bodyText,
|
||||||
|
eventId,
|
||||||
|
sentBy: user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preview email (render template without sending)
|
||||||
|
emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { templateSlug, variables, locale } = body;
|
||||||
|
|
||||||
|
if (!templateSlug) {
|
||||||
|
return c.json({ error: 'Template slug is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await emailService.getTemplate(templateSlug);
|
||||||
|
if (!template) {
|
||||||
|
return c.json({ error: 'Template not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { replaceTemplateVariables, wrapInBaseTemplate } = await import('../lib/emailTemplates.js');
|
||||||
|
|
||||||
|
const allVariables = {
|
||||||
|
...emailService.getCommonVariables(),
|
||||||
|
lang: locale || 'en',
|
||||||
|
...variables,
|
||||||
|
};
|
||||||
|
|
||||||
|
const subject = locale === 'es' && template.subjectEs
|
||||||
|
? template.subjectEs
|
||||||
|
: template.subject;
|
||||||
|
const bodyHtml = locale === 'es' && template.bodyHtmlEs
|
||||||
|
? template.bodyHtmlEs
|
||||||
|
: template.bodyHtml;
|
||||||
|
|
||||||
|
const finalSubject = replaceTemplateVariables(subject, allVariables);
|
||||||
|
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables);
|
||||||
|
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
subject: finalSubject,
|
||||||
|
bodyHtml: finalBodyHtml,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Email Logs Routes ====================
|
||||||
|
|
||||||
|
// Get email logs
|
||||||
|
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const eventId = c.req.query('eventId');
|
||||||
|
const status = c.req.query('status');
|
||||||
|
const limit = parseInt(c.req.query('limit') || '50');
|
||||||
|
const offset = parseInt(c.req.query('offset') || '0');
|
||||||
|
|
||||||
|
let query = (db as any).select().from(emailLogs);
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
if (eventId) {
|
||||||
|
conditions.push(eq((emailLogs as any).eventId, eventId));
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
conditions.push(eq((emailLogs as any).status, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
query = query.where(and(...conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await query
|
||||||
|
.orderBy(desc((emailLogs as any).createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
let countQuery = (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(emailLogs);
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
countQuery = countQuery.where(and(...conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalResult = await countQuery.get();
|
||||||
|
const total = totalResult?.count || 0;
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
logs,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore: offset + logs.length < total,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single email log
|
||||||
|
emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const { id } = c.req.param();
|
||||||
|
|
||||||
|
const log = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(emailLogs)
|
||||||
|
.where(eq((emailLogs as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!log) {
|
||||||
|
return c.json({ error: 'Email log not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ log });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get email stats
|
||||||
|
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const eventId = c.req.query('eventId');
|
||||||
|
|
||||||
|
let baseCondition = eventId ? eq((emailLogs as any).eventId, eventId) : undefined;
|
||||||
|
|
||||||
|
const totalQuery = baseCondition
|
||||||
|
? (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(baseCondition)
|
||||||
|
: (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs);
|
||||||
|
|
||||||
|
const total = (await totalQuery.get())?.count || 0;
|
||||||
|
|
||||||
|
const sentCondition = baseCondition
|
||||||
|
? and(baseCondition, eq((emailLogs as any).status, 'sent'))
|
||||||
|
: eq((emailLogs as any).status, 'sent');
|
||||||
|
const sent = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(sentCondition).get())?.count || 0;
|
||||||
|
|
||||||
|
const failedCondition = baseCondition
|
||||||
|
? and(baseCondition, eq((emailLogs as any).status, 'failed'))
|
||||||
|
: eq((emailLogs as any).status, 'failed');
|
||||||
|
const failed = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(failedCondition).get())?.count || 0;
|
||||||
|
|
||||||
|
const pendingCondition = baseCondition
|
||||||
|
? and(baseCondition, eq((emailLogs as any).status, 'pending'))
|
||||||
|
: eq((emailLogs as any).status, 'pending');
|
||||||
|
const pending = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(pendingCondition).get())?.count || 0;
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
stats: {
|
||||||
|
total,
|
||||||
|
sent,
|
||||||
|
failed,
|
||||||
|
pending,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed default templates (admin only)
|
||||||
|
emailsRouter.post('/seed-templates', requireAuth(['admin']), async (c) => {
|
||||||
|
await emailService.seedDefaultTemplates();
|
||||||
|
return c.json({ message: 'Default templates seeded successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Configuration Routes ====================
|
||||||
|
|
||||||
|
// Get email provider info
|
||||||
|
emailsRouter.get('/config', requireAuth(['admin']), async (c) => {
|
||||||
|
const providerInfo = emailService.getProviderInfo();
|
||||||
|
return c.json(providerInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test email configuration
|
||||||
|
emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { to } = body;
|
||||||
|
|
||||||
|
if (!to) {
|
||||||
|
return c.json({ error: 'Recipient email (to) is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await emailService.testConnection(to);
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default emailsRouter;
|
||||||
269
backend/src/routes/events.ts
Normal file
269
backend/src/routes/events.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db, events, tickets } from '../db/index.js';
|
||||||
|
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
||||||
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
|
|
||||||
|
interface UserContext {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||||
|
|
||||||
|
// Custom validation error handler
|
||||||
|
const validationHook = (result: any, c: any) => {
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.issues.map((i: any) => `${i.path.join('.')}: ${i.message}`).join(', ');
|
||||||
|
return c.json({ error: errors }, 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createEventSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
titleEs: z.string().optional().nullable(),
|
||||||
|
description: z.string().min(1),
|
||||||
|
descriptionEs: z.string().optional().nullable(),
|
||||||
|
startDatetime: z.string(),
|
||||||
|
endDatetime: z.string().optional().nullable(),
|
||||||
|
location: z.string().min(1),
|
||||||
|
locationUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||||
|
price: z.number().min(0).default(0),
|
||||||
|
currency: z.string().default('PYG'),
|
||||||
|
capacity: z.number().min(1).default(50),
|
||||||
|
status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'),
|
||||||
|
// Accept relative paths (/uploads/...) or full URLs
|
||||||
|
bannerUrl: z.string().optional().nullable().or(z.literal('')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateEventSchema = createEventSchema.partial();
|
||||||
|
|
||||||
|
// Get all events (public)
|
||||||
|
eventsRouter.get('/', async (c) => {
|
||||||
|
const status = c.req.query('status');
|
||||||
|
const upcoming = c.req.query('upcoming');
|
||||||
|
|
||||||
|
let query = (db as any).select().from(events);
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
query = query.where(eq((events as any).status, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upcoming === 'true') {
|
||||||
|
const now = getNow();
|
||||||
|
query = query.where(
|
||||||
|
and(
|
||||||
|
eq((events as any).status, 'published'),
|
||||||
|
gte((events as any).startDatetime, now)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query.orderBy(desc((events as any).startDatetime)).all();
|
||||||
|
|
||||||
|
// Get ticket counts for each event
|
||||||
|
const eventsWithCounts = await Promise.all(
|
||||||
|
result.map(async (event: any) => {
|
||||||
|
const ticketCount = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, event.id),
|
||||||
|
eq((tickets as any).status, 'confirmed')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
bookedCount: ticketCount?.count || 0,
|
||||||
|
availableSeats: event.capacity - (ticketCount?.count || 0),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ events: eventsWithCounts });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single event (public)
|
||||||
|
eventsRouter.get('/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const event = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ticket count
|
||||||
|
const ticketCount = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, id),
|
||||||
|
eq((tickets as any).status, 'confirmed')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
event: {
|
||||||
|
...event,
|
||||||
|
bookedCount: ticketCount?.count || 0,
|
||||||
|
availableSeats: event.capacity - (ticketCount?.count || 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get next upcoming event (public)
|
||||||
|
eventsRouter.get('/next/upcoming', async (c) => {
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
const event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((events as any).status, 'published'),
|
||||||
|
gte((events as any).startDatetime, now)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy((events as any).startDatetime)
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ event: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketCount = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, event.id),
|
||||||
|
eq((tickets as any).status, 'confirmed')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
event: {
|
||||||
|
...event,
|
||||||
|
bookedCount: ticketCount?.count || 0,
|
||||||
|
availableSeats: event.capacity - (ticketCount?.count || 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create event (admin/organizer only)
|
||||||
|
eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', createEventSchema, validationHook), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const user = c.get('user');
|
||||||
|
const now = getNow();
|
||||||
|
const id = generateId();
|
||||||
|
|
||||||
|
const newEvent = {
|
||||||
|
id,
|
||||||
|
...data,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(events).values(newEvent);
|
||||||
|
|
||||||
|
return c.json({ event: newEvent }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update event (admin/organizer only)
|
||||||
|
eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateEventSchema, validationHook), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
const existing = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
await (db as any)
|
||||||
|
.update(events)
|
||||||
|
.set({ ...data, updatedAt: now })
|
||||||
|
.where(eq((events as any).id, id));
|
||||||
|
|
||||||
|
const updated = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
|
||||||
|
|
||||||
|
return c.json({ event: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete event (admin only)
|
||||||
|
eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const existing = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (db as any).delete(events).where(eq((events as any).id, id));
|
||||||
|
|
||||||
|
return c.json({ message: 'Event deleted successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get event attendees (admin/organizer only)
|
||||||
|
eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const attendees = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).eventId, id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return c.json({ attendees });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Duplicate event (admin/organizer only)
|
||||||
|
eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const existing = await (db as any).select().from(events).where(eq((events as any).id, id)).get();
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
const newId = generateId();
|
||||||
|
|
||||||
|
// Create a copy with modified title and draft status
|
||||||
|
const duplicatedEvent = {
|
||||||
|
id: newId,
|
||||||
|
title: `${existing.title} (Copy)`,
|
||||||
|
titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null,
|
||||||
|
description: existing.description,
|
||||||
|
descriptionEs: existing.descriptionEs,
|
||||||
|
startDatetime: existing.startDatetime,
|
||||||
|
endDatetime: existing.endDatetime,
|
||||||
|
location: existing.location,
|
||||||
|
locationUrl: existing.locationUrl,
|
||||||
|
price: existing.price,
|
||||||
|
currency: existing.currency,
|
||||||
|
capacity: existing.capacity,
|
||||||
|
status: 'draft',
|
||||||
|
bannerUrl: existing.bannerUrl,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(events).values(duplicatedEvent);
|
||||||
|
|
||||||
|
return c.json({ event: duplicatedEvent, message: 'Event duplicated successfully' }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default eventsRouter;
|
||||||
340
backend/src/routes/lnbits.ts
Normal file
340
backend/src/routes/lnbits.ts
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { streamSSE } from 'hono/streaming';
|
||||||
|
import { db, tickets, payments } from '../db/index.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getNow } from '../lib/utils.js';
|
||||||
|
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
|
||||||
|
import emailService from '../lib/email.js';
|
||||||
|
|
||||||
|
const lnbitsRouter = new Hono();
|
||||||
|
|
||||||
|
// Store for active SSE connections (ticketId -> Set of response writers)
|
||||||
|
const activeConnections = new Map<string, Set<(data: any) => void>>();
|
||||||
|
|
||||||
|
// Store for active background checkers (ticketId -> intervalId)
|
||||||
|
const activeCheckers = new Map<string, NodeJS.Timeout>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNbits webhook payload structure
|
||||||
|
*/
|
||||||
|
interface LNbitsWebhookPayload {
|
||||||
|
payment_hash: string;
|
||||||
|
payment_request?: string;
|
||||||
|
amount: number;
|
||||||
|
memo?: string;
|
||||||
|
status: string;
|
||||||
|
preimage?: string;
|
||||||
|
extra?: {
|
||||||
|
ticketId?: string;
|
||||||
|
eventId?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify all connected clients for a ticket
|
||||||
|
*/
|
||||||
|
function notifyClients(ticketId: string, data: any) {
|
||||||
|
const connections = activeConnections.get(ticketId);
|
||||||
|
if (connections) {
|
||||||
|
connections.forEach(send => {
|
||||||
|
try {
|
||||||
|
send(data);
|
||||||
|
} catch (e) {
|
||||||
|
// Connection might be closed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start background payment checking for a ticket
|
||||||
|
*/
|
||||||
|
function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
|
||||||
|
// Don't start if already checking
|
||||||
|
if (activeCheckers.has(ticketId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const expiryMs = expirySeconds * 1000;
|
||||||
|
let checkCount = 0;
|
||||||
|
|
||||||
|
console.log(`Starting background checker for ticket ${ticketId}, expires in ${expirySeconds}s`);
|
||||||
|
|
||||||
|
const checkInterval = setInterval(async () => {
|
||||||
|
checkCount++;
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Stop if expired
|
||||||
|
if (elapsed >= expiryMs) {
|
||||||
|
console.log(`Invoice expired for ticket ${ticketId}`);
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
activeCheckers.delete(ticketId);
|
||||||
|
notifyClients(ticketId, { type: 'expired', ticketId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await getPaymentStatus(paymentHash);
|
||||||
|
|
||||||
|
if (status?.paid) {
|
||||||
|
console.log(`Payment confirmed for ticket ${ticketId} (check #${checkCount})`);
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
activeCheckers.delete(ticketId);
|
||||||
|
|
||||||
|
await handlePaymentComplete(ticketId, paymentHash);
|
||||||
|
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking payment for ticket ${ticketId}:`, error);
|
||||||
|
}
|
||||||
|
}, 3000); // Check every 3 seconds
|
||||||
|
|
||||||
|
activeCheckers.set(ticketId, checkInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop background checker for a ticket
|
||||||
|
*/
|
||||||
|
function stopBackgroundChecker(ticketId: string) {
|
||||||
|
const interval = activeCheckers.get(ticketId);
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
activeCheckers.delete(ticketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNbits webhook endpoint
|
||||||
|
* Called by LNbits when a payment is received
|
||||||
|
*/
|
||||||
|
lnbitsRouter.post('/webhook', async (c) => {
|
||||||
|
try {
|
||||||
|
const payload: LNbitsWebhookPayload = await c.req.json();
|
||||||
|
|
||||||
|
console.log('LNbits webhook received:', {
|
||||||
|
paymentHash: payload.payment_hash,
|
||||||
|
status: payload.status,
|
||||||
|
amount: payload.amount,
|
||||||
|
extra: payload.extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the payment is actually complete by checking with LNbits
|
||||||
|
const isVerified = await verifyWebhookPayment(payload.payment_hash);
|
||||||
|
|
||||||
|
if (!isVerified) {
|
||||||
|
console.warn('LNbits webhook payment not verified:', payload.payment_hash);
|
||||||
|
return c.json({ received: true, processed: false }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketId = payload.extra?.ticketId;
|
||||||
|
|
||||||
|
if (!ticketId) {
|
||||||
|
console.error('No ticketId in LNbits webhook extra data');
|
||||||
|
return c.json({ received: true, processed: false }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop background checker since webhook confirmed payment
|
||||||
|
stopBackgroundChecker(ticketId);
|
||||||
|
|
||||||
|
await handlePaymentComplete(ticketId, payload.payment_hash);
|
||||||
|
|
||||||
|
// Notify connected clients via SSE
|
||||||
|
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash });
|
||||||
|
|
||||||
|
return c.json({ received: true, processed: true }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LNbits webhook error:', error);
|
||||||
|
return c.json({ error: 'Webhook processing failed' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle successful payment
|
||||||
|
*/
|
||||||
|
async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
// Check if already confirmed to avoid duplicate updates
|
||||||
|
const existingTicket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).id, ticketId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existingTicket?.status === 'confirmed') {
|
||||||
|
console.log(`Ticket ${ticketId} already confirmed, skipping update`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ticket status to confirmed
|
||||||
|
await (db as any)
|
||||||
|
.update(tickets)
|
||||||
|
.set({ status: 'confirmed' })
|
||||||
|
.where(eq((tickets as any).id, ticketId));
|
||||||
|
|
||||||
|
// Update payment status to paid
|
||||||
|
await (db as any)
|
||||||
|
.update(payments)
|
||||||
|
.set({
|
||||||
|
status: 'paid',
|
||||||
|
reference: paymentHash,
|
||||||
|
paidAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((payments as any).ticketId, ticketId));
|
||||||
|
|
||||||
|
console.log(`Ticket ${ticketId} confirmed via Lightning payment (hash: ${paymentHash})`);
|
||||||
|
|
||||||
|
// Get payment for sending receipt
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).ticketId, ticketId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Send confirmation emails asynchronously
|
||||||
|
Promise.all([
|
||||||
|
emailService.sendBookingConfirmation(ticketId),
|
||||||
|
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
|
||||||
|
]).catch(err => {
|
||||||
|
console.error('[Email] Failed to send confirmation emails:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE endpoint for real-time payment status updates
|
||||||
|
* Frontend connects here to receive instant payment notifications
|
||||||
|
*/
|
||||||
|
lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||||
|
const ticketId = c.req.param('ticketId');
|
||||||
|
|
||||||
|
// Verify ticket exists
|
||||||
|
const ticket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).id, ticketId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return c.json({ error: 'Ticket not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already paid, return immediately
|
||||||
|
if (ticket.status === 'confirmed') {
|
||||||
|
return c.json({ type: 'already_paid', ticketId }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get payment to start background checker
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).ticketId, ticketId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Start background checker if not already running
|
||||||
|
if (payment?.reference && !activeCheckers.has(ticketId)) {
|
||||||
|
startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamSSE(c, async (stream) => {
|
||||||
|
// Register this connection
|
||||||
|
if (!activeConnections.has(ticketId)) {
|
||||||
|
activeConnections.set(ticketId, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendEvent = (data: any) => {
|
||||||
|
stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
|
||||||
|
};
|
||||||
|
|
||||||
|
activeConnections.get(ticketId)!.add(sendEvent);
|
||||||
|
|
||||||
|
// Send initial status
|
||||||
|
await stream.writeSSE({
|
||||||
|
data: JSON.stringify({ type: 'connected', ticketId }),
|
||||||
|
event: 'payment'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep connection alive with heartbeat
|
||||||
|
const heartbeat = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await stream.writeSSE({ data: 'ping', event: 'heartbeat' });
|
||||||
|
} catch (e) {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
// Clean up on disconnect
|
||||||
|
stream.onAbort(() => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
const connections = activeConnections.get(ticketId);
|
||||||
|
if (connections) {
|
||||||
|
connections.delete(sendEvent);
|
||||||
|
if (connections.size === 0) {
|
||||||
|
activeConnections.delete(ticketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the stream open
|
||||||
|
while (true) {
|
||||||
|
await stream.sleep(30000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get payment status for a ticket (fallback polling endpoint)
|
||||||
|
*/
|
||||||
|
lnbitsRouter.get('/status/:ticketId', async (c) => {
|
||||||
|
const ticketId = c.req.param('ticketId');
|
||||||
|
|
||||||
|
const ticket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).id, ticketId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return c.json({ error: 'Ticket not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).ticketId, ticketId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
ticketStatus: ticket.status,
|
||||||
|
paymentStatus: payment?.status || 'unknown',
|
||||||
|
isPaid: ticket.status === 'confirmed' || payment?.status === 'paid',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual payment check endpoint
|
||||||
|
*/
|
||||||
|
lnbitsRouter.post('/check/:paymentHash', async (c) => {
|
||||||
|
const paymentHash = c.req.param('paymentHash');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await getPaymentStatus(paymentHash);
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return c.json({ error: 'Payment not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
paymentHash,
|
||||||
|
paid: status.paid,
|
||||||
|
status: status.status,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking payment:', error);
|
||||||
|
return c.json({ error: 'Failed to check payment status' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default lnbitsRouter;
|
||||||
148
backend/src/routes/media.ts
Normal file
148
backend/src/routes/media.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { db, media } from '../db/index.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '../lib/auth.js';
|
||||||
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
|
import { writeFile, mkdir, unlink } from 'fs/promises';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { join, extname } from 'path';
|
||||||
|
|
||||||
|
const mediaRouter = new Hono();
|
||||||
|
|
||||||
|
const UPLOAD_DIR = './uploads';
|
||||||
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
|
// Ensure upload directory exists
|
||||||
|
async function ensureUploadDir() {
|
||||||
|
if (!existsSync(UPLOAD_DIR)) {
|
||||||
|
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload image
|
||||||
|
mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
try {
|
||||||
|
const body = await c.req.parseBody();
|
||||||
|
const file = body['file'] as File;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return c.json({ error: 'No file provided' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return c.json({ error: 'File too large. Maximum size: 5MB' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureUploadDir();
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const id = generateId();
|
||||||
|
const ext = extname(file.name) || '.jpg';
|
||||||
|
const filename = `${id}${ext}`;
|
||||||
|
const filepath = join(UPLOAD_DIR, filename);
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
await writeFile(filepath, Buffer.from(arrayBuffer));
|
||||||
|
|
||||||
|
// Get related info from form data
|
||||||
|
const relatedId = body['relatedId'] as string | undefined;
|
||||||
|
const relatedType = body['relatedType'] as string | undefined;
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
const now = getNow();
|
||||||
|
const mediaRecord = {
|
||||||
|
id,
|
||||||
|
fileUrl: `/uploads/${filename}`,
|
||||||
|
type: 'image' as const,
|
||||||
|
relatedId: relatedId || null,
|
||||||
|
relatedType: relatedType || null,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(media).values(mediaRecord);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
media: mediaRecord,
|
||||||
|
url: mediaRecord.fileUrl,
|
||||||
|
}, 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
return c.json({ error: 'Failed to upload file' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get media by ID
|
||||||
|
mediaRouter.get('/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const mediaRecord = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(media)
|
||||||
|
.where(eq((media as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!mediaRecord) {
|
||||||
|
return c.json({ error: 'Media not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ media: mediaRecord });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete media
|
||||||
|
mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const mediaRecord = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(media)
|
||||||
|
.where(eq((media as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!mediaRecord) {
|
||||||
|
return c.json({ error: 'Media not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file from disk
|
||||||
|
try {
|
||||||
|
const filepath = join('.', mediaRecord.fileUrl);
|
||||||
|
if (existsSync(filepath)) {
|
||||||
|
await unlink(filepath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete file:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
await (db as any).delete(media).where(eq((media as any).id, id));
|
||||||
|
|
||||||
|
return c.json({ message: 'Media deleted successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// List media (admin)
|
||||||
|
mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const relatedType = c.req.query('relatedType');
|
||||||
|
const relatedId = c.req.query('relatedId');
|
||||||
|
|
||||||
|
let query = (db as any).select().from(media);
|
||||||
|
|
||||||
|
if (relatedType) {
|
||||||
|
query = query.where(eq((media as any).relatedType, relatedType));
|
||||||
|
}
|
||||||
|
if (relatedId) {
|
||||||
|
query = query.where(eq((media as any).relatedId, relatedId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query.all();
|
||||||
|
|
||||||
|
return c.json({ media: result });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default mediaRouter;
|
||||||
278
backend/src/routes/payment-options.ts
Normal file
278
backend/src/routes/payment-options.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db, paymentOptions, eventPaymentOverrides, events } from '../db/index.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '../lib/auth.js';
|
||||||
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
|
|
||||||
|
const paymentOptionsRouter = new Hono();
|
||||||
|
|
||||||
|
// Schema for updating global payment options
|
||||||
|
const updatePaymentOptionsSchema = z.object({
|
||||||
|
tpagoEnabled: z.boolean().optional(),
|
||||||
|
tpagoLink: z.string().optional().nullable(),
|
||||||
|
tpagoInstructions: z.string().optional().nullable(),
|
||||||
|
tpagoInstructionsEs: z.string().optional().nullable(),
|
||||||
|
bankTransferEnabled: z.boolean().optional(),
|
||||||
|
bankName: z.string().optional().nullable(),
|
||||||
|
bankAccountHolder: z.string().optional().nullable(),
|
||||||
|
bankAccountNumber: z.string().optional().nullable(),
|
||||||
|
bankAlias: z.string().optional().nullable(),
|
||||||
|
bankPhone: z.string().optional().nullable(),
|
||||||
|
bankNotes: z.string().optional().nullable(),
|
||||||
|
bankNotesEs: z.string().optional().nullable(),
|
||||||
|
lightningEnabled: z.boolean().optional(),
|
||||||
|
cashEnabled: z.boolean().optional(),
|
||||||
|
cashInstructions: z.string().optional().nullable(),
|
||||||
|
cashInstructionsEs: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema for event-level overrides
|
||||||
|
const updateEventOverridesSchema = z.object({
|
||||||
|
tpagoEnabled: z.boolean().optional().nullable(),
|
||||||
|
tpagoLink: z.string().optional().nullable(),
|
||||||
|
tpagoInstructions: z.string().optional().nullable(),
|
||||||
|
tpagoInstructionsEs: z.string().optional().nullable(),
|
||||||
|
bankTransferEnabled: z.boolean().optional().nullable(),
|
||||||
|
bankName: z.string().optional().nullable(),
|
||||||
|
bankAccountHolder: z.string().optional().nullable(),
|
||||||
|
bankAccountNumber: z.string().optional().nullable(),
|
||||||
|
bankAlias: z.string().optional().nullable(),
|
||||||
|
bankPhone: z.string().optional().nullable(),
|
||||||
|
bankNotes: z.string().optional().nullable(),
|
||||||
|
bankNotesEs: z.string().optional().nullable(),
|
||||||
|
lightningEnabled: z.boolean().optional().nullable(),
|
||||||
|
cashEnabled: z.boolean().optional().nullable(),
|
||||||
|
cashInstructions: z.string().optional().nullable(),
|
||||||
|
cashInstructionsEs: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get global payment options
|
||||||
|
paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||||
|
const options = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(paymentOptions)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// If no options exist yet, return defaults
|
||||||
|
if (!options) {
|
||||||
|
return c.json({
|
||||||
|
paymentOptions: {
|
||||||
|
tpagoEnabled: false,
|
||||||
|
tpagoLink: null,
|
||||||
|
tpagoInstructions: null,
|
||||||
|
tpagoInstructionsEs: null,
|
||||||
|
bankTransferEnabled: false,
|
||||||
|
bankName: null,
|
||||||
|
bankAccountHolder: null,
|
||||||
|
bankAccountNumber: null,
|
||||||
|
bankAlias: null,
|
||||||
|
bankPhone: null,
|
||||||
|
bankNotes: null,
|
||||||
|
bankNotesEs: null,
|
||||||
|
lightningEnabled: true,
|
||||||
|
cashEnabled: true,
|
||||||
|
cashInstructions: null,
|
||||||
|
cashInstructionsEs: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ paymentOptions: options });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update global payment options
|
||||||
|
paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updatePaymentOptionsSchema), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const user = (c as any).get('user');
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
// Check if options exist
|
||||||
|
const existing = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(paymentOptions)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing
|
||||||
|
await (db as any)
|
||||||
|
.update(paymentOptions)
|
||||||
|
.set({
|
||||||
|
...data,
|
||||||
|
updatedAt: now,
|
||||||
|
updatedBy: user.id,
|
||||||
|
})
|
||||||
|
.where(eq((paymentOptions as any).id, existing.id));
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
const id = generateId();
|
||||||
|
await (db as any).insert(paymentOptions).values({
|
||||||
|
id,
|
||||||
|
...data,
|
||||||
|
updatedAt: now,
|
||||||
|
updatedBy: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(paymentOptions)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({ paymentOptions: updated, message: 'Payment options updated successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get payment options for a specific event (merged with global)
|
||||||
|
paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
||||||
|
const eventId = c.req.param('eventId');
|
||||||
|
|
||||||
|
// Get the event first to verify it exists
|
||||||
|
const event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get global options
|
||||||
|
const globalOptions = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(paymentOptions)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Get event overrides
|
||||||
|
const overrides = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(eventPaymentOverrides)
|
||||||
|
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Merge global with overrides (override takes precedence if not null)
|
||||||
|
const defaults = {
|
||||||
|
tpagoEnabled: false,
|
||||||
|
tpagoLink: null,
|
||||||
|
tpagoInstructions: null,
|
||||||
|
tpagoInstructionsEs: null,
|
||||||
|
bankTransferEnabled: false,
|
||||||
|
bankName: null,
|
||||||
|
bankAccountHolder: null,
|
||||||
|
bankAccountNumber: null,
|
||||||
|
bankAlias: null,
|
||||||
|
bankPhone: null,
|
||||||
|
bankNotes: null,
|
||||||
|
bankNotesEs: null,
|
||||||
|
lightningEnabled: true,
|
||||||
|
cashEnabled: true,
|
||||||
|
cashInstructions: null,
|
||||||
|
cashInstructionsEs: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const global = globalOptions || defaults;
|
||||||
|
|
||||||
|
// Merge: override values take precedence if they're not null/undefined
|
||||||
|
const merged = {
|
||||||
|
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
|
||||||
|
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
|
||||||
|
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
|
||||||
|
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
|
||||||
|
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
|
||||||
|
bankName: overrides?.bankName ?? global.bankName,
|
||||||
|
bankAccountHolder: overrides?.bankAccountHolder ?? global.bankAccountHolder,
|
||||||
|
bankAccountNumber: overrides?.bankAccountNumber ?? global.bankAccountNumber,
|
||||||
|
bankAlias: overrides?.bankAlias ?? global.bankAlias,
|
||||||
|
bankPhone: overrides?.bankPhone ?? global.bankPhone,
|
||||||
|
bankNotes: overrides?.bankNotes ?? global.bankNotes,
|
||||||
|
bankNotesEs: overrides?.bankNotesEs ?? global.bankNotesEs,
|
||||||
|
lightningEnabled: overrides?.lightningEnabled ?? global.lightningEnabled,
|
||||||
|
cashEnabled: overrides?.cashEnabled ?? global.cashEnabled,
|
||||||
|
cashInstructions: overrides?.cashInstructions ?? global.cashInstructions,
|
||||||
|
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
paymentOptions: merged,
|
||||||
|
hasOverrides: !!overrides,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get event payment overrides (admin only)
|
||||||
|
paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const eventId = c.req.param('eventId');
|
||||||
|
|
||||||
|
const overrides = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(eventPaymentOverrides)
|
||||||
|
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({ overrides: overrides || null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update event payment overrides
|
||||||
|
paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), zValidator('json', updateEventOverridesSchema), async (c) => {
|
||||||
|
const eventId = c.req.param('eventId');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
// Verify event exists
|
||||||
|
const event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if overrides exist
|
||||||
|
const existing = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(eventPaymentOverrides)
|
||||||
|
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await (db as any)
|
||||||
|
.update(eventPaymentOverrides)
|
||||||
|
.set({
|
||||||
|
...data,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((eventPaymentOverrides as any).id, existing.id));
|
||||||
|
} else {
|
||||||
|
const id = generateId();
|
||||||
|
await (db as any).insert(eventPaymentOverrides).values({
|
||||||
|
id,
|
||||||
|
eventId,
|
||||||
|
...data,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(eventPaymentOverrides)
|
||||||
|
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({ overrides: updated, message: 'Event payment overrides updated successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete event payment overrides (revert to global)
|
||||||
|
paymentOptionsRouter.delete('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const eventId = c.req.param('eventId');
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.delete(eventPaymentOverrides)
|
||||||
|
.where(eq((eventPaymentOverrides as any).eventId, eventId));
|
||||||
|
|
||||||
|
return c.json({ message: 'Event payment overrides removed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default paymentOptionsRouter;
|
||||||
431
backend/src/routes/payments.ts
Normal file
431
backend/src/routes/payments.ts
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db, payments, tickets, events } from '../db/index.js';
|
||||||
|
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '../lib/auth.js';
|
||||||
|
import { getNow } from '../lib/utils.js';
|
||||||
|
import emailService from '../lib/email.js';
|
||||||
|
|
||||||
|
const paymentsRouter = new Hono();
|
||||||
|
|
||||||
|
const updatePaymentSchema = z.object({
|
||||||
|
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed']),
|
||||||
|
reference: z.string().optional(),
|
||||||
|
adminNote: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const approvePaymentSchema = z.object({
|
||||||
|
adminNote: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectPaymentSchema = z.object({
|
||||||
|
adminNote: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all payments (admin) - with ticket and event details
|
||||||
|
paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||||
|
const status = c.req.query('status');
|
||||||
|
const provider = c.req.query('provider');
|
||||||
|
const pendingApproval = c.req.query('pendingApproval');
|
||||||
|
|
||||||
|
// Get all payments with their associated tickets
|
||||||
|
let allPayments = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.orderBy(desc((payments as any).createdAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if (status) {
|
||||||
|
allPayments = allPayments.filter((p: any) => p.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for pending approval specifically
|
||||||
|
if (pendingApproval === 'true') {
|
||||||
|
allPayments = allPayments.filter((p: any) => p.status === 'pending_approval');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by provider
|
||||||
|
if (provider) {
|
||||||
|
allPayments = allPayments.filter((p: any) => p.provider === provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich with ticket and event data
|
||||||
|
const enrichedPayments = await Promise.all(
|
||||||
|
allPayments.map(async (payment: any) => {
|
||||||
|
const ticket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
let event = null;
|
||||||
|
if (ticket) {
|
||||||
|
event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payment,
|
||||||
|
ticket: ticket ? {
|
||||||
|
id: ticket.id,
|
||||||
|
attendeeFirstName: ticket.attendeeFirstName,
|
||||||
|
attendeeLastName: ticket.attendeeLastName,
|
||||||
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
|
attendeePhone: ticket.attendeePhone,
|
||||||
|
status: ticket.status,
|
||||||
|
} : null,
|
||||||
|
event: event ? {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ payments: enrichedPayments });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get payments pending approval (admin dashboard view)
|
||||||
|
paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const pendingPayments = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).status, 'pending_approval'))
|
||||||
|
.orderBy(desc((payments as any).userMarkedPaidAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Enrich with ticket and event data
|
||||||
|
const enrichedPayments = await Promise.all(
|
||||||
|
pendingPayments.map(async (payment: any) => {
|
||||||
|
const ticket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
let event = null;
|
||||||
|
if (ticket) {
|
||||||
|
event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payment,
|
||||||
|
ticket: ticket ? {
|
||||||
|
id: ticket.id,
|
||||||
|
attendeeFirstName: ticket.attendeeFirstName,
|
||||||
|
attendeeLastName: ticket.attendeeLastName,
|
||||||
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
|
attendeePhone: ticket.attendeePhone,
|
||||||
|
status: ticket.status,
|
||||||
|
} : null,
|
||||||
|
event: event ? {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
} : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ payments: enrichedPayments });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get payment by ID (admin)
|
||||||
|
paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
return c.json({ error: 'Payment not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get associated ticket
|
||||||
|
const ticket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({ payment: { ...payment, ticket } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update payment (admin) - for manual payment confirmation
|
||||||
|
paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updatePaymentSchema), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const user = (c as any).get('user');
|
||||||
|
|
||||||
|
const existing = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ error: 'Payment not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
const updateData: any = { ...data, updatedAt: now };
|
||||||
|
|
||||||
|
// If marking as paid, record who approved it and when
|
||||||
|
if (data.status === 'paid' && existing.status !== 'paid') {
|
||||||
|
updateData.paidAt = now;
|
||||||
|
updateData.paidByAdminId = user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(payments)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq((payments as any).id, id));
|
||||||
|
|
||||||
|
// If payment confirmed, update ticket status and send emails
|
||||||
|
if (data.status === 'paid') {
|
||||||
|
await (db as any)
|
||||||
|
.update(tickets)
|
||||||
|
.set({ status: 'confirmed' })
|
||||||
|
.where(eq((tickets as any).id, existing.ticketId));
|
||||||
|
|
||||||
|
// Send confirmation emails asynchronously (don't block the response)
|
||||||
|
Promise.all([
|
||||||
|
emailService.sendBookingConfirmation(existing.ticketId),
|
||||||
|
emailService.sendPaymentReceipt(id),
|
||||||
|
]).catch(err => {
|
||||||
|
console.error('[Email] Failed to send confirmation emails:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({ payment: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Approve payment (admin) - specifically for pending_approval payments
|
||||||
|
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const { adminNote } = c.req.valid('json');
|
||||||
|
const user = (c as any).get('user');
|
||||||
|
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
return c.json({ error: 'Payment not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can approve pending or pending_approval payments
|
||||||
|
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||||
|
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
// Update payment status to paid
|
||||||
|
await (db as any)
|
||||||
|
.update(payments)
|
||||||
|
.set({
|
||||||
|
status: 'paid',
|
||||||
|
paidAt: now,
|
||||||
|
paidByAdminId: user.id,
|
||||||
|
adminNote: adminNote || payment.adminNote,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((payments as any).id, id));
|
||||||
|
|
||||||
|
// Update ticket status to confirmed
|
||||||
|
await (db as any)
|
||||||
|
.update(tickets)
|
||||||
|
.set({ status: 'confirmed' })
|
||||||
|
.where(eq((tickets as any).id, payment.ticketId));
|
||||||
|
|
||||||
|
// Send confirmation emails asynchronously
|
||||||
|
Promise.all([
|
||||||
|
emailService.sendBookingConfirmation(payment.ticketId),
|
||||||
|
emailService.sendPaymentReceipt(id),
|
||||||
|
]).catch(err => {
|
||||||
|
console.error('[Email] Failed to send confirmation emails:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({ payment: updated, message: 'Payment approved successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reject payment (admin)
|
||||||
|
paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidator('json', rejectPaymentSchema), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const { adminNote } = c.req.valid('json');
|
||||||
|
const user = (c as any).get('user');
|
||||||
|
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
return c.json({ error: 'Payment not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||||
|
return c.json({ error: 'Payment cannot be rejected in its current state' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
// Update payment status to failed
|
||||||
|
await (db as any)
|
||||||
|
.update(payments)
|
||||||
|
.set({
|
||||||
|
status: 'failed',
|
||||||
|
paidByAdminId: user.id,
|
||||||
|
adminNote: adminNote || payment.adminNote,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((payments as any).id, id));
|
||||||
|
|
||||||
|
// Note: We don't cancel the ticket automatically - admin can do that separately if needed
|
||||||
|
|
||||||
|
const updated = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({ payment: updated, message: 'Payment rejected' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update admin note
|
||||||
|
paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { adminNote } = body;
|
||||||
|
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
return c.json({ error: 'Payment not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(payments)
|
||||||
|
.set({
|
||||||
|
adminNote: adminNote || null,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((payments as any).id, id));
|
||||||
|
|
||||||
|
const updated = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({ payment: updated, message: 'Note updated' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process refund (admin)
|
||||||
|
paymentsRouter.post('/:id/refund', requireAuth(['admin']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
return c.json({ error: 'Payment not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.status !== 'paid') {
|
||||||
|
return c.json({ error: 'Can only refund paid payments' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
// Update payment status
|
||||||
|
await (db as any)
|
||||||
|
.update(payments)
|
||||||
|
.set({ status: 'refunded', updatedAt: now })
|
||||||
|
.where(eq((payments as any).id, id));
|
||||||
|
|
||||||
|
// Cancel associated ticket
|
||||||
|
await (db as any)
|
||||||
|
.update(tickets)
|
||||||
|
.set({ status: 'cancelled' })
|
||||||
|
.where(eq((tickets as any).id, payment.ticketId));
|
||||||
|
|
||||||
|
return c.json({ message: 'Refund processed successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Payment webhook (for Stripe/MercadoPago)
|
||||||
|
paymentsRouter.post('/webhook', async (c) => {
|
||||||
|
// This would handle webhook notifications from payment providers
|
||||||
|
// Implementation depends on which provider is used
|
||||||
|
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
// Log webhook for debugging
|
||||||
|
console.log('Payment webhook received:', body);
|
||||||
|
|
||||||
|
// TODO: Implement provider-specific webhook handling
|
||||||
|
// - Verify webhook signature
|
||||||
|
// - Update payment status
|
||||||
|
// - Update ticket status
|
||||||
|
|
||||||
|
return c.json({ received: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get payment statistics (admin)
|
||||||
|
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||||
|
const allPayments = await (db as any).select().from(payments).all();
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: allPayments.length,
|
||||||
|
pending: allPayments.filter((p: any) => p.status === 'pending').length,
|
||||||
|
paid: allPayments.filter((p: any) => p.status === 'paid').length,
|
||||||
|
refunded: allPayments.filter((p: any) => p.status === 'refunded').length,
|
||||||
|
failed: allPayments.filter((p: any) => p.status === 'failed').length,
|
||||||
|
totalRevenue: allPayments
|
||||||
|
.filter((p: any) => p.status === 'paid')
|
||||||
|
.reduce((sum: number, p: any) => sum + (p.amount || 0), 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json({ stats });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default paymentsRouter;
|
||||||
652
backend/src/routes/tickets.ts
Normal file
652
backend/src/routes/tickets.ts
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db, tickets, events, users, payments } from '../db/index.js';
|
||||||
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
|
import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
|
||||||
|
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||||
|
import emailService from '../lib/email.js';
|
||||||
|
|
||||||
|
const ticketsRouter = new Hono();
|
||||||
|
|
||||||
|
const createTicketSchema = z.object({
|
||||||
|
eventId: z.string(),
|
||||||
|
firstName: z.string().min(2),
|
||||||
|
lastName: z.string().min(2),
|
||||||
|
email: z.string().email(),
|
||||||
|
phone: z.string().min(6, 'Phone number is required'),
|
||||||
|
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||||
|
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
||||||
|
ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTicketSchema = z.object({
|
||||||
|
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in']).optional(),
|
||||||
|
adminNote: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateNoteSchema = z.object({
|
||||||
|
note: z.string().max(1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminCreateTicketSchema = z.object({
|
||||||
|
eventId: z.string(),
|
||||||
|
firstName: z.string().min(2),
|
||||||
|
lastName: z.string().optional().or(z.literal('')),
|
||||||
|
email: z.string().email().optional().or(z.literal('')),
|
||||||
|
phone: z.string().optional().or(z.literal('')),
|
||||||
|
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||||
|
autoCheckin: z.boolean().optional().default(false),
|
||||||
|
adminNote: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Book a ticket (public)
|
||||||
|
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
// Get event
|
||||||
|
const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get();
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.status !== 'published') {
|
||||||
|
return c.json({ error: 'Event is not available for booking' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check capacity
|
||||||
|
const ticketCount = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, data.eventId),
|
||||||
|
eq((tickets as any).status, 'confirmed')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if ((ticketCount?.count || 0) >= event.capacity) {
|
||||||
|
return c.json({ error: 'Event is sold out' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create user
|
||||||
|
let user = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get();
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
const fullName = `${data.firstName} ${data.lastName}`.trim();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const userId = generateId();
|
||||||
|
user = {
|
||||||
|
id: userId,
|
||||||
|
email: data.email,
|
||||||
|
password: '', // No password for guest bookings
|
||||||
|
name: fullName,
|
||||||
|
phone: data.phone || null,
|
||||||
|
role: 'user',
|
||||||
|
languagePreference: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
await (db as any).insert(users).values(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate booking
|
||||||
|
const existingTicket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).userId, user.id),
|
||||||
|
eq((tickets as any).eventId, data.eventId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||||
|
return c.json({ error: 'You have already booked this event' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ticket
|
||||||
|
const ticketId = generateId();
|
||||||
|
const qrCode = generateTicketCode();
|
||||||
|
|
||||||
|
// Cash payments start as pending, card/lightning start as pending until payment confirmed
|
||||||
|
const ticketStatus = 'pending';
|
||||||
|
|
||||||
|
const newTicket = {
|
||||||
|
id: ticketId,
|
||||||
|
userId: user.id,
|
||||||
|
eventId: data.eventId,
|
||||||
|
attendeeFirstName: data.firstName,
|
||||||
|
attendeeLastName: data.lastName,
|
||||||
|
attendeeEmail: data.email,
|
||||||
|
attendeePhone: data.phone,
|
||||||
|
attendeeRuc: data.ruc || null,
|
||||||
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
|
status: ticketStatus,
|
||||||
|
qrCode,
|
||||||
|
checkinAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(tickets).values(newTicket);
|
||||||
|
|
||||||
|
// Create payment record
|
||||||
|
const paymentId = generateId();
|
||||||
|
const newPayment = {
|
||||||
|
id: paymentId,
|
||||||
|
ticketId,
|
||||||
|
provider: data.paymentMethod,
|
||||||
|
amount: event.price,
|
||||||
|
currency: event.currency,
|
||||||
|
status: 'pending',
|
||||||
|
reference: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(payments).values(newPayment);
|
||||||
|
|
||||||
|
// If Lightning payment, create LNbits invoice
|
||||||
|
let lnbitsInvoice = null;
|
||||||
|
if (data.paymentMethod === 'lightning' && event.price > 0) {
|
||||||
|
if (!isLNbitsConfigured()) {
|
||||||
|
// Delete the ticket and payment we just created
|
||||||
|
await (db as any).delete(payments).where(eq((payments as any).id, paymentId));
|
||||||
|
await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId));
|
||||||
|
return c.json({
|
||||||
|
error: 'Bitcoin Lightning payments are not available at this time'
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
// Pass the fiat currency directly to LNbits - it handles conversion automatically
|
||||||
|
lnbitsInvoice = await createInvoice({
|
||||||
|
amount: event.price,
|
||||||
|
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc.
|
||||||
|
memo: `Spanglish: ${event.title} - ${fullName}`,
|
||||||
|
webhookUrl: `${apiUrl}/api/lnbits/webhook`,
|
||||||
|
expiry: 900, // 15 minutes expiry for faster UX
|
||||||
|
extra: {
|
||||||
|
ticketId,
|
||||||
|
eventId: event.id,
|
||||||
|
eventTitle: event.title,
|
||||||
|
attendeeName: fullName,
|
||||||
|
attendeeEmail: data.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update payment with LNbits payment hash reference
|
||||||
|
await (db as any)
|
||||||
|
.update(payments)
|
||||||
|
.set({ reference: lnbitsInvoice.paymentHash })
|
||||||
|
.where(eq((payments as any).id, paymentId));
|
||||||
|
|
||||||
|
(newPayment as any).reference = lnbitsInvoice.paymentHash;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to create Lightning invoice:', error);
|
||||||
|
// Delete the ticket and payment we just created since Lightning payment failed
|
||||||
|
await (db as any).delete(payments).where(eq((payments as any).id, paymentId));
|
||||||
|
await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId));
|
||||||
|
return c.json({
|
||||||
|
error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}`
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
ticket: {
|
||||||
|
...newTicket,
|
||||||
|
event: {
|
||||||
|
title: event.title,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
location: event.location,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payment: newPayment,
|
||||||
|
lightningInvoice: lnbitsInvoice ? {
|
||||||
|
paymentHash: lnbitsInvoice.paymentHash,
|
||||||
|
paymentRequest: lnbitsInvoice.paymentRequest,
|
||||||
|
amount: lnbitsInvoice.amount, // Amount in satoshis
|
||||||
|
fiatAmount: lnbitsInvoice.fiatAmount,
|
||||||
|
fiatCurrency: lnbitsInvoice.fiatCurrency,
|
||||||
|
expiry: lnbitsInvoice.expiry,
|
||||||
|
} : null,
|
||||||
|
message: 'Booking created successfully',
|
||||||
|
}, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get ticket by ID
|
||||||
|
ticketsRouter.get('/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return c.json({ error: 'Ticket not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get associated event
|
||||||
|
const event = await (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)).get();
|
||||||
|
|
||||||
|
// Get payment
|
||||||
|
const payment = await (db as any).select().from(payments).where(eq((payments as any).ticketId, id)).get();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
ticket: {
|
||||||
|
...ticket,
|
||||||
|
event,
|
||||||
|
payment,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update ticket status (admin/organizer)
|
||||||
|
ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateTicketSchema), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return c.json({ error: 'Ticket not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: any = {};
|
||||||
|
|
||||||
|
if (data.status) {
|
||||||
|
updates.status = data.status;
|
||||||
|
if (data.status === 'checked_in') {
|
||||||
|
updates.checkinAt = getNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await (db as any).update(tickets).set(updates).where(eq((tickets as any).id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
|
return c.json({ ticket: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check-in ticket
|
||||||
|
ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return c.json({ error: 'Ticket not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.status === 'checked_in') {
|
||||||
|
return c.json({ error: 'Ticket already checked in' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.status !== 'confirmed') {
|
||||||
|
return c.json({ error: 'Ticket must be confirmed before check-in' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(tickets)
|
||||||
|
.set({ status: 'checked_in', checkinAt: getNow() })
|
||||||
|
.where(eq((tickets as any).id, id));
|
||||||
|
|
||||||
|
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
|
return c.json({ ticket: updated, message: 'Check-in successful' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark payment as received (for cash payments - admin only)
|
||||||
|
ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const user = (c as any).get('user');
|
||||||
|
|
||||||
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return c.json({ error: 'Ticket not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.status === 'confirmed') {
|
||||||
|
return c.json({ error: 'Ticket already confirmed' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.status === 'cancelled') {
|
||||||
|
return c.json({ error: 'Cannot confirm cancelled ticket' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
// Update ticket status
|
||||||
|
await (db as any)
|
||||||
|
.update(tickets)
|
||||||
|
.set({ status: 'confirmed' })
|
||||||
|
.where(eq((tickets as any).id, id));
|
||||||
|
|
||||||
|
// Update payment status
|
||||||
|
await (db as any)
|
||||||
|
.update(payments)
|
||||||
|
.set({
|
||||||
|
status: 'paid',
|
||||||
|
paidAt: now,
|
||||||
|
paidByAdminId: user.id,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((payments as any).ticketId, id));
|
||||||
|
|
||||||
|
// Get payment for sending receipt
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).ticketId, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Send confirmation emails asynchronously (don't block the response)
|
||||||
|
Promise.all([
|
||||||
|
emailService.sendBookingConfirmation(id),
|
||||||
|
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
|
||||||
|
]).catch(err => {
|
||||||
|
console.error('[Email] Failed to send confirmation emails:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
|
return c.json({ ticket: updated, message: 'Payment marked as received' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// User marks payment as sent (for manual payment methods: bank_transfer, tpago)
|
||||||
|
// This sets status to "pending_approval" and notifies admin
|
||||||
|
ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return c.json({ error: 'Ticket not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the payment
|
||||||
|
const payment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).ticketId, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
return c.json({ error: 'Payment not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow for manual payment methods
|
||||||
|
if (!['bank_transfer', 'tpago'].includes(payment.provider)) {
|
||||||
|
return c.json({ error: 'This action is only available for bank transfer or TPago payments' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow if currently pending
|
||||||
|
if (payment.status !== 'pending') {
|
||||||
|
return c.json({ error: 'Payment has already been processed' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
// Update payment status to pending_approval
|
||||||
|
await (db as any)
|
||||||
|
.update(payments)
|
||||||
|
.set({
|
||||||
|
status: 'pending_approval',
|
||||||
|
userMarkedPaidAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((payments as any).id, payment.id));
|
||||||
|
|
||||||
|
// Get updated payment
|
||||||
|
const updatedPayment = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).id, payment.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// TODO: Send notification to admin about pending payment approval
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
payment: updatedPayment,
|
||||||
|
message: 'Payment marked as sent. Waiting for admin approval.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel ticket
|
||||||
|
ticketsRouter.post('/:id/cancel', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const user = await getAuthUser(c);
|
||||||
|
|
||||||
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return c.json({ error: 'Ticket not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authorization (admin or ticket owner)
|
||||||
|
if (!user || (user.role !== 'admin' && user.id !== ticket.userId)) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.status === 'cancelled') {
|
||||||
|
return c.json({ error: 'Ticket already cancelled' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (db as any).update(tickets).set({ status: 'cancelled' }).where(eq((tickets as any).id, id));
|
||||||
|
|
||||||
|
return c.json({ message: 'Ticket cancelled successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove check-in (reset to confirmed)
|
||||||
|
ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return c.json({ error: 'Ticket not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.status !== 'checked_in') {
|
||||||
|
return c.json({ error: 'Ticket is not checked in' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(tickets)
|
||||||
|
.set({ status: 'confirmed', checkinAt: null })
|
||||||
|
.where(eq((tickets as any).id, id));
|
||||||
|
|
||||||
|
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
|
return c.json({ ticket: updated, message: 'Check-in removed successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update admin note
|
||||||
|
ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateNoteSchema), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const { note } = c.req.valid('json');
|
||||||
|
|
||||||
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return c.json({ error: 'Ticket not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(tickets)
|
||||||
|
.set({ adminNote: note || null })
|
||||||
|
.where(eq((tickets as any).id, id));
|
||||||
|
|
||||||
|
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||||
|
|
||||||
|
return c.json({ ticket: updated, message: 'Note updated successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin create ticket (at the door)
|
||||||
|
ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', adminCreateTicketSchema), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
|
// Get event
|
||||||
|
const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get();
|
||||||
|
if (!event) {
|
||||||
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check capacity
|
||||||
|
const ticketCount = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, data.eventId),
|
||||||
|
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if ((ticketCount?.count || 0) >= event.capacity) {
|
||||||
|
return c.json({ error: 'Event is at capacity' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
// For door sales, email might be empty - use a generated placeholder
|
||||||
|
const attendeeEmail = data.email && data.email.trim()
|
||||||
|
? data.email.trim()
|
||||||
|
: `door-${generateId()}@doorentry.local`;
|
||||||
|
|
||||||
|
// Find or create user
|
||||||
|
let user = await (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)).get();
|
||||||
|
|
||||||
|
const adminFullName = data.lastName && data.lastName.trim()
|
||||||
|
? `${data.firstName} ${data.lastName}`.trim()
|
||||||
|
: data.firstName;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const userId = generateId();
|
||||||
|
user = {
|
||||||
|
id: userId,
|
||||||
|
email: attendeeEmail,
|
||||||
|
password: '',
|
||||||
|
name: adminFullName,
|
||||||
|
phone: data.phone || null,
|
||||||
|
role: 'user',
|
||||||
|
languagePreference: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
await (db as any).insert(users).values(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing active ticket for this user and event (only if real email provided)
|
||||||
|
if (data.email && data.email.trim() && !data.email.includes('@doorentry.local')) {
|
||||||
|
const existingTicket = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).userId, user.id),
|
||||||
|
eq((tickets as any).eventId, data.eventId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||||
|
return c.json({ error: 'This person already has a ticket for this event' }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ticket
|
||||||
|
const ticketId = generateId();
|
||||||
|
const qrCode = generateTicketCode();
|
||||||
|
|
||||||
|
// For door sales, mark as confirmed (or checked_in if auto-checkin)
|
||||||
|
const ticketStatus = data.autoCheckin ? 'checked_in' : 'confirmed';
|
||||||
|
|
||||||
|
const newTicket = {
|
||||||
|
id: ticketId,
|
||||||
|
userId: user.id,
|
||||||
|
eventId: data.eventId,
|
||||||
|
attendeeFirstName: data.firstName,
|
||||||
|
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
||||||
|
attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null,
|
||||||
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
|
status: ticketStatus,
|
||||||
|
qrCode,
|
||||||
|
checkinAt: data.autoCheckin ? now : null,
|
||||||
|
adminNote: data.adminNote || null,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(tickets).values(newTicket);
|
||||||
|
|
||||||
|
// Create payment record (marked as paid for door sales)
|
||||||
|
const paymentId = generateId();
|
||||||
|
const adminUser = (c as any).get('user');
|
||||||
|
const newPayment = {
|
||||||
|
id: paymentId,
|
||||||
|
ticketId,
|
||||||
|
provider: 'cash',
|
||||||
|
amount: event.price,
|
||||||
|
currency: event.currency,
|
||||||
|
status: 'paid',
|
||||||
|
reference: 'Door sale',
|
||||||
|
paidAt: now,
|
||||||
|
paidByAdminId: adminUser?.id || null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(payments).values(newPayment);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
ticket: {
|
||||||
|
...newTicket,
|
||||||
|
event: {
|
||||||
|
title: event.title,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
location: event.location,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payment: newPayment,
|
||||||
|
message: data.autoCheckin
|
||||||
|
? 'Attendee added and checked in successfully'
|
||||||
|
: 'Attendee added successfully',
|
||||||
|
}, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all tickets (admin)
|
||||||
|
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
|
const eventId = c.req.query('eventId');
|
||||||
|
const status = c.req.query('status');
|
||||||
|
|
||||||
|
let query = (db as any).select().from(tickets);
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
if (eventId) {
|
||||||
|
conditions.push(eq((tickets as any).eventId, eventId));
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
conditions.push(eq((tickets as any).status, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
query = query.where(and(...conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query.all();
|
||||||
|
|
||||||
|
return c.json({ tickets: result });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ticketsRouter;
|
||||||
224
backend/src/routes/users.ts
Normal file
224
backend/src/routes/users.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { db, users, tickets, events, payments } from '../db/index.js';
|
||||||
|
import { eq, desc, sql } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '../lib/auth.js';
|
||||||
|
import { getNow } from '../lib/utils.js';
|
||||||
|
|
||||||
|
interface UserContext {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||||
|
|
||||||
|
const updateUserSchema = z.object({
|
||||||
|
name: z.string().min(2).optional(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(),
|
||||||
|
languagePreference: z.enum(['en', 'es']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all users (admin only)
|
||||||
|
usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||||
|
const role = c.req.query('role');
|
||||||
|
|
||||||
|
let query = (db as any).select({
|
||||||
|
id: (users as any).id,
|
||||||
|
email: (users as any).email,
|
||||||
|
name: (users as any).name,
|
||||||
|
phone: (users as any).phone,
|
||||||
|
role: (users as any).role,
|
||||||
|
languagePreference: (users as any).languagePreference,
|
||||||
|
createdAt: (users as any).createdAt,
|
||||||
|
}).from(users);
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
query = query.where(eq((users as any).role, role));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query.orderBy(desc((users as any).createdAt)).all();
|
||||||
|
|
||||||
|
return c.json({ users: result });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user by ID (admin or self)
|
||||||
|
usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
|
||||||
|
// Users can only view their own profile unless admin
|
||||||
|
if (currentUser.role !== 'admin' && currentUser.id !== id) {
|
||||||
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await (db as any)
|
||||||
|
.select({
|
||||||
|
id: (users as any).id,
|
||||||
|
email: (users as any).email,
|
||||||
|
name: (users as any).name,
|
||||||
|
phone: (users as any).phone,
|
||||||
|
role: (users as any).role,
|
||||||
|
languagePreference: (users as any).languagePreference,
|
||||||
|
createdAt: (users as any).createdAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq((users as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ error: 'User not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ user });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user (admin or self)
|
||||||
|
usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), zValidator('json', updateUserSchema), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
|
||||||
|
// Users can only update their own profile unless admin
|
||||||
|
if (currentUser.role !== 'admin' && currentUser.id !== id) {
|
||||||
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only admin can change roles
|
||||||
|
if (data.role && currentUser.role !== 'admin') {
|
||||||
|
delete data.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await (db as any).select().from(users).where(eq((users as any).id, id)).get();
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ error: 'User not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(users)
|
||||||
|
.set({ ...data, updatedAt: getNow() })
|
||||||
|
.where(eq((users as any).id, id));
|
||||||
|
|
||||||
|
const updated = await (db as any)
|
||||||
|
.select({
|
||||||
|
id: (users as any).id,
|
||||||
|
email: (users as any).email,
|
||||||
|
name: (users as any).name,
|
||||||
|
phone: (users as any).phone,
|
||||||
|
role: (users as any).role,
|
||||||
|
languagePreference: (users as any).languagePreference,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq((users as any).id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({ user: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user's ticket history
|
||||||
|
usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
|
||||||
|
// Users can only view their own history unless admin/organizer
|
||||||
|
if (!['admin', 'organizer'].includes(currentUser.role) && currentUser.id !== id) {
|
||||||
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userTickets = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).userId, id))
|
||||||
|
.orderBy(desc((tickets as any).createdAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Get event details for each ticket
|
||||||
|
const history = await Promise.all(
|
||||||
|
userTickets.map(async (ticket: any) => {
|
||||||
|
const event = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...ticket,
|
||||||
|
event,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ history });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete user (admin only)
|
||||||
|
usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
|
||||||
|
// Prevent self-deletion
|
||||||
|
if (currentUser.id === id) {
|
||||||
|
return c.json({ error: 'Cannot delete your own account' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await (db as any).select().from(users).where(eq((users as any).id, id)).get();
|
||||||
|
if (!existing) {
|
||||||
|
return c.json({ error: 'User not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting admin users
|
||||||
|
if (existing.role === 'admin') {
|
||||||
|
return c.json({ error: 'Cannot delete admin users' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all tickets for this user
|
||||||
|
const userTickets = await (db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).userId, id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Delete payments associated with user's tickets
|
||||||
|
for (const ticket of userTickets) {
|
||||||
|
await (db as any).delete(payments).where(eq((payments as any).ticketId, ticket.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete user's tickets
|
||||||
|
await (db as any).delete(tickets).where(eq((tickets as any).userId, id));
|
||||||
|
|
||||||
|
// Delete the user
|
||||||
|
await (db as any).delete(users).where(eq((users as any).id, id));
|
||||||
|
|
||||||
|
return c.json({ message: 'User deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting user:', error);
|
||||||
|
return c.json({ error: 'Failed to delete user. They may have related records.' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user statistics (admin)
|
||||||
|
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||||
|
const totalUsers = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(users)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const adminCount = await (db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(users)
|
||||||
|
.where(eq((users as any).role, 'admin'))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
stats: {
|
||||||
|
total: totalUsers?.count || 0,
|
||||||
|
admins: adminCount?.count || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default usersRouter;
|
||||||
16
backend/tsconfig.json
Normal file
16
backend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
8
frontend/.env.example
Normal file
8
frontend/.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# API URL (leave empty for same-origin proxy)
|
||||||
|
NEXT_PUBLIC_API_URL=
|
||||||
|
|
||||||
|
# Social Links (optional - leave empty to hide)
|
||||||
|
NEXT_PUBLIC_WHATSAPP=+595991234567
|
||||||
|
NEXT_PUBLIC_INSTAGRAM=spanglish_py
|
||||||
|
NEXT_PUBLIC_EMAIL=hola@spanglish.com.py
|
||||||
|
NEXT_PUBLIC_TELEGRAM=spanglish_py
|
||||||
221
frontend/legal/privacy_policy.md
Normal file
221
frontend/legal/privacy_policy.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# Spanglish Website – Privacy Policy
|
||||||
|
|
||||||
|
Last updated: [Insert Date]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Introduction
|
||||||
|
|
||||||
|
Spanglish ("we", "us", "our") operates a community events and language exchange platform in Paraguay.
|
||||||
|
|
||||||
|
This Privacy Policy explains how we collect, use, store, and protect personal data when users interact with our website, services, and events.
|
||||||
|
|
||||||
|
By using our website and services, you agree to the practices described in this policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Legal Basis
|
||||||
|
|
||||||
|
This policy is aligned with Paraguayan regulations on electronic commerce and personal data protection, as well as international best practices.
|
||||||
|
|
||||||
|
We process personal data based on:
|
||||||
|
|
||||||
|
* User consent
|
||||||
|
* Contractual necessity (event participation)
|
||||||
|
* Legal obligations (invoicing, accounting)
|
||||||
|
* Legitimate business interests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Information We Collect
|
||||||
|
|
||||||
|
We may collect the following personal data:
|
||||||
|
|
||||||
|
### 3.1 Identification Data
|
||||||
|
|
||||||
|
* Full name
|
||||||
|
* Email address
|
||||||
|
* Phone number
|
||||||
|
* Username (if applicable)
|
||||||
|
|
||||||
|
### 3.2 Account Data
|
||||||
|
|
||||||
|
* Login credentials (encrypted)
|
||||||
|
* Authentication methods (Google, email code)
|
||||||
|
* Account status
|
||||||
|
|
||||||
|
### 3.3 Booking & Event Data
|
||||||
|
|
||||||
|
* Event registrations
|
||||||
|
* Ticket history
|
||||||
|
* Attendance records
|
||||||
|
* Payment status
|
||||||
|
|
||||||
|
### 3.4 Financial & Tax Data
|
||||||
|
|
||||||
|
* Payment references
|
||||||
|
* Transaction records
|
||||||
|
* RUC number (if provided)
|
||||||
|
* Invoice information
|
||||||
|
|
||||||
|
We do not store credit card details.
|
||||||
|
|
||||||
|
### 3.5 Technical Data
|
||||||
|
|
||||||
|
* IP address
|
||||||
|
* Browser type
|
||||||
|
* Device information
|
||||||
|
* Access logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. How We Use Your Information
|
||||||
|
|
||||||
|
We use personal data to:
|
||||||
|
|
||||||
|
* Manage event registrations
|
||||||
|
* Process payments
|
||||||
|
* Generate invoices
|
||||||
|
* Communicate with users
|
||||||
|
* Provide customer support
|
||||||
|
* Improve our services
|
||||||
|
* Comply with legal obligations
|
||||||
|
* Prevent fraud and abuse
|
||||||
|
|
||||||
|
We do not sell personal data to third parties.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Legal Use of RUC Information
|
||||||
|
|
||||||
|
If you provide a RUC number, it is used exclusively for:
|
||||||
|
|
||||||
|
* Issuing tax-compliant invoices
|
||||||
|
* Accounting and reporting obligations
|
||||||
|
|
||||||
|
RUC data is stored securely and accessed only by authorized staff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Cookies and Tracking
|
||||||
|
|
||||||
|
We may use cookies and similar technologies to:
|
||||||
|
|
||||||
|
* Maintain user sessions
|
||||||
|
* Analyze website usage
|
||||||
|
* Improve performance
|
||||||
|
|
||||||
|
Users may disable cookies in their browser settings. Some features may not function correctly without cookies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Data Sharing
|
||||||
|
|
||||||
|
We may share limited data with trusted service providers, including:
|
||||||
|
|
||||||
|
* Payment processors (TPago, Bancard, LNbits)
|
||||||
|
* Email service providers
|
||||||
|
* Hosting providers
|
||||||
|
* Analytics providers
|
||||||
|
|
||||||
|
These providers process data only under our instructions.
|
||||||
|
|
||||||
|
We may disclose information when required by law.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Data Storage and Security
|
||||||
|
|
||||||
|
We implement technical and organizational measures to protect personal data, including:
|
||||||
|
|
||||||
|
* Encrypted passwords
|
||||||
|
* Secure servers
|
||||||
|
* Access controls
|
||||||
|
* Regular backups
|
||||||
|
* Monitoring and logging
|
||||||
|
|
||||||
|
Despite our efforts, no system is completely secure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Data Retention
|
||||||
|
|
||||||
|
We retain personal data only as long as necessary for:
|
||||||
|
|
||||||
|
* Service provision
|
||||||
|
* Legal compliance
|
||||||
|
* Accounting requirements
|
||||||
|
* Dispute resolution
|
||||||
|
|
||||||
|
When no longer required, data is securely deleted or anonymized.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. User Rights
|
||||||
|
|
||||||
|
Users have the right to:
|
||||||
|
|
||||||
|
* Access their personal data
|
||||||
|
* Correct inaccurate data
|
||||||
|
* Request deletion
|
||||||
|
* Withdraw consent
|
||||||
|
* Object to certain processing
|
||||||
|
* Request data export
|
||||||
|
|
||||||
|
Requests may be submitted via our contact page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Marketing Communications
|
||||||
|
|
||||||
|
Users may opt in to receive promotional emails.
|
||||||
|
|
||||||
|
Users may opt out at any time using the unsubscribe link or account settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. International Data Transfers
|
||||||
|
|
||||||
|
Some service providers may be located outside Paraguay.
|
||||||
|
|
||||||
|
When data is transferred internationally, we ensure adequate protection measures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Children’s Privacy
|
||||||
|
|
||||||
|
Our services are intended for users aged 16 and older.
|
||||||
|
|
||||||
|
We do not knowingly collect data from minors without parental consent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Changes to This Policy
|
||||||
|
|
||||||
|
We may update this Privacy Policy periodically.
|
||||||
|
|
||||||
|
Changes will be published on this page with an updated date.
|
||||||
|
|
||||||
|
Continued use of the service constitutes acceptance of the revised policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Contact Information
|
||||||
|
|
||||||
|
For privacy-related questions or requests, contact:
|
||||||
|
|
||||||
|
Spanglish
|
||||||
|
Email: [Insert Contact Email]
|
||||||
|
Website: [Insert Website URL]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Supervisory Authority
|
||||||
|
|
||||||
|
If you believe your data protection rights have been violated, you may contact the relevant Paraguayan regulatory authority.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Acceptance
|
||||||
|
|
||||||
|
By using our website and services, you acknowledge that you have read and understood this Privacy Policy.
|
||||||
189
frontend/legal/refund_cancelation_policy.md
Normal file
189
frontend/legal/refund_cancelation_policy.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Spanglish Website – Refund & Cancellation Policy
|
||||||
|
|
||||||
|
Last updated: [Insert Date]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This Refund & Cancellation Policy defines the conditions under which refunds, cancellations, and changes are handled by Spanglish.
|
||||||
|
|
||||||
|
Its purpose is to ensure transparency, fairness, and operational stability for both participants and organizers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
This policy applies to all event bookings made through the Spanglish platform, including in-person and online events.
|
||||||
|
|
||||||
|
By purchasing a ticket, users agree to this policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Booking Confirmation
|
||||||
|
|
||||||
|
A booking is considered confirmed only after payment has been approved.
|
||||||
|
|
||||||
|
Unpaid or unapproved bookings do not guarantee participation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. User-Initiated Cancellations
|
||||||
|
|
||||||
|
### 4.1 Cancellation Deadline
|
||||||
|
|
||||||
|
Users may request a cancellation up to **48 hours before** the scheduled start of the event.
|
||||||
|
|
||||||
|
Cancellation requests must be submitted through the user dashboard or via official support channels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Refund Eligibility
|
||||||
|
|
||||||
|
If a valid cancellation request is received within the deadline:
|
||||||
|
|
||||||
|
* The user may choose a full refund, or
|
||||||
|
* A credit for a future event
|
||||||
|
|
||||||
|
The chosen option must be indicated at the time of request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Late Cancellations and No-Shows
|
||||||
|
|
||||||
|
Cancellations made less than 48 hours before the event, or failure to attend without notice, are not eligible for a refund.
|
||||||
|
|
||||||
|
Exceptions may be granted at Spanglish’s discretion in cases of emergency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Organizer-Initiated Cancellations
|
||||||
|
|
||||||
|
### 5.1 Event Cancellation
|
||||||
|
|
||||||
|
If Spanglish cancels an event, users may choose:
|
||||||
|
|
||||||
|
* A full refund, or
|
||||||
|
* Transfer to a future event
|
||||||
|
|
||||||
|
Users will be notified promptly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Event Rescheduling
|
||||||
|
|
||||||
|
If an event is rescheduled:
|
||||||
|
|
||||||
|
* Tickets remain valid for the new date
|
||||||
|
* Users unable to attend may request a refund
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Payment Method Refund Processing
|
||||||
|
|
||||||
|
### 6.1 Card and Online Payments
|
||||||
|
|
||||||
|
Refunds are processed using the original payment method when possible.
|
||||||
|
|
||||||
|
Processing times depend on the payment provider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.2 Bank Transfers
|
||||||
|
|
||||||
|
Refunds via bank transfer require valid bank details from the user.
|
||||||
|
|
||||||
|
Processing may take up to 10 business days.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.3 Bitcoin / Lightning
|
||||||
|
|
||||||
|
Lightning payments are refunded in Bitcoin when technically feasible.
|
||||||
|
|
||||||
|
If not feasible, alternative compensation may be offered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.4 Cash Payments
|
||||||
|
|
||||||
|
Cash payments are refunded in cash at a future event or by arrangement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Administrative Fees
|
||||||
|
|
||||||
|
Spanglish reserves the right to deduct reasonable administrative or processing fees from refunds when applicable.
|
||||||
|
|
||||||
|
Any deductions will be clearly communicated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Non-Refundable Situations
|
||||||
|
|
||||||
|
Refunds are not provided in the following cases:
|
||||||
|
|
||||||
|
* Removal due to misconduct
|
||||||
|
* Violation of event rules
|
||||||
|
* False or misleading information
|
||||||
|
* Force majeure events
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Force Majeure
|
||||||
|
|
||||||
|
In cases of force majeure, including natural disasters, government restrictions, or public emergencies, Spanglish may:
|
||||||
|
|
||||||
|
* Reschedule events
|
||||||
|
* Offer credits
|
||||||
|
* Provide partial refunds
|
||||||
|
|
||||||
|
Decisions are made in good faith.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Refund Requests Procedure
|
||||||
|
|
||||||
|
To request a refund, users must provide:
|
||||||
|
|
||||||
|
* Full name
|
||||||
|
* Email address
|
||||||
|
* Event details
|
||||||
|
* Reason for request
|
||||||
|
* Preferred refund method
|
||||||
|
|
||||||
|
Requests are reviewed within 5 business days.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Dispute Resolution
|
||||||
|
|
||||||
|
If a user disagrees with a refund decision, they may submit a formal appeal.
|
||||||
|
|
||||||
|
Appeals are reviewed by management.
|
||||||
|
|
||||||
|
Decisions are final.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Policy Changes
|
||||||
|
|
||||||
|
Spanglish may update this policy periodically.
|
||||||
|
|
||||||
|
Updates will be published with a revised date.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Contact Information
|
||||||
|
|
||||||
|
For refund and cancellation inquiries, contact:
|
||||||
|
|
||||||
|
Spanglish
|
||||||
|
Email: [Insert Contact Email]
|
||||||
|
Website: [Insert Website URL]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Acceptance
|
||||||
|
|
||||||
|
By purchasing a ticket, users acknowledge and agree to this Refund & Cancellation Policy.
|
||||||
241
frontend/legal/terms_policy.md
Normal file
241
frontend/legal/terms_policy.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# Spanglish Website – Terms & Conditions
|
||||||
|
|
||||||
|
Last updated: [Insert Date]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Introduction
|
||||||
|
|
||||||
|
These Terms and Conditions ("Terms") govern the use of the Spanglish website and services.
|
||||||
|
|
||||||
|
By accessing or using our platform, you agree to be bound by these Terms.
|
||||||
|
|
||||||
|
If you do not agree, you must not use our services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. About Spanglish
|
||||||
|
|
||||||
|
Spanglish is a community-based language exchange and cultural events platform operating in Paraguay.
|
||||||
|
|
||||||
|
We organize in-person and online events for language learning and social interaction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Eligibility
|
||||||
|
|
||||||
|
To use our services, you must:
|
||||||
|
|
||||||
|
* Be at least 16 years old
|
||||||
|
* Provide accurate and complete information
|
||||||
|
* Have legal capacity to enter into agreements
|
||||||
|
|
||||||
|
We reserve the right to refuse service to anyone at our discretion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. User Accounts
|
||||||
|
|
||||||
|
### 4.1 Account Creation
|
||||||
|
|
||||||
|
Users may create accounts using email, password, Google login, or email codes.
|
||||||
|
|
||||||
|
Accounts may also be created automatically during booking.
|
||||||
|
|
||||||
|
### 4.2 Account Responsibility
|
||||||
|
|
||||||
|
Users are responsible for:
|
||||||
|
|
||||||
|
* Maintaining account security
|
||||||
|
* Protecting login credentials
|
||||||
|
* All activity under their account
|
||||||
|
|
||||||
|
We are not liable for unauthorized access caused by user negligence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Event Registration and Tickets
|
||||||
|
|
||||||
|
### 5.1 Booking
|
||||||
|
|
||||||
|
Event bookings are subject to availability.
|
||||||
|
|
||||||
|
A booking is only considered confirmed after payment is approved.
|
||||||
|
|
||||||
|
### 5.2 Ticket Ownership
|
||||||
|
|
||||||
|
Tickets are personal and non-transferable unless explicitly allowed.
|
||||||
|
|
||||||
|
Group tickets remain the responsibility of the purchaser.
|
||||||
|
|
||||||
|
### 5.3 Admission Rights
|
||||||
|
|
||||||
|
We reserve the right to deny entry to participants who:
|
||||||
|
|
||||||
|
* Violate event rules
|
||||||
|
* Engage in disruptive behavior
|
||||||
|
* Appear intoxicated
|
||||||
|
* Harass other participants
|
||||||
|
|
||||||
|
No refunds are provided in such cases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Payments
|
||||||
|
|
||||||
|
### 6.1 Accepted Methods
|
||||||
|
|
||||||
|
We accept payments through:
|
||||||
|
|
||||||
|
* Paraguayan bank transfer
|
||||||
|
* TPago / Bancard
|
||||||
|
* Bitcoin / Lightning
|
||||||
|
* Cash at the door
|
||||||
|
|
||||||
|
Availability may vary per event.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.2 Payment Approval
|
||||||
|
|
||||||
|
Some payment methods require manual verification.
|
||||||
|
|
||||||
|
Tickets are confirmed only after payment approval.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.3 Pricing
|
||||||
|
|
||||||
|
All prices are displayed in Paraguayan Guaraní (Gs) unless otherwise stated.
|
||||||
|
|
||||||
|
Prices may change without prior notice for future events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Invoices
|
||||||
|
|
||||||
|
Invoices are issued for paid bookings when RUC information is provided.
|
||||||
|
|
||||||
|
Users are responsible for providing accurate tax data.
|
||||||
|
|
||||||
|
Incorrect data may result in invalid invoices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Cancellations and Changes
|
||||||
|
|
||||||
|
Event details such as date, time, or location may change due to unforeseen circumstances.
|
||||||
|
|
||||||
|
Users will be notified of significant changes.
|
||||||
|
|
||||||
|
Spanglish reserves the right to cancel events if necessary.
|
||||||
|
|
||||||
|
Refunds in such cases are governed by the Refund & Cancellation Policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Refund Policy Reference
|
||||||
|
|
||||||
|
Refunds and cancellations are governed by the separate Refund & Cancellation Policy document, which forms part of these Terms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. User Conduct
|
||||||
|
|
||||||
|
Users agree to:
|
||||||
|
|
||||||
|
* Respect other participants
|
||||||
|
* Follow staff instructions
|
||||||
|
* Avoid discriminatory or abusive behavior
|
||||||
|
* Comply with local laws
|
||||||
|
|
||||||
|
Violations may result in removal without refund.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Intellectual Property
|
||||||
|
|
||||||
|
All website content, including text, graphics, logos, and software, is owned by Spanglish or its licensors.
|
||||||
|
|
||||||
|
Unauthorized use is prohibited.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Limitation of Liability
|
||||||
|
|
||||||
|
To the maximum extent permitted by law:
|
||||||
|
|
||||||
|
* Spanglish is not liable for indirect damages
|
||||||
|
* Participation is at the user’s own risk
|
||||||
|
* We are not responsible for lost belongings
|
||||||
|
* We are not responsible for third-party services
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Health and Safety
|
||||||
|
|
||||||
|
Participants are responsible for their own health and safety during events.
|
||||||
|
|
||||||
|
Users must inform staff of relevant medical conditions if necessary.
|
||||||
|
|
||||||
|
We may refuse participation if safety is compromised.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Force Majeure
|
||||||
|
|
||||||
|
We are not liable for failure to perform due to events beyond our control, including:
|
||||||
|
|
||||||
|
* Natural disasters
|
||||||
|
* Government actions
|
||||||
|
* Pandemics
|
||||||
|
* Power failures
|
||||||
|
* Internet outages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Termination
|
||||||
|
|
||||||
|
We may suspend or terminate accounts for violations of these Terms.
|
||||||
|
|
||||||
|
Termination does not affect outstanding payment obligations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Privacy
|
||||||
|
|
||||||
|
Personal data is processed in accordance with our Privacy Policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Governing Law and Jurisdiction
|
||||||
|
|
||||||
|
These Terms are governed by the laws of the Republic of Paraguay.
|
||||||
|
|
||||||
|
Any disputes shall be resolved in the courts of Paraguay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Changes to These Terms
|
||||||
|
|
||||||
|
We may update these Terms periodically.
|
||||||
|
|
||||||
|
Changes will be published with an updated date.
|
||||||
|
|
||||||
|
Continued use constitutes acceptance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Contact Information
|
||||||
|
|
||||||
|
For questions regarding these Terms, contact:
|
||||||
|
|
||||||
|
Spanglish
|
||||||
|
Email: [Insert Contact Email]
|
||||||
|
Website: [Insert Website URL]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Acceptance
|
||||||
|
|
||||||
|
By using our website and services, you confirm that you have read, understood, and agreed to these Terms and Conditions.
|
||||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
31
frontend/next.config.js
Normal file
31
frontend/next.config.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
domains: ['localhost', 'images.unsplash.com'],
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: '**',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: '3001',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/:path*',
|
||||||
|
destination: 'http://localhost:3001/api/:path*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/uploads/:path*',
|
||||||
|
destination: 'http://localhost:3001/uploads/:path*',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
32
frontend/package.json
Normal file
32
frontend/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3002",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3002",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@heroicons/react": "^2.1.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"next": "^14.2.4",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"swr": "^2.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.9",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"tailwindcss": "^3.4.4",
|
||||||
|
"typescript": "^5.5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
frontend/public/images/2026-01-29 13.09.59.jpg
Normal file
BIN
frontend/public/images/2026-01-29 13.09.59.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
BIN
frontend/public/images/2026-01-29 13.10.16.jpg
Normal file
BIN
frontend/public/images/2026-01-29 13.10.16.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
BIN
frontend/public/images/2026-01-29 13.10.23.jpg
Normal file
BIN
frontend/public/images/2026-01-29 13.10.23.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
BIN
frontend/public/images/2026-01-29 13.10.26.jpg
Normal file
BIN
frontend/public/images/2026-01-29 13.10.26.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
172
frontend/src/app/(public)/auth/claim-account/page.tsx
Normal file
172
frontend/src/app/(public)/auth/claim-account/page.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, Suspense } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import { authApi } from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
function ClaimAccountContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { locale: language } = useLanguage();
|
||||||
|
const { setAuthData } = useAuth();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
toast.error(language === 'es' ? 'Token no válido' : 'Invalid token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password.length < 10) {
|
||||||
|
toast.error(
|
||||||
|
language === 'es'
|
||||||
|
? 'La contraseña debe tener al menos 10 caracteres'
|
||||||
|
: 'Password must be at least 10 characters'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authApi.confirmClaimAccount(token, { password: formData.password });
|
||||||
|
setAuthData({ user: result.user, token: result.token });
|
||||||
|
toast.success(language === 'es' ? '¡Cuenta activada!' : 'Account activated!');
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || (language === 'es' ? 'Error' : 'Failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">
|
||||||
|
{language === 'es' ? 'Enlace Inválido' : 'Invalid Link'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
{language === 'es'
|
||||||
|
? 'Este enlace de activación no es válido o ha expirado.'
|
||||||
|
: 'This activation link is invalid or has expired.'}
|
||||||
|
</p>
|
||||||
|
<Link href="/login">
|
||||||
|
<Button>
|
||||||
|
{language === 'es' ? 'Ir a Iniciar Sesión' : 'Go to Login'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
{language === 'es' ? 'Activar tu Cuenta' : 'Activate Your Account'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
{language === 'es'
|
||||||
|
? 'Configura una contraseña para acceder a tu cuenta'
|
||||||
|
: 'Set a password to access your account'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-8">
|
||||||
|
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
{language === 'es'
|
||||||
|
? 'Tu cuenta fue creada durante una reservación. Establece una contraseña para acceder a tu historial de entradas y más.'
|
||||||
|
: 'Your account was created during a booking. Set a password to access your ticket history and more.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
label={language === 'es' ? 'Contraseña' : 'Password'}
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 -mt-4">
|
||||||
|
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
|
||||||
|
type="password"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
|
||||||
|
{language === 'es' ? 'Activar Cuenta' : 'Activate Account'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-gray-600">
|
||||||
|
{language === 'es' ? '¿Ya tienes acceso?' : 'Already have access?'}{' '}
|
||||||
|
<Link href="/login" className="text-secondary-blue hover:underline font-medium">
|
||||||
|
{language === 'es' ? 'Iniciar Sesión' : 'Log In'}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingFallback() {
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClaimAccountPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<ClaimAccountContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
frontend/src/app/(public)/auth/forgot-password/page.tsx
Normal file
103
frontend/src/app/(public)/auth/forgot-password/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import { authApi } from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const { locale: language } = useLanguage();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authApi.requestPasswordReset(email);
|
||||||
|
setSent(true);
|
||||||
|
toast.success(
|
||||||
|
language === 'es'
|
||||||
|
? 'Revisa tu correo para el enlace de restablecimiento'
|
||||||
|
: 'Check your email for the reset link'
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || (language === 'es' ? 'Error' : 'Failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
{language === 'es' ? 'Restablecer Contraseña' : 'Reset Password'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
{language === 'es'
|
||||||
|
? 'Ingresa tu email para recibir un enlace de restablecimiento'
|
||||||
|
: 'Enter your email to receive a reset link'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-8">
|
||||||
|
{sent ? (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">
|
||||||
|
{language === 'es' ? 'Revisa tu Email' : 'Check Your Email'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
|
{language === 'es'
|
||||||
|
? `Si existe una cuenta con ${email}, recibirás un enlace para restablecer tu contraseña.`
|
||||||
|
: `If an account exists with ${email}, you'll receive a password reset link.`}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setSent(false)}
|
||||||
|
className="text-secondary-blue hover:underline text-sm"
|
||||||
|
>
|
||||||
|
{language === 'es' ? 'Intentar con otro email' : 'Try a different email'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
|
||||||
|
{language === 'es' ? 'Enviar Enlace' : 'Send Reset Link'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-gray-600">
|
||||||
|
{language === 'es' ? '¿Recordaste tu contraseña?' : 'Remember your password?'}{' '}
|
||||||
|
<Link href="/login" className="text-secondary-blue hover:underline font-medium">
|
||||||
|
{language === 'es' ? 'Iniciar Sesión' : 'Log In'}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
frontend/src/app/(public)/auth/magic-link/page.tsx
Normal file
119
frontend/src/app/(public)/auth/magic-link/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, Suspense, useRef } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
function MagicLinkContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { locale: language } = useLanguage();
|
||||||
|
const { loginWithMagicLink } = useAuth();
|
||||||
|
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const verificationAttempted = useRef(false);
|
||||||
|
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Prevent duplicate verification attempts (React StrictMode double-invokes effects)
|
||||||
|
if (verificationAttempted.current) return;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
verificationAttempted.current = true;
|
||||||
|
verifyToken();
|
||||||
|
} else {
|
||||||
|
setStatus('error');
|
||||||
|
setError(language === 'es' ? 'Token no encontrado' : 'Token not found');
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const verifyToken = async () => {
|
||||||
|
try {
|
||||||
|
await loginWithMagicLink(token!);
|
||||||
|
setStatus('success');
|
||||||
|
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!');
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/dashboard');
|
||||||
|
}, 1500);
|
||||||
|
} catch (err: any) {
|
||||||
|
setStatus('error');
|
||||||
|
setError(err.message || (language === 'es' ? 'Enlace inválido o expirado' : 'Invalid or expired link'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
{status === 'loading' && (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue mx-auto mb-4"></div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">
|
||||||
|
{language === 'es' ? 'Verificando...' : 'Verifying...'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{language === 'es' ? 'Por favor espera' : 'Please wait'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<>
|
||||||
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">
|
||||||
|
{language === 'es' ? '¡Inicio de sesión exitoso!' : 'Login successful!'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{language === 'es' ? 'Redirigiendo...' : 'Redirecting...'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<>
|
||||||
|
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">
|
||||||
|
{language === 'es' ? 'Error de Verificación' : 'Verification Error'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-6">{error}</p>
|
||||||
|
<Button onClick={() => router.push('/login')}>
|
||||||
|
{language === 'es' ? 'Ir a Iniciar Sesión' : 'Go to Login'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingFallback() {
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MagicLinkPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<MagicLinkContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
frontend/src/app/(public)/auth/reset-password/page.tsx
Normal file
178
frontend/src/app/(public)/auth/reset-password/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, Suspense } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import { authApi } from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
function ResetPasswordContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { locale: language } = useLanguage();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
toast.error(language === 'es' ? 'Token no válido' : 'Invalid token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password.length < 10) {
|
||||||
|
toast.error(
|
||||||
|
language === 'es'
|
||||||
|
? 'La contraseña debe tener al menos 10 caracteres'
|
||||||
|
: 'Password must be at least 10 characters'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authApi.confirmPasswordReset(token, formData.password);
|
||||||
|
setSuccess(true);
|
||||||
|
toast.success(language === 'es' ? 'Contraseña actualizada' : 'Password updated');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || (language === 'es' ? 'Error' : 'Failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">
|
||||||
|
{language === 'es' ? 'Enlace Inválido' : 'Invalid Link'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
{language === 'es'
|
||||||
|
? 'Este enlace de restablecimiento no es válido o ha expirado.'
|
||||||
|
: 'This reset link is invalid or has expired.'}
|
||||||
|
</p>
|
||||||
|
<Link href="/auth/forgot-password">
|
||||||
|
<Button>
|
||||||
|
{language === 'es' ? 'Solicitar Nuevo Enlace' : 'Request New Link'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
{language === 'es' ? 'Nueva Contraseña' : 'New Password'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
{language === 'es'
|
||||||
|
? 'Ingresa tu nueva contraseña'
|
||||||
|
: 'Enter your new password'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-8">
|
||||||
|
{success ? (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">
|
||||||
|
{language === 'es' ? '¡Contraseña Actualizada!' : 'Password Updated!'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm mb-6">
|
||||||
|
{language === 'es'
|
||||||
|
? 'Tu contraseña ha sido cambiada exitosamente.'
|
||||||
|
: 'Your password has been successfully changed.'}
|
||||||
|
</p>
|
||||||
|
<Link href="/login">
|
||||||
|
<Button>
|
||||||
|
{language === 'es' ? 'Iniciar Sesión' : 'Log In'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
label={language === 'es' ? 'Nueva Contraseña' : 'New Password'}
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 -mt-4">
|
||||||
|
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
|
||||||
|
type="password"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
|
||||||
|
{language === 'es' ? 'Actualizar Contraseña' : 'Update Password'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingFallback() {
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<ResetPasswordContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
1073
frontend/src/app/(public)/book/[eventId]/page.tsx
Normal file
1073
frontend/src/app/(public)/book/[eventId]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
244
frontend/src/app/(public)/booking/success/[ticketId]/page.tsx
Normal file
244
frontend/src/app/(public)/booking/success/[ticketId]/page.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { ticketsApi, Ticket } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
|
ClockIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
TicketIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export default function BookingSuccessPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [polling, setPolling] = useState(false);
|
||||||
|
|
||||||
|
const ticketId = params.ticketId as string;
|
||||||
|
|
||||||
|
const checkPaymentStatus = async () => {
|
||||||
|
try {
|
||||||
|
const { ticket: ticketData } = await ticketsApi.getById(ticketId);
|
||||||
|
setTicket(ticketData);
|
||||||
|
|
||||||
|
// If still pending, continue polling
|
||||||
|
if (ticketData.status === 'pending' && ticketData.payment?.status === 'pending') {
|
||||||
|
return false; // Not done yet
|
||||||
|
}
|
||||||
|
return true; // Done polling
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking payment status:', error);
|
||||||
|
return true; // Stop polling on error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ticketId) return;
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
checkPaymentStatus().finally(() => setLoading(false));
|
||||||
|
|
||||||
|
// Poll for payment status every 3 seconds
|
||||||
|
setPolling(true);
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
const isDone = await checkPaymentStatus();
|
||||||
|
if (isDone) {
|
||||||
|
setPolling(false);
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
// Stop polling after 5 minutes
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setPolling(false);
|
||||||
|
clearInterval(interval);
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [ticketId]);
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page max-w-2xl text-center">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||||
|
<p className="mt-4 text-gray-600">
|
||||||
|
{locale === 'es' ? 'Verificando pago...' : 'Verifying payment...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page max-w-2xl">
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<XCircleIcon className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
||||||
|
{locale === 'es' ? 'Reserva no encontrada' : 'Booking not found'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'No pudimos encontrar tu reserva. Por favor, contacta con soporte.'
|
||||||
|
: 'We could not find your booking. Please contact support.'}
|
||||||
|
</p>
|
||||||
|
<Link href="/events">
|
||||||
|
<Button>{locale === 'es' ? 'Ver Eventos' : 'Browse Events'}</Button>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPaid = ticket.status === 'confirmed' || ticket.payment?.status === 'paid';
|
||||||
|
const isPending = ticket.status === 'pending' && ticket.payment?.status === 'pending';
|
||||||
|
const isFailed = ticket.payment?.status === 'failed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page max-w-2xl">
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
{/* Status Icon */}
|
||||||
|
{isPaid ? (
|
||||||
|
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-6">
|
||||||
|
<CheckCircleIcon className="w-10 h-10 text-green-600" />
|
||||||
|
</div>
|
||||||
|
) : isPending ? (
|
||||||
|
<div className="w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center mx-auto mb-6">
|
||||||
|
<ClockIcon className="w-10 h-10 text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-6">
|
||||||
|
<XCircleIcon className="w-10 h-10 text-red-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
||||||
|
{isPaid
|
||||||
|
? (locale === 'es' ? '¡Pago Confirmado!' : 'Payment Confirmed!')
|
||||||
|
: isPending
|
||||||
|
? (locale === 'es' ? 'Esperando Pago...' : 'Waiting for Payment...')
|
||||||
|
: (locale === 'es' ? 'Pago Fallido' : 'Payment Failed')
|
||||||
|
}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
{isPaid
|
||||||
|
? (locale === 'es'
|
||||||
|
? 'Tu reserva está confirmada. ¡Te esperamos!'
|
||||||
|
: 'Your booking is confirmed. See you there!')
|
||||||
|
: isPending
|
||||||
|
? (locale === 'es'
|
||||||
|
? 'Estamos verificando tu pago. Esto puede tomar unos segundos.'
|
||||||
|
: 'We are verifying your payment. This may take a few seconds.')
|
||||||
|
: (locale === 'es'
|
||||||
|
? 'Hubo un problema con tu pago. Por favor, intenta de nuevo.'
|
||||||
|
: 'There was an issue with your payment. Please try again.')
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Polling indicator */}
|
||||||
|
{polling && isPending && (
|
||||||
|
<div className="flex items-center justify-center gap-2 text-yellow-600 mb-6">
|
||||||
|
<ArrowPathIcon className="w-5 h-5 animate-spin" />
|
||||||
|
<span className="text-sm">
|
||||||
|
{locale === 'es' ? 'Verificando...' : 'Checking...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ticket Details */}
|
||||||
|
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-4">
|
||||||
|
<TicketIcon className="w-6 h-6 text-primary-yellow" />
|
||||||
|
<span className="font-mono text-lg font-bold">{ticket.qrCode}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-600 space-y-2">
|
||||||
|
{ticket.event && (
|
||||||
|
<>
|
||||||
|
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
|
||||||
|
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
|
||||||
|
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p>
|
||||||
|
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<span className={`inline-block px-4 py-2 rounded-full text-sm font-medium ${
|
||||||
|
isPaid
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: isPending
|
||||||
|
? 'bg-yellow-100 text-yellow-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{isPaid
|
||||||
|
? (locale === 'es' ? 'Confirmado' : 'Confirmed')
|
||||||
|
: isPending
|
||||||
|
? (locale === 'es' ? 'Pendiente de Pago' : 'Pending Payment')
|
||||||
|
: (locale === 'es' ? 'Pago Fallido' : 'Payment Failed')
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email note */}
|
||||||
|
{isPaid && (
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Un correo de confirmación ha sido enviado a tu bandeja de entrada.'
|
||||||
|
: 'A confirmation email has been sent to your inbox.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<Link href="/events">
|
||||||
|
<Button variant="outline">
|
||||||
|
{locale === 'es' ? 'Ver Más Eventos' : 'Browse More Events'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/">
|
||||||
|
<Button>
|
||||||
|
{locale === 'es' ? 'Volver al Inicio' : 'Back to Home'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
frontend/src/app/(public)/community/page.tsx
Normal file
153
frontend/src/app/(public)/community/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
ChatBubbleLeftRightIcon,
|
||||||
|
CameraIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
HeartIcon
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import {
|
||||||
|
socialConfig,
|
||||||
|
getWhatsAppUrl,
|
||||||
|
getInstagramUrl,
|
||||||
|
getTelegramUrl
|
||||||
|
} from '@/lib/socialLinks';
|
||||||
|
|
||||||
|
export default function CommunityPage() {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
// Get social URLs from environment config
|
||||||
|
const whatsappUrl = getWhatsAppUrl(socialConfig.whatsapp);
|
||||||
|
const instagramUrl = getInstagramUrl(socialConfig.instagram);
|
||||||
|
const telegramUrl = getTelegramUrl(socialConfig.telegram);
|
||||||
|
|
||||||
|
const guidelines = t('community.guidelines.items') as unknown as string[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="text-center max-w-2xl mx-auto">
|
||||||
|
<h1 className="section-title">{t('community.title')}</h1>
|
||||||
|
<p className="section-subtitle">{t('community.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Links */}
|
||||||
|
<div className="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||||
|
{/* WhatsApp Card */}
|
||||||
|
{whatsappUrl && (
|
||||||
|
<Card className="p-8 text-center card-hover">
|
||||||
|
<div className="w-20 h-20 mx-auto bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-10 h-10 text-green-600" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-6 text-xl font-semibold">{t('community.whatsapp.title')}</h3>
|
||||||
|
<p className="mt-3 text-gray-600">{t('community.whatsapp.description')}</p>
|
||||||
|
<a
|
||||||
|
href={whatsappUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-block mt-6"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
{t('community.whatsapp.button')}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Telegram Card */}
|
||||||
|
{telegramUrl && (
|
||||||
|
<Card className="p-8 text-center card-hover">
|
||||||
|
<div className="w-20 h-20 mx-auto bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-10 h-10 text-blue-500" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-6 text-xl font-semibold">{t('community.telegram.title')}</h3>
|
||||||
|
<p className="mt-3 text-gray-600">{t('community.telegram.description')}</p>
|
||||||
|
<a
|
||||||
|
href={telegramUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-block mt-6"
|
||||||
|
>
|
||||||
|
<Button variant="outline">
|
||||||
|
{t('community.telegram.button')}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Instagram Card */}
|
||||||
|
{instagramUrl && (
|
||||||
|
<Card className="p-8 text-center card-hover">
|
||||||
|
<div className="w-20 h-20 mx-auto bg-gradient-to-br from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
|
||||||
|
<CameraIcon className="w-10 h-10 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-6 text-xl font-semibold">{t('community.instagram.title')}</h3>
|
||||||
|
<p className="mt-3 text-gray-600">{t('community.instagram.description')}</p>
|
||||||
|
<a
|
||||||
|
href={instagramUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-block mt-6"
|
||||||
|
>
|
||||||
|
<Button variant="secondary">
|
||||||
|
{t('community.instagram.button')}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Guidelines */}
|
||||||
|
<div className="mt-20 max-w-3xl mx-auto">
|
||||||
|
<Card className="p-8">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="w-12 h-12 bg-primary-yellow/20 rounded-full flex items-center justify-center">
|
||||||
|
<HeartIcon className="w-6 h-6 text-primary-dark" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold">{t('community.guidelines.title')}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{Array.isArray(guidelines) && guidelines.map((item, index) => (
|
||||||
|
<li key={index} className="flex items-start gap-3">
|
||||||
|
<span className="w-6 h-6 bg-primary-yellow rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-700">{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volunteer Section */}
|
||||||
|
<div className="mt-20 max-w-3xl mx-auto">
|
||||||
|
<Card className="p-8 bg-gradient-to-br from-primary-yellow/10 to-secondary-blue/10">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-8">
|
||||||
|
<div className="w-24 h-24 bg-white rounded-full flex items-center justify-center shadow-card flex-shrink-0">
|
||||||
|
<UserGroupIcon className="w-12 h-12 text-primary-dark" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center md:text-left">
|
||||||
|
<h2 className="text-2xl font-bold">{t('community.volunteer.title')}</h2>
|
||||||
|
<p className="mt-2 text-gray-600">{t('community.volunteer.description')}</p>
|
||||||
|
<Link href="/contact" className="inline-block mt-4">
|
||||||
|
<Button variant="outline">
|
||||||
|
{t('community.volunteer.button')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
frontend/src/app/(public)/contact/page.tsx
Normal file
158
frontend/src/app/(public)/contact/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { contactsApi } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import { ChatBubbleLeftRightIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { getSocialLinks, socialIcons, socialConfig } from '@/lib/socialLinks';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function ContactPage() {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const socialLinks = getSocialLinks();
|
||||||
|
const emailLink = socialLinks.find(l => l.type === 'email');
|
||||||
|
const otherLinks = socialLinks.filter(l => l.type !== 'email');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await contactsApi.submit(formData);
|
||||||
|
toast.success(t('contact.success'));
|
||||||
|
setFormData({ name: '', email: '', message: '' });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t('contact.error'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="text-center max-w-2xl mx-auto">
|
||||||
|
<h1 className="section-title">{t('contact.title')}</h1>
|
||||||
|
<p className="section-subtitle">{t('contact.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16 grid grid-cols-1 lg:grid-cols-2 gap-12 max-w-5xl mx-auto">
|
||||||
|
{/* Contact Form */}
|
||||||
|
<Card className="p-8">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
label={t('contact.form.name')}
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
label={t('contact.form.email')}
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="message"
|
||||||
|
className="block text-sm font-medium text-primary-dark mb-1.5"
|
||||||
|
>
|
||||||
|
{t('contact.form.message')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
rows={5}
|
||||||
|
value={formData.message}
|
||||||
|
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent placeholder:text-gray-400 resize-none"
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
|
||||||
|
{t('contact.form.submit')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Contact Info */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Email Card */}
|
||||||
|
{emailLink && (
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 bg-primary-yellow/20 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
{socialIcons.email}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">{t('contact.info.email')}</h3>
|
||||||
|
<a
|
||||||
|
href={emailLink.url}
|
||||||
|
className="text-secondary-blue hover:underline"
|
||||||
|
>
|
||||||
|
{emailLink.handle}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Social Links Card */}
|
||||||
|
{otherLinks.length > 0 && (
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 bg-primary-yellow/20 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<ChatBubbleLeftRightIcon className="w-6 h-6 text-primary-dark" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-lg">{t('contact.info.social')}</h3>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{otherLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.type}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 text-secondary-blue hover:text-primary-dark transition-colors group"
|
||||||
|
>
|
||||||
|
<span className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-100 group-hover:bg-primary-yellow/20 transition-colors">
|
||||||
|
{socialIcons[link.type]}
|
||||||
|
</span>
|
||||||
|
<span className="hover:underline">{link.handle || link.label}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Map placeholder */}
|
||||||
|
<Card className="h-64 bg-gradient-to-br from-secondary-gray to-secondary-light-gray flex items-center justify-center">
|
||||||
|
<div className="text-center text-gray-400">
|
||||||
|
<div className="text-4xl mb-2">📍</div>
|
||||||
|
<p>Asunción, Paraguay</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx
Normal file
207
frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { UserPayment } from '@/lib/api';
|
||||||
|
|
||||||
|
interface PaymentsTabProps {
|
||||||
|
payments: UserPayment[];
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
|
||||||
|
const [filter, setFilter] = useState<'all' | 'paid' | 'pending'>('all');
|
||||||
|
|
||||||
|
const filteredPayments = payments.filter((payment) => {
|
||||||
|
if (filter === 'all') return true;
|
||||||
|
if (filter === 'paid') return payment.status === 'paid';
|
||||||
|
if (filter === 'pending') return payment.status !== 'paid' && payment.status !== 'refunded';
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number, currency: string = 'PYG') => {
|
||||||
|
if (currency === 'PYG') {
|
||||||
|
return `${amount.toLocaleString('es-PY')} PYG`;
|
||||||
|
}
|
||||||
|
return `$${amount.toFixed(2)} ${currency}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
paid: 'bg-green-100 text-green-800',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-800',
|
||||||
|
pending_approval: 'bg-orange-100 text-orange-800',
|
||||||
|
refunded: 'bg-purple-100 text-purple-800',
|
||||||
|
failed: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
const labels: Record<string, Record<string, string>> = {
|
||||||
|
en: {
|
||||||
|
paid: 'Paid',
|
||||||
|
pending: 'Pending',
|
||||||
|
pending_approval: 'Awaiting Approval',
|
||||||
|
refunded: 'Refunded',
|
||||||
|
failed: 'Failed',
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
paid: 'Pagado',
|
||||||
|
pending: 'Pendiente',
|
||||||
|
pending_approval: 'Esperando Aprobación',
|
||||||
|
refunded: 'Reembolsado',
|
||||||
|
failed: 'Fallido',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||||
|
{labels[language]?.[status] || status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProviderLabel = (provider: string) => {
|
||||||
|
const labels: Record<string, Record<string, string>> = {
|
||||||
|
en: {
|
||||||
|
lightning: 'Lightning (Bitcoin)',
|
||||||
|
cash: 'Cash',
|
||||||
|
bank_transfer: 'Bank Transfer',
|
||||||
|
tpago: 'TPago',
|
||||||
|
bancard: 'Card',
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
lightning: 'Lightning (Bitcoin)',
|
||||||
|
cash: 'Efectivo',
|
||||||
|
bank_transfer: 'Transferencia Bancaria',
|
||||||
|
tpago: 'TPago',
|
||||||
|
bancard: 'Tarjeta',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return labels[language]?.[provider] || provider;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Summary calculations
|
||||||
|
const totalPaid = payments
|
||||||
|
.filter((p) => p.status === 'paid')
|
||||||
|
.reduce((sum, p) => sum + Number(p.amount), 0);
|
||||||
|
const totalPending = payments
|
||||||
|
.filter((p) => p.status !== 'paid' && p.status !== 'refunded')
|
||||||
|
.reduce((sum, p) => sum + Number(p.amount), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-sm text-gray-600 mb-1">
|
||||||
|
{language === 'es' ? 'Total Pagado' : 'Total Paid'}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">
|
||||||
|
{formatCurrency(totalPaid)}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-sm text-gray-600 mb-1">
|
||||||
|
{language === 'es' ? 'Pendiente' : 'Pending'}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-yellow-600">
|
||||||
|
{formatCurrency(totalPending)}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Buttons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['all', 'paid', 'pending'] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
filter === f
|
||||||
|
? 'bg-secondary-blue text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f === 'all' && (language === 'es' ? 'Todos' : 'All')}
|
||||||
|
{f === 'paid' && (language === 'es' ? 'Pagados' : 'Paid')}
|
||||||
|
{f === 'pending' && (language === 'es' ? 'Pendientes' : 'Pending')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payments List */}
|
||||||
|
{filteredPayments.length === 0 ? (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{language === 'es' ? 'No hay pagos que mostrar' : 'No payments to display'}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredPayments.map((payment) => (
|
||||||
|
<Card key={payment.id} className="p-4">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{formatCurrency(Number(payment.amount), payment.currency)}
|
||||||
|
</span>
|
||||||
|
{getStatusBadge(payment.status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
{payment.event && (
|
||||||
|
<p>
|
||||||
|
{language === 'es' && payment.event.titleEs
|
||||||
|
? payment.event.titleEs
|
||||||
|
: payment.event.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">
|
||||||
|
{language === 'es' ? 'Método:' : 'Method:'}
|
||||||
|
</span>{' '}
|
||||||
|
{getProviderLabel(payment.provider)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">
|
||||||
|
{language === 'es' ? 'Fecha:' : 'Date:'}
|
||||||
|
</span>{' '}
|
||||||
|
{formatDate(payment.createdAt)}
|
||||||
|
</p>
|
||||||
|
{payment.reference && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">
|
||||||
|
{language === 'es' ? 'Referencia:' : 'Reference:'}
|
||||||
|
</span>{' '}
|
||||||
|
{payment.reference}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{payment.invoice && (
|
||||||
|
<a
|
||||||
|
href={payment.invoice.pdfUrl || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{language === 'es' ? 'Descargar Factura' : 'Download Invoice'}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
frontend/src/app/(public)/dashboard/components/ProfileTab.tsx
Normal file
210
frontend/src/app/(public)/dashboard/components/ProfileTab.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import { dashboardApi, UserProfile } from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface ProfileTabProps {
|
||||||
|
onUpdate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileTab({ onUpdate }: ProfileTabProps) {
|
||||||
|
const { locale: language } = useLanguage();
|
||||||
|
const { updateUser, user } = useAuth();
|
||||||
|
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
phone: '',
|
||||||
|
languagePreference: '',
|
||||||
|
rucNumber: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadProfile = async () => {
|
||||||
|
try {
|
||||||
|
const res = await dashboardApi.getProfile();
|
||||||
|
setProfile(res.profile);
|
||||||
|
setFormData({
|
||||||
|
name: res.profile.name || '',
|
||||||
|
phone: res.profile.phone || '',
|
||||||
|
languagePreference: res.profile.languagePreference || '',
|
||||||
|
rucNumber: res.profile.rucNumber || '',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(language === 'es' ? 'Error al cargar perfil' : 'Failed to load profile');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await dashboardApi.updateProfile(formData);
|
||||||
|
toast.success(language === 'es' ? 'Perfil actualizado' : 'Profile updated');
|
||||||
|
|
||||||
|
// Update auth context
|
||||||
|
if (user) {
|
||||||
|
updateUser({
|
||||||
|
...user,
|
||||||
|
name: formData.name,
|
||||||
|
phone: formData.phone,
|
||||||
|
languagePreference: formData.languagePreference,
|
||||||
|
rucNumber: formData.rucNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onUpdate) onUpdate();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || (language === 'es' ? 'Error al actualizar' : 'Update failed'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
{/* Account Info Card */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
{language === 'es' ? 'Información de la Cuenta' : 'Account Information'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex justify-between py-2 border-b">
|
||||||
|
<span className="text-gray-600">Email</span>
|
||||||
|
<span className="font-medium">{profile?.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{language === 'es' ? 'Estado de Cuenta' : 'Account Status'}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||||
|
profile?.accountStatus === 'active'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-yellow-100 text-yellow-800'
|
||||||
|
}`}>
|
||||||
|
{profile?.accountStatus === 'active'
|
||||||
|
? (language === 'es' ? 'Activo' : 'Active')
|
||||||
|
: profile?.accountStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-b">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{language === 'es' ? 'Miembro Desde' : 'Member Since'}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{profile?.memberSince
|
||||||
|
? new Date(profile.memberSince).toLocaleDateString(
|
||||||
|
language === 'es' ? 'es-ES' : 'en-US',
|
||||||
|
{ year: 'numeric', month: 'long', day: 'numeric' }
|
||||||
|
)
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{language === 'es' ? 'Días de Membresía' : 'Membership Days'}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{profile?.membershipDays || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Edit Profile Form */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
{language === 'es' ? 'Editar Perfil' : 'Edit Profile'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
label={language === 'es' ? 'Nombre' : 'Name'}
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
label={language === 'es' ? 'Teléfono' : 'Phone'}
|
||||||
|
type="tel"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{language === 'es' ? 'Idioma Preferido' : 'Preferred Language'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.languagePreference}
|
||||||
|
onChange={(e) => setFormData({ ...formData, languagePreference: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary-blue"
|
||||||
|
>
|
||||||
|
<option value="">{language === 'es' ? 'Seleccionar' : 'Select'}</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="es">Español</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="rucNumber"
|
||||||
|
label={language === 'es' ? 'Número de RUC (para facturas)' : 'RUC Number (for invoices)'}
|
||||||
|
value={formData.rucNumber}
|
||||||
|
onChange={(e) => setFormData({ ...formData, rucNumber: e.target.value })}
|
||||||
|
placeholder={language === 'es' ? 'Opcional' : 'Optional'}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 -mt-2">
|
||||||
|
{language === 'es'
|
||||||
|
? 'Tu número de RUC paraguayo para facturas fiscales'
|
||||||
|
: 'Your Paraguayan RUC number for fiscal invoices'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button type="submit" isLoading={saving}>
|
||||||
|
{language === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Email Change Notice */}
|
||||||
|
<Card className="p-6 bg-gray-50">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">
|
||||||
|
{language === 'es' ? 'Cambiar Email' : 'Change Email'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
{language === 'es'
|
||||||
|
? 'Para cambiar tu dirección de email, por favor contacta al soporte.'
|
||||||
|
: 'To change your email address, please contact support.'}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" disabled>
|
||||||
|
{language === 'es' ? 'Contactar Soporte' : 'Contact Support'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
380
frontend/src/app/(public)/dashboard/components/SecurityTab.tsx
Normal file
380
frontend/src/app/(public)/dashboard/components/SecurityTab.tsx
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function SecurityTab() {
|
||||||
|
const { locale: language } = useLanguage();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [changingPassword, setChangingPassword] = useState(false);
|
||||||
|
const [settingPassword, setSettingPassword] = useState(false);
|
||||||
|
|
||||||
|
const [passwordForm, setPasswordForm] = useState({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [newPasswordForm, setNewPasswordForm] = useState({
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [profileRes, sessionsRes] = await Promise.all([
|
||||||
|
dashboardApi.getProfile(),
|
||||||
|
dashboardApi.getSessions(),
|
||||||
|
]);
|
||||||
|
setProfile(profileRes.profile);
|
||||||
|
setSessions(sessionsRes.sessions);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(language === 'es' ? 'Error al cargar datos' : 'Failed to load data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||||
|
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordForm.newPassword.length < 10) {
|
||||||
|
toast.error(language === 'es' ? 'La contraseña debe tener al menos 10 caracteres' : 'Password must be at least 10 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setChangingPassword(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authApi.changePassword(passwordForm.currentPassword, passwordForm.newPassword);
|
||||||
|
toast.success(language === 'es' ? 'Contraseña actualizada' : 'Password updated');
|
||||||
|
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || (language === 'es' ? 'Error al cambiar contraseña' : 'Failed to change password'));
|
||||||
|
} finally {
|
||||||
|
setChangingPassword(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetPassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (newPasswordForm.password !== newPasswordForm.confirmPassword) {
|
||||||
|
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPasswordForm.password.length < 10) {
|
||||||
|
toast.error(language === 'es' ? 'La contraseña debe tener al menos 10 caracteres' : 'Password must be at least 10 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettingPassword(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dashboardApi.setPassword(newPasswordForm.password);
|
||||||
|
toast.success(language === 'es' ? 'Contraseña establecida' : 'Password set');
|
||||||
|
setNewPasswordForm({ password: '', confirmPassword: '' });
|
||||||
|
loadData(); // Reload to update profile
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || (language === 'es' ? 'Error al establecer contraseña' : 'Failed to set password'));
|
||||||
|
} finally {
|
||||||
|
setSettingPassword(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnlinkGoogle = async () => {
|
||||||
|
if (!confirm(language === 'es'
|
||||||
|
? '¿Estás seguro de que quieres desvincular tu cuenta de Google?'
|
||||||
|
: 'Are you sure you want to unlink your Google account?'
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dashboardApi.unlinkGoogle();
|
||||||
|
toast.success(language === 'es' ? 'Google desvinculado' : 'Google unlinked');
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || (language === 'es' ? 'Error' : 'Failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeSession = async (sessionId: string) => {
|
||||||
|
try {
|
||||||
|
await dashboardApi.revokeSession(sessionId);
|
||||||
|
setSessions(sessions.filter((s) => s.id !== sessionId));
|
||||||
|
toast.success(language === 'es' ? 'Sesión cerrada' : 'Session revoked');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(language === 'es' ? 'Error' : 'Failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeAllSessions = async () => {
|
||||||
|
if (!confirm(language === 'es'
|
||||||
|
? '¿Cerrar todas las sesiones? Serás desconectado.'
|
||||||
|
: 'Log out of all sessions? You will be logged out.'
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dashboardApi.revokeAllSessions();
|
||||||
|
toast.success(language === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked');
|
||||||
|
logout();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(language === 'es' ? 'Error' : 'Failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
{/* Authentication Methods */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
{language === 'es' ? 'Métodos de Autenticación' : 'Authentication Methods'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Password Status */}
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||||
|
profile?.hasPassword ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-500'
|
||||||
|
}`}>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{language === 'es' ? 'Contraseña' : 'Password'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{profile?.hasPassword
|
||||||
|
? (language === 'es' ? 'Configurada' : 'Set')
|
||||||
|
: (language === 'es' ? 'No configurada' : 'Not set')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{profile?.hasPassword && (
|
||||||
|
<span className="text-green-600 text-sm font-medium">
|
||||||
|
{language === 'es' ? 'Activo' : 'Active'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Google Status */}
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||||
|
profile?.hasGoogleLinked ? 'bg-red-100 text-red-600' : 'bg-gray-200 text-gray-500'
|
||||||
|
}`}>
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Google</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{profile?.hasGoogleLinked
|
||||||
|
? (language === 'es' ? 'Vinculado' : 'Linked')
|
||||||
|
: (language === 'es' ? 'No vinculado' : 'Not linked')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{profile?.hasGoogleLinked && profile?.hasPassword && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleUnlinkGoogle}>
|
||||||
|
{language === 'es' ? 'Desvincular' : 'Unlink'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Password Management */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
{profile?.hasPassword
|
||||||
|
? (language === 'es' ? 'Cambiar Contraseña' : 'Change Password')
|
||||||
|
: (language === 'es' ? 'Establecer Contraseña' : 'Set Password')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{profile?.hasPassword ? (
|
||||||
|
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
id="currentPassword"
|
||||||
|
type="password"
|
||||||
|
label={language === 'es' ? 'Contraseña Actual' : 'Current Password'}
|
||||||
|
value={passwordForm.currentPassword}
|
||||||
|
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
label={language === 'es' ? 'Nueva Contraseña' : 'New Password'}
|
||||||
|
value={passwordForm.newPassword}
|
||||||
|
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 -mt-2">
|
||||||
|
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
|
||||||
|
value={passwordForm.confirmPassword}
|
||||||
|
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" isLoading={changingPassword}>
|
||||||
|
{language === 'es' ? 'Cambiar Contraseña' : 'Change Password'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSetPassword} className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
{language === 'es'
|
||||||
|
? 'Actualmente inicias sesión con Google. Establece una contraseña para más opciones de acceso.'
|
||||||
|
: 'You currently sign in with Google. Set a password for more access options.'}
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
label={language === 'es' ? 'Nueva Contraseña' : 'New Password'}
|
||||||
|
value={newPasswordForm.password}
|
||||||
|
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, password: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 -mt-2">
|
||||||
|
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
id="confirmNewPassword"
|
||||||
|
type="password"
|
||||||
|
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
|
||||||
|
value={newPasswordForm.confirmPassword}
|
||||||
|
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, confirmPassword: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" isLoading={settingPassword}>
|
||||||
|
{language === 'es' ? 'Establecer Contraseña' : 'Set Password'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Active Sessions */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{language === 'es' ? 'Sesiones Activas' : 'Active Sessions'}
|
||||||
|
</h3>
|
||||||
|
{sessions.length > 1 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRevokeAllSessions}>
|
||||||
|
{language === 'es' ? 'Cerrar Todas' : 'Logout All'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{language === 'es' ? 'No hay sesiones activas' : 'No active sessions'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sessions.map((session, index) => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">
|
||||||
|
{session.userAgent
|
||||||
|
? session.userAgent.substring(0, 50) + (session.userAgent.length > 50 ? '...' : '')
|
||||||
|
: (language === 'es' ? 'Dispositivo desconocido' : 'Unknown device')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{language === 'es' ? 'Última actividad:' : 'Last active:'} {formatDate(session.lastActiveAt)}
|
||||||
|
{session.ipAddress && ` • ${session.ipAddress}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{index !== 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRevokeSession(session.id)}
|
||||||
|
>
|
||||||
|
{language === 'es' ? 'Cerrar' : 'Revoke'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{index === 0 && (
|
||||||
|
<span className="text-xs text-green-600 font-medium">
|
||||||
|
{language === 'es' ? 'Esta sesión' : 'This session'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<Card className="p-6 border-red-200 bg-red-50">
|
||||||
|
<h3 className="text-lg font-semibold text-red-800 mb-4">
|
||||||
|
{language === 'es' ? 'Zona de Peligro' : 'Danger Zone'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-red-700 mb-4">
|
||||||
|
{language === 'es'
|
||||||
|
? 'Si deseas eliminar tu cuenta, contacta al soporte.'
|
||||||
|
: 'If you want to delete your account, please contact support.'}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" className="border-red-300 text-red-700 hover:bg-red-100" disabled>
|
||||||
|
{language === 'es' ? 'Eliminar Cuenta' : 'Delete Account'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
frontend/src/app/(public)/dashboard/components/TicketsTab.tsx
Normal file
193
frontend/src/app/(public)/dashboard/components/TicketsTab.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { UserTicket } from '@/lib/api';
|
||||||
|
|
||||||
|
interface TicketsTabProps {
|
||||||
|
tickets: UserTicket[];
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TicketsTab({ tickets, language }: TicketsTabProps) {
|
||||||
|
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('all');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const filteredTickets = tickets.filter((ticket) => {
|
||||||
|
if (filter === 'all') return true;
|
||||||
|
const eventDate = ticket.event?.startDatetime
|
||||||
|
? new Date(ticket.event.startDatetime)
|
||||||
|
: null;
|
||||||
|
if (filter === 'upcoming') return eventDate && eventDate > now;
|
||||||
|
if (filter === 'past') return eventDate && eventDate <= now;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number, currency: string = 'PYG') => {
|
||||||
|
if (currency === 'PYG') {
|
||||||
|
return `${amount.toLocaleString('es-PY')} PYG`;
|
||||||
|
}
|
||||||
|
return `$${amount.toFixed(2)} ${currency}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
confirmed: 'bg-green-100 text-green-800',
|
||||||
|
checked_in: 'bg-blue-100 text-blue-800',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-800',
|
||||||
|
cancelled: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
const labels: Record<string, Record<string, string>> = {
|
||||||
|
en: {
|
||||||
|
confirmed: 'Confirmed',
|
||||||
|
checked_in: 'Checked In',
|
||||||
|
pending: 'Pending',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
confirmed: 'Confirmado',
|
||||||
|
checked_in: 'Registrado',
|
||||||
|
pending: 'Pendiente',
|
||||||
|
cancelled: 'Cancelado',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||||
|
{labels[language]?.[status] || status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Filter Buttons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['all', 'upcoming', 'past'] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
filter === f
|
||||||
|
? 'bg-secondary-blue text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f === 'all' && (language === 'es' ? 'Todas' : 'All')}
|
||||||
|
{f === 'upcoming' && (language === 'es' ? 'Próximas' : 'Upcoming')}
|
||||||
|
{f === 'past' && (language === 'es' ? 'Pasadas' : 'Past')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tickets List */}
|
||||||
|
{filteredTickets.length === 0 ? (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
{language === 'es' ? 'No tienes entradas' : 'You have no tickets'}
|
||||||
|
</p>
|
||||||
|
<Link href="/events">
|
||||||
|
<Button>
|
||||||
|
{language === 'es' ? 'Explorar Eventos' : 'Explore Events'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredTickets.map((ticket) => (
|
||||||
|
<Card key={ticket.id} className="p-4">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||||
|
{/* Event Image */}
|
||||||
|
{ticket.event?.bannerUrl && (
|
||||||
|
<div className="w-full md:w-32 h-24 rounded-lg overflow-hidden flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={ticket.event.bannerUrl}
|
||||||
|
alt={ticket.event.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ticket Info */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="font-semibold">
|
||||||
|
{language === 'es' && ticket.event?.titleEs
|
||||||
|
? ticket.event.titleEs
|
||||||
|
: ticket.event?.title || 'Event'}
|
||||||
|
</h3>
|
||||||
|
{getStatusBadge(ticket.status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 space-y-1 text-sm text-gray-600">
|
||||||
|
{ticket.event?.startDatetime && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">
|
||||||
|
{language === 'es' ? 'Fecha:' : 'Date:'}
|
||||||
|
</span>{' '}
|
||||||
|
{formatDate(ticket.event.startDatetime)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{ticket.event?.location && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">
|
||||||
|
{language === 'es' ? 'Lugar:' : 'Location:'}
|
||||||
|
</span>{' '}
|
||||||
|
{ticket.event.location}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{ticket.payment && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">
|
||||||
|
{language === 'es' ? 'Pago:' : 'Payment:'}
|
||||||
|
</span>{' '}
|
||||||
|
{formatCurrency(ticket.payment.amount, ticket.payment.currency)} -
|
||||||
|
<span className={`ml-1 ${
|
||||||
|
ticket.payment.status === 'paid' ? 'text-green-600' : 'text-yellow-600'
|
||||||
|
}`}>
|
||||||
|
{ticket.payment.status === 'paid'
|
||||||
|
? (language === 'es' ? 'Pagado' : 'Paid')
|
||||||
|
: (language === 'es' ? 'Pendiente' : 'Pending')}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Link href={`/booking/success/${ticket.id}`}>
|
||||||
|
<Button size="sm" className="w-full">
|
||||||
|
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{ticket.invoice && (
|
||||||
|
<a
|
||||||
|
href={ticket.invoice.pdfUrl || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-center"
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm" className="w-full">
|
||||||
|
{language === 'es' ? 'Descargar Factura' : 'Download Invoice'}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
410
frontend/src/app/(public)/dashboard/page.tsx
Normal file
410
frontend/src/app/(public)/dashboard/page.tsx
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
// Tab components
|
||||||
|
import TicketsTab from './components/TicketsTab';
|
||||||
|
import PaymentsTab from './components/PaymentsTab';
|
||||||
|
import ProfileTab from './components/ProfileTab';
|
||||||
|
import SecurityTab from './components/SecurityTab';
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'tickets' | 'payments' | 'profile' | 'security';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t, locale: language } = useLanguage();
|
||||||
|
const { user, isLoading: authLoading, token } = useAuth();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||||
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||||
|
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
|
||||||
|
const [tickets, setTickets] = useState<UserTicket[]>([]);
|
||||||
|
const [payments, setPayments] = useState<UserPayment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !user) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user && token) {
|
||||||
|
loadDashboardData();
|
||||||
|
}
|
||||||
|
}, [user, authLoading, token]);
|
||||||
|
|
||||||
|
const loadDashboardData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [summaryRes, nextEventRes, ticketsRes, paymentsRes] = await Promise.all([
|
||||||
|
dashboardApi.getSummary(),
|
||||||
|
dashboardApi.getNextEvent(),
|
||||||
|
dashboardApi.getTickets(),
|
||||||
|
dashboardApi.getPayments(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setSummary(summaryRes.summary);
|
||||||
|
setNextEvent(nextEventRes.nextEvent);
|
||||||
|
setTickets(ticketsRes.tickets);
|
||||||
|
setPayments(paymentsRes.payments);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load dashboard:', error);
|
||||||
|
toast.error('Failed to load dashboard data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs: { id: Tab; label: string }[] = [
|
||||||
|
{ id: 'overview', label: language === 'es' ? 'Resumen' : 'Overview' },
|
||||||
|
{ id: 'tickets', label: language === 'es' ? 'Mis Entradas' : 'My Tickets' },
|
||||||
|
{ id: 'payments', label: language === 'es' ? 'Pagos y Facturas' : 'Payments & Invoices' },
|
||||||
|
{ id: 'profile', label: language === 'es' ? 'Perfil' : 'Profile' },
|
||||||
|
{ id: 'security', label: language === 'es' ? 'Seguridad' : 'Security' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (authLoading || !user) {
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleTimeString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh]">
|
||||||
|
<div className="container-page">
|
||||||
|
{/* Welcome Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">
|
||||||
|
{language === 'es' ? `Hola, ${user.name}!` : `Welcome, ${user.name}!`}
|
||||||
|
</h1>
|
||||||
|
{summary && (
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{language === 'es'
|
||||||
|
? `Miembro desde hace ${summary.user.membershipDays} días`
|
||||||
|
: `Member for ${summary.user.membershipDays} days`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="border-b border-gray-200 mb-6">
|
||||||
|
<nav className="flex gap-4 -mb-px overflow-x-auto">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-secondary-blue text-secondary-blue'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<OverviewTab
|
||||||
|
summary={summary}
|
||||||
|
nextEvent={nextEvent}
|
||||||
|
tickets={tickets}
|
||||||
|
language={language}
|
||||||
|
formatDate={formatDate}
|
||||||
|
formatTime={formatTime}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'tickets' && (
|
||||||
|
<TicketsTab tickets={tickets} language={language} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'payments' && (
|
||||||
|
<PaymentsTab payments={payments} language={language} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'profile' && (
|
||||||
|
<ProfileTab onUpdate={loadDashboardData} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'security' && (
|
||||||
|
<SecurityTab />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overview Tab Component
|
||||||
|
function OverviewTab({
|
||||||
|
summary,
|
||||||
|
nextEvent,
|
||||||
|
tickets,
|
||||||
|
language,
|
||||||
|
formatDate,
|
||||||
|
formatTime,
|
||||||
|
}: {
|
||||||
|
summary: DashboardSummary | null;
|
||||||
|
nextEvent: NextEventInfo | null;
|
||||||
|
tickets: UserTicket[];
|
||||||
|
language: string;
|
||||||
|
formatDate: (date: string) => string;
|
||||||
|
formatTime: (date: string) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats Cards */}
|
||||||
|
{summary && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<Card className="p-4 text-center">
|
||||||
|
<div className="text-3xl font-bold text-secondary-blue">{summary.stats.totalTickets}</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{language === 'es' ? 'Total Entradas' : 'Total Tickets'}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 text-center">
|
||||||
|
<div className="text-3xl font-bold text-green-600">{summary.stats.confirmedTickets}</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{language === 'es' ? 'Confirmadas' : 'Confirmed'}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 text-center">
|
||||||
|
<div className="text-3xl font-bold text-purple-600">{summary.stats.upcomingEvents}</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{language === 'es' ? 'Próximos' : 'Upcoming'}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 text-center">
|
||||||
|
<div className="text-3xl font-bold text-orange-500">{summary.stats.pendingPayments}</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{language === 'es' ? 'Pagos Pendientes' : 'Pending Payments'}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Next Event Card */}
|
||||||
|
{nextEvent ? (
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
{language === 'es' ? 'Tu Próximo Evento' : 'Your Next Event'}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
|
{nextEvent.event.bannerUrl && (
|
||||||
|
<div className="w-full md:w-48 h-32 rounded-lg overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={nextEvent.event.bannerUrl}
|
||||||
|
alt={nextEvent.event.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-xl font-bold mb-2">
|
||||||
|
{language === 'es' && nextEvent.event.titleEs
|
||||||
|
? nextEvent.event.titleEs
|
||||||
|
: nextEvent.event.title}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2 text-gray-600">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">
|
||||||
|
{language === 'es' ? 'Fecha:' : 'Date:'}
|
||||||
|
</span>{' '}
|
||||||
|
{formatDate(nextEvent.event.startDatetime)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">
|
||||||
|
{language === 'es' ? 'Hora:' : 'Time:'}
|
||||||
|
</span>{' '}
|
||||||
|
{formatTime(nextEvent.event.startDatetime)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">
|
||||||
|
{language === 'es' ? 'Lugar:' : 'Location:'}
|
||||||
|
</span>{' '}
|
||||||
|
{nextEvent.event.location}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">
|
||||||
|
{language === 'es' ? 'Estado:' : 'Status:'}
|
||||||
|
</span>{' '}
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
|
||||||
|
nextEvent.payment?.status === 'paid'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-yellow-100 text-yellow-800'
|
||||||
|
}`}>
|
||||||
|
{nextEvent.payment?.status === 'paid'
|
||||||
|
? (language === 'es' ? 'Pagado' : 'Paid')
|
||||||
|
: (language === 'es' ? 'Pendiente' : 'Pending')}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<Link href={`/booking/success/${nextEvent.ticket.id}`}>
|
||||||
|
<Button size="sm">
|
||||||
|
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{nextEvent.event.locationUrl && (
|
||||||
|
<a
|
||||||
|
href={nextEvent.event.locationUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{language === 'es' ? 'Ver Mapa' : 'View Map'}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="p-6 text-center">
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
{language === 'es'
|
||||||
|
? 'No tienes eventos próximos'
|
||||||
|
: 'You have no upcoming events'}
|
||||||
|
</p>
|
||||||
|
<Link href="/events">
|
||||||
|
<Button>
|
||||||
|
{language === 'es' ? 'Explorar Eventos' : 'Explore Events'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Tickets */}
|
||||||
|
{tickets.length > 0 && (
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
{language === 'es' ? 'Entradas Recientes' : 'Recent Tickets'}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tickets.slice(0, 3).map((ticket) => (
|
||||||
|
<div
|
||||||
|
key={ticket.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{language === 'es' && ticket.event?.titleEs
|
||||||
|
? ticket.event.titleEs
|
||||||
|
: ticket.event?.title || 'Event'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{ticket.event?.startDatetime
|
||||||
|
? formatDate(ticket.event.startDatetime)
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||||
|
ticket.status === 'confirmed' || ticket.status === 'checked_in'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: ticket.status === 'cancelled'
|
||||||
|
? 'bg-red-100 text-red-800'
|
||||||
|
: 'bg-yellow-100 text-yellow-800'
|
||||||
|
}`}>
|
||||||
|
{ticket.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{tickets.length > 3 && (
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => {}}>
|
||||||
|
{language === 'es' ? 'Ver Todas' : 'View All'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Community Links */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
{language === 'es' ? 'Comunidad' : 'Community'}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<a
|
||||||
|
href="https://wa.me/your-number"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 p-3 bg-green-50 rounded-lg hover:bg-green-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">WhatsApp Group</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://instagram.com/spanglish"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 p-3 bg-pink-50 rounded-lg hover:bg-pink-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-r from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">Instagram</span>
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href="/community"
|
||||||
|
className="flex items-center gap-3 p-3 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 bg-secondary-blue rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">
|
||||||
|
{language === 'es' ? 'Comunidad' : 'Community Page'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
frontend/src/app/(public)/events/[id]/page.tsx
Normal file
213
frontend/src/app/(public)/events/[id]/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { eventsApi, Event } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import ShareButtons from '@/components/ShareButtons';
|
||||||
|
import {
|
||||||
|
CalendarIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export default function EventDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [event, setEvent] = useState<Event | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (params.id) {
|
||||||
|
eventsApi.getById(params.id as string)
|
||||||
|
.then(({ event }) => setEvent(event))
|
||||||
|
.catch(() => router.push('/events'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
}, [params.id, router]);
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page text-center">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSoldOut = event.availableSeats === 0;
|
||||||
|
const isCancelled = event.status === 'cancelled';
|
||||||
|
const isPastEvent = new Date(event.startDatetime) < new Date();
|
||||||
|
const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page">
|
||||||
|
<Link
|
||||||
|
href="/events"
|
||||||
|
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-8"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" />
|
||||||
|
{t('common.back')}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Event Details */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
{/* Banner */}
|
||||||
|
{event.bannerUrl ? (
|
||||||
|
<img
|
||||||
|
src={event.bannerUrl}
|
||||||
|
alt={event.title}
|
||||||
|
className="h-64 w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-64 bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
|
||||||
|
<CalendarIcon className="w-24 h-24 text-primary-dark/30" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<h1 className="text-3xl font-bold text-primary-dark">
|
||||||
|
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||||
|
</h1>
|
||||||
|
{isCancelled && (
|
||||||
|
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
|
||||||
|
)}
|
||||||
|
{isSoldOut && !isCancelled && (
|
||||||
|
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CalendarIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t('events.details.date')}</p>
|
||||||
|
<p className="text-gray-600">{formatDate(event.startDatetime)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="w-6 h-6 flex items-center justify-center text-primary-yellow text-xl">⏰</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t('events.details.time')}</p>
|
||||||
|
<p className="text-gray-600">{formatTime(event.startDatetime)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<MapPinIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t('events.details.location')}</p>
|
||||||
|
<p className="text-gray-600">{event.location}</p>
|
||||||
|
{event.locationUrl && (
|
||||||
|
<a
|
||||||
|
href={event.locationUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-secondary-blue hover:underline text-sm"
|
||||||
|
>
|
||||||
|
View on map
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t('events.details.capacity')}</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
|
||||||
|
<h2 className="font-semibold text-lg mb-4">About this event</h2>
|
||||||
|
<p className="text-gray-700 whitespace-pre-line">
|
||||||
|
{locale === 'es' && event.descriptionEs
|
||||||
|
? event.descriptionEs
|
||||||
|
: event.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Sharing */}
|
||||||
|
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
|
||||||
|
<ShareButtons
|
||||||
|
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||||
|
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Booking Card */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<Card className="p-6 sticky top-24">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
|
||||||
|
<p className="text-4xl font-bold text-primary-dark">
|
||||||
|
{event.price === 0
|
||||||
|
? t('events.details.free')
|
||||||
|
: `${event.price.toLocaleString()} ${event.currency}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canBook ? (
|
||||||
|
<Link href={`/book/${event.id}`}>
|
||||||
|
<Button className="w-full" size="lg">
|
||||||
|
{t('events.booking.join')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Button className="w-full" size="lg" disabled>
|
||||||
|
{isPastEvent
|
||||||
|
? t('events.details.eventEnded')
|
||||||
|
: isSoldOut
|
||||||
|
? t('events.details.soldOut')
|
||||||
|
: t('events.details.cancelled')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-sm text-gray-500">
|
||||||
|
{event.availableSeats} {t('events.details.spotsLeft')}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
frontend/src/app/(public)/events/page.tsx
Normal file
165
frontend/src/app/(public)/events/page.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { eventsApi, Event } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export default function EventsPage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
eventsApi.getAll()
|
||||||
|
.then(({ events }) => setEvents(events))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const upcomingEvents = events.filter(e =>
|
||||||
|
e.status === 'published' && new Date(e.startDatetime) >= now
|
||||||
|
);
|
||||||
|
const pastEvents = events.filter(e =>
|
||||||
|
e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now)
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (event: Event) => {
|
||||||
|
if (event.status === 'cancelled') {
|
||||||
|
return <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
|
||||||
|
}
|
||||||
|
if (event.availableSeats === 0) {
|
||||||
|
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page">
|
||||||
|
<h1 className="section-title">{t('events.title')}</h1>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="mt-8 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('upcoming')}
|
||||||
|
className={clsx(
|
||||||
|
'px-4 py-2 rounded-btn font-medium transition-colors',
|
||||||
|
filter === 'upcoming'
|
||||||
|
? 'bg-primary-yellow text-primary-dark'
|
||||||
|
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('events.upcoming')} ({upcomingEvents.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('past')}
|
||||||
|
className={clsx(
|
||||||
|
'px-4 py-2 rounded-btn font-medium transition-colors',
|
||||||
|
filter === 'past'
|
||||||
|
? 'bg-primary-yellow text-primary-dark'
|
||||||
|
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('events.past')} ({pastEvents.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events grid */}
|
||||||
|
<div className="mt-8">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||||
|
</div>
|
||||||
|
) : displayedEvents.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-gray-500">
|
||||||
|
<CalendarIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||||
|
<p className="text-lg">{t('events.noEvents')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{displayedEvents.map((event) => (
|
||||||
|
<Link key={event.id} href={`/events/${event.id}`} className="block">
|
||||||
|
<Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full">
|
||||||
|
{/* Event banner */}
|
||||||
|
{event.bannerUrl ? (
|
||||||
|
<img
|
||||||
|
src={event.bannerUrl}
|
||||||
|
alt={event.title}
|
||||||
|
className="h-40 w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-40 bg-gradient-to-br from-primary-yellow/30 to-secondary-blue/20 flex items-center justify-center">
|
||||||
|
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="font-semibold text-lg text-primary-dark">
|
||||||
|
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||||
|
</h3>
|
||||||
|
{getStatusBadge(event)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2 text-sm text-gray-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarIcon className="w-4 h-4" />
|
||||||
|
<span>{formatDate(event.startDatetime)} - {formatTime(event.startDatetime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPinIcon className="w-4 h-4" />
|
||||||
|
<span className="truncate">{event.location}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserGroupIcon className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<span className="font-bold text-xl text-primary-dark">
|
||||||
|
{event.price === 0
|
||||||
|
? t('events.details.free')
|
||||||
|
: `${event.price.toLocaleString()} ${event.currency}`}
|
||||||
|
</span>
|
||||||
|
<Button size="sm">
|
||||||
|
{t('common.moreInfo')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
frontend/src/app/(public)/faq/page.tsx
Normal file
151
frontend/src/app/(public)/faq/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface FAQItem {
|
||||||
|
question: string;
|
||||||
|
questionEs: string;
|
||||||
|
answer: string;
|
||||||
|
answerEs: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const faqs: FAQItem[] = [
|
||||||
|
{
|
||||||
|
question: "What is Spanglish?",
|
||||||
|
questionEs: "¿Qué es Spanglish?",
|
||||||
|
answer: "Spanglish is a language exchange community in Asunción, Paraguay. We organize monthly events where Spanish and English speakers come together to practice languages, meet new people, and have fun in a relaxed social environment.",
|
||||||
|
answerEs: "Spanglish es una comunidad de intercambio de idiomas en Asunción, Paraguay. Organizamos eventos mensuales donde hablantes de español e inglés se reúnen para practicar idiomas, conocer gente nueva y divertirse en un ambiente social relajado."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Who can attend Spanglish events?",
|
||||||
|
questionEs: "¿Quién puede asistir a los eventos de Spanglish?",
|
||||||
|
answer: "Anyone interested in practicing English or Spanish is welcome! We accept all levels - from complete beginners to native speakers. Our events are designed to be inclusive and welcoming to everyone.",
|
||||||
|
answerEs: "¡Cualquier persona interesada en practicar inglés o español es bienvenida! Aceptamos todos los niveles - desde principiantes hasta hablantes nativos. Nuestros eventos están diseñados para ser inclusivos y acogedores para todos."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "How do events work?",
|
||||||
|
questionEs: "¿Cómo funcionan los eventos?",
|
||||||
|
answer: "Our events typically last 2-3 hours. You'll be paired with people who speak the language you want to practice. We rotate partners throughout the evening so you can meet multiple people. There are also group activities and free conversation time.",
|
||||||
|
answerEs: "Nuestros eventos suelen durar 2-3 horas. Serás emparejado con personas que hablan el idioma que quieres practicar. Rotamos parejas durante la noche para que puedas conocer a varias personas. También hay actividades grupales y tiempo de conversación libre."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "How much does it cost to attend?",
|
||||||
|
questionEs: "¿Cuánto cuesta asistir?",
|
||||||
|
answer: "Event prices vary but are always kept affordable. The price covers venue costs and event organization. Check each event page for specific pricing. Some special events may be free!",
|
||||||
|
answerEs: "Los precios de los eventos varían pero siempre se mantienen accesibles. El precio cubre los costos del local y la organización del evento. Consulta la página de cada evento para precios específicos. ¡Algunos eventos especiales pueden ser gratis!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "What payment methods do you accept?",
|
||||||
|
questionEs: "¿Qué métodos de pago aceptan?",
|
||||||
|
answer: "We accept multiple payment methods: credit/debit cards through Bancard, Bitcoin Lightning for crypto enthusiasts, and cash payment at the event. You can choose your preferred method when booking.",
|
||||||
|
answerEs: "Aceptamos múltiples métodos de pago: tarjetas de crédito/débito a través de Bancard, Bitcoin Lightning para entusiastas de cripto, y pago en efectivo en el evento. Puedes elegir tu método preferido al reservar."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Do I need to speak the language already?",
|
||||||
|
questionEs: "¿Necesito ya hablar el idioma?",
|
||||||
|
answer: "Not at all! We welcome complete beginners. Our events are structured to support all levels. Native speakers are patient and happy to help beginners practice. It's a judgment-free zone for learning.",
|
||||||
|
answerEs: "¡Para nada! Damos la bienvenida a principiantes absolutos. Nuestros eventos están estructurados para apoyar todos los niveles. Los hablantes nativos son pacientes y felices de ayudar a los principiantes a practicar. Es una zona libre de juicios para aprender."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Can I come alone?",
|
||||||
|
questionEs: "¿Puedo ir solo/a?",
|
||||||
|
answer: "Absolutely! Most people come alone and that's totally fine. In fact, it's a great way to meet new people. Our events are designed to be social, so you'll quickly find conversation partners.",
|
||||||
|
answerEs: "¡Absolutamente! La mayoría de las personas vienen solas y eso está totalmente bien. De hecho, es una excelente manera de conocer gente nueva. Nuestros eventos están diseñados para ser sociales, así que encontrarás compañeros de conversación rápidamente."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "What if I can't make it after booking?",
|
||||||
|
questionEs: "¿Qué pasa si no puedo asistir después de reservar?",
|
||||||
|
answer: "If you can't attend, please let us know as soon as possible so we can offer your spot to someone on the waitlist. Contact us through the website or WhatsApp group to cancel your booking.",
|
||||||
|
answerEs: "Si no puedes asistir, por favor avísanos lo antes posible para poder ofrecer tu lugar a alguien en la lista de espera. Contáctanos a través del sitio web o el grupo de WhatsApp para cancelar tu reserva."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "How can I stay updated about events?",
|
||||||
|
questionEs: "¿Cómo puedo mantenerme actualizado sobre los eventos?",
|
||||||
|
answer: "Join our WhatsApp group for instant updates, follow us on Instagram for announcements and photos, or subscribe to our newsletter on the website. We typically announce events 2-3 weeks in advance.",
|
||||||
|
answerEs: "Únete a nuestro grupo de WhatsApp para actualizaciones instantáneas, síguenos en Instagram para anuncios y fotos, o suscríbete a nuestro boletín en el sitio web. Normalmente anunciamos eventos con 2-3 semanas de anticipación."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Can I volunteer or help organize events?",
|
||||||
|
questionEs: "¿Puedo ser voluntario o ayudar a organizar eventos?",
|
||||||
|
answer: "Yes! We're always looking for enthusiastic volunteers. Volunteers help with setup, greeting newcomers, facilitating activities, and more. Contact us through the website if you're interested in getting involved.",
|
||||||
|
answerEs: "¡Sí! Siempre estamos buscando voluntarios entusiastas. Los voluntarios ayudan con la preparación, saludar a los recién llegados, facilitar actividades y más. Contáctanos a través del sitio web si estás interesado en participar."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FAQPage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const toggleFAQ = (index: number) => {
|
||||||
|
setOpenIndex(openIndex === index ? null : index);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page max-w-3xl">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h1 className="text-4xl font-bold text-primary-dark mb-4">
|
||||||
|
{locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish'
|
||||||
|
: 'Find answers to the most common questions about Spanglish'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{faqs.map((faq, index) => (
|
||||||
|
<Card key={index} className="overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleFAQ(index)}
|
||||||
|
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-primary-dark pr-4">
|
||||||
|
{locale === 'es' ? faq.questionEs : faq.question}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={clsx(
|
||||||
|
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
|
||||||
|
openIndex === index && 'transform rotate-180'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'overflow-hidden transition-all duration-200',
|
||||||
|
openIndex === index ? 'max-h-96' : 'max-h-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="px-6 pb-4 text-gray-600">
|
||||||
|
{locale === 'es' ? faq.answerEs : faq.answer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
|
||||||
|
<h2 className="text-xl font-semibold text-primary-dark mb-2">
|
||||||
|
{locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!'
|
||||||
|
: "Don't hesitate to reach out. We're here to help!"}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/contact"
|
||||||
|
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
|
||||||
|
>
|
||||||
|
{locale === 'es' ? 'Contáctanos' : 'Contact Us'}
|
||||||
|
</a>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/app/(public)/layout.tsx
Normal file
16
frontend/src/app/(public)/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Header from '@/components/layout/Header';
|
||||||
|
import Footer from '@/components/layout/Footer';
|
||||||
|
|
||||||
|
export default function PublicLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/app/(public)/legal/[slug]/page.tsx
Normal file
46
frontend/src/app/(public)/legal/[slug]/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { getLegalPage, getAllLegalSlugs } from '@/lib/legal';
|
||||||
|
import LegalPageLayout from '@/components/layout/LegalPageLayout';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: { slug: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate static params for all legal pages
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const slugs = getAllLegalSlugs();
|
||||||
|
return slugs.map((slug) => ({ slug }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate metadata for SEO
|
||||||
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const legalPage = getLegalPage(params.slug);
|
||||||
|
|
||||||
|
if (!legalPage) {
|
||||||
|
return {
|
||||||
|
title: 'Not Found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${legalPage.title} | Spanglish`,
|
||||||
|
description: `${legalPage.title} for Spanglish - Language Exchange Community in Paraguay`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LegalPage({ params }: PageProps) {
|
||||||
|
const legalPage = getLegalPage(params.slug);
|
||||||
|
|
||||||
|
if (!legalPage) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LegalPageLayout
|
||||||
|
title={legalPage.title}
|
||||||
|
content={legalPage.content}
|
||||||
|
lastUpdated={legalPage.lastUpdated}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
frontend/src/app/(public)/linktree/page.tsx
Normal file
222
frontend/src/app/(public)/linktree/page.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { eventsApi, Event } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
CalendarIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
ChatBubbleLeftRightIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export default function LinktreePage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [nextEvent, setNextEvent] = useState<Event | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Social links from environment variables
|
||||||
|
const whatsappLink = process.env.NEXT_PUBLIC_WHATSAPP;
|
||||||
|
const instagramHandle = process.env.NEXT_PUBLIC_INSTAGRAM;
|
||||||
|
const telegramHandle = process.env.NEXT_PUBLIC_TELEGRAM;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
eventsApi.getNextUpcoming()
|
||||||
|
.then(({ event }) => setNextEvent(event))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle both full URLs and handles
|
||||||
|
const instagramUrl = instagramHandle
|
||||||
|
? (instagramHandle.startsWith('http') ? instagramHandle : `https://instagram.com/${instagramHandle.replace('@', '')}`)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const telegramUrl = telegramHandle
|
||||||
|
? (telegramHandle.startsWith('http') ? telegramHandle : `https://t.me/${telegramHandle.replace('@', '')}`)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-primary-dark via-gray-900 to-primary-dark">
|
||||||
|
<div className="max-w-md mx-auto px-4 py-8 pb-16">
|
||||||
|
{/* Profile Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-24 h-24 mx-auto bg-primary-yellow rounded-full flex items-center justify-center mb-4 shadow-lg">
|
||||||
|
<ChatBubbleLeftRightIcon className="w-12 h-12 text-primary-dark" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Spanglish</h1>
|
||||||
|
<p className="text-gray-400 mt-1">{t('linktree.tagline')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next Event Card */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-sm font-semibold text-primary-yellow uppercase tracking-wider mb-3 text-center">
|
||||||
|
{t('linktree.nextEvent')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 text-center">
|
||||||
|
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||||
|
</div>
|
||||||
|
) : nextEvent ? (
|
||||||
|
<Link href={`/book/${nextEvent.id}`} className="block group">
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-5 border border-white/10 transition-all duration-300 hover:bg-white/15 hover:scale-[1.02] hover:shadow-xl">
|
||||||
|
<h3 className="font-bold text-lg text-white group-hover:text-primary-yellow transition-colors">
|
||||||
|
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
||||||
|
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
|
<span>{formatDate(nextEvent.startDatetime)} • {formatTime(nextEvent.startDatetime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
||||||
|
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
|
<span className="truncate">{nextEvent.location}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<span className="font-bold text-primary-yellow">
|
||||||
|
{nextEvent.price === 0
|
||||||
|
? t('events.details.free')
|
||||||
|
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 bg-primary-yellow text-primary-dark font-semibold py-3 px-4 rounded-xl text-center transition-all duration-200 group-hover:bg-yellow-400">
|
||||||
|
{t('linktree.bookNow')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 text-center text-gray-400">
|
||||||
|
<CalendarIcon className="w-10 h-10 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>{t('linktree.noEvents')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Links */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold text-primary-yellow uppercase tracking-wider mb-3 text-center">
|
||||||
|
{t('linktree.joinCommunity')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* WhatsApp */}
|
||||||
|
{whatsappLink && (
|
||||||
|
<a
|
||||||
|
href={whatsappLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-4 bg-white/10 backdrop-blur-sm rounded-2xl p-4 border border-white/10 transition-all duration-300 hover:bg-[#25D366]/20 hover:border-[#25D366]/30 hover:scale-[1.02] group"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-[#25D366] rounded-xl flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-white group-hover:text-[#25D366] transition-colors">
|
||||||
|
{t('linktree.whatsapp.title')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400">{t('linktree.whatsapp.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<svg className="w-5 h-5 text-gray-400 group-hover:text-[#25D366] transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Telegram */}
|
||||||
|
{telegramUrl && (
|
||||||
|
<a
|
||||||
|
href={telegramUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-4 bg-white/10 backdrop-blur-sm rounded-2xl p-4 border border-white/10 transition-all duration-300 hover:bg-[#0088cc]/20 hover:border-[#0088cc]/30 hover:scale-[1.02] group"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-[#0088cc] rounded-xl flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-white group-hover:text-[#0088cc] transition-colors">
|
||||||
|
{t('linktree.telegram.title')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400">{t('linktree.telegram.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<svg className="w-5 h-5 text-gray-400 group-hover:text-[#0088cc] transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Instagram */}
|
||||||
|
{instagramUrl && (
|
||||||
|
<a
|
||||||
|
href={instagramUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-4 bg-white/10 backdrop-blur-sm rounded-2xl p-4 border border-white/10 transition-all duration-300 hover:bg-[#E4405F]/20 hover:border-[#E4405F]/30 hover:scale-[1.02] group"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-[#833AB4] via-[#E4405F] to-[#FCAF45] rounded-xl flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-white group-hover:text-[#E4405F] transition-colors">
|
||||||
|
{t('linktree.instagram.title')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400">{t('linktree.instagram.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<svg className="w-5 h-5 text-gray-400 group-hover:text-[#E4405F] transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Website Link */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center justify-center gap-2 bg-primary-yellow text-primary-dark font-semibold py-4 px-6 rounded-2xl transition-all duration-300 hover:bg-yellow-400 hover:scale-[1.02]"
|
||||||
|
>
|
||||||
|
<span>{t('linktree.visitWebsite')}</span>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
{t('footer.copyright', { year: new Date().getFullYear().toString() })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
287
frontend/src/app/(public)/login/page.tsx
Normal file
287
frontend/src/app/(public)/login/page.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Script from 'next/script';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import { authApi } from '@/lib/api';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
google?: {
|
||||||
|
accounts: {
|
||||||
|
id: {
|
||||||
|
initialize: (config: any) => void;
|
||||||
|
renderButton: (element: HTMLElement | null, options: any) => void;
|
||||||
|
prompt: () => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { t, locale: language } = useLanguage();
|
||||||
|
const { login, loginWithGoogle } = useAuth();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loginMode, setLoginMode] = useState<'password' | 'magic-link'>('password');
|
||||||
|
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for redirect after login
|
||||||
|
const redirectTo = searchParams.get('redirect') || '/dashboard';
|
||||||
|
|
||||||
|
// Initialize Google Sign-In
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && window.google) {
|
||||||
|
initializeGoogleSignIn();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const initializeGoogleSignIn = () => {
|
||||||
|
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||||
|
if (!clientId || !window.google) return;
|
||||||
|
|
||||||
|
window.google.accounts.id.initialize({
|
||||||
|
client_id: clientId,
|
||||||
|
callback: handleGoogleCallback,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttonElement = document.getElementById('google-signin-button');
|
||||||
|
if (buttonElement) {
|
||||||
|
window.google.accounts.id.renderButton(buttonElement, {
|
||||||
|
type: 'standard',
|
||||||
|
theme: 'outline',
|
||||||
|
size: 'large',
|
||||||
|
text: 'continue_with',
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleCallback = async (response: { credential: string }) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await loginWithGoogle(response.credential);
|
||||||
|
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!');
|
||||||
|
router.push(redirectTo);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || (language === 'es' ? 'Error de inicio de sesión' : 'Login failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(formData.email, formData.password);
|
||||||
|
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome back!');
|
||||||
|
router.push(redirectTo);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || t('auth.errors.invalidCredentials'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMagicLinkRequest = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formData.email) {
|
||||||
|
toast.error(language === 'es' ? 'Ingresa tu email' : 'Please enter your email');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await authApi.requestMagicLink(formData.email);
|
||||||
|
setMagicLinkSent(true);
|
||||||
|
toast.success(
|
||||||
|
language === 'es'
|
||||||
|
? 'Revisa tu correo para el enlace de acceso'
|
||||||
|
: 'Check your email for the login link'
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || (language === 'es' ? 'Error' : 'Failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Script
|
||||||
|
src="https://accounts.google.com/gsi/client"
|
||||||
|
strategy="afterInteractive"
|
||||||
|
onLoad={initializeGoogleSignIn}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">{t('auth.login.title')}</h1>
|
||||||
|
<p className="mt-2 text-gray-600">{t('auth.login.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-8">
|
||||||
|
{/* Google Sign-In Button */}
|
||||||
|
<div id="google-signin-button" className="mb-4 flex justify-center"></div>
|
||||||
|
|
||||||
|
{/* Or Divider */}
|
||||||
|
<div className="relative my-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-gray-500">
|
||||||
|
{language === 'es' ? 'o continuar con' : 'or continue with'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Mode Tabs */}
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLoginMode('password')}
|
||||||
|
className={`flex-1 py-2 px-4 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
loginMode === 'password'
|
||||||
|
? 'bg-secondary-blue text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{language === 'es' ? 'Contraseña' : 'Password'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLoginMode('magic-link')}
|
||||||
|
className={`flex-1 py-2 px-4 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
loginMode === 'magic-link'
|
||||||
|
? 'bg-secondary-blue text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{language === 'es' ? 'Enlace por Email' : 'Email Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loginMode === 'password' ? (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
label={t('auth.login.email')}
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
label={t('auth.login.password')}
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Link
|
||||||
|
href="/auth/forgot-password"
|
||||||
|
className="text-sm text-secondary-blue hover:underline"
|
||||||
|
>
|
||||||
|
{language === 'es' ? '¿Olvidaste tu contraseña?' : 'Forgot password?'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
|
||||||
|
{t('auth.login.submit')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : magicLinkSent ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">
|
||||||
|
{language === 'es' ? 'Revisa tu Email' : 'Check Your Email'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
|
{language === 'es'
|
||||||
|
? `Enviamos un enlace de acceso a ${formData.email}`
|
||||||
|
: `We sent a login link to ${formData.email}`}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setMagicLinkSent(false)}
|
||||||
|
className="text-secondary-blue hover:underline text-sm"
|
||||||
|
>
|
||||||
|
{language === 'es' ? 'Usar otro email' : 'Use a different email'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleMagicLinkRequest} className="space-y-6">
|
||||||
|
<Input
|
||||||
|
id="magic-email"
|
||||||
|
label={t('auth.login.email')}
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-500 -mt-4">
|
||||||
|
{language === 'es'
|
||||||
|
? 'Te enviaremos un enlace para iniciar sesión sin contraseña'
|
||||||
|
: "We'll send you a link to sign in without a password"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
|
||||||
|
{language === 'es' ? 'Enviar Enlace' : 'Send Login Link'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-gray-600">
|
||||||
|
{t('auth.login.noAccount')}{' '}
|
||||||
|
<Link href="/register" className="text-secondary-blue hover:underline font-medium">
|
||||||
|
{t('auth.login.register')}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingFallback() {
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<LoginContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
frontend/src/app/(public)/page.tsx
Normal file
303
frontend/src/app/(public)/page.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { eventsApi, contactsApi, Event } from '@/lib/api';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import {
|
||||||
|
CalendarIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
ChatBubbleLeftRightIcon,
|
||||||
|
AcademicCapIcon,
|
||||||
|
SparklesIcon
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [nextEvent, setNextEvent] = useState<Event | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [subscribing, setSubscribing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
eventsApi.getNextUpcoming()
|
||||||
|
.then(({ event }) => setNextEvent(event))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubscribe = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!email) return;
|
||||||
|
|
||||||
|
setSubscribing(true);
|
||||||
|
try {
|
||||||
|
await contactsApi.subscribe(email);
|
||||||
|
toast.success(t('home.newsletter.success'));
|
||||||
|
setEmail('');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t('home.newsletter.error'));
|
||||||
|
} finally {
|
||||||
|
setSubscribing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="bg-gradient-to-br from-white via-secondary-gray to-white">
|
||||||
|
<div className="container-page py-16 md:py-24">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-primary-dark leading-tight text-balance">
|
||||||
|
{t('home.hero.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 text-xl text-gray-600">
|
||||||
|
{t('home.hero.subtitle')}
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex flex-wrap gap-4">
|
||||||
|
<Link href="/events">
|
||||||
|
<Button size="lg">
|
||||||
|
{t('home.hero.cta')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/community">
|
||||||
|
<Button variant="outline" size="lg">
|
||||||
|
{t('common.learnMore')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Image Grid */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="relative rounded-card h-32 flex items-center justify-center overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src="/images/2026-01-29 13.10.26.jpg"
|
||||||
|
alt="Language exchange event"
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 1024px) 50vw, 25vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-primary-yellow/60" />
|
||||||
|
<ChatBubbleLeftRightIcon className="relative z-10 w-16 h-16 text-primary-dark opacity-50" />
|
||||||
|
</div>
|
||||||
|
<div className="relative rounded-card h-48 overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src="/images/2026-01-29 13.10.23.jpg"
|
||||||
|
alt="Group language practice"
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 1024px) 50vw, 25vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 pt-8">
|
||||||
|
<div className="relative rounded-card h-48 overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src="/images/2026-01-29 13.10.16.jpg"
|
||||||
|
alt="Community meetup"
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 1024px) 50vw, 25vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative rounded-card h-32 flex items-center justify-center overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src="/images/2026-01-29 13.09.59.jpg"
|
||||||
|
alt="Language exchange group"
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 1024px) 50vw, 25vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-secondary-brown/40" />
|
||||||
|
<UserGroupIcon className="relative z-10 w-16 h-16 text-secondary-brown opacity-70" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Decorative elements */}
|
||||||
|
<div className="absolute -top-4 -left-4 w-24 h-24 bg-primary-yellow/30 rounded-full blur-2xl" />
|
||||||
|
<div className="absolute -bottom-4 -right-4 w-32 h-32 bg-secondary-blue/20 rounded-full blur-2xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Next Event Section */}
|
||||||
|
<section className="section-padding bg-white">
|
||||||
|
<div className="container-page">
|
||||||
|
<h2 className="section-title text-center">
|
||||||
|
{t('home.nextEvent.title')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-12 max-w-3xl mx-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||||
|
</div>
|
||||||
|
) : nextEvent ? (
|
||||||
|
<Link href={`/events/${nextEvent.id}`} className="block">
|
||||||
|
<Card variant="elevated" className="p-8 cursor-pointer hover:shadow-lg transition-shadow">
|
||||||
|
<div className="flex flex-col md:flex-row gap-8">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-2xl font-bold text-primary-dark">
|
||||||
|
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-gray-600">
|
||||||
|
{locale === 'es' && nextEvent.descriptionEs
|
||||||
|
? nextEvent.descriptionEs
|
||||||
|
: nextEvent.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
<div className="flex items-center gap-3 text-gray-700">
|
||||||
|
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
|
<span>{formatDate(nextEvent.startDatetime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-gray-700">
|
||||||
|
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow font-bold">
|
||||||
|
⏰
|
||||||
|
</span>
|
||||||
|
<span>{formatTime(nextEvent.startDatetime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-gray-700">
|
||||||
|
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||||
|
<span>{nextEvent.location}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-between items-start md:items-end">
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-3xl font-bold text-primary-dark">
|
||||||
|
{nextEvent.price === 0
|
||||||
|
? t('events.details.free')
|
||||||
|
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="lg" className="mt-6">
|
||||||
|
{t('common.moreInfo')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
<CalendarIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||||
|
<p className="text-lg">{t('home.nextEvent.noEvents')}</p>
|
||||||
|
<p className="mt-2">{t('home.nextEvent.stayTuned')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* About Section */}
|
||||||
|
<section className="section-padding bg-secondary-gray">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="text-center max-w-3xl mx-auto">
|
||||||
|
<h2 className="section-title">{t('home.about.title')}</h2>
|
||||||
|
<p className="section-subtitle">
|
||||||
|
{t('home.about.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
<Card className="p-8 text-center card-hover">
|
||||||
|
<div className="w-16 h-16 mx-auto bg-primary-yellow/20 rounded-full flex items-center justify-center">
|
||||||
|
<CalendarIcon className="w-8 h-8 text-primary-dark" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-6 text-xl font-semibold">
|
||||||
|
{t('home.about.feature1')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-gray-600">
|
||||||
|
{t('home.about.feature1Desc')}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-8 text-center card-hover">
|
||||||
|
<div className="w-16 h-16 mx-auto bg-primary-yellow/20 rounded-full flex items-center justify-center">
|
||||||
|
<ChatBubbleLeftRightIcon className="w-8 h-8 text-primary-dark" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-6 text-xl font-semibold">
|
||||||
|
{t('home.about.feature2')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-gray-600">
|
||||||
|
{t('home.about.feature2Desc')}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-8 text-center card-hover">
|
||||||
|
<div className="w-16 h-16 mx-auto bg-primary-yellow/20 rounded-full flex items-center justify-center">
|
||||||
|
<AcademicCapIcon className="w-8 h-8 text-primary-dark" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-6 text-xl font-semibold">
|
||||||
|
{t('home.about.feature3')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-gray-600">
|
||||||
|
{t('home.about.feature3Desc')}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Newsletter Section */}
|
||||||
|
<section className="section-padding bg-primary-dark text-white">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="max-w-2xl mx-auto text-center">
|
||||||
|
<SparklesIcon className="w-12 h-12 mx-auto text-primary-yellow" />
|
||||||
|
<h2 className="mt-6 text-3xl md:text-4xl font-bold">
|
||||||
|
{t('home.newsletter.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-gray-300">
|
||||||
|
{t('home.newsletter.description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubscribe} className="mt-8 flex flex-col sm:flex-row gap-4 max-w-md mx-auto">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder={t('home.newsletter.placeholder')}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="flex-1 bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" isLoading={subscribing}>
|
||||||
|
{t('home.newsletter.button')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
frontend/src/app/(public)/register/page.tsx
Normal file
103
frontend/src/app/(public)/register/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { register } = useAuth();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
phone: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register(formData);
|
||||||
|
toast.success('Account created successfully!');
|
||||||
|
router.push('/');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || t('auth.errors.emailExists'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">{t('auth.register.title')}</h1>
|
||||||
|
<p className="mt-2 text-gray-600">{t('auth.register.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-8">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
label={t('auth.register.name')}
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
minLength={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
label={t('auth.register.email')}
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
label={t('auth.register.password')}
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
label={t('auth.register.phone')}
|
||||||
|
type="tel"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
|
||||||
|
{t('auth.register.submit')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-gray-600">
|
||||||
|
{t('auth.register.hasAccount')}{' '}
|
||||||
|
<Link href="/login" className="text-secondary-blue hover:underline font-medium">
|
||||||
|
{t('auth.register.login')}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
426
frontend/src/app/admin/bookings/page.tsx
Normal file
426
frontend/src/app/admin/bookings/page.tsx
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
TicketIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
CurrencyDollarIcon,
|
||||||
|
UserIcon,
|
||||||
|
EnvelopeIcon,
|
||||||
|
PhoneIcon,
|
||||||
|
FunnelIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface TicketWithDetails extends Omit<Ticket, 'payment'> {
|
||||||
|
event?: Event;
|
||||||
|
payment?: {
|
||||||
|
id: string;
|
||||||
|
ticketId?: string;
|
||||||
|
provider: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: string;
|
||||||
|
reference?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminBookingsPage() {
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const [tickets, setTickets] = useState<TicketWithDetails[]>([]);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [processing, setProcessing] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<string>('');
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<string>('');
|
||||||
|
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [ticketsRes, eventsRes] = await Promise.all([
|
||||||
|
ticketsApi.getAll(),
|
||||||
|
eventsApi.getAll(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fetch full ticket details with payment info
|
||||||
|
const ticketsWithDetails = await Promise.all(
|
||||||
|
ticketsRes.tickets.map(async (ticket) => {
|
||||||
|
try {
|
||||||
|
const { ticket: fullTicket } = await ticketsApi.getById(ticket.id);
|
||||||
|
return fullTicket;
|
||||||
|
} catch {
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setTickets(ticketsWithDetails);
|
||||||
|
setEvents(eventsRes.events);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load bookings');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkPaid = async (ticketId: string) => {
|
||||||
|
setProcessing(ticketId);
|
||||||
|
try {
|
||||||
|
await ticketsApi.markPaid(ticketId);
|
||||||
|
toast.success('Payment marked as received');
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to mark payment');
|
||||||
|
} finally {
|
||||||
|
setProcessing(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckin = async (ticketId: string) => {
|
||||||
|
setProcessing(ticketId);
|
||||||
|
try {
|
||||||
|
await ticketsApi.checkin(ticketId);
|
||||||
|
toast.success('Check-in successful');
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to check in');
|
||||||
|
} finally {
|
||||||
|
setProcessing(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async (ticketId: string) => {
|
||||||
|
if (!confirm('Are you sure you want to cancel this booking?')) return;
|
||||||
|
|
||||||
|
setProcessing(ticketId);
|
||||||
|
try {
|
||||||
|
await ticketsApi.cancel(ticketId);
|
||||||
|
toast.success('Booking cancelled');
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to cancel');
|
||||||
|
} finally {
|
||||||
|
setProcessing(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'confirmed':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
case 'checked_in':
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPaymentStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'paid':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'failed':
|
||||||
|
case 'cancelled':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
case 'refunded':
|
||||||
|
return 'bg-purple-100 text-purple-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPaymentMethodLabel = (provider: string) => {
|
||||||
|
switch (provider) {
|
||||||
|
case 'bancard':
|
||||||
|
return 'TPago / Card';
|
||||||
|
case 'lightning':
|
||||||
|
return 'Bitcoin Lightning';
|
||||||
|
case 'cash':
|
||||||
|
return 'Cash at Event';
|
||||||
|
default:
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter tickets
|
||||||
|
const filteredTickets = tickets.filter((ticket) => {
|
||||||
|
if (selectedEvent && ticket.eventId !== selectedEvent) return false;
|
||||||
|
if (selectedStatus && ticket.status !== selectedStatus) return false;
|
||||||
|
if (selectedPaymentStatus && ticket.payment?.status !== selectedPaymentStatus) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by created date (newest first)
|
||||||
|
const sortedTickets = [...filteredTickets].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const stats = {
|
||||||
|
total: tickets.length,
|
||||||
|
pending: tickets.filter(t => t.status === 'pending').length,
|
||||||
|
confirmed: tickets.filter(t => t.status === 'confirmed').length,
|
||||||
|
checkedIn: tickets.filter(t => t.status === 'checked_in').length,
|
||||||
|
cancelled: tickets.filter(t => t.status === 'cancelled').length,
|
||||||
|
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark">Manage Bookings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
|
||||||
|
<Card className="p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-primary-dark">{stats.total}</p>
|
||||||
|
<p className="text-sm text-gray-500">Total</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 text-center border-l-4 border-yellow-400">
|
||||||
|
<p className="text-2xl font-bold text-yellow-600">{stats.pending}</p>
|
||||||
|
<p className="text-sm text-gray-500">Pending</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 text-center border-l-4 border-green-400">
|
||||||
|
<p className="text-2xl font-bold text-green-600">{stats.confirmed}</p>
|
||||||
|
<p className="text-sm text-gray-500">Confirmed</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 text-center border-l-4 border-blue-400">
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{stats.checkedIn}</p>
|
||||||
|
<p className="text-sm text-gray-500">Checked In</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 text-center border-l-4 border-red-400">
|
||||||
|
<p className="text-2xl font-bold text-red-600">{stats.cancelled}</p>
|
||||||
|
<p className="text-sm text-gray-500">Cancelled</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 text-center border-l-4 border-orange-400">
|
||||||
|
<p className="text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
|
||||||
|
<p className="text-sm text-gray-500">Pending Payment</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card className="p-4 mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<FunnelIcon className="w-5 h-5 text-gray-500" />
|
||||||
|
<span className="font-medium">Filters</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||||
|
<select
|
||||||
|
value={selectedEvent}
|
||||||
|
onChange={(e) => setSelectedEvent(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="">All Events</option>
|
||||||
|
{events.map((event) => (
|
||||||
|
<option key={event.id} value={event.id}>{event.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label>
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="confirmed">Confirmed</option>
|
||||||
|
<option value="checked_in">Checked In</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
|
||||||
|
<select
|
||||||
|
value={selectedPaymentStatus}
|
||||||
|
onChange={(e) => setSelectedPaymentStatus(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="">All Payment Statuses</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="paid">Paid</option>
|
||||||
|
<option value="refunded">Refunded</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bookings List */}
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-secondary-gray">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Attendee</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Payment</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</th>
|
||||||
|
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
|
{sortedTickets.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||||
|
No bookings found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
sortedTickets.map((ticket) => (
|
||||||
|
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserIcon className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<EnvelopeIcon className="w-4 h-4" />
|
||||||
|
<span>{ticket.attendeeEmail || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<PhoneIcon className="w-4 h-4" />
|
||||||
|
<span>{ticket.attendeePhone || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-sm">
|
||||||
|
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
|
||||||
|
{ticket.payment?.status || 'pending'}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}
|
||||||
|
</p>
|
||||||
|
{ticket.payment && (
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{ticket.payment.amount?.toLocaleString()} {ticket.payment.currency}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
|
||||||
|
{ticket.status}
|
||||||
|
</span>
|
||||||
|
{ticket.qrCode && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1 font-mono">{ticket.qrCode}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
|
{formatDate(ticket.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{/* Mark as Paid (for pending payments) */}
|
||||||
|
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleMarkPaid(ticket.id)}
|
||||||
|
isLoading={processing === ticket.id}
|
||||||
|
className="text-green-600 hover:bg-green-50"
|
||||||
|
>
|
||||||
|
<CurrencyDollarIcon className="w-4 h-4 mr-1" />
|
||||||
|
Mark Paid
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Check-in (for confirmed tickets) */}
|
||||||
|
{ticket.status === 'confirmed' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleCheckin(ticket.id)}
|
||||||
|
isLoading={processing === ticket.id}
|
||||||
|
className="text-blue-600 hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
||||||
|
Check In
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cancel (for pending/confirmed) */}
|
||||||
|
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleCancel(ticket.id)}
|
||||||
|
isLoading={processing === ticket.id}
|
||||||
|
className="text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="w-4 h-4 mr-1" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ticket.status === 'checked_in' && (
|
||||||
|
<span className="text-sm text-green-600 flex items-center gap-1">
|
||||||
|
<CheckCircleIcon className="w-4 h-4" />
|
||||||
|
Attended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ticket.status === 'cancelled' && (
|
||||||
|
<span className="text-sm text-gray-400">Cancelled</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
frontend/src/app/admin/contacts/page.tsx
Normal file
198
frontend/src/app/admin/contacts/page.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { contactsApi, Contact } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function AdminContactsPage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadContacts();
|
||||||
|
}, [statusFilter]);
|
||||||
|
|
||||||
|
const loadContacts = async () => {
|
||||||
|
try {
|
||||||
|
const { contacts } = await contactsApi.getAll(statusFilter || undefined);
|
||||||
|
setContacts(contacts);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load contacts');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (id: string, status: string) => {
|
||||||
|
try {
|
||||||
|
await contactsApi.updateStatus(id, status);
|
||||||
|
toast.success('Status updated');
|
||||||
|
loadContacts();
|
||||||
|
if (selectedContact?.id === id) {
|
||||||
|
setSelectedContact({ ...selectedContact, status: status as any });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to update status');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
new: 'badge-info',
|
||||||
|
read: 'badge-warning',
|
||||||
|
replied: 'badge-success',
|
||||||
|
};
|
||||||
|
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.nav.contacts')}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card className="p-4 mb-6">
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="new">New</option>
|
||||||
|
<option value="read">Read</option>
|
||||||
|
<option value="replied">Replied</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Messages List */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<Card className="divide-y divide-secondary-light-gray max-h-[600px] overflow-y-auto">
|
||||||
|
{contacts.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
<EnvelopeIcon className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
||||||
|
<p>No messages</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
contacts.map((contact) => (
|
||||||
|
<button
|
||||||
|
key={contact.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedContact(contact);
|
||||||
|
if (contact.status === 'new') {
|
||||||
|
handleStatusChange(contact.id, 'read');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`w-full text-left p-4 hover:bg-gray-50 transition-colors ${
|
||||||
|
selectedContact?.id === contact.id ? 'bg-secondary-gray' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{contact.status === 'new' ? (
|
||||||
|
<EnvelopeIcon className="w-4 h-4 text-secondary-blue" />
|
||||||
|
) : (
|
||||||
|
<EnvelopeOpenIcon className="w-4 h-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
<span className={`font-medium text-sm ${contact.status === 'new' ? 'text-primary-dark' : 'text-gray-600'}`}>
|
||||||
|
{contact.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge(contact.status)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">{contact.email}</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-600 truncate">{contact.message}</p>
|
||||||
|
<p className="mt-2 text-xs text-gray-400">{formatDate(contact.createdAt)}</p>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Detail */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card className="p-6 min-h-[400px]">
|
||||||
|
{selectedContact ? (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold">{selectedContact.name}</h2>
|
||||||
|
<a
|
||||||
|
href={`mailto:${selectedContact.email}`}
|
||||||
|
className="text-secondary-blue hover:underline"
|
||||||
|
>
|
||||||
|
{selectedContact.email}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedContact.status !== 'replied' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleStatusChange(selectedContact.id, 'replied')}
|
||||||
|
>
|
||||||
|
<CheckIcon className="w-4 h-4 mr-1" />
|
||||||
|
Mark as Replied
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<a href={`mailto:${selectedContact.email}`}>
|
||||||
|
<Button size="sm">
|
||||||
|
Reply
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-secondary-light-gray pt-6">
|
||||||
|
<p className="text-sm text-gray-500 mb-2">
|
||||||
|
Received: {formatDate(selectedContact.createdAt)}
|
||||||
|
</p>
|
||||||
|
<div className="prose prose-sm max-w-none">
|
||||||
|
<p className="whitespace-pre-wrap text-gray-700">{selectedContact.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-500">
|
||||||
|
<div className="text-center">
|
||||||
|
<EnvelopeIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||||
|
<p>Select a message to view</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
961
frontend/src/app/admin/emails/page.tsx
Normal file
961
frontend/src/app/admin/emails/page.tsx
Normal file
@@ -0,0 +1,961 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import {
|
||||||
|
EnvelopeIcon,
|
||||||
|
PencilIcon,
|
||||||
|
DocumentDuplicateIcon,
|
||||||
|
EyeIcon,
|
||||||
|
PaperAirplaneIcon,
|
||||||
|
ClockIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
type TabType = 'templates' | 'logs' | 'compose';
|
||||||
|
|
||||||
|
const DRAFT_STORAGE_KEY = 'spanglish-email-draft';
|
||||||
|
|
||||||
|
interface EmailDraft {
|
||||||
|
eventId: string;
|
||||||
|
templateSlug: string;
|
||||||
|
customSubject: string;
|
||||||
|
customBody: string;
|
||||||
|
recipientFilter: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||||
|
savedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminEmailsPage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('templates');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Templates state
|
||||||
|
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||||
|
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||||
|
const [showTemplateForm, setShowTemplateForm] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Logs state
|
||||||
|
const [logs, setLogs] = useState<EmailLog[]>([]);
|
||||||
|
const [logsOffset, setLogsOffset] = useState(0);
|
||||||
|
const [logsTotal, setLogsTotal] = useState(0);
|
||||||
|
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
|
||||||
|
|
||||||
|
// Stats state
|
||||||
|
const [stats, setStats] = useState<EmailStats | null>(null);
|
||||||
|
|
||||||
|
// Preview state
|
||||||
|
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
||||||
|
const [previewSubject, setPreviewSubject] = useState<string>('');
|
||||||
|
|
||||||
|
// Template form state
|
||||||
|
const [templateForm, setTemplateForm] = useState({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
subject: '',
|
||||||
|
subjectEs: '',
|
||||||
|
bodyHtml: '',
|
||||||
|
bodyHtmlEs: '',
|
||||||
|
bodyText: '',
|
||||||
|
bodyTextEs: '',
|
||||||
|
description: '',
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compose/Draft state
|
||||||
|
const [events, setEvents] = useState<any[]>([]);
|
||||||
|
const [composeForm, setComposeForm] = useState<EmailDraft>({
|
||||||
|
eventId: '',
|
||||||
|
templateSlug: '',
|
||||||
|
customSubject: '',
|
||||||
|
customBody: '',
|
||||||
|
recipientFilter: 'confirmed',
|
||||||
|
savedAt: '',
|
||||||
|
});
|
||||||
|
const [hasDraft, setHasDraft] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [showRecipientPreview, setShowRecipientPreview] = useState(false);
|
||||||
|
const [previewRecipients, setPreviewRecipients] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
loadEvents();
|
||||||
|
loadDraft();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadEvents = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/events', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setEvents(data.events || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load events');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDraft = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(DRAFT_STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const draft = JSON.parse(saved) as EmailDraft;
|
||||||
|
setComposeForm(draft);
|
||||||
|
setHasDraft(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load draft');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveDraft = () => {
|
||||||
|
try {
|
||||||
|
const draft: EmailDraft = {
|
||||||
|
...composeForm,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft));
|
||||||
|
setHasDraft(true);
|
||||||
|
toast.success('Draft saved');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to save draft');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearDraft = () => {
|
||||||
|
localStorage.removeItem(DRAFT_STORAGE_KEY);
|
||||||
|
setComposeForm({
|
||||||
|
eventId: '',
|
||||||
|
templateSlug: '',
|
||||||
|
customSubject: '',
|
||||||
|
customBody: '',
|
||||||
|
recipientFilter: 'confirmed',
|
||||||
|
savedAt: '',
|
||||||
|
});
|
||||||
|
setHasDraft(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRecipientPreview = async () => {
|
||||||
|
if (!composeForm.eventId) {
|
||||||
|
toast.error('Please select an event');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/events/${composeForm.eventId}/attendees`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
let attendees = data.attendees || [];
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
if (composeForm.recipientFilter !== 'all') {
|
||||||
|
attendees = attendees.filter((a: any) => a.status === composeForm.recipientFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewRecipients(attendees);
|
||||||
|
setShowRecipientPreview(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load recipients');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendEmail = async () => {
|
||||||
|
if (!composeForm.eventId || !composeForm.templateSlug) {
|
||||||
|
toast.error('Please select an event and template');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to send this email to ${previewRecipients.length} recipients?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const res = await emailsApi.sendToEvent(composeForm.eventId, {
|
||||||
|
templateSlug: composeForm.templateSlug,
|
||||||
|
recipientFilter: composeForm.recipientFilter,
|
||||||
|
customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.success || res.sentCount > 0) {
|
||||||
|
toast.success(`Sent ${res.sentCount} emails successfully`);
|
||||||
|
if (res.failedCount > 0) {
|
||||||
|
toast.error(`${res.failedCount} emails failed`);
|
||||||
|
}
|
||||||
|
clearDraft();
|
||||||
|
setShowRecipientPreview(false);
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to send emails');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to send emails');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'logs') {
|
||||||
|
loadLogs();
|
||||||
|
}
|
||||||
|
}, [activeTab, logsOffset]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [templatesRes, statsRes] = await Promise.all([
|
||||||
|
emailsApi.getTemplates(),
|
||||||
|
emailsApi.getStats(),
|
||||||
|
]);
|
||||||
|
setTemplates(templatesRes.templates);
|
||||||
|
setStats(statsRes.stats);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load email data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadLogs = async () => {
|
||||||
|
try {
|
||||||
|
const res = await emailsApi.getLogs({ limit: 20, offset: logsOffset });
|
||||||
|
setLogs(res.logs);
|
||||||
|
setLogsTotal(res.pagination.total);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load email logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetTemplateForm = () => {
|
||||||
|
setTemplateForm({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
subject: '',
|
||||||
|
subjectEs: '',
|
||||||
|
bodyHtml: '',
|
||||||
|
bodyHtmlEs: '',
|
||||||
|
bodyText: '',
|
||||||
|
bodyTextEs: '',
|
||||||
|
description: '',
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
setEditingTemplate(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditTemplate = (template: EmailTemplate) => {
|
||||||
|
setTemplateForm({
|
||||||
|
name: template.name,
|
||||||
|
slug: template.slug,
|
||||||
|
subject: template.subject,
|
||||||
|
subjectEs: template.subjectEs || '',
|
||||||
|
bodyHtml: template.bodyHtml,
|
||||||
|
bodyHtmlEs: template.bodyHtmlEs || '',
|
||||||
|
bodyText: template.bodyText || '',
|
||||||
|
bodyTextEs: template.bodyTextEs || '',
|
||||||
|
description: template.description || '',
|
||||||
|
isActive: template.isActive,
|
||||||
|
});
|
||||||
|
setEditingTemplate(template);
|
||||||
|
setShowTemplateForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTemplate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
...templateForm,
|
||||||
|
subjectEs: templateForm.subjectEs || undefined,
|
||||||
|
bodyHtmlEs: templateForm.bodyHtmlEs || undefined,
|
||||||
|
bodyText: templateForm.bodyText || undefined,
|
||||||
|
bodyTextEs: templateForm.bodyTextEs || undefined,
|
||||||
|
description: templateForm.description || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingTemplate) {
|
||||||
|
await emailsApi.updateTemplate(editingTemplate.id, data);
|
||||||
|
toast.success('Template updated');
|
||||||
|
} else {
|
||||||
|
await emailsApi.createTemplate(data);
|
||||||
|
toast.success('Template created');
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowTemplateForm(false);
|
||||||
|
resetTemplateForm();
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to save template');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreviewTemplate = async (template: EmailTemplate) => {
|
||||||
|
try {
|
||||||
|
const res = await emailsApi.preview({
|
||||||
|
templateSlug: template.slug,
|
||||||
|
variables: {
|
||||||
|
attendeeName: 'John Doe',
|
||||||
|
attendeeEmail: 'john@example.com',
|
||||||
|
ticketId: 'TKT-ABC123',
|
||||||
|
eventTitle: 'Spanglish Night - January Edition',
|
||||||
|
eventDate: 'January 28, 2026',
|
||||||
|
eventTime: '7:00 PM',
|
||||||
|
eventLocation: 'Casa Cultural, Asunción',
|
||||||
|
eventLocationUrl: 'https://maps.google.com',
|
||||||
|
eventPrice: '50,000 PYG',
|
||||||
|
paymentAmount: '50,000 PYG',
|
||||||
|
paymentMethod: 'Lightning',
|
||||||
|
paymentReference: 'PAY-XYZ789',
|
||||||
|
paymentDate: 'January 28, 2026',
|
||||||
|
customMessage: 'This is a preview message.',
|
||||||
|
},
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
setPreviewSubject(res.subject);
|
||||||
|
setPreviewHtml(res.bodyHtml);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to preview template');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTemplate = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this template?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await emailsApi.deleteTemplate(id);
|
||||||
|
toast.success('Template deleted');
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to delete template');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'sent':
|
||||||
|
return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircleIcon className="w-5 h-5 text-red-500" />;
|
||||||
|
case 'pending':
|
||||||
|
return <ClockIcon className="w-5 h-5 text-yellow-500" />;
|
||||||
|
case 'bounced':
|
||||||
|
return <ExclamationTriangleIcon className="w-5 h-5 text-orange-500" />;
|
||||||
|
default:
|
||||||
|
return <ClockIcon className="w-5 h-5 text-gray-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark">Email Center</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<EnvelopeIcon className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{stats.total}</p>
|
||||||
|
<p className="text-sm text-gray-500">Total Sent</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<CheckCircleIcon className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{stats.sent}</p>
|
||||||
|
<p className="text-sm text-gray-500">Delivered</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||||
|
<ClockIcon className="w-5 h-5 text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{stats.pending}</p>
|
||||||
|
<p className="text-sm text-gray-500">Pending</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<XCircleIcon className="w-5 h-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{stats.failed}</p>
|
||||||
|
<p className="text-sm text-gray-500">Failed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-secondary-light-gray mb-6">
|
||||||
|
<nav className="flex gap-6">
|
||||||
|
{(['templates', 'compose', 'logs'] as TabType[]).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={clsx(
|
||||||
|
'py-3 px-1 border-b-2 font-medium text-sm transition-colors relative',
|
||||||
|
{
|
||||||
|
'border-primary-yellow text-primary-dark': activeTab === tab,
|
||||||
|
'border-transparent text-gray-500 hover:text-gray-700': activeTab !== tab,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab === 'templates' ? 'Templates' : tab === 'compose' ? 'Compose' : 'Email Logs'}
|
||||||
|
{tab === 'compose' && hasDraft && (
|
||||||
|
<span className="absolute -top-1 -right-2 w-2 h-2 bg-primary-yellow rounded-full" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Templates Tab */}
|
||||||
|
{activeTab === 'templates' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<p className="text-gray-600">Manage email templates for booking confirmations, receipts, and updates.</p>
|
||||||
|
<Button onClick={() => { resetTemplateForm(); setShowTemplateForm(true); }}>
|
||||||
|
Create Template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<Card key={template.id} className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-lg">{template.name}</h3>
|
||||||
|
{template.isSystem && (
|
||||||
|
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">System</span>
|
||||||
|
)}
|
||||||
|
{!template.isActive && (
|
||||||
|
<span className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded">Inactive</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{template.slug}</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-2">{template.description || 'No description'}</p>
|
||||||
|
<p className="text-sm font-medium mt-2">Subject: {template.subject}</p>
|
||||||
|
{template.variables && template.variables.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{template.variables.slice(0, 5).map((v: any) => (
|
||||||
|
<span key={v.name} className="text-xs bg-primary-yellow/20 text-primary-dark px-2 py-0.5 rounded">
|
||||||
|
{`{{${v.name}}}`}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{template.variables.length > 5 && (
|
||||||
|
<span className="text-xs text-gray-500">+{template.variables.length - 5} more</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePreviewTemplate(template)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||||
|
title="Preview"
|
||||||
|
>
|
||||||
|
<EyeIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditTemplate(template)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<PencilIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
{!template.isSystem && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteTemplate(template.id)}
|
||||||
|
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Compose Tab */}
|
||||||
|
{activeTab === 'compose' && (
|
||||||
|
<div>
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-lg font-semibold">Compose Email to Event Attendees</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasDraft && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString() : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={saveDraft}>
|
||||||
|
Save Draft
|
||||||
|
</Button>
|
||||||
|
{hasDraft && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearDraft}>
|
||||||
|
Clear Draft
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Event Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Select Event *</label>
|
||||||
|
<select
|
||||||
|
value={composeForm.eventId}
|
||||||
|
onChange={(e) => setComposeForm({ ...composeForm, eventId: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="">Choose an event</option>
|
||||||
|
{events.filter(e => e.status === 'published').map((event) => (
|
||||||
|
<option key={event.id} value={event.id}>
|
||||||
|
{event.title} - {new Date(event.startDatetime).toLocaleDateString()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recipient Filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Recipients</label>
|
||||||
|
<select
|
||||||
|
value={composeForm.recipientFilter}
|
||||||
|
onChange={(e) => setComposeForm({ ...composeForm, recipientFilter: e.target.value as any })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="all">All attendees</option>
|
||||||
|
<option value="confirmed">Confirmed only</option>
|
||||||
|
<option value="pending">Pending only</option>
|
||||||
|
<option value="checked_in">Checked in only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Email Template *</label>
|
||||||
|
<select
|
||||||
|
value={composeForm.templateSlug}
|
||||||
|
onChange={(e) => setComposeForm({ ...composeForm, templateSlug: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="">Choose a template</option>
|
||||||
|
{templates.filter(t => t.isActive).map((template) => (
|
||||||
|
<option key={template.id} value={template.slug}>
|
||||||
|
{template.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Message */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Custom Message (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={composeForm.customBody}
|
||||||
|
onChange={(e) => setComposeForm({ ...composeForm, customBody: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||||
|
rows={4}
|
||||||
|
placeholder="Add a custom message that will be included in the email..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
This will be available as {'{{customMessage}}'} in the template
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-4 border-t border-secondary-light-gray">
|
||||||
|
<Button
|
||||||
|
onClick={loadRecipientPreview}
|
||||||
|
disabled={!composeForm.eventId}
|
||||||
|
>
|
||||||
|
Preview Recipients
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recipient Preview Modal */}
|
||||||
|
{showRecipientPreview && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="p-4 border-b border-secondary-light-gray">
|
||||||
|
<h2 className="text-lg font-bold">Recipient Preview</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{previewRecipients.length} recipient(s) will receive this email
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{previewRecipients.length === 0 ? (
|
||||||
|
<p className="text-center text-gray-500 py-8">
|
||||||
|
No recipients match your filter criteria
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{previewRecipients.map((recipient: any) => (
|
||||||
|
<div
|
||||||
|
key={recipient.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-secondary-gray rounded-btn"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{recipient.attendeeFirstName} {recipient.attendeeLastName || ''}</p>
|
||||||
|
<p className="text-xs text-gray-500">{recipient.attendeeEmail}</p>
|
||||||
|
</div>
|
||||||
|
<span className={clsx('badge text-xs', {
|
||||||
|
'badge-success': recipient.status === 'confirmed',
|
||||||
|
'badge-warning': recipient.status === 'pending',
|
||||||
|
'badge-info': recipient.status === 'checked_in',
|
||||||
|
'badge-gray': recipient.status === 'cancelled',
|
||||||
|
})}>
|
||||||
|
{recipient.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-secondary-light-gray flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleSendEmail}
|
||||||
|
isLoading={sending}
|
||||||
|
disabled={previewRecipients.length === 0}
|
||||||
|
>
|
||||||
|
Send to {previewRecipients.length} Recipients
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setShowRecipientPreview(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logs Tab */}
|
||||||
|
{activeTab === 'logs' && (
|
||||||
|
<div>
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-secondary-gray">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Recipient</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Subject</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Sent</th>
|
||||||
|
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||||
|
No emails sent yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<tr key={log.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusIcon(log.status)}
|
||||||
|
<span className="capitalize text-sm">{log.status}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p>
|
||||||
|
<p className="text-sm text-gray-500">{log.recipientEmail}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 max-w-xs">
|
||||||
|
<p className="text-sm truncate">{log.subject}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
|
{formatDate(log.sentAt || log.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedLog(log)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||||
|
title="View Email"
|
||||||
|
>
|
||||||
|
<EyeIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{logsTotal > 20 && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary-light-gray">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={logsOffset === 0}
|
||||||
|
onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={logsOffset + 20 >= logsTotal}
|
||||||
|
onClick={() => setLogsOffset(logsOffset + 20)}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Template Form Modal */}
|
||||||
|
{showTemplateForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-4xl max-h-[90vh] overflow-y-auto p-6">
|
||||||
|
<h2 className="text-xl font-bold mb-6">
|
||||||
|
{editingTemplate ? 'Edit Template' : 'Create Template'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveTemplate} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Template Name"
|
||||||
|
value={templateForm.name}
|
||||||
|
onChange={(e) => setTemplateForm({ ...templateForm, name: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="e.g., Booking Confirmation"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Slug (unique identifier)"
|
||||||
|
value={templateForm.slug}
|
||||||
|
onChange={(e) => setTemplateForm({ ...templateForm, slug: e.target.value })}
|
||||||
|
required
|
||||||
|
disabled={editingTemplate?.isSystem}
|
||||||
|
placeholder="e.g., booking-confirmation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Subject (English)"
|
||||||
|
value={templateForm.subject}
|
||||||
|
onChange={(e) => setTemplateForm({ ...templateForm, subject: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="e.g., Your Spanglish ticket is confirmed"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Subject (Spanish)"
|
||||||
|
value={templateForm.subjectEs}
|
||||||
|
onChange={(e) => setTemplateForm({ ...templateForm, subjectEs: e.target.value })}
|
||||||
|
placeholder="e.g., Tu entrada de Spanglish está confirmada"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Body HTML (English)</label>
|
||||||
|
<textarea
|
||||||
|
value={templateForm.bodyHtml}
|
||||||
|
onChange={(e) => setTemplateForm({ ...templateForm, bodyHtml: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow font-mono text-sm"
|
||||||
|
rows={8}
|
||||||
|
required
|
||||||
|
placeholder="<h2>Your Booking is Confirmed!</h2>..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Use {`{{variableName}}`} for dynamic content. Common variables: attendeeName, eventTitle, eventDate, ticketId
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Body HTML (Spanish)</label>
|
||||||
|
<textarea
|
||||||
|
value={templateForm.bodyHtmlEs}
|
||||||
|
onChange={(e) => setTemplateForm({ ...templateForm, bodyHtmlEs: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow font-mono text-sm"
|
||||||
|
rows={6}
|
||||||
|
placeholder="<h2>¡Tu Reserva está Confirmada!</h2>..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={templateForm.description}
|
||||||
|
onChange={(e) => setTemplateForm({ ...templateForm, description: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
rows={2}
|
||||||
|
placeholder="What is this template used for?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isActive"
|
||||||
|
checked={templateForm.isActive}
|
||||||
|
onChange={(e) => setTemplateForm({ ...templateForm, isActive: e.target.checked })}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<label htmlFor="isActive" className="text-sm">Template is active</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button type="submit" isLoading={saving}>
|
||||||
|
{editingTemplate ? 'Update Template' : 'Create Template'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview Modal */}
|
||||||
|
{previewHtml && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold">Email Preview</h2>
|
||||||
|
<p className="text-sm text-gray-500">Subject: {previewSubject}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<iframe
|
||||||
|
srcDoc={previewHtml}
|
||||||
|
className="w-full h-full min-h-[500px]"
|
||||||
|
title="Email Preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Log Detail Modal */}
|
||||||
|
{selectedLog && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold">Email Details</h2>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{getStatusIcon(selectedLog.status)}
|
||||||
|
<span className="capitalize text-sm">{selectedLog.status}</span>
|
||||||
|
{selectedLog.errorMessage && (
|
||||||
|
<span className="text-sm text-red-500">- {selectedLog.errorMessage}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setSelectedLog(null)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50">
|
||||||
|
<p><strong>To:</strong> {selectedLog.recipientName} <{selectedLog.recipientEmail}></p>
|
||||||
|
<p><strong>Subject:</strong> {selectedLog.subject}</p>
|
||||||
|
<p><strong>Sent:</strong> {formatDate(selectedLog.sentAt || selectedLog.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{selectedLog.bodyHtml ? (
|
||||||
|
<iframe
|
||||||
|
srcDoc={selectedLog.bodyHtml}
|
||||||
|
className="w-full h-full min-h-[400px]"
|
||||||
|
title="Email Content"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-gray-500">Email content not available</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1488
frontend/src/app/admin/events/[id]/page.tsx
Normal file
1488
frontend/src/app/admin/events/[id]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
538
frontend/src/app/admin/events/page.tsx
Normal file
538
frontend/src/app/admin/events/page.tsx
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { eventsApi, mediaApi, Event } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, ArrowUpTrayIcon, DocumentDuplicateIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export default function AdminEventsPage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<{
|
||||||
|
title: string;
|
||||||
|
titleEs: string;
|
||||||
|
description: string;
|
||||||
|
descriptionEs: string;
|
||||||
|
startDatetime: string;
|
||||||
|
endDatetime: string;
|
||||||
|
location: string;
|
||||||
|
locationUrl: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
capacity: number;
|
||||||
|
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
|
||||||
|
bannerUrl: string;
|
||||||
|
}>({
|
||||||
|
title: '',
|
||||||
|
titleEs: '',
|
||||||
|
description: '',
|
||||||
|
descriptionEs: '',
|
||||||
|
startDatetime: '',
|
||||||
|
endDatetime: '',
|
||||||
|
location: '',
|
||||||
|
locationUrl: '',
|
||||||
|
price: 0,
|
||||||
|
currency: 'PYG',
|
||||||
|
capacity: 50,
|
||||||
|
status: 'draft',
|
||||||
|
bannerUrl: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEvents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadEvents = async () => {
|
||||||
|
try {
|
||||||
|
const { events } = await eventsApi.getAll();
|
||||||
|
setEvents(events);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load events');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
title: '',
|
||||||
|
titleEs: '',
|
||||||
|
description: '',
|
||||||
|
descriptionEs: '',
|
||||||
|
startDatetime: '',
|
||||||
|
endDatetime: '',
|
||||||
|
location: '',
|
||||||
|
locationUrl: '',
|
||||||
|
price: 0,
|
||||||
|
currency: 'PYG',
|
||||||
|
capacity: 50,
|
||||||
|
status: 'draft' as const,
|
||||||
|
bannerUrl: '',
|
||||||
|
});
|
||||||
|
setEditingEvent(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (event: Event) => {
|
||||||
|
setFormData({
|
||||||
|
title: event.title,
|
||||||
|
titleEs: event.titleEs || '',
|
||||||
|
description: event.description,
|
||||||
|
descriptionEs: event.descriptionEs || '',
|
||||||
|
startDatetime: event.startDatetime.slice(0, 16),
|
||||||
|
endDatetime: event.endDatetime?.slice(0, 16) || '',
|
||||||
|
location: event.location,
|
||||||
|
locationUrl: event.locationUrl || '',
|
||||||
|
price: event.price,
|
||||||
|
currency: event.currency,
|
||||||
|
capacity: event.capacity,
|
||||||
|
status: event.status,
|
||||||
|
bannerUrl: event.bannerUrl || '',
|
||||||
|
});
|
||||||
|
setEditingEvent(event);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventData = {
|
||||||
|
title: formData.title,
|
||||||
|
titleEs: formData.titleEs || undefined,
|
||||||
|
description: formData.description,
|
||||||
|
descriptionEs: formData.descriptionEs || undefined,
|
||||||
|
startDatetime: new Date(formData.startDatetime).toISOString(),
|
||||||
|
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
|
||||||
|
location: formData.location,
|
||||||
|
locationUrl: formData.locationUrl || undefined,
|
||||||
|
price: formData.price,
|
||||||
|
currency: formData.currency,
|
||||||
|
capacity: formData.capacity,
|
||||||
|
status: formData.status,
|
||||||
|
bannerUrl: formData.bannerUrl || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingEvent) {
|
||||||
|
await eventsApi.update(editingEvent.id, eventData);
|
||||||
|
toast.success('Event updated');
|
||||||
|
} else {
|
||||||
|
await eventsApi.create(eventData);
|
||||||
|
toast.success('Event created');
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowForm(false);
|
||||||
|
resetForm();
|
||||||
|
loadEvents();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to save event');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this event?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await eventsApi.delete(id);
|
||||||
|
toast.success('Event deleted');
|
||||||
|
loadEvents();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to delete event');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (event: Event, status: Event['status']) => {
|
||||||
|
try {
|
||||||
|
await eventsApi.update(event.id, { status });
|
||||||
|
toast.success('Status updated');
|
||||||
|
loadEvents();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to update status');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const result = await mediaApi.upload(file, editingEvent?.id, 'event');
|
||||||
|
// Use proxied path so it works through Next.js rewrites
|
||||||
|
setFormData({ ...formData, bannerUrl: result.url });
|
||||||
|
toast.success('Image uploaded successfully');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to upload image');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
draft: 'badge-gray',
|
||||||
|
published: 'badge-success',
|
||||||
|
cancelled: 'badge-danger',
|
||||||
|
completed: 'badge-info',
|
||||||
|
archived: 'badge-gray',
|
||||||
|
};
|
||||||
|
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async (event: Event) => {
|
||||||
|
try {
|
||||||
|
await eventsApi.duplicate(event.id);
|
||||||
|
toast.success('Event duplicated successfully');
|
||||||
|
loadEvents();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to duplicate event');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async (event: Event) => {
|
||||||
|
try {
|
||||||
|
await eventsApi.update(event.id, { status: 'archived' });
|
||||||
|
toast.success('Event archived');
|
||||||
|
loadEvents();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to archive event');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
|
||||||
|
<Button onClick={() => { resetForm(); setShowForm(true); }}>
|
||||||
|
<PlusIcon className="w-5 h-5 mr-2" />
|
||||||
|
{t('admin.events.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Form Modal */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
|
||||||
|
<h2 className="text-xl font-bold mb-6">
|
||||||
|
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Title (English)"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Title (Spanish)"
|
||||||
|
value={formData.titleEs}
|
||||||
|
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description (English)</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
rows={3}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description (Spanish)</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.descriptionEs}
|
||||||
|
onChange={(e) => setFormData({ ...formData, descriptionEs: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Start Date & Time"
|
||||||
|
type="datetime-local"
|
||||||
|
value={formData.startDatetime}
|
||||||
|
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="End Date & Time"
|
||||||
|
type="datetime-local"
|
||||||
|
value={formData.endDatetime}
|
||||||
|
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Location"
|
||||||
|
value={formData.location}
|
||||||
|
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Location URL (Google Maps)"
|
||||||
|
type="url"
|
||||||
|
value={formData.locationUrl}
|
||||||
|
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Price"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.price}
|
||||||
|
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Currency</label>
|
||||||
|
<select
|
||||||
|
value={formData.currency}
|
||||||
|
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="PYG">PYG</option>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Capacity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={formData.capacity}
|
||||||
|
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="published">Published</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image Upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Event Banner Image</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
{formData.bannerUrl ? (
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={formData.bannerUrl}
|
||||||
|
alt="Event banner"
|
||||||
|
className="w-full h-40 object-cover rounded-btn"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, bannerUrl: '' })}
|
||||||
|
className="absolute top-2 right-2 bg-red-500 text-white p-1 rounded-full hover:bg-red-600"
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="border-2 border-dashed border-secondary-light-gray rounded-btn p-8 text-center cursor-pointer hover:border-primary-yellow transition-colors"
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
<p className="mt-2 text-sm text-gray-500">Uploading...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<PhotoIcon className="w-12 h-12 text-gray-400" />
|
||||||
|
<p className="mt-2 text-sm text-gray-600">Click to upload event image</p>
|
||||||
|
<p className="text-xs text-gray-400">JPEG, PNG, GIF, WebP (max 5MB)</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp,image/avif"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button type="submit" isLoading={saving}>
|
||||||
|
{editingEvent ? 'Update Event' : 'Create Event'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => { setShowForm(false); resetForm(); }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Events Table */}
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-secondary-gray">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Date</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Capacity</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
||||||
|
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||||
|
No events found. Create your first event!
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
events.map((event) => (
|
||||||
|
<tr key={event.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{event.bannerUrl ? (
|
||||||
|
<img
|
||||||
|
src={event.bannerUrl}
|
||||||
|
alt={event.title}
|
||||||
|
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
|
||||||
|
<PhotoIcon className="w-6 h-6 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{event.title}</p>
|
||||||
|
<p className="text-sm text-gray-500 truncate max-w-xs">{event.location}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
|
{formatDate(event.startDatetime)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm">
|
||||||
|
{event.bookedCount || 0} / {event.capacity}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{getStatusBadge(event.status)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{event.status === 'draft' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleStatusChange(event, 'published')}
|
||||||
|
>
|
||||||
|
Publish
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/admin/events/${event.id}`}
|
||||||
|
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn"
|
||||||
|
title="Manage Event"
|
||||||
|
>
|
||||||
|
<EyeIcon className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(event)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<PencilIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDuplicate(event)}
|
||||||
|
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
|
||||||
|
title="Duplicate"
|
||||||
|
>
|
||||||
|
<DocumentDuplicateIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{event.status !== 'archived' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleArchive(event)}
|
||||||
|
className="p-2 hover:bg-gray-100 text-gray-600 rounded-btn"
|
||||||
|
title="Archive"
|
||||||
|
>
|
||||||
|
<ArchiveBoxIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(event.id)}
|
||||||
|
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
305
frontend/src/app/admin/gallery/page.tsx
Normal file
305
frontend/src/app/admin/gallery/page.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { mediaApi, Media } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
PhotoIcon,
|
||||||
|
TrashIcon,
|
||||||
|
ArrowUpTrayIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
LinkIcon,
|
||||||
|
CheckIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function AdminGalleryPage() {
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const [media, setMedia] = useState<Media[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<Media | null>(null);
|
||||||
|
const [filter, setFilter] = useState<string>('');
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMedia();
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
const loadMedia = async () => {
|
||||||
|
try {
|
||||||
|
// We need to call the media API - let's add it if it doesn't exist
|
||||||
|
const res = await fetch('/api/media', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setMedia(data.media || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load media');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
try {
|
||||||
|
await mediaApi.upload(file, undefined, 'gallery');
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast.success(`${successCount} image(s) uploaded successfully`);
|
||||||
|
}
|
||||||
|
if (failCount > 0) {
|
||||||
|
toast.error(`${failCount} image(s) failed to upload`);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMedia();
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this image?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mediaApi.delete(id);
|
||||||
|
toast.success('Image deleted');
|
||||||
|
loadMedia();
|
||||||
|
if (selectedImage?.id === id) {
|
||||||
|
setSelectedImage(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to delete image');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyUrl = async (url: string, id: string) => {
|
||||||
|
const fullUrl = window.location.origin + url;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(fullUrl);
|
||||||
|
setCopiedId(id);
|
||||||
|
toast.success('URL copied');
|
||||||
|
setTimeout(() => setCopiedId(null), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to copy URL');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredMedia = media.filter(m => {
|
||||||
|
if (!filter) return true;
|
||||||
|
if (filter === 'gallery') return m.relatedType === 'gallery' || !m.relatedType;
|
||||||
|
if (filter === 'event') return m.relatedType === 'event';
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark">Gallery Management</h1>
|
||||||
|
<Button onClick={() => fileInputRef.current?.click()} isLoading={uploading}>
|
||||||
|
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
|
||||||
|
Upload Images
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp,image/avif"
|
||||||
|
multiple
|
||||||
|
onChange={handleUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<Card className="p-4 text-center">
|
||||||
|
<p className="text-3xl font-bold text-primary-dark">{media.length}</p>
|
||||||
|
<p className="text-sm text-gray-500">Total Images</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 text-center">
|
||||||
|
<p className="text-3xl font-bold text-blue-600">
|
||||||
|
{media.filter(m => m.relatedType === 'event').length}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">Event Images</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 text-center">
|
||||||
|
<p className="text-3xl font-bold text-green-600">
|
||||||
|
{media.filter(m => m.relatedType === 'gallery' || !m.relatedType).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">Gallery Images</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<Card className="p-4 mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="text-sm font-medium">Filter:</label>
|
||||||
|
<select
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="">All Images</option>
|
||||||
|
<option value="gallery">Gallery Only</option>
|
||||||
|
<option value="event">Event Banners</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Gallery Grid */}
|
||||||
|
{filteredMedia.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<PhotoIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-600 mb-2">No images yet</h3>
|
||||||
|
<p className="text-gray-500 mb-4">Upload images to build your gallery</p>
|
||||||
|
<Button onClick={() => fileInputRef.current?.click()}>
|
||||||
|
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
|
||||||
|
Upload First Image
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{filteredMedia.map((item) => (
|
||||||
|
<Card key={item.id} className="group relative overflow-hidden aspect-square">
|
||||||
|
<img
|
||||||
|
src={item.fileUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover cursor-pointer hover:scale-105 transition-transform"
|
||||||
|
onClick={() => setSelectedImage(item)}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedImage(item)}
|
||||||
|
className="p-2 bg-white rounded-full hover:bg-gray-100"
|
||||||
|
title="View"
|
||||||
|
>
|
||||||
|
<MagnifyingGlassIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => copyUrl(item.fileUrl, item.id)}
|
||||||
|
className="p-2 bg-white rounded-full hover:bg-gray-100"
|
||||||
|
title="Copy URL"
|
||||||
|
>
|
||||||
|
{copiedId === item.id ? (
|
||||||
|
<CheckIcon className="w-5 h-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<LinkIcon className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(item.id)}
|
||||||
|
className="p-2 bg-white rounded-full hover:bg-red-100 text-red-600"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{item.relatedType && (
|
||||||
|
<div className="absolute top-2 left-2">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded ${
|
||||||
|
item.relatedType === 'event' ? 'bg-blue-500 text-white' : 'bg-green-500 text-white'
|
||||||
|
}`}>
|
||||||
|
{item.relatedType === 'event' ? 'Event' : 'Gallery'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image Preview Modal */}
|
||||||
|
{selectedImage && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
||||||
|
onClick={() => setSelectedImage(null)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="absolute top-4 right-4 text-white hover:text-gray-300"
|
||||||
|
onClick={() => setSelectedImage(null)}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-8 h-8" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="max-w-4xl max-h-[80vh] flex flex-col items-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={selectedImage.fileUrl}
|
||||||
|
alt=""
|
||||||
|
className="max-w-full max-h-[70vh] object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
<div className="mt-4 bg-white rounded-lg p-4 w-full max-w-md">
|
||||||
|
<p className="text-sm text-gray-500 mb-2">
|
||||||
|
Uploaded: {formatDate(selectedImage.createdAt)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={window.location.origin + selectedImage.fileUrl}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-3 py-2 text-sm border rounded-btn bg-gray-50"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => copyUrl(selectedImage.fileUrl, selectedImage.id)}
|
||||||
|
>
|
||||||
|
{copiedId === selectedImage.id ? 'Copied!' : 'Copy'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => handleDelete(selectedImage.id)}
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
frontend/src/app/admin/layout.tsx
Normal file
182
frontend/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import LanguageToggle from '@/components/LanguageToggle';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
HomeIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
TicketIcon,
|
||||||
|
UsersIcon,
|
||||||
|
CreditCardIcon,
|
||||||
|
EnvelopeIcon,
|
||||||
|
InboxIcon,
|
||||||
|
PhotoIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
ArrowLeftOnRectangleIcon,
|
||||||
|
Bars3Icon,
|
||||||
|
XMarkIcon,
|
||||||
|
BanknotesIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export default function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const { user, isAdmin, isLoading, logout } = useAuth();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && (!user || !isAdmin)) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [user, isAdmin, isLoading, router]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user || !isAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
|
||||||
|
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon },
|
||||||
|
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon },
|
||||||
|
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon },
|
||||||
|
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon },
|
||||||
|
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon },
|
||||||
|
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
|
||||||
|
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
|
||||||
|
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
router.push('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-secondary-gray">
|
||||||
|
{/* Mobile sidebar backdrop */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={clsx(
|
||||||
|
'fixed top-0 left-0 z-50 h-full w-64 bg-white shadow-lg transform transition-transform duration-300 lg:transform-none',
|
||||||
|
{
|
||||||
|
'translate-x-0': sidebarOpen,
|
||||||
|
'-translate-x-full lg:translate-x-0': !sidebarOpen,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="p-6 border-b border-secondary-light-gray">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<span className="text-xl font-bold font-heading text-primary-dark">
|
||||||
|
Span<span className="text-primary-yellow">glish</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{t('admin.nav.dashboard')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-3 px-4 py-3 rounded-btn transition-colors',
|
||||||
|
{
|
||||||
|
'bg-primary-yellow text-primary-dark font-medium': isActive,
|
||||||
|
'text-gray-700 hover:bg-gray-100': !isActive,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User section */}
|
||||||
|
<div className="p-4 border-t border-secondary-light-gray">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center">
|
||||||
|
<span className="font-semibold text-primary-dark">
|
||||||
|
{user.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{user.name}</p>
|
||||||
|
<p className="text-xs text-gray-500 capitalize">{user.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-btn transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeftOnRectangleIcon className="w-5 h-5" />
|
||||||
|
{t('nav.logout')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="lg:pl-64">
|
||||||
|
{/* Top bar */}
|
||||||
|
<header className="sticky top-0 z-30 bg-white shadow-sm">
|
||||||
|
<div className="flex items-center justify-between px-4 py-4">
|
||||||
|
<button
|
||||||
|
className="lg:hidden p-2 rounded-btn hover:bg-gray-100"
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
>
|
||||||
|
<Bars3Icon className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 ml-auto">
|
||||||
|
<LanguageToggle />
|
||||||
|
<Link href="/">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
View Site
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
frontend/src/app/admin/page.tsx
Normal file
246
frontend/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { adminApi, DashboardData } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import {
|
||||||
|
UsersIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
TicketIcon,
|
||||||
|
CurrencyDollarIcon,
|
||||||
|
EnvelopeIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export default function AdminDashboardPage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminApi.getDashboard()
|
||||||
|
.then(({ dashboard }) => setData(dashboard))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const statCards = data ? [
|
||||||
|
{
|
||||||
|
label: t('admin.dashboard.stats.users'),
|
||||||
|
value: data.stats.totalUsers,
|
||||||
|
icon: UsersIcon,
|
||||||
|
color: 'bg-blue-100 text-blue-600',
|
||||||
|
href: '/admin/users',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('admin.dashboard.stats.events'),
|
||||||
|
value: data.stats.totalEvents,
|
||||||
|
icon: CalendarIcon,
|
||||||
|
color: 'bg-purple-100 text-purple-600',
|
||||||
|
href: '/admin/events',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('admin.dashboard.stats.tickets'),
|
||||||
|
value: data.stats.confirmedTickets,
|
||||||
|
icon: TicketIcon,
|
||||||
|
color: 'bg-green-100 text-green-600',
|
||||||
|
href: '/admin/tickets',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('admin.dashboard.stats.revenue'),
|
||||||
|
value: `${data.stats.totalRevenue.toLocaleString()} PYG`,
|
||||||
|
icon: CurrencyDollarIcon,
|
||||||
|
color: 'bg-yellow-100 text-yellow-600',
|
||||||
|
href: '/admin/payments',
|
||||||
|
},
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.dashboard.title')}</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{t('admin.dashboard.welcome')}, {user?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
{statCards.map((stat) => (
|
||||||
|
<Link key={stat.label} href={stat.href}>
|
||||||
|
<Card className="p-6 hover:shadow-card-hover transition-shadow">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">{stat.label}</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-primary-dark">{stat.value}</p>
|
||||||
|
</div>
|
||||||
|
<div className={`w-12 h-12 rounded-btn flex items-center justify-center ${stat.color}`}>
|
||||||
|
<stat.icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Alerts */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Low capacity warnings */}
|
||||||
|
{data?.upcomingEvents
|
||||||
|
.filter(event => {
|
||||||
|
const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0));
|
||||||
|
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
|
||||||
|
return percentFull >= 80 && availableSeats > 0;
|
||||||
|
})
|
||||||
|
.map(event => {
|
||||||
|
const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0));
|
||||||
|
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={event.id}
|
||||||
|
href="/admin/events"
|
||||||
|
className="flex items-center justify-between p-3 bg-orange-50 rounded-btn hover:bg-orange-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">{event.title}</span>
|
||||||
|
<p className="text-xs text-gray-500">Only {availableSeats} spots left ({percentFull}% full)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="badge badge-warning">Low capacity</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Sold out events */}
|
||||||
|
{data?.upcomingEvents
|
||||||
|
.filter(event => (event.availableSeats ?? (event.capacity - (event.bookedCount || 0))) === 0)
|
||||||
|
.map(event => (
|
||||||
|
<Link
|
||||||
|
key={event.id}
|
||||||
|
href="/admin/events"
|
||||||
|
className="flex items-center justify-between p-3 bg-red-50 rounded-btn hover:bg-red-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ExclamationTriangleIcon className="w-5 h-5 text-red-600" />
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">{event.title}</span>
|
||||||
|
<p className="text-xs text-gray-500">Event is sold out!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="badge badge-danger">Sold out</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{data && data.stats.pendingPayments > 0 && (
|
||||||
|
<Link
|
||||||
|
href="/admin/payments"
|
||||||
|
className="flex items-center justify-between p-3 bg-yellow-50 rounded-btn hover:bg-yellow-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CurrencyDollarIcon className="w-5 h-5 text-yellow-600" />
|
||||||
|
<span className="text-sm">Pending payments</span>
|
||||||
|
</div>
|
||||||
|
<span className="badge badge-warning">{data.stats.pendingPayments}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{data && data.stats.newContacts > 0 && (
|
||||||
|
<Link
|
||||||
|
href="/admin/contacts"
|
||||||
|
className="flex items-center justify-between p-3 bg-blue-50 rounded-btn hover:bg-blue-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<EnvelopeIcon className="w-5 h-5 text-blue-600" />
|
||||||
|
<span className="text-sm">New messages</span>
|
||||||
|
</div>
|
||||||
|
<span className="badge badge-info">{data.stats.newContacts}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No alerts */}
|
||||||
|
{data &&
|
||||||
|
data.stats.pendingPayments === 0 &&
|
||||||
|
data.stats.newContacts === 0 &&
|
||||||
|
!data.upcomingEvents.some(e => ((e.bookedCount || 0) / e.capacity) >= 0.8) && (
|
||||||
|
<p className="text-gray-500 text-sm text-center py-2">No alerts at this time</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Upcoming Events */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="font-semibold text-lg">Upcoming Events</h2>
|
||||||
|
<Link href="/admin/events" className="text-sm text-secondary-blue hover:underline">
|
||||||
|
{t('common.viewAll')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{data?.upcomingEvents.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm">No upcoming events</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data?.upcomingEvents.slice(0, 5).map((event) => (
|
||||||
|
<Link
|
||||||
|
key={event.id}
|
||||||
|
href={`/admin/events`}
|
||||||
|
className="flex items-center justify-between p-3 bg-secondary-gray rounded-btn hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{event.title}</p>
|
||||||
|
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{event.bookedCount || 0}/{event.capacity}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h2 className="font-semibold text-lg mb-4">Quick Stats</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="text-center p-4 bg-secondary-gray rounded-btn">
|
||||||
|
<UserGroupIcon className="w-8 h-8 mx-auto text-gray-400" />
|
||||||
|
<p className="mt-2 text-2xl font-bold">{data?.stats.totalSubscribers || 0}</p>
|
||||||
|
<p className="text-xs text-gray-500">Subscribers</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-secondary-gray rounded-btn">
|
||||||
|
<TicketIcon className="w-8 h-8 mx-auto text-gray-400" />
|
||||||
|
<p className="mt-2 text-2xl font-bold">{data?.stats.totalTickets || 0}</p>
|
||||||
|
<p className="text-xs text-gray-500">Total Bookings</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
461
frontend/src/app/admin/payment-options/page.tsx
Normal file
461
frontend/src/app/admin/payment-options/page.tsx
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { paymentOptionsApi, PaymentOptionsConfig } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import {
|
||||||
|
CreditCardIcon,
|
||||||
|
BanknotesIcon,
|
||||||
|
BoltIcon,
|
||||||
|
BuildingLibraryIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function PaymentOptionsPage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [options, setOptions] = useState<PaymentOptionsConfig>({
|
||||||
|
tpagoEnabled: false,
|
||||||
|
tpagoLink: null,
|
||||||
|
tpagoInstructions: null,
|
||||||
|
tpagoInstructionsEs: null,
|
||||||
|
bankTransferEnabled: false,
|
||||||
|
bankName: null,
|
||||||
|
bankAccountHolder: null,
|
||||||
|
bankAccountNumber: null,
|
||||||
|
bankAlias: null,
|
||||||
|
bankPhone: null,
|
||||||
|
bankNotes: null,
|
||||||
|
bankNotesEs: null,
|
||||||
|
lightningEnabled: true,
|
||||||
|
cashEnabled: true,
|
||||||
|
cashInstructions: null,
|
||||||
|
cashInstructionsEs: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOptions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadOptions = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { paymentOptions } = await paymentOptionsApi.getGlobal();
|
||||||
|
setOptions(paymentOptions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load payment options:', error);
|
||||||
|
toast.error('Failed to load payment options');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
await paymentOptionsApi.updateGlobal(options);
|
||||||
|
toast.success('Payment options saved successfully');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to save payment options');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOption = <K extends keyof PaymentOptionsConfig>(
|
||||||
|
key: K,
|
||||||
|
value: PaymentOptionsConfig[K]
|
||||||
|
) => {
|
||||||
|
setOptions((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark">
|
||||||
|
{locale === 'es' ? 'Opciones de Pago' : 'Payment Options'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Configura los métodos de pago disponibles para todos los eventos'
|
||||||
|
: 'Configure payment methods available for all events'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSave} isLoading={saving}>
|
||||||
|
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TPago / International Card */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<CreditCardIcon className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{locale === 'es' ? 'TPago / Tarjeta Internacional' : 'TPago / International Card'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => updateOption('tpagoEnabled', !options.tpagoEnabled)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
options.tpagoEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
options.tpagoEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{options.tpagoEnabled && (
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{locale === 'es' ? 'Enlace de Pago TPago' : 'TPago Payment Link'}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={options.tpagoLink || ''}
|
||||||
|
onChange={(e) => updateOption('tpagoLink', e.target.value || null)}
|
||||||
|
placeholder="https://www.tpago.com.py/links?alias=..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Instructions (English)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={options.tpagoInstructions || ''}
|
||||||
|
onChange={(e) => updateOption('tpagoInstructions', e.target.value || null)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="Instructions for users..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Instrucciones (Español)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={options.tpagoInstructionsEs || ''}
|
||||||
|
onChange={(e) => updateOption('tpagoInstructionsEs', e.target.value || null)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="Instrucciones para usuarios..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bank Transfer */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<BuildingLibraryIcon className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => updateOption('bankTransferEnabled', !options.bankTransferEnabled)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
options.bankTransferEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
options.bankTransferEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{options.bankTransferEnabled && (
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{locale === 'es' ? 'Nombre del Banco' : 'Bank Name'}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={options.bankName || ''}
|
||||||
|
onChange={(e) => updateOption('bankName', e.target.value || null)}
|
||||||
|
placeholder="e.g., Banco Itaú"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{locale === 'es' ? 'Titular de la Cuenta' : 'Account Holder'}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={options.bankAccountHolder || ''}
|
||||||
|
onChange={(e) => updateOption('bankAccountHolder', e.target.value || null)}
|
||||||
|
placeholder="e.g., Juan Pérez"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{locale === 'es' ? 'Número de Cuenta' : 'Account Number'}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={options.bankAccountNumber || ''}
|
||||||
|
onChange={(e) => updateOption('bankAccountNumber', e.target.value || null)}
|
||||||
|
placeholder="e.g., 1234567890"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Alias
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={options.bankAlias || ''}
|
||||||
|
onChange={(e) => updateOption('bankAlias', e.target.value || null)}
|
||||||
|
placeholder="e.g., spanglish.pagos"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{locale === 'es' ? 'Teléfono' : 'Phone Number'}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={options.bankPhone || ''}
|
||||||
|
onChange={(e) => updateOption('bankPhone', e.target.value || null)}
|
||||||
|
placeholder="e.g., +595 981 123456"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Additional Notes (English)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={options.bankNotes || ''}
|
||||||
|
onChange={(e) => updateOption('bankNotes', e.target.value || null)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="Additional notes for users..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Notas Adicionales (Español)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={options.bankNotesEs || ''}
|
||||||
|
onChange={(e) => updateOption('bankNotesEs', e.target.value || null)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="Notas adicionales para usuarios..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bitcoin Lightning */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
|
||||||
|
<BoltIcon className="w-5 h-5 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">Bitcoin Lightning</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{locale === 'es' ? 'Pago instantáneo - confirmación automática' : 'Instant payment - automatic confirmation'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => updateOption('lightningEnabled', !options.lightningEnabled)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
options.lightningEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
options.lightningEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{options.lightningEnabled && (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-orange-800">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Lightning está configurado a través de las variables de entorno de LNbits. Verifica que LNBITS_URL y LNBITS_API_KEY estén configurados correctamente.'
|
||||||
|
: 'Lightning is configured via LNbits environment variables. Make sure LNBITS_URL and LNBITS_API_KEY are properly set.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Cash at Door */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||||
|
<BanknotesIcon className="w-5 h-5 text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{locale === 'es' ? 'Efectivo en el Evento' : 'Cash at the Door'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => updateOption('cashEnabled', !options.cashEnabled)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
options.cashEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
options.cashEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{options.cashEnabled && (
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Instructions (English)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={options.cashInstructions || ''}
|
||||||
|
onChange={(e) => updateOption('cashInstructions', e.target.value || null)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="Instructions for cash payments..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Instrucciones (Español)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={options.cashInstructionsEs || ''}
|
||||||
|
onChange={(e) => updateOption('cashInstructionsEs', e.target.value || null)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="Instrucciones para pagos en efectivo..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="font-semibold text-lg mb-4">
|
||||||
|
{locale === 'es' ? 'Resumen de Métodos Activos' : 'Active Methods Summary'}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{options.tpagoEnabled ? (
|
||||||
|
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
||||||
|
)}
|
||||||
|
<span className={options.tpagoEnabled ? 'text-gray-900' : 'text-gray-400'}>
|
||||||
|
TPago
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{options.bankTransferEnabled ? (
|
||||||
|
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
||||||
|
)}
|
||||||
|
<span className={options.bankTransferEnabled ? 'text-gray-900' : 'text-gray-400'}>
|
||||||
|
{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{options.lightningEnabled ? (
|
||||||
|
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
||||||
|
)}
|
||||||
|
<span className={options.lightningEnabled ? 'text-gray-900' : 'text-gray-400'}>
|
||||||
|
Lightning
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{options.cashEnabled ? (
|
||||||
|
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
||||||
|
)}
|
||||||
|
<span className={options.cashEnabled ? 'text-gray-900' : 'text-gray-400'}>
|
||||||
|
{locale === 'es' ? 'Efectivo' : 'Cash'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Save button (bottom) */}
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<Button onClick={handleSave} isLoading={saving} size="lg">
|
||||||
|
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
744
frontend/src/app/admin/payments/page.tsx
Normal file
744
frontend/src/app/admin/payments/page.tsx
Normal file
@@ -0,0 +1,744 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
ArrowDownTrayIcon,
|
||||||
|
DocumentArrowDownIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
ClockIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
ChatBubbleLeftIcon,
|
||||||
|
BoltIcon,
|
||||||
|
BanknotesIcon,
|
||||||
|
BuildingLibraryIcon,
|
||||||
|
CreditCardIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
type Tab = 'pending_approval' | 'all';
|
||||||
|
|
||||||
|
export default function AdminPaymentsPage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
|
||||||
|
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
|
const [providerFilter, setProviderFilter] = useState<string>('');
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null);
|
||||||
|
const [noteText, setNoteText] = useState('');
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
||||||
|
// Export state
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [exportData, setExportData] = useState<{ payments: ExportedPayment[]; summary: FinancialSummary } | null>(null);
|
||||||
|
const [exportFilters, setExportFilters] = useState({
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
eventId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [statusFilter, providerFilter]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [pendingRes, allRes, eventsRes] = await Promise.all([
|
||||||
|
paymentsApi.getPendingApproval(),
|
||||||
|
paymentsApi.getAll({
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
provider: providerFilter || undefined
|
||||||
|
}),
|
||||||
|
eventsApi.getAll(),
|
||||||
|
]);
|
||||||
|
setPendingApprovalPayments(pendingRes.payments);
|
||||||
|
setPayments(allRes.payments);
|
||||||
|
setEvents(eventsRes.events);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load payments');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprove = async (payment: PaymentWithDetails) => {
|
||||||
|
setProcessing(true);
|
||||||
|
try {
|
||||||
|
await paymentsApi.approve(payment.id, noteText);
|
||||||
|
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
|
||||||
|
setSelectedPayment(null);
|
||||||
|
setNoteText('');
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to approve payment');
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async (payment: PaymentWithDetails) => {
|
||||||
|
setProcessing(true);
|
||||||
|
try {
|
||||||
|
await paymentsApi.reject(payment.id, noteText);
|
||||||
|
toast.success(locale === 'es' ? 'Pago rechazado' : 'Payment rejected');
|
||||||
|
setSelectedPayment(null);
|
||||||
|
setNoteText('');
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to reject payment');
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmPayment = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await paymentsApi.approve(id);
|
||||||
|
toast.success('Payment confirmed');
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to confirm payment');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefund = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to process this refund?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await paymentsApi.refund(id);
|
||||||
|
toast.success('Refund processed');
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to process refund');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const data = await adminApi.exportFinancial({
|
||||||
|
startDate: exportFilters.startDate || undefined,
|
||||||
|
endDate: exportFilters.endDate || undefined,
|
||||||
|
eventId: exportFilters.eventId || undefined,
|
||||||
|
});
|
||||||
|
setExportData(data);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to generate export');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadCSV = () => {
|
||||||
|
if (!exportData) return;
|
||||||
|
|
||||||
|
const headers = ['Payment ID', 'Amount', 'Currency', 'Provider', 'Status', 'Reference', 'Paid At', 'Created At', 'Attendee Name', 'Attendee Email', 'Event Title', 'Event Date'];
|
||||||
|
const rows = exportData.payments.map(p => [
|
||||||
|
p.paymentId,
|
||||||
|
p.amount,
|
||||||
|
p.currency,
|
||||||
|
p.provider,
|
||||||
|
p.status,
|
||||||
|
p.reference || '',
|
||||||
|
p.paidAt || '',
|
||||||
|
p.createdAt,
|
||||||
|
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
|
||||||
|
p.attendeeEmail || '',
|
||||||
|
p.eventTitle,
|
||||||
|
p.eventDate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = `financial-export-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
link.click();
|
||||||
|
toast.success('CSV downloaded');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number, currency: string) => {
|
||||||
|
return `${amount.toLocaleString()} ${currency}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
pending: 'bg-gray-100 text-gray-700',
|
||||||
|
pending_approval: 'bg-yellow-100 text-yellow-700',
|
||||||
|
paid: 'bg-green-100 text-green-700',
|
||||||
|
refunded: 'bg-blue-100 text-blue-700',
|
||||||
|
failed: 'bg-red-100 text-red-700',
|
||||||
|
cancelled: 'bg-gray-100 text-gray-700',
|
||||||
|
};
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
pending: locale === 'es' ? 'Pendiente' : 'Pending',
|
||||||
|
pending_approval: locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval',
|
||||||
|
paid: locale === 'es' ? 'Pagado' : 'Paid',
|
||||||
|
refunded: locale === 'es' ? 'Reembolsado' : 'Refunded',
|
||||||
|
failed: locale === 'es' ? 'Fallido' : 'Failed',
|
||||||
|
cancelled: locale === 'es' ? 'Cancelado' : 'Cancelled',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] || 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{labels[status] || status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProviderIcon = (provider: string) => {
|
||||||
|
const icons: Record<string, typeof BoltIcon> = {
|
||||||
|
lightning: BoltIcon,
|
||||||
|
cash: BanknotesIcon,
|
||||||
|
bank_transfer: BuildingLibraryIcon,
|
||||||
|
tpago: CreditCardIcon,
|
||||||
|
bancard: CreditCardIcon,
|
||||||
|
};
|
||||||
|
const Icon = icons[provider] || CreditCardIcon;
|
||||||
|
return <Icon className="w-4 h-4" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProviderLabel = (provider: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
cash: locale === 'es' ? 'Efectivo' : 'Cash',
|
||||||
|
bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
|
||||||
|
lightning: 'Lightning',
|
||||||
|
tpago: 'TPago',
|
||||||
|
bancard: 'Bancard',
|
||||||
|
};
|
||||||
|
return labels[provider] || provider;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totalPending = payments
|
||||||
|
.filter(p => p.status === 'pending' || p.status === 'pending_approval')
|
||||||
|
.reduce((sum, p) => sum + p.amount, 0);
|
||||||
|
const totalPaid = payments
|
||||||
|
.filter(p => p.status === 'paid')
|
||||||
|
.reduce((sum, p) => sum + p.amount, 0);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.payments.title')}</h1>
|
||||||
|
<Button onClick={() => setShowExportModal(true)}>
|
||||||
|
<DocumentArrowDownIcon className="w-5 h-5 mr-2" />
|
||||||
|
{locale === 'es' ? 'Exportar Datos' : 'Export Data'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approval Detail Modal */}
|
||||||
|
{selectedPayment && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-lg p-6">
|
||||||
|
<h2 className="text-xl font-bold mb-4">
|
||||||
|
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">{locale === 'es' ? 'Monto' : 'Amount'}</p>
|
||||||
|
<p className="font-bold text-lg">{formatCurrency(selectedPayment.amount, selectedPayment.currency)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">{locale === 'es' ? 'Método' : 'Method'}</p>
|
||||||
|
<p className="font-medium flex items-center gap-2">
|
||||||
|
{getProviderIcon(selectedPayment.provider)}
|
||||||
|
{getProviderLabel(selectedPayment.provider)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedPayment.ticket && (
|
||||||
|
<div className="border rounded-lg p-4">
|
||||||
|
<h4 className="font-medium mb-2">{locale === 'es' ? 'Asistente' : 'Attendee'}</h4>
|
||||||
|
<p className="font-bold">
|
||||||
|
{selectedPayment.ticket.attendeeFirstName} {selectedPayment.ticket.attendeeLastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeeEmail}</p>
|
||||||
|
{selectedPayment.ticket.attendeePhone && (
|
||||||
|
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedPayment.event && (
|
||||||
|
<div className="border rounded-lg p-4">
|
||||||
|
<h4 className="font-medium mb-2">{locale === 'es' ? 'Evento' : 'Event'}</h4>
|
||||||
|
<p className="font-bold">{selectedPayment.event.title}</p>
|
||||||
|
<p className="text-sm text-gray-600">{formatDate(selectedPayment.event.startDatetime)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedPayment.userMarkedPaidAt && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<ClockIcon className="w-4 h-4" />
|
||||||
|
{locale === 'es' ? 'Usuario marcó como pagado:' : 'User marked as paid:'} {formatDate(selectedPayment.userMarkedPaidAt)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
{locale === 'es' ? 'Nota interna (opcional)' : 'Internal note (optional)'}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={noteText}
|
||||||
|
onChange={(e) => setNoteText(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder={locale === 'es' ? 'Agregar nota...' : 'Add a note...'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleApprove(selectedPayment)}
|
||||||
|
isLoading={processing}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||||
|
{locale === 'es' ? 'Aprobar' : 'Approve'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleReject(selectedPayment)}
|
||||||
|
isLoading={processing}
|
||||||
|
className="flex-1 border-red-300 text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="w-5 h-5 mr-2" />
|
||||||
|
{locale === 'es' ? 'Rechazar' : 'Reject'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedPayment(null); setNoteText(''); }}
|
||||||
|
className="w-full mt-3 py-2 text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Export Modal */}
|
||||||
|
{showExportModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
|
||||||
|
<h2 className="text-xl font-bold mb-6">{locale === 'es' ? 'Exportar Datos Financieros' : 'Export Financial Data'}</h2>
|
||||||
|
|
||||||
|
{!exportData ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'Fecha Inicio' : 'Start Date'}
|
||||||
|
type="date"
|
||||||
|
value={exportFilters.startDate}
|
||||||
|
onChange={(e) => setExportFilters({ ...exportFilters, startDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={locale === 'es' ? 'Fecha Fin' : 'End Date'}
|
||||||
|
type="date"
|
||||||
|
value={exportFilters.endDate}
|
||||||
|
onChange={(e) => setExportFilters({ ...exportFilters, endDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Evento (opcional)' : 'Event (optional)'}</label>
|
||||||
|
<select
|
||||||
|
value={exportFilters.eventId}
|
||||||
|
onChange={(e) => setExportFilters({ ...exportFilters, eventId: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="">{locale === 'es' ? 'Todos los Eventos' : 'All Events'}</option>
|
||||||
|
{events.map((event) => (
|
||||||
|
<option key={event.id} value={event.id}>{event.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button onClick={handleExport} isLoading={exporting}>
|
||||||
|
{locale === 'es' ? 'Generar Reporte' : 'Generate Report'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setShowExportModal(false)}>
|
||||||
|
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="bg-secondary-gray rounded-btn p-4">
|
||||||
|
<h3 className="font-semibold mb-4">{locale === 'es' ? 'Resumen Financiero' : 'Financial Summary'}</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">{locale === 'es' ? 'Total Pagado' : 'Total Paid'}</p>
|
||||||
|
<p className="text-xl font-bold text-green-600">{exportData.summary.totalPaid.toLocaleString()} PYG</p>
|
||||||
|
<p className="text-xs text-gray-500">{exportData.summary.paidCount} {locale === 'es' ? 'pagos' : 'payments'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
|
||||||
|
<p className="text-xl font-bold text-yellow-600">{exportData.summary.totalPending.toLocaleString()} PYG</p>
|
||||||
|
<p className="text-xs text-gray-500">{exportData.summary.pendingCount} {locale === 'es' ? 'pagos' : 'payments'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">{locale === 'es' ? 'Total Reembolsado' : 'Total Refunded'}</p>
|
||||||
|
<p className="text-xl font-bold text-blue-600">{exportData.summary.totalRefunded.toLocaleString()} PYG</p>
|
||||||
|
<p className="text-xs text-gray-500">{exportData.summary.refundedCount} {locale === 'es' ? 'pagos' : 'payments'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">{locale === 'es' ? 'Total Registros' : 'Total Records'}</p>
|
||||||
|
<p className="text-xl font-bold">{exportData.summary.totalPayments}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* By Provider */}
|
||||||
|
<div className="bg-secondary-gray rounded-btn p-4">
|
||||||
|
<h3 className="font-semibold mb-4">{locale === 'es' ? 'Ingresos por Método' : 'Revenue by Method'}</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">{locale === 'es' ? 'Efectivo' : 'Cash'}</p>
|
||||||
|
<p className="text-lg font-bold">{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Lightning</p>
|
||||||
|
<p className="text-lg font-bold">{exportData.summary.byProvider.lightning?.toLocaleString() || 0} PYG</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</p>
|
||||||
|
<p className="text-lg font-bold">{(exportData.summary.byProvider as any).bank_transfer?.toLocaleString() || 0} PYG</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">TPago</p>
|
||||||
|
<p className="text-lg font-bold">{(exportData.summary.byProvider as any).tpago?.toLocaleString() || 0} PYG</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Bancard</p>
|
||||||
|
<p className="text-lg font-bold">{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button onClick={downloadCSV}>
|
||||||
|
<ArrowDownTrayIcon className="w-4 h-4 mr-2" />
|
||||||
|
{locale === 'es' ? 'Descargar CSV' : 'Download CSV'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setExportData(null)}>
|
||||||
|
{locale === 'es' ? 'Nuevo Reporte' : 'New Report'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => { setShowExportModal(false); setExportData(null); }}>
|
||||||
|
{locale === 'es' ? 'Cerrar' : 'Close'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||||
|
<ExclamationTriangleIcon className="w-5 h-5 text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}</p>
|
||||||
|
<p className="text-xl font-bold text-yellow-600">{pendingApprovalPayments.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||||
|
<ClockIcon className="w-5 h-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
|
||||||
|
<p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<CheckCircleIcon className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pagado' : 'Total Paid'}</p>
|
||||||
|
<p className="text-xl font-bold text-green-600">{formatCurrency(totalPaid, 'PYG')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<BoltIcon className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pagos' : 'Total Payments'}</p>
|
||||||
|
<p className="text-xl font-bold">{payments.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b mb-6">
|
||||||
|
<nav className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('pending_approval')}
|
||||||
|
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'pending_approval'
|
||||||
|
? 'border-primary-yellow text-primary-dark'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}
|
||||||
|
{pendingApprovalPayments.length > 0 && (
|
||||||
|
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{pendingApprovalPayments.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'all'
|
||||||
|
? 'border-primary-yellow text-primary-dark'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{locale === 'es' ? 'Todos los Pagos' : 'All Payments'}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pending Approval Tab */}
|
||||||
|
{activeTab === 'pending_approval' && (
|
||||||
|
<>
|
||||||
|
{pendingApprovalPayments.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<CheckCircleIcon className="w-12 h-12 text-green-400 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'No hay pagos pendientes de aprobación'
|
||||||
|
: 'No payments pending approval'}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{pendingApprovalPayments.map((payment) => (
|
||||||
|
<Card key={payment.id} className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
{getProviderIcon(payment.provider)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<p className="font-bold text-lg">{formatCurrency(payment.amount, payment.currency)}</p>
|
||||||
|
{getStatusBadge(payment.status)}
|
||||||
|
</div>
|
||||||
|
{payment.ticket && (
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{payment.event && (
|
||||||
|
<p className="text-sm text-gray-500">{payment.event.title}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{getProviderIcon(payment.provider)}
|
||||||
|
{getProviderLabel(payment.provider)}
|
||||||
|
</span>
|
||||||
|
{payment.userMarkedPaidAt && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<ClockIcon className="w-3 h-3" />
|
||||||
|
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setSelectedPayment(payment)}>
|
||||||
|
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All Payments Tab */}
|
||||||
|
{activeTab === 'all' && (
|
||||||
|
<>
|
||||||
|
{/* Filters */}
|
||||||
|
<Card className="p-4 mb-6">
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
||||||
|
>
|
||||||
|
<option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option>
|
||||||
|
<option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
|
||||||
|
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
|
||||||
|
<option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option>
|
||||||
|
<option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
|
||||||
|
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
|
||||||
|
<select
|
||||||
|
value={providerFilter}
|
||||||
|
onChange={(e) => setProviderFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
||||||
|
>
|
||||||
|
<option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option>
|
||||||
|
<option value="lightning">Lightning</option>
|
||||||
|
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||||||
|
<option value="bank_transfer">{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}</option>
|
||||||
|
<option value="tpago">TPago</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Payments Table */}
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-secondary-gray">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Asistente' : 'Attendee'}</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Evento' : 'Event'}</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Monto' : 'Amount'}</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Método' : 'Method'}</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Fecha' : 'Date'}</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
||||||
|
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
|
{payments.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
||||||
|
{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
payments.map((payment) => (
|
||||||
|
<tr key={payment.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{payment.ticket ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">
|
||||||
|
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">{payment.ticket.attendeeEmail}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-sm">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{payment.event ? (
|
||||||
|
<p className="text-sm">{payment.event.title}</p>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-sm">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 font-medium">
|
||||||
|
{formatCurrency(payment.amount, payment.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
{getProviderIcon(payment.provider)}
|
||||||
|
{getProviderLabel(payment.provider)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
|
{formatDate(payment.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{getStatusBadge(payment.status)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedPayment(payment)}
|
||||||
|
>
|
||||||
|
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
||||||
|
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{payment.status === 'paid' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleRefund(payment.id)}
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="w-4 h-4 mr-1" />
|
||||||
|
{t('admin.payments.refund')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
407
frontend/src/app/admin/tickets/page.tsx
Normal file
407
frontend/src/app/admin/tickets/page.tsx
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import Input from '@/components/ui/Input';
|
||||||
|
import { CheckCircleIcon, XCircleIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export default function AdminTicketsPage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<string>('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
|
|
||||||
|
// Manual ticket creation state
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createForm, setCreateForm] = useState({
|
||||||
|
eventId: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
preferredLanguage: 'en' as 'en' | 'es',
|
||||||
|
autoCheckin: false,
|
||||||
|
adminNote: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
ticketsApi.getAll(),
|
||||||
|
eventsApi.getAll(),
|
||||||
|
])
|
||||||
|
.then(([ticketsRes, eventsRes]) => {
|
||||||
|
setTickets(ticketsRes.tickets);
|
||||||
|
setEvents(eventsRes.events);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTickets = async () => {
|
||||||
|
try {
|
||||||
|
const params: any = {};
|
||||||
|
if (selectedEvent) params.eventId = selectedEvent;
|
||||||
|
if (statusFilter) params.status = statusFilter;
|
||||||
|
const { tickets } = await ticketsApi.getAll(params);
|
||||||
|
setTickets(tickets);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load tickets');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading) {
|
||||||
|
loadTickets();
|
||||||
|
}
|
||||||
|
}, [selectedEvent, statusFilter]);
|
||||||
|
|
||||||
|
const handleCheckin = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await ticketsApi.checkin(id);
|
||||||
|
toast.success('Check-in successful');
|
||||||
|
loadTickets();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Check-in failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to cancel this ticket?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ticketsApi.cancel(id);
|
||||||
|
toast.success('Ticket cancelled');
|
||||||
|
loadTickets();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to cancel ticket');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await ticketsApi.updateStatus(id, 'confirmed');
|
||||||
|
toast.success('Ticket confirmed');
|
||||||
|
loadTickets();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to confirm ticket');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTicket = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!createForm.eventId) {
|
||||||
|
toast.error('Please select an event');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await ticketsApi.adminCreate({
|
||||||
|
eventId: createForm.eventId,
|
||||||
|
firstName: createForm.firstName,
|
||||||
|
lastName: createForm.lastName || undefined,
|
||||||
|
email: createForm.email,
|
||||||
|
phone: createForm.phone,
|
||||||
|
preferredLanguage: createForm.preferredLanguage,
|
||||||
|
autoCheckin: createForm.autoCheckin,
|
||||||
|
adminNote: createForm.adminNote || undefined,
|
||||||
|
});
|
||||||
|
toast.success('Ticket created successfully');
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setCreateForm({
|
||||||
|
eventId: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
preferredLanguage: 'en',
|
||||||
|
autoCheckin: false,
|
||||||
|
adminNote: '',
|
||||||
|
});
|
||||||
|
loadTickets();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to create ticket');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
pending: 'badge-warning',
|
||||||
|
confirmed: 'badge-success',
|
||||||
|
cancelled: 'badge-danger',
|
||||||
|
checked_in: 'badge-info',
|
||||||
|
};
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
pending: t('admin.tickets.status.pending'),
|
||||||
|
confirmed: t('admin.tickets.status.confirmed'),
|
||||||
|
cancelled: t('admin.tickets.status.cancelled'),
|
||||||
|
checked_in: t('admin.tickets.status.checkedIn'),
|
||||||
|
};
|
||||||
|
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{labels[status] || status}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventName = (eventId: string) => {
|
||||||
|
const event = events.find(e => e.id === eventId);
|
||||||
|
return event?.title || 'Unknown Event';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.tickets.title')}</h1>
|
||||||
|
<Button onClick={() => setShowCreateForm(true)}>
|
||||||
|
<PlusIcon className="w-5 h-5 mr-2" />
|
||||||
|
Create Ticket
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Ticket Creation Modal */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-lg p-6">
|
||||||
|
<h2 className="text-xl font-bold mb-6">Create Ticket Manually</h2>
|
||||||
|
<form onSubmit={handleCreateTicket} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Event *</label>
|
||||||
|
<select
|
||||||
|
value={createForm.eventId}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, eventId: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select an event</option>
|
||||||
|
{events.filter(e => e.status === 'published').map((event) => (
|
||||||
|
<option key={event.id} value={event.id}>
|
||||||
|
{event.title} ({event.availableSeats} spots left)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="First Name *"
|
||||||
|
value={createForm.firstName}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, firstName: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="First name"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Last Name (optional)"
|
||||||
|
value={createForm.lastName}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, lastName: e.target.value })}
|
||||||
|
placeholder="Last name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Email (optional)"
|
||||||
|
type="email"
|
||||||
|
value={createForm.email}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
|
||||||
|
placeholder="attendee@email.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Phone (optional)"
|
||||||
|
value={createForm.phone}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, phone: e.target.value })}
|
||||||
|
placeholder="+595 XXX XXX XXX"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Preferred Language</label>
|
||||||
|
<select
|
||||||
|
value={createForm.preferredLanguage}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, preferredLanguage: e.target.value as 'en' | 'es' })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="es">Spanish</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Admin Note</label>
|
||||||
|
<textarea
|
||||||
|
value={createForm.adminNote}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, adminNote: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Internal note about this booking (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="autoCheckin"
|
||||||
|
checked={createForm.autoCheckin}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, autoCheckin: e.target.checked })}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<label htmlFor="autoCheckin" className="text-sm">
|
||||||
|
Automatically check in (mark as present)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-btn p-3 text-sm text-yellow-800">
|
||||||
|
Note: This creates a ticket with cash payment marked as paid. Use this for walk-ins at the door. Email and phone are optional for door entries.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button type="submit" isLoading={creating}>
|
||||||
|
Create Ticket
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowCreateForm(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card className="p-4 mb-6">
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Event</label>
|
||||||
|
<select
|
||||||
|
value={selectedEvent}
|
||||||
|
onChange={(e) => setSelectedEvent(e.target.value)}
|
||||||
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[200px]"
|
||||||
|
>
|
||||||
|
<option value="">All Events</option>
|
||||||
|
{events.map((event) => (
|
||||||
|
<option key={event.id} value={event.id}>{event.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
||||||
|
>
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="confirmed">Confirmed</option>
|
||||||
|
<option value="checked_in">Checked In</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tickets Table */}
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-secondary-gray">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Ticket</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
||||||
|
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
|
{tickets.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||||
|
No tickets found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
tickets.map((ticket) => (
|
||||||
|
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
|
||||||
|
<p className="text-xs text-gray-500">ID: {ticket.id.slice(0, 8)}...</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm">
|
||||||
|
{getEventName(ticket.eventId)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
|
{formatDate(ticket.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{getStatusBadge(ticket.status)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{ticket.status === 'pending' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleConfirm(ticket.id)}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{ticket.status === 'confirmed' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCheckin(ticket.id)}
|
||||||
|
>
|
||||||
|
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
||||||
|
{t('admin.tickets.checkin')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCancel(ticket.id)}
|
||||||
|
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
frontend/src/app/admin/users/page.tsx
Normal file
183
frontend/src/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { usersApi, User } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { TrashIcon } from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function AdminUsersPage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers();
|
||||||
|
}, [roleFilter]);
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
const { users } = await usersApi.getAll(roleFilter || undefined);
|
||||||
|
setUsers(users);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load users');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleChange = async (userId: string, newRole: string) => {
|
||||||
|
try {
|
||||||
|
await usersApi.update(userId, { role: newRole as any });
|
||||||
|
toast.success('Role updated');
|
||||||
|
loadUsers();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to update role');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (userId: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await usersApi.delete(userId);
|
||||||
|
toast.success('User deleted');
|
||||||
|
loadUsers();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to delete user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleBadge = (role: string) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
admin: 'badge-danger',
|
||||||
|
organizer: 'badge-info',
|
||||||
|
staff: 'badge-warning',
|
||||||
|
marketing: 'badge-success',
|
||||||
|
user: 'badge-gray',
|
||||||
|
};
|
||||||
|
return <span className={`badge ${styles[role] || 'badge-gray'}`}>{t(`admin.users.roles.${role}`)}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.users.title')}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card className="p-4 mb-6">
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label>
|
||||||
|
<select
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
||||||
|
>
|
||||||
|
<option value="">All Roles</option>
|
||||||
|
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||||
|
<option value="organizer">{t('admin.users.roles.organizer')}</option>
|
||||||
|
<option value="staff">{t('admin.users.roles.staff')}</option>
|
||||||
|
<option value="marketing">{t('admin.users.roles.marketing')}</option>
|
||||||
|
<option value="user">{t('admin.users.roles.user')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Users Table */}
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-secondary-gray">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">User</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Contact</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Role</th>
|
||||||
|
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Joined</th>
|
||||||
|
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||||
|
No users found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<tr key={user.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center">
|
||||||
|
<span className="font-semibold text-primary-dark">
|
||||||
|
{user.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{user.name}</p>
|
||||||
|
<p className="text-sm text-gray-500">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
|
{user.phone || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<select
|
||||||
|
value={user.role}
|
||||||
|
onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||||
|
className="px-2 py-1 rounded border border-secondary-light-gray text-sm"
|
||||||
|
>
|
||||||
|
<option value="user">{t('admin.users.roles.user')}</option>
|
||||||
|
<option value="staff">{t('admin.users.roles.staff')}</option>
|
||||||
|
<option value="marketing">{t('admin.users.roles.marketing')}</option>
|
||||||
|
<option value="organizer">{t('admin.users.roles.organizer')}</option>
|
||||||
|
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
|
{formatDate(user.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(user.id)}
|
||||||
|
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
frontend/src/app/globals.css
Normal file
78
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Poppins:wght@500;600;700&display=swap');
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply font-sans text-primary-dark antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
@apply font-heading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.container-page {
|
||||||
|
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-padding {
|
||||||
|
@apply py-16 md:py-24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
@apply text-3xl md:text-4xl font-bold text-primary-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
@apply text-lg text-gray-600 mt-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styles */
|
||||||
|
.form-group {
|
||||||
|
@apply space-y-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card hover effect */
|
||||||
|
.card-hover {
|
||||||
|
@apply transition-all duration-300 hover:shadow-card-hover hover:-translate-y-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
@apply bg-green-100 text-green-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
@apply bg-yellow-100 text-yellow-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
@apply bg-red-100 text-red-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
@apply bg-blue-100 text-blue-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-gray {
|
||||||
|
@apply bg-gray-100 text-gray-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
frontend/src/app/layout.tsx
Normal file
58
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
import { LanguageProvider } from '@/context/LanguageContext';
|
||||||
|
import { AuthProvider } from '@/context/AuthContext';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Spanglish - Language Exchange in Asunción',
|
||||||
|
description: 'Practice English and Spanish with native speakers at our language exchange events in Asunción, Paraguay.',
|
||||||
|
keywords: ['language exchange', 'Spanish', 'English', 'Asunción', 'Paraguay', 'intercambio de idiomas'],
|
||||||
|
openGraph: {
|
||||||
|
title: 'Spanglish - Language Exchange in Asunción',
|
||||||
|
description: 'Practice English and Spanish with native speakers at our language exchange events.',
|
||||||
|
type: 'website',
|
||||||
|
locale: 'en_US',
|
||||||
|
alternateLocale: 'es_ES',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<AuthProvider>
|
||||||
|
<LanguageProvider>
|
||||||
|
{children}
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 4000,
|
||||||
|
style: {
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
style: {
|
||||||
|
background: '#F0FDF4',
|
||||||
|
color: '#166534',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
style: {
|
||||||
|
background: '#FEF2F2',
|
||||||
|
color: '#991B1B',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</LanguageProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
frontend/src/components/LanguageToggle.tsx
Normal file
94
frontend/src/components/LanguageToggle.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { Locale, localeNames, localeFlags } from '@/i18n';
|
||||||
|
import { ChevronDownIcon, GlobeAltIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface LanguageToggleProps {
|
||||||
|
variant?: 'dropdown' | 'buttons';
|
||||||
|
showFlags?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LanguageToggle({
|
||||||
|
variant = 'dropdown',
|
||||||
|
showFlags = true
|
||||||
|
}: LanguageToggleProps) {
|
||||||
|
const { locale, setLocale } = useLanguage();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const availableLocales = Object.keys(localeNames) as Locale[];
|
||||||
|
|
||||||
|
if (variant === 'buttons') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 bg-secondary-gray rounded-btn p-1">
|
||||||
|
{availableLocales.map((loc) => (
|
||||||
|
<button
|
||||||
|
key={loc}
|
||||||
|
onClick={() => setLocale(loc)}
|
||||||
|
className={clsx(
|
||||||
|
'px-3 py-1.5 rounded-btn text-sm font-medium transition-colors',
|
||||||
|
{
|
||||||
|
'bg-white shadow-sm text-primary-dark': locale === loc,
|
||||||
|
'text-gray-600 hover:text-primary-dark': locale !== loc,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showFlags && <span className="mr-1">{localeFlags[loc]}</span>}
|
||||||
|
{loc.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-btn hover:bg-secondary-gray transition-colors"
|
||||||
|
>
|
||||||
|
<GlobeAltIcon className="w-5 h-5 text-gray-600" />
|
||||||
|
{showFlags && <span>{localeFlags[locale]}</span>}
|
||||||
|
<span className="text-sm font-medium">{localeNames[locale]}</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={clsx(
|
||||||
|
'w-4 h-4 text-gray-500 transition-transform',
|
||||||
|
{ 'rotate-180': isOpen }
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 mt-2 w-40 bg-white rounded-card shadow-card-hover border border-secondary-light-gray z-20 overflow-hidden">
|
||||||
|
{availableLocales.map((loc) => (
|
||||||
|
<button
|
||||||
|
key={loc}
|
||||||
|
onClick={() => {
|
||||||
|
setLocale(loc);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'w-full flex items-center gap-2 px-4 py-2.5 text-left transition-colors',
|
||||||
|
{
|
||||||
|
'bg-secondary-gray': locale === loc,
|
||||||
|
'hover:bg-gray-50': locale !== loc,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showFlags && <span>{localeFlags[loc]}</span>}
|
||||||
|
<span className="text-sm font-medium">{localeNames[loc]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
frontend/src/components/ShareButtons.tsx
Normal file
148
frontend/src/components/ShareButtons.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import {
|
||||||
|
ShareIcon,
|
||||||
|
LinkIcon,
|
||||||
|
CheckIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface ShareButtonsProps {
|
||||||
|
title: string;
|
||||||
|
url?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShareButtons({ title, url, description }: ShareButtonsProps) {
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Use provided URL or current page URL
|
||||||
|
const shareUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
||||||
|
const shareText = description || title;
|
||||||
|
|
||||||
|
const handleCopyLink = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success(locale === 'es' ? 'Enlace copiado' : 'Link copied');
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(locale === 'es' ? 'Error al copiar' : 'Failed to copy');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareToWhatsApp = () => {
|
||||||
|
const text = encodeURIComponent(`${shareText}\n\n${shareUrl}`);
|
||||||
|
window.open(`https://wa.me/?text=${text}`, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareToFacebook = () => {
|
||||||
|
const encodedUrl = encodeURIComponent(shareUrl);
|
||||||
|
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareToTwitter = () => {
|
||||||
|
const text = encodeURIComponent(shareText);
|
||||||
|
const encodedUrl = encodeURIComponent(shareUrl);
|
||||||
|
window.open(`https://twitter.com/intent/tweet?text=${text}&url=${encodedUrl}`, '_blank', 'width=600,height=400');
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareToLinkedIn = () => {
|
||||||
|
const encodedUrl = encodeURIComponent(shareUrl);
|
||||||
|
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`, '_blank', 'width=600,height=400');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNativeShare = async () => {
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title,
|
||||||
|
text: shareText,
|
||||||
|
url: shareUrl,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// User cancelled or error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm font-medium text-gray-600">
|
||||||
|
{locale === 'es' ? 'Compartir evento' : 'Share event'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{/* WhatsApp */}
|
||||||
|
<button
|
||||||
|
onClick={shareToWhatsApp}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-full bg-[#25D366] text-white hover:opacity-90 transition-opacity"
|
||||||
|
title="WhatsApp"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Facebook */}
|
||||||
|
<button
|
||||||
|
onClick={shareToFacebook}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-full bg-[#1877F2] text-white hover:opacity-90 transition-opacity"
|
||||||
|
title="Facebook"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Twitter/X */}
|
||||||
|
<button
|
||||||
|
onClick={shareToTwitter}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-full bg-black text-white hover:opacity-90 transition-opacity"
|
||||||
|
title="X (Twitter)"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* LinkedIn */}
|
||||||
|
<button
|
||||||
|
onClick={shareToLinkedIn}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-full bg-[#0A66C2] text-white hover:opacity-90 transition-opacity"
|
||||||
|
title="LinkedIn"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Copy Link */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors"
|
||||||
|
title={locale === 'es' ? 'Copiar enlace' : 'Copy link'}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckIcon className="w-5 h-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<LinkIcon className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Native Share (mobile) */}
|
||||||
|
{typeof navigator !== 'undefined' && typeof navigator.share === 'function' && (
|
||||||
|
<button
|
||||||
|
onClick={handleNativeShare}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-yellow text-primary-dark hover:bg-primary-yellow/90 transition-colors"
|
||||||
|
title={locale === 'es' ? 'Más opciones' : 'More options'}
|
||||||
|
>
|
||||||
|
<ShareIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
frontend/src/components/layout/Footer.tsx
Normal file
119
frontend/src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { getSocialLinks, socialIcons } from '@/lib/socialLinks';
|
||||||
|
|
||||||
|
const legalLinks = [
|
||||||
|
{ slug: 'terms-policy', en: 'Terms & Conditions', es: 'Términos y Condiciones' },
|
||||||
|
{ slug: 'privacy-policy', en: 'Privacy Policy', es: 'Política de Privacidad' },
|
||||||
|
{ slug: 'refund-cancelation-policy', en: 'Refund Policy', es: 'Política de Reembolso' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const socialLinks = getSocialLinks();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="bg-secondary-gray border-t border-secondary-light-gray">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="col-span-1 md:col-span-2">
|
||||||
|
<Link href="/" className="inline-block">
|
||||||
|
<span className="text-2xl font-bold font-heading text-primary-dark">
|
||||||
|
Span<span className="text-primary-yellow">glish</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<p className="mt-3 text-gray-600 max-w-md">
|
||||||
|
{t('footer.tagline')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-primary-dark mb-4">
|
||||||
|
{t('footer.links')}
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/events"
|
||||||
|
className="text-gray-600 hover:text-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
{t('nav.events')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/community"
|
||||||
|
className="text-gray-600 hover:text-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
{t('nav.community')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/contact"
|
||||||
|
className="text-gray-600 hover:text-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
{t('nav.contact')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/faq"
|
||||||
|
className="text-gray-600 hover:text-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
{t('nav.faq')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social */}
|
||||||
|
{socialLinks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-primary-dark mb-4">
|
||||||
|
{t('footer.social')}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{socialLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.type}
|
||||||
|
href={link.url}
|
||||||
|
target={link.type === 'email' ? undefined : '_blank'}
|
||||||
|
rel={link.type === 'email' ? undefined : 'noopener noreferrer'}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-full bg-white shadow-sm hover:shadow-md hover:bg-primary-yellow/10 transition-all"
|
||||||
|
title={link.label}
|
||||||
|
>
|
||||||
|
{socialIcons[link.type]}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legal Links */}
|
||||||
|
<div className="border-t border-secondary-light-gray mt-10 pt-8">
|
||||||
|
<div className="flex flex-wrap justify-center gap-4 md:gap-6 mb-4">
|
||||||
|
{legalLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.slug}
|
||||||
|
href={`/legal/${link.slug}`}
|
||||||
|
className="text-gray-500 hover:text-primary-dark transition-colors text-sm"
|
||||||
|
>
|
||||||
|
{locale === 'es' ? link.es : link.en}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-gray-500 text-sm">
|
||||||
|
{t('footer.copyright', { year: currentYear })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
frontend/src/components/layout/Header.tsx
Normal file
165
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import LanguageToggle from '@/components/LanguageToggle';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { user, isAdmin, logout } = useAuth();
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ href: '/', label: t('nav.home') },
|
||||||
|
{ href: '/events', label: t('nav.events') },
|
||||||
|
{ href: '/community', label: t('nav.community') },
|
||||||
|
{ href: '/contact', label: t('nav.contact') },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 bg-white shadow-sm">
|
||||||
|
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl font-bold font-heading text-primary-dark">
|
||||||
|
Span<span className="text-primary-yellow">glish</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<div className="hidden md:flex items-center gap-6">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="text-gray-700 hover:text-primary-dark font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side actions */}
|
||||||
|
<div className="hidden md:flex items-center gap-4">
|
||||||
|
<LanguageToggle />
|
||||||
|
|
||||||
|
{user ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/dashboard">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
{t('nav.dashboard')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{isAdmin && (
|
||||||
|
<Link href="/admin">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
{t('nav.admin')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-600">{user.name}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={logout}>
|
||||||
|
{t('nav.logout')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href="/login">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
{t('nav.login')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/events">
|
||||||
|
<Button size="sm">
|
||||||
|
{t('nav.joinEvent')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
<button
|
||||||
|
className="md:hidden p-2 rounded-lg hover:bg-gray-100"
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? (
|
||||||
|
<XMarkIcon className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<Bars3Icon className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Navigation */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'md:hidden overflow-hidden transition-all duration-300',
|
||||||
|
{
|
||||||
|
'max-h-0': !mobileMenuOpen,
|
||||||
|
'max-h-96 pb-4': mobileMenuOpen,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 pt-4">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="px-4 py-2 text-gray-700 hover:bg-gray-50 rounded-lg font-medium"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="border-t border-gray-100 mt-2 pt-4 px-4">
|
||||||
|
<LanguageToggle variant="buttons" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 pt-2 flex flex-col gap-2">
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
<Button variant="outline" className="w-full">
|
||||||
|
{t('nav.dashboard')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{isAdmin && (
|
||||||
|
<Link href="/admin" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
<Button variant="outline" className="w-full">
|
||||||
|
{t('nav.admin')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" onClick={logout} className="w-full">
|
||||||
|
{t('nav.logout')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link href="/login" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
<Button variant="outline" className="w-full">
|
||||||
|
{t('nav.login')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/events" onClick={() => setMobileMenuOpen(false)}>
|
||||||
|
<Button className="w-full">
|
||||||
|
{t('nav.joinEvent')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
frontend/src/components/layout/LegalPageLayout.tsx
Normal file
191
frontend/src/components/layout/LegalPageLayout.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
interface LegalPageLayoutProps {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
lastUpdated?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LegalPageLayout({ title, content, lastUpdated }: LegalPageLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page max-w-4xl">
|
||||||
|
{/* Back link */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center text-gray-600 hover:text-primary-dark transition-colors mb-8"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div className="mb-8 pb-6 border-b border-gray-200">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-primary-dark mb-2">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{lastUpdated && lastUpdated !== '[Insert Date]' && (
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Last updated: {lastUpdated}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Markdown content */}
|
||||||
|
<article className="prose prose-gray max-w-none legal-content">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
// Style headings
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h1 className="text-3xl font-bold text-primary-dark mt-8 mb-4 first:mt-0">
|
||||||
|
{children}
|
||||||
|
</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 className="text-2xl font-semibold text-primary-dark mt-8 mb-4 pb-2 border-b border-gray-200">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 className="text-xl font-semibold text-primary-dark mt-6 mb-3">
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
),
|
||||||
|
h4: ({ children }) => (
|
||||||
|
<h4 className="text-lg font-semibold text-primary-dark mt-4 mb-2">
|
||||||
|
{children}
|
||||||
|
</h4>
|
||||||
|
),
|
||||||
|
// Style paragraphs
|
||||||
|
p: ({ children }) => (
|
||||||
|
<p className="text-gray-700 leading-relaxed mb-4">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
// Style lists
|
||||||
|
ul: ({ children }) => (
|
||||||
|
<ul className="list-disc list-inside space-y-2 mb-4 text-gray-700 ml-4">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
ol: ({ children }) => (
|
||||||
|
<ol className="list-decimal list-inside space-y-2 mb-4 text-gray-700 ml-4">
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
),
|
||||||
|
li: ({ children }) => (
|
||||||
|
<li className="text-gray-700">
|
||||||
|
{children}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
// Style links
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className="text-primary-dark underline hover:text-primary-yellow transition-colors"
|
||||||
|
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||||
|
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
// Style horizontal rules
|
||||||
|
hr: () => (
|
||||||
|
<hr className="my-8 border-gray-200" />
|
||||||
|
),
|
||||||
|
// Style blockquotes
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="border-l-4 border-primary-yellow pl-4 my-4 italic text-gray-600">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
// Style tables
|
||||||
|
table: ({ children }) => (
|
||||||
|
<div className="overflow-x-auto my-6">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 border border-gray-200 rounded-lg">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
thead: ({ children }) => (
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
{children}
|
||||||
|
</thead>
|
||||||
|
),
|
||||||
|
tbody: ({ children }) => (
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{children}
|
||||||
|
</tbody>
|
||||||
|
),
|
||||||
|
tr: ({ children }) => (
|
||||||
|
<tr>
|
||||||
|
{children}
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
th: ({ children }) => (
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-primary-dark">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
td: ({ children }) => (
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-700">
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
// Style code blocks
|
||||||
|
code: ({ className, children }) => {
|
||||||
|
const isInline = !className;
|
||||||
|
if (isInline) {
|
||||||
|
return (
|
||||||
|
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm text-gray-800">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code className={className}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pre: ({ children }) => (
|
||||||
|
<pre className="bg-gray-100 rounded-lg p-4 overflow-x-auto my-4">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
// Style strong and emphasis
|
||||||
|
strong: ({ children }) => (
|
||||||
|
<strong className="font-semibold text-primary-dark">
|
||||||
|
{children}
|
||||||
|
</strong>
|
||||||
|
),
|
||||||
|
em: ({ children }) => (
|
||||||
|
<em className="italic">
|
||||||
|
{children}
|
||||||
|
</em>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* Back to top link */}
|
||||||
|
<div className="mt-12 pt-6 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
|
className="text-gray-500 hover:text-primary-dark transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Back to top
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
frontend/src/components/ui/Button.tsx
Normal file
77
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex items-center justify-center font-medium transition-all duration-200 rounded-btn focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
// Variants
|
||||||
|
'bg-primary-yellow text-primary-dark hover:bg-yellow-400 focus:ring-yellow-400':
|
||||||
|
variant === 'primary',
|
||||||
|
'bg-primary-dark text-white hover:bg-gray-800 focus:ring-gray-800':
|
||||||
|
variant === 'secondary',
|
||||||
|
'border-2 border-primary-dark text-primary-dark bg-transparent hover:bg-gray-50 focus:ring-gray-400':
|
||||||
|
variant === 'outline',
|
||||||
|
'text-primary-dark hover:bg-gray-100 focus:ring-gray-300':
|
||||||
|
variant === 'ghost',
|
||||||
|
'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500':
|
||||||
|
variant === 'danger',
|
||||||
|
// Sizes
|
||||||
|
'text-sm px-3 py-1.5': size === 'sm',
|
||||||
|
'text-base px-5 py-2.5': size === 'md',
|
||||||
|
'text-lg px-7 py-3': size === 'lg',
|
||||||
|
// States
|
||||||
|
'opacity-50 cursor-not-allowed': disabled || isLoading,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export default Button;
|
||||||
35
frontend/src/components/ui/Card.tsx
Normal file
35
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { HTMLAttributes, forwardRef } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: 'default' | 'elevated' | 'bordered';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||||
|
({ className, variant = 'default', children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(
|
||||||
|
'bg-white rounded-card overflow-hidden',
|
||||||
|
{
|
||||||
|
'shadow-card': variant === 'default',
|
||||||
|
'shadow-card-hover hover:shadow-lg transition-shadow duration-200':
|
||||||
|
variant === 'elevated',
|
||||||
|
'border border-secondary-light-gray': variant === 'bordered',
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
export default Card;
|
||||||
48
frontend/src/components/ui/Input.tsx
Normal file
48
frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { InputHTMLAttributes, forwardRef } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, label, error, id, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className="block text-sm font-medium text-primary-dark mb-1.5"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
className={clsx(
|
||||||
|
'w-full px-4 py-3 rounded-btn border transition-colors duration-200',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent',
|
||||||
|
'placeholder:text-gray-400',
|
||||||
|
{
|
||||||
|
'border-secondary-light-gray': !error,
|
||||||
|
'border-red-500 focus:ring-red-500': error,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1.5 text-sm text-red-600">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export default Input;
|
||||||
173
frontend/src/context/AuthContext.tsx
Normal file
173
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
phone?: string;
|
||||||
|
languagePreference?: string;
|
||||||
|
isClaimed?: boolean;
|
||||||
|
rucNumber?: string;
|
||||||
|
accountStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
loginWithGoogle: (credential: string) => Promise<void>;
|
||||||
|
loginWithMagicLink: (token: string) => Promise<void>;
|
||||||
|
register: (data: RegisterData) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
updateUser: (user: User) => void;
|
||||||
|
setAuthData: (data: { user: User; token: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterData {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
languagePreference?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'spanglish-token';
|
||||||
|
const USER_KEY = 'spanglish-user';
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load auth state from localStorage
|
||||||
|
const savedToken = localStorage.getItem(TOKEN_KEY);
|
||||||
|
const savedUser = localStorage.getItem(USER_KEY);
|
||||||
|
|
||||||
|
if (savedToken && savedUser) {
|
||||||
|
setToken(savedToken);
|
||||||
|
setUser(JSON.parse(savedUser));
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAuthData = useCallback((data: { user: User; token: string }) => {
|
||||||
|
setToken(data.token);
|
||||||
|
setUser(data.user);
|
||||||
|
localStorage.setItem(TOKEN_KEY, data.token);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.error || 'Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setAuthData(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginWithGoogle = async (credential: string) => {
|
||||||
|
const res = await fetch('/api/auth/google', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ credential }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.error || 'Google login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setAuthData(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginWithMagicLink = async (magicToken: string) => {
|
||||||
|
const res = await fetch('/api/auth/magic-link/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: magicToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.error || 'Magic link login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setAuthData(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (registerData: RegisterData) => {
|
||||||
|
const res = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(registerData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.error || 'Registration failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setAuthData(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateUser = useCallback((updatedUser: User) => {
|
||||||
|
setUser(updatedUser);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(updatedUser));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isLoading,
|
||||||
|
isAdmin,
|
||||||
|
login,
|
||||||
|
loginWithGoogle,
|
||||||
|
loginWithMagicLink,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
updateUser,
|
||||||
|
setAuthData,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
72
frontend/src/context/LanguageContext.tsx
Normal file
72
frontend/src/context/LanguageContext.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { Locale, defaultLocale, t, locales } from '@/i18n';
|
||||||
|
|
||||||
|
interface LanguageContextType {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (locale: Locale) => void;
|
||||||
|
t: (key: string, params?: Record<string, string | number>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'spanglish-locale';
|
||||||
|
|
||||||
|
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [locale, setLocaleState] = useState<Locale>(defaultLocale);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load saved locale from localStorage
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY) as Locale | null;
|
||||||
|
if (saved && locales[saved]) {
|
||||||
|
setLocaleState(saved);
|
||||||
|
} else {
|
||||||
|
// Try to detect browser language
|
||||||
|
const browserLang = navigator.language.split('-')[0] as Locale;
|
||||||
|
if (locales[browserLang]) {
|
||||||
|
setLocaleState(browserLang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLocale = (newLocale: Locale) => {
|
||||||
|
setLocaleState(newLocale);
|
||||||
|
localStorage.setItem(STORAGE_KEY, newLocale);
|
||||||
|
};
|
||||||
|
|
||||||
|
const translate = (key: string, params?: Record<string, string | number>) => {
|
||||||
|
return t(locale, key, params);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent hydration mismatch
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<LanguageContext.Provider
|
||||||
|
value={{
|
||||||
|
locale: defaultLocale,
|
||||||
|
setLocale,
|
||||||
|
t: (key, params) => t(defaultLocale, key, params),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</LanguageContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LanguageContext.Provider value={{ locale, setLocale, t: translate }}>
|
||||||
|
{children}
|
||||||
|
</LanguageContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLanguage() {
|
||||||
|
const context = useContext(LanguageContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useLanguage must be used within a LanguageProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
59
frontend/src/i18n/index.ts
Normal file
59
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import en from './locales/en.json';
|
||||||
|
import es from './locales/es.json';
|
||||||
|
|
||||||
|
// Type for available locales - easily extendable
|
||||||
|
export type Locale = 'en' | 'es';
|
||||||
|
|
||||||
|
// Add new languages here
|
||||||
|
export const locales: Record<Locale, typeof en> = {
|
||||||
|
en,
|
||||||
|
es,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Language display names
|
||||||
|
export const localeNames: Record<Locale, string> = {
|
||||||
|
en: 'English',
|
||||||
|
es: 'Español',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Language flags (emoji or use icons)
|
||||||
|
export const localeFlags: Record<Locale, string> = {
|
||||||
|
en: '🇺🇸',
|
||||||
|
es: '🇪🇸',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultLocale: Locale = 'en';
|
||||||
|
|
||||||
|
// Get nested translation value
|
||||||
|
function getNestedValue(obj: any, path: string): string {
|
||||||
|
return path.split('.').reduce((acc, part) => acc && acc[part], obj) || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translation function
|
||||||
|
export function t(locale: Locale, key: string, params?: Record<string, string | number>): string {
|
||||||
|
const translations = locales[locale] || locales[defaultLocale];
|
||||||
|
let value = getNestedValue(translations, key);
|
||||||
|
|
||||||
|
// Replace parameters
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([paramKey, paramValue]) => {
|
||||||
|
value = value.replace(`{${paramKey}}`, String(paramValue));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HOW TO ADD NEW LANGUAGES:
|
||||||
|
// 1. Create a new JSON file in locales/ (e.g., pt.json for Portuguese)
|
||||||
|
// 2. Add the locale to the Locale type above
|
||||||
|
// 3. Import and add to the locales object
|
||||||
|
// 4. Add display name to localeNames
|
||||||
|
// 5. Add flag to localeFlags
|
||||||
|
//
|
||||||
|
// Example for Portuguese:
|
||||||
|
// import pt from './locales/pt.json';
|
||||||
|
// export type Locale = 'en' | 'es' | 'pt';
|
||||||
|
// export const locales = { en, es, pt };
|
||||||
|
// export const localeNames = { en: 'English', es: 'Español', pt: 'Português' };
|
||||||
|
// export const localeFlags = { en: '🇺🇸', es: '🇪🇸', pt: '🇧🇷' };
|
||||||
321
frontend/src/i18n/locales/en.json
Normal file
321
frontend/src/i18n/locales/en.json
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "An error occurred",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"create": "Create",
|
||||||
|
"search": "Search",
|
||||||
|
"filter": "Filter",
|
||||||
|
"submit": "Submit",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"viewAll": "View All",
|
||||||
|
"learnMore": "Learn More",
|
||||||
|
"moreInfo": "More Info"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"events": "Events",
|
||||||
|
"community": "Community",
|
||||||
|
"contact": "Contact",
|
||||||
|
"faq": "FAQ",
|
||||||
|
"joinEvent": "Join Event",
|
||||||
|
"login": "Login",
|
||||||
|
"register": "Sign Up",
|
||||||
|
"logout": "Logout",
|
||||||
|
"admin": "Admin",
|
||||||
|
"dashboard": "My Account"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"hero": {
|
||||||
|
"title": "Practice English & Spanish in Asunción",
|
||||||
|
"subtitle": "Meet people. Learn languages. Have fun.",
|
||||||
|
"cta": "Join Next Event"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "What is Spanglish?",
|
||||||
|
"description": "Spanglish is a language exchange community where Spanish and English speakers come together to practice, learn, and connect. Our monthly events create a friendly environment for language learning through real conversations.",
|
||||||
|
"feature1": "Monthly Events",
|
||||||
|
"feature1Desc": "Regular meetups at welcoming venues",
|
||||||
|
"feature2": "Native Speakers",
|
||||||
|
"feature2Desc": "Practice with native English and Spanish speakers",
|
||||||
|
"feature3": "All Levels",
|
||||||
|
"feature3Desc": "Beginners to advanced are welcome"
|
||||||
|
},
|
||||||
|
"nextEvent": {
|
||||||
|
"title": "Next Event",
|
||||||
|
"noEvents": "No upcoming events scheduled",
|
||||||
|
"stayTuned": "Stay tuned for announcements!"
|
||||||
|
},
|
||||||
|
"gallery": {
|
||||||
|
"title": "Our Community"
|
||||||
|
},
|
||||||
|
"newsletter": {
|
||||||
|
"title": "Stay Updated",
|
||||||
|
"description": "Subscribe to get notified about upcoming events",
|
||||||
|
"placeholder": "Enter your email",
|
||||||
|
"button": "Subscribe",
|
||||||
|
"success": "Thanks for subscribing!",
|
||||||
|
"error": "Subscription failed. Please try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "Events",
|
||||||
|
"upcoming": "Upcoming Events",
|
||||||
|
"past": "Past Events",
|
||||||
|
"noEvents": "No events found",
|
||||||
|
"details": {
|
||||||
|
"date": "Date",
|
||||||
|
"time": "Time",
|
||||||
|
"location": "Location",
|
||||||
|
"price": "Price",
|
||||||
|
"free": "Free",
|
||||||
|
"capacity": "Capacity",
|
||||||
|
"spotsLeft": "spots left",
|
||||||
|
"soldOut": "Sold Out",
|
||||||
|
"cancelled": "Cancelled",
|
||||||
|
"eventEnded": "Event Ended"
|
||||||
|
},
|
||||||
|
"booking": {
|
||||||
|
"join": "Join Event",
|
||||||
|
"book": "Book Now",
|
||||||
|
"register": "Register"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"booking": {
|
||||||
|
"title": "Book Your Spot",
|
||||||
|
"form": {
|
||||||
|
"personalInfo": "Your Information",
|
||||||
|
"fullName": "Full Name",
|
||||||
|
"fullNamePlaceholder": "Enter your full name",
|
||||||
|
"firstName": "First Name",
|
||||||
|
"firstNamePlaceholder": "Enter your first name",
|
||||||
|
"lastName": "Last Name",
|
||||||
|
"lastNamePlaceholder": "Enter your last name",
|
||||||
|
"email": "Email Address",
|
||||||
|
"emailPlaceholder": "your@email.com",
|
||||||
|
"phone": "Phone / WhatsApp",
|
||||||
|
"phonePlaceholder": "+595 XXX XXX XXX",
|
||||||
|
"preferredLanguage": "Preferred Language",
|
||||||
|
"paymentMethod": "Payment Method",
|
||||||
|
"ruc": "RUC (Tax ID)",
|
||||||
|
"rucPlaceholder": "12345678-9",
|
||||||
|
"rucOptional": "Optional - for invoice",
|
||||||
|
"reserveSpot": "Reserve My Spot",
|
||||||
|
"proceedPayment": "Proceed to Payment",
|
||||||
|
"termsNote": "By booking, you agree to our terms and conditions.",
|
||||||
|
"soldOutMessage": "This event is fully booked. Check back later or browse other events.",
|
||||||
|
"errors": {
|
||||||
|
"nameRequired": "Please enter your full name",
|
||||||
|
"firstNameRequired": "Please enter your first name",
|
||||||
|
"lastNameRequired": "Please enter your last name",
|
||||||
|
"emailInvalid": "Please enter a valid email address",
|
||||||
|
"phoneRequired": "Phone number is required",
|
||||||
|
"bookingFailed": "Booking failed. Please try again.",
|
||||||
|
"rucInvalidFormat": "Invalid format. Example: 12345678-9",
|
||||||
|
"rucInvalidCheckDigit": "Invalid RUC. Please verify the number."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"title": "Booking Summary",
|
||||||
|
"event": "Event",
|
||||||
|
"date": "Date",
|
||||||
|
"price": "Price",
|
||||||
|
"total": "Total"
|
||||||
|
},
|
||||||
|
"confirm": "Confirm Booking",
|
||||||
|
"success": {
|
||||||
|
"title": "Booking Confirmed!",
|
||||||
|
"message": "Your spot has been reserved successfully!",
|
||||||
|
"description": "We've sent a confirmation to your email.",
|
||||||
|
"event": "Event",
|
||||||
|
"date": "Date",
|
||||||
|
"time": "Time",
|
||||||
|
"location": "Location",
|
||||||
|
"ticketId": "Ticket ID",
|
||||||
|
"instructions": "Please save this ticket ID. You'll need it at check-in.",
|
||||||
|
"cashNote": "Payment Required",
|
||||||
|
"cashDescription": "Please bring the exact amount in cash to pay at the event entrance.",
|
||||||
|
"cardNote": "You will be redirected to complete your card payment shortly.",
|
||||||
|
"lightningNote": "A Lightning invoice will be generated for payment.",
|
||||||
|
"emailSent": "A confirmation email has been sent to your inbox.",
|
||||||
|
"browseEvents": "Browse More Events",
|
||||||
|
"backHome": "Back to Home"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"community": {
|
||||||
|
"title": "Join Our Community",
|
||||||
|
"subtitle": "Connect with us on social media and stay updated",
|
||||||
|
"whatsapp": {
|
||||||
|
"title": "WhatsApp Group",
|
||||||
|
"description": "Join our WhatsApp group for event updates and community chat",
|
||||||
|
"button": "Join WhatsApp"
|
||||||
|
},
|
||||||
|
"instagram": {
|
||||||
|
"title": "Instagram",
|
||||||
|
"description": "Follow us for photos, stories, and announcements",
|
||||||
|
"button": "Follow Us"
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"title": "Telegram Channel",
|
||||||
|
"description": "Join our Telegram channel for news and announcements",
|
||||||
|
"button": "Join Telegram"
|
||||||
|
},
|
||||||
|
"guidelines": {
|
||||||
|
"title": "Community Guidelines",
|
||||||
|
"items": [
|
||||||
|
"Be respectful to all participants",
|
||||||
|
"Help others practice - we're all learning",
|
||||||
|
"Speak in the language you're practicing",
|
||||||
|
"Have fun and be open to making new friends"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"volunteer": {
|
||||||
|
"title": "Become a Volunteer",
|
||||||
|
"description": "Help us organize events and grow the community",
|
||||||
|
"button": "Contact Us"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "Contact Us",
|
||||||
|
"subtitle": "Have questions? We'd love to hear from you.",
|
||||||
|
"form": {
|
||||||
|
"name": "Your Name",
|
||||||
|
"email": "Your Email",
|
||||||
|
"message": "Your Message",
|
||||||
|
"submit": "Send Message"
|
||||||
|
},
|
||||||
|
"success": "Message sent successfully!",
|
||||||
|
"error": "Failed to send message. Please try again.",
|
||||||
|
"info": {
|
||||||
|
"email": "Email",
|
||||||
|
"social": "Social Media"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": {
|
||||||
|
"title": "Welcome Back",
|
||||||
|
"subtitle": "Sign in to your account",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"submit": "Sign In",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"register": "Sign Up"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Create Account",
|
||||||
|
"subtitle": "Join the Spanglish community",
|
||||||
|
"name": "Full Name",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password (min. 8 characters)",
|
||||||
|
"phone": "Phone (optional)",
|
||||||
|
"submit": "Create Account",
|
||||||
|
"hasAccount": "Already have an account?",
|
||||||
|
"login": "Sign In"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"invalidCredentials": "Invalid email or password",
|
||||||
|
"emailExists": "Email already registered"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"welcome": "Welcome back",
|
||||||
|
"stats": {
|
||||||
|
"users": "Total Users",
|
||||||
|
"events": "Total Events",
|
||||||
|
"tickets": "Total Tickets",
|
||||||
|
"revenue": "Total Revenue"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"events": "Events",
|
||||||
|
"bookings": "Bookings",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"users": "Users",
|
||||||
|
"payments": "Payments",
|
||||||
|
"contacts": "Messages",
|
||||||
|
"emails": "Emails",
|
||||||
|
"gallery": "Gallery",
|
||||||
|
"settings": "Settings"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "Manage Events",
|
||||||
|
"create": "Create Event",
|
||||||
|
"edit": "Edit Event",
|
||||||
|
"delete": "Delete Event",
|
||||||
|
"publish": "Publish",
|
||||||
|
"unpublish": "Unpublish"
|
||||||
|
},
|
||||||
|
"tickets": {
|
||||||
|
"title": "Manage Tickets",
|
||||||
|
"checkin": "Check In",
|
||||||
|
"cancel": "Cancel Ticket",
|
||||||
|
"status": {
|
||||||
|
"pending": "Pending",
|
||||||
|
"confirmed": "Confirmed",
|
||||||
|
"cancelled": "Cancelled",
|
||||||
|
"checkedIn": "Checked In"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "Manage Users",
|
||||||
|
"role": "Role",
|
||||||
|
"roles": {
|
||||||
|
"admin": "Admin",
|
||||||
|
"organizer": "Organizer",
|
||||||
|
"staff": "Staff",
|
||||||
|
"marketing": "Marketing",
|
||||||
|
"user": "User"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"payments": {
|
||||||
|
"title": "Payments",
|
||||||
|
"confirm": "Confirm Payment",
|
||||||
|
"refund": "Refund",
|
||||||
|
"status": {
|
||||||
|
"pending": "Pending",
|
||||||
|
"paid": "Paid",
|
||||||
|
"refunded": "Refunded",
|
||||||
|
"failed": "Failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"tagline": "Language exchange community in Asunción",
|
||||||
|
"links": "Quick Links",
|
||||||
|
"social": "Follow Us",
|
||||||
|
"copyright": "© {year} Spanglish. All rights reserved.",
|
||||||
|
"legal": {
|
||||||
|
"title": "Legal",
|
||||||
|
"terms": "Terms & Conditions",
|
||||||
|
"privacy": "Privacy Policy",
|
||||||
|
"refund": "Refund Policy"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linktree": {
|
||||||
|
"tagline": "Language Exchange Community",
|
||||||
|
"nextEvent": "Next Event",
|
||||||
|
"noEvents": "No upcoming events",
|
||||||
|
"bookNow": "Book Now",
|
||||||
|
"joinCommunity": "Join Our Community",
|
||||||
|
"visitWebsite": "Visit Our Website",
|
||||||
|
"whatsapp": {
|
||||||
|
"title": "WhatsApp Community",
|
||||||
|
"subtitle": "Chat & event updates"
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"title": "Telegram Channel",
|
||||||
|
"subtitle": "News & announcements"
|
||||||
|
},
|
||||||
|
"instagram": {
|
||||||
|
"title": "Instagram",
|
||||||
|
"subtitle": "Photos & stories"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
321
frontend/src/i18n/locales/es.json
Normal file
321
frontend/src/i18n/locales/es.json
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Cargando...",
|
||||||
|
"error": "Ocurrió un error",
|
||||||
|
"save": "Guardar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"edit": "Editar",
|
||||||
|
"create": "Crear",
|
||||||
|
"search": "Buscar",
|
||||||
|
"filter": "Filtrar",
|
||||||
|
"submit": "Enviar",
|
||||||
|
"back": "Volver",
|
||||||
|
"next": "Siguiente",
|
||||||
|
"viewAll": "Ver Todo",
|
||||||
|
"learnMore": "Saber Más",
|
||||||
|
"moreInfo": "Más Info"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Inicio",
|
||||||
|
"events": "Eventos",
|
||||||
|
"community": "Comunidad",
|
||||||
|
"contact": "Contacto",
|
||||||
|
"faq": "Preguntas Frecuentes",
|
||||||
|
"joinEvent": "Unirse al Evento",
|
||||||
|
"login": "Iniciar Sesión",
|
||||||
|
"register": "Registrarse",
|
||||||
|
"logout": "Cerrar Sesión",
|
||||||
|
"admin": "Admin",
|
||||||
|
"dashboard": "Mi Cuenta"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"hero": {
|
||||||
|
"title": "Practica Inglés y Español en Asunción",
|
||||||
|
"subtitle": "Conoce gente. Aprende idiomas. Diviértete.",
|
||||||
|
"cta": "Unirse al Próximo Evento"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "¿Qué es Spanglish?",
|
||||||
|
"description": "Spanglish es una comunidad de intercambio de idiomas donde hablantes de español e inglés se reúnen para practicar, aprender y conectar. Nuestros eventos mensuales crean un ambiente amigable para el aprendizaje de idiomas a través de conversaciones reales.",
|
||||||
|
"feature1": "Eventos Mensuales",
|
||||||
|
"feature1Desc": "Encuentros regulares en lugares acogedores",
|
||||||
|
"feature2": "Hablantes Nativos",
|
||||||
|
"feature2Desc": "Practica con hablantes nativos de inglés y español",
|
||||||
|
"feature3": "Todos los Niveles",
|
||||||
|
"feature3Desc": "Desde principiantes hasta avanzados"
|
||||||
|
},
|
||||||
|
"nextEvent": {
|
||||||
|
"title": "Próximo Evento",
|
||||||
|
"noEvents": "No hay eventos programados",
|
||||||
|
"stayTuned": "¡Mantente atento a los anuncios!"
|
||||||
|
},
|
||||||
|
"gallery": {
|
||||||
|
"title": "Nuestra Comunidad"
|
||||||
|
},
|
||||||
|
"newsletter": {
|
||||||
|
"title": "Mantente Informado",
|
||||||
|
"description": "Suscríbete para recibir notificaciones sobre próximos eventos",
|
||||||
|
"placeholder": "Ingresa tu email",
|
||||||
|
"button": "Suscribirse",
|
||||||
|
"success": "¡Gracias por suscribirte!",
|
||||||
|
"error": "Error al suscribirse. Por favor intenta de nuevo."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "Eventos",
|
||||||
|
"upcoming": "Próximos Eventos",
|
||||||
|
"past": "Eventos Pasados",
|
||||||
|
"noEvents": "No se encontraron eventos",
|
||||||
|
"details": {
|
||||||
|
"date": "Fecha",
|
||||||
|
"time": "Hora",
|
||||||
|
"location": "Ubicación",
|
||||||
|
"price": "Precio",
|
||||||
|
"free": "Gratis",
|
||||||
|
"capacity": "Capacidad",
|
||||||
|
"spotsLeft": "lugares disponibles",
|
||||||
|
"soldOut": "Agotado",
|
||||||
|
"cancelled": "Cancelado",
|
||||||
|
"eventEnded": "Evento Finalizado"
|
||||||
|
},
|
||||||
|
"booking": {
|
||||||
|
"join": "Unirse al Evento",
|
||||||
|
"book": "Reservar Ahora",
|
||||||
|
"register": "Registrarse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"booking": {
|
||||||
|
"title": "Reserva tu Lugar",
|
||||||
|
"form": {
|
||||||
|
"personalInfo": "Tu Información",
|
||||||
|
"fullName": "Nombre Completo",
|
||||||
|
"fullNamePlaceholder": "Ingresa tu nombre completo",
|
||||||
|
"firstName": "Nombre",
|
||||||
|
"firstNamePlaceholder": "Ingresa tu nombre",
|
||||||
|
"lastName": "Apellido",
|
||||||
|
"lastNamePlaceholder": "Ingresa tu apellido",
|
||||||
|
"email": "Correo Electrónico",
|
||||||
|
"emailPlaceholder": "tu@email.com",
|
||||||
|
"phone": "Teléfono / WhatsApp",
|
||||||
|
"phonePlaceholder": "+595 XXX XXX XXX",
|
||||||
|
"preferredLanguage": "Idioma Preferido",
|
||||||
|
"paymentMethod": "Método de Pago",
|
||||||
|
"ruc": "RUC",
|
||||||
|
"rucPlaceholder": "Ej: 12345678-9",
|
||||||
|
"rucOptional": "Opcional - para facturación",
|
||||||
|
"reserveSpot": "Reservar Mi Lugar",
|
||||||
|
"proceedPayment": "Proceder al Pago",
|
||||||
|
"termsNote": "Al reservar, aceptas nuestros términos y condiciones.",
|
||||||
|
"soldOutMessage": "Este evento está lleno. Vuelve más tarde o explora otros eventos.",
|
||||||
|
"errors": {
|
||||||
|
"nameRequired": "Por favor ingresa tu nombre completo",
|
||||||
|
"firstNameRequired": "Por favor ingresa tu nombre",
|
||||||
|
"lastNameRequired": "Por favor ingresa tu apellido",
|
||||||
|
"emailInvalid": "Por favor ingresa un correo electrónico válido",
|
||||||
|
"phoneRequired": "El número de teléfono es requerido",
|
||||||
|
"bookingFailed": "La reserva falló. Por favor intenta de nuevo.",
|
||||||
|
"rucInvalidFormat": "Formato inválido. Ej: 12345678-9",
|
||||||
|
"rucInvalidCheckDigit": "RUC inválido. Verifique el número."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"title": "Resumen de Reserva",
|
||||||
|
"event": "Evento",
|
||||||
|
"date": "Fecha",
|
||||||
|
"price": "Precio",
|
||||||
|
"total": "Total"
|
||||||
|
},
|
||||||
|
"confirm": "Confirmar Reserva",
|
||||||
|
"success": {
|
||||||
|
"title": "¡Reserva Confirmada!",
|
||||||
|
"message": "¡Tu lugar ha sido reservado exitosamente!",
|
||||||
|
"description": "Hemos enviado una confirmación a tu correo.",
|
||||||
|
"event": "Evento",
|
||||||
|
"date": "Fecha",
|
||||||
|
"time": "Hora",
|
||||||
|
"location": "Ubicación",
|
||||||
|
"ticketId": "ID del Ticket",
|
||||||
|
"instructions": "Por favor guarda este ID de ticket. Lo necesitarás en el check-in.",
|
||||||
|
"cashNote": "Pago Requerido",
|
||||||
|
"cashDescription": "Por favor trae el monto exacto en efectivo para pagar en la entrada del evento.",
|
||||||
|
"cardNote": "Serás redirigido para completar tu pago con tarjeta en breve.",
|
||||||
|
"lightningNote": "Se generará una factura Lightning para el pago.",
|
||||||
|
"emailSent": "Un correo de confirmación ha sido enviado a tu bandeja de entrada.",
|
||||||
|
"browseEvents": "Ver Más Eventos",
|
||||||
|
"backHome": "Volver al Inicio"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"community": {
|
||||||
|
"title": "Únete a Nuestra Comunidad",
|
||||||
|
"subtitle": "Conéctate con nosotros en redes sociales",
|
||||||
|
"whatsapp": {
|
||||||
|
"title": "Grupo de WhatsApp",
|
||||||
|
"description": "Únete a nuestro grupo de WhatsApp para actualizaciones y chat comunitario",
|
||||||
|
"button": "Unirse a WhatsApp"
|
||||||
|
},
|
||||||
|
"instagram": {
|
||||||
|
"title": "Instagram",
|
||||||
|
"description": "Síguenos para fotos, historias y anuncios",
|
||||||
|
"button": "Seguirnos"
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"title": "Canal de Telegram",
|
||||||
|
"description": "Únete a nuestro canal de Telegram para noticias y anuncios",
|
||||||
|
"button": "Unirse a Telegram"
|
||||||
|
},
|
||||||
|
"guidelines": {
|
||||||
|
"title": "Reglas de la Comunidad",
|
||||||
|
"items": [
|
||||||
|
"Sé respetuoso con todos los participantes",
|
||||||
|
"Ayuda a otros a practicar - todos estamos aprendiendo",
|
||||||
|
"Habla en el idioma que estás practicando",
|
||||||
|
"Diviértete y abierto a hacer nuevos amigos"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"volunteer": {
|
||||||
|
"title": "Conviértete en Voluntario",
|
||||||
|
"description": "Ayúdanos a organizar eventos y hacer crecer la comunidad",
|
||||||
|
"button": "Contáctanos"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "Contáctanos",
|
||||||
|
"subtitle": "¿Tienes preguntas? Nos encantaría saber de ti.",
|
||||||
|
"form": {
|
||||||
|
"name": "Tu Nombre",
|
||||||
|
"email": "Tu Email",
|
||||||
|
"message": "Tu Mensaje",
|
||||||
|
"submit": "Enviar Mensaje"
|
||||||
|
},
|
||||||
|
"success": "¡Mensaje enviado exitosamente!",
|
||||||
|
"error": "Error al enviar el mensaje. Por favor intenta de nuevo.",
|
||||||
|
"info": {
|
||||||
|
"email": "Email",
|
||||||
|
"social": "Redes Sociales"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": {
|
||||||
|
"title": "Bienvenido de Nuevo",
|
||||||
|
"subtitle": "Inicia sesión en tu cuenta",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Contraseña",
|
||||||
|
"submit": "Iniciar Sesión",
|
||||||
|
"noAccount": "¿No tienes cuenta?",
|
||||||
|
"register": "Registrarse"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Crear Cuenta",
|
||||||
|
"subtitle": "Únete a la comunidad Spanglish",
|
||||||
|
"name": "Nombre Completo",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Contraseña (mín. 8 caracteres)",
|
||||||
|
"phone": "Teléfono (opcional)",
|
||||||
|
"submit": "Crear Cuenta",
|
||||||
|
"hasAccount": "¿Ya tienes cuenta?",
|
||||||
|
"login": "Iniciar Sesión"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"invalidCredentials": "Email o contraseña inválidos",
|
||||||
|
"emailExists": "El email ya está registrado"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Panel de Control",
|
||||||
|
"welcome": "Bienvenido de nuevo",
|
||||||
|
"stats": {
|
||||||
|
"users": "Usuarios Totales",
|
||||||
|
"events": "Eventos Totales",
|
||||||
|
"tickets": "Tickets Totales",
|
||||||
|
"revenue": "Ingresos Totales"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Panel",
|
||||||
|
"events": "Eventos",
|
||||||
|
"bookings": "Reservas",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"users": "Usuarios",
|
||||||
|
"payments": "Pagos",
|
||||||
|
"contacts": "Mensajes",
|
||||||
|
"emails": "Emails",
|
||||||
|
"gallery": "Galería",
|
||||||
|
"settings": "Configuración"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "Gestionar Eventos",
|
||||||
|
"create": "Crear Evento",
|
||||||
|
"edit": "Editar Evento",
|
||||||
|
"delete": "Eliminar Evento",
|
||||||
|
"publish": "Publicar",
|
||||||
|
"unpublish": "Despublicar"
|
||||||
|
},
|
||||||
|
"tickets": {
|
||||||
|
"title": "Gestionar Tickets",
|
||||||
|
"checkin": "Check In",
|
||||||
|
"cancel": "Cancelar Ticket",
|
||||||
|
"status": {
|
||||||
|
"pending": "Pendiente",
|
||||||
|
"confirmed": "Confirmado",
|
||||||
|
"cancelled": "Cancelado",
|
||||||
|
"checkedIn": "Check In Realizado"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "Gestionar Usuarios",
|
||||||
|
"role": "Rol",
|
||||||
|
"roles": {
|
||||||
|
"admin": "Administrador",
|
||||||
|
"organizer": "Organizador",
|
||||||
|
"staff": "Staff",
|
||||||
|
"marketing": "Marketing",
|
||||||
|
"user": "Usuario"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"payments": {
|
||||||
|
"title": "Pagos",
|
||||||
|
"confirm": "Confirmar Pago",
|
||||||
|
"refund": "Reembolsar",
|
||||||
|
"status": {
|
||||||
|
"pending": "Pendiente",
|
||||||
|
"paid": "Pagado",
|
||||||
|
"refunded": "Reembolsado",
|
||||||
|
"failed": "Fallido"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"tagline": "Comunidad de intercambio de idiomas en Asunción",
|
||||||
|
"links": "Enlaces Rápidos",
|
||||||
|
"social": "Síguenos",
|
||||||
|
"copyright": "© {year} Spanglish. Todos los derechos reservados.",
|
||||||
|
"legal": {
|
||||||
|
"title": "Legal",
|
||||||
|
"terms": "Términos y Condiciones",
|
||||||
|
"privacy": "Política de Privacidad",
|
||||||
|
"refund": "Política de Reembolso"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linktree": {
|
||||||
|
"tagline": "Comunidad de Intercambio de Idiomas",
|
||||||
|
"nextEvent": "Próximo Evento",
|
||||||
|
"noEvents": "No hay eventos próximos",
|
||||||
|
"bookNow": "Reservar Ahora",
|
||||||
|
"joinCommunity": "Únete a Nuestra Comunidad",
|
||||||
|
"visitWebsite": "Visitar Nuestro Sitio",
|
||||||
|
"whatsapp": {
|
||||||
|
"title": "Comunidad WhatsApp",
|
||||||
|
"subtitle": "Chat y novedades"
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"title": "Canal de Telegram",
|
||||||
|
"subtitle": "Noticias y anuncios"
|
||||||
|
},
|
||||||
|
"instagram": {
|
||||||
|
"title": "Instagram",
|
||||||
|
"subtitle": "Fotos e historias"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
882
frontend/src/lib/api.ts
Normal file
882
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,882 @@
|
|||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchApi<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const token = typeof window !== 'undefined'
|
||||||
|
? localStorage.getItem('spanglish-token')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json().catch(() => ({ error: 'Request failed' }));
|
||||||
|
const errorMessage = typeof errorData.error === 'string'
|
||||||
|
? errorData.error
|
||||||
|
: (errorData.message || JSON.stringify(errorData) || 'Request failed');
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events API
|
||||||
|
export const eventsApi = {
|
||||||
|
getAll: (params?: { status?: string; upcoming?: boolean }) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.status) query.set('status', params.status);
|
||||||
|
if (params?.upcoming) query.set('upcoming', 'true');
|
||||||
|
return fetchApi<{ events: Event[] }>(`/api/events?${query}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: (id: string) => fetchApi<{ event: Event }>(`/api/events/${id}`),
|
||||||
|
|
||||||
|
getNextUpcoming: () => fetchApi<{ event: Event | null }>('/api/events/next/upcoming'),
|
||||||
|
|
||||||
|
create: (data: Partial<Event>) =>
|
||||||
|
fetchApi<{ event: Event }>('/api/events', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<Event>) =>
|
||||||
|
fetchApi<{ event: Event }>(`/api/events/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
fetchApi<{ message: string }>(`/api/events/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
duplicate: (id: string) =>
|
||||||
|
fetchApi<{ event: Event; message: string }>(`/api/events/${id}/duplicate`, { method: 'POST' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tickets API
|
||||||
|
export const ticketsApi = {
|
||||||
|
book: (data: BookingData) =>
|
||||||
|
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getById: (id: string) => fetchApi<{ ticket: Ticket }>(`/api/tickets/${id}`),
|
||||||
|
|
||||||
|
getAll: (params?: { eventId?: string; status?: string }) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.eventId) query.set('eventId', params.eventId);
|
||||||
|
if (params?.status) query.set('status', params.status);
|
||||||
|
return fetchApi<{ tickets: Ticket[] }>(`/api/tickets?${query}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
checkin: (id: string) =>
|
||||||
|
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/checkin`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeCheckin: (id: string) =>
|
||||||
|
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/remove-checkin`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
|
||||||
|
cancel: (id: string) =>
|
||||||
|
fetchApi<{ message: string }>(`/api/tickets/${id}/cancel`, { method: 'POST' }),
|
||||||
|
|
||||||
|
updateStatus: (id: string, status: string) =>
|
||||||
|
fetchApi<{ ticket: Ticket }>(`/api/tickets/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateNote: (id: string, note: string) =>
|
||||||
|
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/note`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ note }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
markPaid: (id: string) =>
|
||||||
|
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/mark-paid`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// For manual payment methods (bank_transfer, tpago) - user marks payment as sent
|
||||||
|
markPaymentSent: (id: string) =>
|
||||||
|
fetchApi<{ payment: Payment; message: string }>(`/api/tickets/${id}/mark-payment-sent`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
|
||||||
|
adminCreate: (data: {
|
||||||
|
eventId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
preferredLanguage?: 'en' | 'es';
|
||||||
|
autoCheckin?: boolean;
|
||||||
|
adminNote?: string;
|
||||||
|
}) =>
|
||||||
|
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
checkPaymentStatus: (ticketId: string) =>
|
||||||
|
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
|
||||||
|
`/api/lnbits/status/${ticketId}`
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Contacts API
|
||||||
|
export const contactsApi = {
|
||||||
|
submit: (data: { name: string; email: string; message: string }) =>
|
||||||
|
fetchApi<{ message: string }>('/api/contacts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
subscribe: (email: string, name?: string) =>
|
||||||
|
fetchApi<{ message: string }>('/api/contacts/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, name }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getAll: (status?: string) => {
|
||||||
|
const query = status ? `?status=${status}` : '';
|
||||||
|
return fetchApi<{ contacts: Contact[] }>(`/api/contacts${query}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateStatus: (id: string, status: string) =>
|
||||||
|
fetchApi<{ contact: Contact }>(`/api/contacts/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Users API
|
||||||
|
export const usersApi = {
|
||||||
|
getAll: (role?: string) => {
|
||||||
|
const query = role ? `?role=${role}` : '';
|
||||||
|
return fetchApi<{ users: User[] }>(`/api/users${query}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: (id: string) => fetchApi<{ user: User }>(`/api/users/${id}`),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<User>) =>
|
||||||
|
fetchApi<{ user: User }>(`/api/users/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
fetchApi<{ message: string }>(`/api/users/${id}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Payments API
|
||||||
|
export const paymentsApi = {
|
||||||
|
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean }) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.status) query.set('status', params.status);
|
||||||
|
if (params?.provider) query.set('provider', params.provider);
|
||||||
|
if (params?.pendingApproval) query.set('pendingApproval', 'true');
|
||||||
|
return fetchApi<{ payments: PaymentWithDetails[] }>(`/api/payments?${query}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getPendingApproval: () =>
|
||||||
|
fetchApi<{ payments: PaymentWithDetails[] }>('/api/payments/pending-approval'),
|
||||||
|
|
||||||
|
update: (id: string, data: { status: string; reference?: string; adminNote?: string }) =>
|
||||||
|
fetchApi<{ payment: Payment }>(`/api/payments/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
approve: (id: string, adminNote?: string) =>
|
||||||
|
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ adminNote }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
reject: (id: string, adminNote?: string) =>
|
||||||
|
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ adminNote }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateNote: (id: string, adminNote: string) =>
|
||||||
|
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/note`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ adminNote }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
refund: (id: string) =>
|
||||||
|
fetchApi<{ message: string }>(`/api/payments/${id}/refund`, { method: 'POST' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Payment Options API
|
||||||
|
export const paymentOptionsApi = {
|
||||||
|
// Global payment options
|
||||||
|
getGlobal: () =>
|
||||||
|
fetchApi<{ paymentOptions: PaymentOptionsConfig }>('/api/payment-options'),
|
||||||
|
|
||||||
|
updateGlobal: (data: Partial<PaymentOptionsConfig>) =>
|
||||||
|
fetchApi<{ paymentOptions: PaymentOptionsConfig; message: string }>('/api/payment-options', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Event-specific options (merged with global)
|
||||||
|
getForEvent: (eventId: string) =>
|
||||||
|
fetchApi<{ paymentOptions: PaymentOptionsConfig; hasOverrides: boolean }>(
|
||||||
|
`/api/payment-options/event/${eventId}`
|
||||||
|
),
|
||||||
|
|
||||||
|
// Event overrides (admin only)
|
||||||
|
getEventOverrides: (eventId: string) =>
|
||||||
|
fetchApi<{ overrides: Partial<PaymentOptionsConfig> | null }>(
|
||||||
|
`/api/payment-options/event/${eventId}/overrides`
|
||||||
|
),
|
||||||
|
|
||||||
|
updateEventOverrides: (eventId: string, data: Partial<PaymentOptionsConfig>) =>
|
||||||
|
fetchApi<{ overrides: Partial<PaymentOptionsConfig>; message: string }>(
|
||||||
|
`/api/payment-options/event/${eventId}/overrides`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
deleteEventOverrides: (eventId: string) =>
|
||||||
|
fetchApi<{ message: string }>(`/api/payment-options/event/${eventId}/overrides`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Media API
|
||||||
|
export const mediaApi = {
|
||||||
|
upload: async (file: File, relatedId?: string, relatedType?: string) => {
|
||||||
|
const token = typeof window !== 'undefined'
|
||||||
|
? localStorage.getItem('spanglish-token')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (relatedId) formData.append('relatedId', relatedId);
|
||||||
|
if (relatedType) formData.append('relatedType', relatedType);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/media/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json().catch(() => ({ error: 'Upload failed' }));
|
||||||
|
throw new Error(errorData.error || 'Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<{ media: Media; url: string }>;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
fetchApi<{ message: string }>(`/api/media/${id}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin API
|
||||||
|
export const adminApi = {
|
||||||
|
getDashboard: () => fetchApi<{ dashboard: DashboardData }>('/api/admin/dashboard'),
|
||||||
|
getAnalytics: () => fetchApi<{ analytics: AnalyticsData }>('/api/admin/analytics'),
|
||||||
|
exportTickets: (eventId?: string) => {
|
||||||
|
const query = eventId ? `?eventId=${eventId}` : '';
|
||||||
|
return fetchApi<{ tickets: ExportedTicket[] }>(`/api/admin/export/tickets${query}`);
|
||||||
|
},
|
||||||
|
exportFinancial: (params?: { startDate?: string; endDate?: string; eventId?: string }) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.startDate) query.set('startDate', params.startDate);
|
||||||
|
if (params?.endDate) query.set('endDate', params.endDate);
|
||||||
|
if (params?.eventId) query.set('eventId', params.eventId);
|
||||||
|
return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emails API
|
||||||
|
export const emailsApi = {
|
||||||
|
// Templates
|
||||||
|
getTemplates: () => fetchApi<{ templates: EmailTemplate[] }>('/api/emails/templates'),
|
||||||
|
|
||||||
|
getTemplate: (id: string) => fetchApi<{ template: EmailTemplate }>(`/api/emails/templates/${id}`),
|
||||||
|
|
||||||
|
createTemplate: (data: Partial<EmailTemplate>) =>
|
||||||
|
fetchApi<{ template: EmailTemplate; message: string }>('/api/emails/templates', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateTemplate: (id: string, data: Partial<EmailTemplate>) =>
|
||||||
|
fetchApi<{ template: EmailTemplate; message: string }>(`/api/emails/templates/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteTemplate: (id: string) =>
|
||||||
|
fetchApi<{ message: string }>(`/api/emails/templates/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
getTemplateVariables: (slug: string) =>
|
||||||
|
fetchApi<{ variables: EmailVariable[] }>(`/api/emails/templates/${slug}/variables`),
|
||||||
|
|
||||||
|
// Sending
|
||||||
|
sendToEvent: (eventId: string, data: {
|
||||||
|
templateSlug: string;
|
||||||
|
customVariables?: Record<string, any>;
|
||||||
|
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||||
|
}) =>
|
||||||
|
fetchApi<{ success: boolean; sentCount: number; failedCount: number; errors: string[] }>(
|
||||||
|
`/api/emails/send/event/${eventId}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
sendCustom: (data: {
|
||||||
|
to: string;
|
||||||
|
toName?: string;
|
||||||
|
subject: string;
|
||||||
|
bodyHtml: string;
|
||||||
|
bodyText?: string;
|
||||||
|
eventId?: string;
|
||||||
|
}) =>
|
||||||
|
fetchApi<{ success: boolean; logId?: string; error?: string }>('/api/emails/send/custom', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
preview: (data: {
|
||||||
|
templateSlug: string;
|
||||||
|
variables?: Record<string, any>;
|
||||||
|
locale?: string;
|
||||||
|
}) =>
|
||||||
|
fetchApi<{ subject: string; bodyHtml: string }>('/api/emails/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
getLogs: (params?: { eventId?: string; status?: string; limit?: number; offset?: number }) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.eventId) query.set('eventId', params.eventId);
|
||||||
|
if (params?.status) query.set('status', params.status);
|
||||||
|
if (params?.limit) query.set('limit', params.limit.toString());
|
||||||
|
if (params?.offset) query.set('offset', params.offset.toString());
|
||||||
|
return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`),
|
||||||
|
|
||||||
|
getStats: (eventId?: string) => {
|
||||||
|
const query = eventId ? `?eventId=${eventId}` : '';
|
||||||
|
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
seedTemplates: () =>
|
||||||
|
fetchApi<{ message: string }>('/api/emails/seed-templates', { method: 'POST' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface Event {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
titleEs?: string;
|
||||||
|
description: string;
|
||||||
|
descriptionEs?: string;
|
||||||
|
startDatetime: string;
|
||||||
|
endDatetime?: string;
|
||||||
|
location: string;
|
||||||
|
locationUrl?: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
capacity: number;
|
||||||
|
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
|
||||||
|
bannerUrl?: string;
|
||||||
|
bookedCount?: number;
|
||||||
|
availableSeats?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ticket {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
eventId: string;
|
||||||
|
attendeeFirstName: string;
|
||||||
|
attendeeLastName?: string;
|
||||||
|
attendeeEmail?: string;
|
||||||
|
attendeePhone?: string;
|
||||||
|
preferredLanguage?: string;
|
||||||
|
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in';
|
||||||
|
checkinAt?: string;
|
||||||
|
qrCode: string;
|
||||||
|
adminNote?: string;
|
||||||
|
createdAt: string;
|
||||||
|
event?: Event;
|
||||||
|
payment?: Payment;
|
||||||
|
user?: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Payment {
|
||||||
|
id: string;
|
||||||
|
ticketId: string;
|
||||||
|
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed';
|
||||||
|
reference?: string;
|
||||||
|
userMarkedPaidAt?: string;
|
||||||
|
paidAt?: string;
|
||||||
|
paidByAdminId?: string;
|
||||||
|
adminNote?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentWithDetails extends Payment {
|
||||||
|
ticket: {
|
||||||
|
id: string;
|
||||||
|
attendeeFirstName: string;
|
||||||
|
attendeeLastName?: string;
|
||||||
|
attendeeEmail?: string;
|
||||||
|
attendeePhone?: string;
|
||||||
|
status: string;
|
||||||
|
} | null;
|
||||||
|
event: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startDatetime: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentOptionsConfig {
|
||||||
|
tpagoEnabled: boolean;
|
||||||
|
tpagoLink?: string | null;
|
||||||
|
tpagoInstructions?: string | null;
|
||||||
|
tpagoInstructionsEs?: string | null;
|
||||||
|
bankTransferEnabled: boolean;
|
||||||
|
bankName?: string | null;
|
||||||
|
bankAccountHolder?: string | null;
|
||||||
|
bankAccountNumber?: string | null;
|
||||||
|
bankAlias?: string | null;
|
||||||
|
bankPhone?: string | null;
|
||||||
|
bankNotes?: string | null;
|
||||||
|
bankNotesEs?: string | null;
|
||||||
|
lightningEnabled: boolean;
|
||||||
|
cashEnabled: boolean;
|
||||||
|
cashInstructions?: string | null;
|
||||||
|
cashInstructionsEs?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
role: 'admin' | 'organizer' | 'staff' | 'marketing' | 'user';
|
||||||
|
languagePreference?: string;
|
||||||
|
isClaimed?: boolean;
|
||||||
|
rucNumber?: string;
|
||||||
|
accountStatus?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Contact {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
message: string;
|
||||||
|
status: 'new' | 'read' | 'replied';
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingData {
|
||||||
|
eventId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
preferredLanguage?: 'en' | 'es';
|
||||||
|
paymentMethod: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
|
||||||
|
ruc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
stats: {
|
||||||
|
totalUsers: number;
|
||||||
|
totalEvents: number;
|
||||||
|
totalTickets: number;
|
||||||
|
confirmedTickets: number;
|
||||||
|
pendingPayments: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
newContacts: number;
|
||||||
|
totalSubscribers: number;
|
||||||
|
};
|
||||||
|
upcomingEvents: Event[];
|
||||||
|
recentTickets: Ticket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsData {
|
||||||
|
events: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
capacity: number;
|
||||||
|
totalBookings: number;
|
||||||
|
confirmedBookings: number;
|
||||||
|
checkedIn: number;
|
||||||
|
revenue: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Media {
|
||||||
|
id: string;
|
||||||
|
fileUrl: string;
|
||||||
|
type: 'image' | 'video' | 'document';
|
||||||
|
relatedId?: string;
|
||||||
|
relatedType?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportedTicket {
|
||||||
|
ticketId: string;
|
||||||
|
ticketStatus: string;
|
||||||
|
qrCode: string;
|
||||||
|
checkinAt?: string;
|
||||||
|
userName: string;
|
||||||
|
userEmail: string;
|
||||||
|
userPhone?: string;
|
||||||
|
eventTitle: string;
|
||||||
|
eventDate: string;
|
||||||
|
paymentStatus: string;
|
||||||
|
paymentAmount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
subject: string;
|
||||||
|
subjectEs?: string;
|
||||||
|
bodyHtml: string;
|
||||||
|
bodyHtmlEs?: string;
|
||||||
|
bodyText?: string;
|
||||||
|
bodyTextEs?: string;
|
||||||
|
description?: string;
|
||||||
|
variables: EmailVariable[];
|
||||||
|
isSystem: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailVariable {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
example: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailLog {
|
||||||
|
id: string;
|
||||||
|
templateId?: string;
|
||||||
|
eventId?: string;
|
||||||
|
recipientEmail: string;
|
||||||
|
recipientName?: string;
|
||||||
|
subject: string;
|
||||||
|
bodyHtml?: string;
|
||||||
|
status: 'pending' | 'sent' | 'failed' | 'bounced';
|
||||||
|
errorMessage?: string;
|
||||||
|
sentAt?: string;
|
||||||
|
sentBy?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailStats {
|
||||||
|
total: number;
|
||||||
|
sent: number;
|
||||||
|
failed: number;
|
||||||
|
pending: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pagination {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportedPayment {
|
||||||
|
paymentId: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
provider: string;
|
||||||
|
status: string;
|
||||||
|
reference?: string;
|
||||||
|
paidAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
ticketId: string;
|
||||||
|
attendeeFirstName: string;
|
||||||
|
attendeeLastName?: string;
|
||||||
|
attendeeEmail?: string;
|
||||||
|
eventId: string;
|
||||||
|
eventTitle: string;
|
||||||
|
eventDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinancialSummary {
|
||||||
|
totalPayments: number;
|
||||||
|
totalPaid: number;
|
||||||
|
totalPending: number;
|
||||||
|
totalRefunded: number;
|
||||||
|
byProvider: {
|
||||||
|
bancard: number;
|
||||||
|
lightning: number;
|
||||||
|
cash: number;
|
||||||
|
bank_transfer: number;
|
||||||
|
tpago: number;
|
||||||
|
};
|
||||||
|
paidCount: number;
|
||||||
|
pendingCount: number;
|
||||||
|
pendingApprovalCount: number;
|
||||||
|
refundedCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== User Dashboard Types ====================
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
languagePreference?: string;
|
||||||
|
rucNumber?: string;
|
||||||
|
isClaimed: boolean;
|
||||||
|
accountStatus: string;
|
||||||
|
hasPassword: boolean;
|
||||||
|
hasGoogleLinked: boolean;
|
||||||
|
memberSince: string;
|
||||||
|
membershipDays: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserTicket extends Ticket {
|
||||||
|
invoice?: {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
pdfUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPayment extends Payment {
|
||||||
|
ticket: {
|
||||||
|
id: string;
|
||||||
|
attendeeFirstName: string;
|
||||||
|
attendeeLastName?: string;
|
||||||
|
status: string;
|
||||||
|
} | null;
|
||||||
|
event: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
titleEs?: string;
|
||||||
|
startDatetime: string;
|
||||||
|
} | null;
|
||||||
|
invoice?: {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
pdfUrl?: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInvoice {
|
||||||
|
id: string;
|
||||||
|
paymentId: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
rucNumber?: string;
|
||||||
|
legalName?: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
pdfUrl?: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
event?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
titleEs?: string;
|
||||||
|
startDatetime: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSession {
|
||||||
|
id: string;
|
||||||
|
userAgent?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
lastActiveAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSummary {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
accountStatus: string;
|
||||||
|
memberSince: string;
|
||||||
|
membershipDays: number;
|
||||||
|
};
|
||||||
|
stats: {
|
||||||
|
totalTickets: number;
|
||||||
|
confirmedTickets: number;
|
||||||
|
upcomingEvents: number;
|
||||||
|
pendingPayments: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NextEventInfo {
|
||||||
|
event: Event;
|
||||||
|
ticket: Ticket;
|
||||||
|
payment: Payment | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Auth API (new methods) ====================
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
// Magic link
|
||||||
|
requestMagicLink: (email: string) =>
|
||||||
|
fetchApi<{ message: string }>('/api/auth/magic-link/request', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
verifyMagicLink: (token: string) =>
|
||||||
|
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/magic-link/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Password reset
|
||||||
|
requestPasswordReset: (email: string) =>
|
||||||
|
fetchApi<{ message: string }>('/api/auth/password-reset/request', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
confirmPasswordReset: (token: string, password: string) =>
|
||||||
|
fetchApi<{ message: string }>('/api/auth/password-reset/confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ token, password }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Account claiming
|
||||||
|
requestClaimAccount: (email: string) =>
|
||||||
|
fetchApi<{ message: string }>('/api/auth/claim-account/request', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
confirmClaimAccount: (token: string, data: { password?: string; googleId?: string }) =>
|
||||||
|
fetchApi<{ user: User; token: string; refreshToken: string; message: string }>(
|
||||||
|
'/api/auth/claim-account/confirm',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ token, ...data }),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
// Google OAuth
|
||||||
|
googleAuth: (credential: string) =>
|
||||||
|
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/google', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ credential }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Change password
|
||||||
|
changePassword: (currentPassword: string, newPassword: string) =>
|
||||||
|
fetchApi<{ message: string }>('/api/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
me: () => fetchApi<{ user: User }>('/api/auth/me'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== User Dashboard API ====================
|
||||||
|
|
||||||
|
export const dashboardApi = {
|
||||||
|
// Summary
|
||||||
|
getSummary: () =>
|
||||||
|
fetchApi<{ summary: DashboardSummary }>('/api/dashboard/summary'),
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
getProfile: () =>
|
||||||
|
fetchApi<{ profile: UserProfile }>('/api/dashboard/profile'),
|
||||||
|
|
||||||
|
updateProfile: (data: { name?: string; phone?: string; languagePreference?: string; rucNumber?: string }) =>
|
||||||
|
fetchApi<{ profile: UserProfile; message: string }>('/api/dashboard/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Tickets
|
||||||
|
getTickets: () =>
|
||||||
|
fetchApi<{ tickets: UserTicket[] }>('/api/dashboard/tickets'),
|
||||||
|
|
||||||
|
getTicket: (id: string) =>
|
||||||
|
fetchApi<{ ticket: UserTicket }>(`/api/dashboard/tickets/${id}`),
|
||||||
|
|
||||||
|
// Next event
|
||||||
|
getNextEvent: () =>
|
||||||
|
fetchApi<{ nextEvent: NextEventInfo | null }>('/api/dashboard/next-event'),
|
||||||
|
|
||||||
|
// Payments
|
||||||
|
getPayments: () =>
|
||||||
|
fetchApi<{ payments: UserPayment[] }>('/api/dashboard/payments'),
|
||||||
|
|
||||||
|
// Invoices
|
||||||
|
getInvoices: () =>
|
||||||
|
fetchApi<{ invoices: UserInvoice[] }>('/api/dashboard/invoices'),
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
getSessions: () =>
|
||||||
|
fetchApi<{ sessions: UserSession[] }>('/api/dashboard/sessions'),
|
||||||
|
|
||||||
|
revokeSession: (id: string) =>
|
||||||
|
fetchApi<{ message: string }>(`/api/dashboard/sessions/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
revokeAllSessions: () =>
|
||||||
|
fetchApi<{ message: string }>('/api/dashboard/sessions/revoke-all', { method: 'POST' }),
|
||||||
|
|
||||||
|
// Security
|
||||||
|
setPassword: (password: string) =>
|
||||||
|
fetchApi<{ message: string }>('/api/dashboard/set-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
unlinkGoogle: () =>
|
||||||
|
fetchApi<{ message: string }>('/api/dashboard/unlink-google', { method: 'POST' }),
|
||||||
|
};
|
||||||
98
frontend/src/lib/legal.ts
Normal file
98
frontend/src/lib/legal.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export interface LegalPage {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
lastUpdated?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegalPageMeta {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map file names to display titles
|
||||||
|
const titleMap: Record<string, { en: string; es: string }> = {
|
||||||
|
'privacy_policy': { en: 'Privacy Policy', es: 'Política de Privacidad' },
|
||||||
|
'terms_policy': { en: 'Terms & Conditions', es: 'Términos y Condiciones' },
|
||||||
|
'refund_cancelation_policy': { en: 'Refund & Cancellation Policy', es: 'Política de Reembolso y Cancelación' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert file name to URL-friendly slug
|
||||||
|
export function fileNameToSlug(fileName: string): string {
|
||||||
|
return fileName.replace('.md', '').replace(/_/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert slug back to file name
|
||||||
|
export function slugToFileName(slug: string): string {
|
||||||
|
return slug.replace(/-/g, '_') + '.md';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the legal directory path
|
||||||
|
function getLegalDir(): string {
|
||||||
|
return path.join(process.cwd(), 'legal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all legal page slugs for static generation
|
||||||
|
export function getAllLegalSlugs(): string[] {
|
||||||
|
const legalDir = getLegalDir();
|
||||||
|
|
||||||
|
if (!fs.existsSync(legalDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(legalDir);
|
||||||
|
return files
|
||||||
|
.filter(file => file.endsWith('.md'))
|
||||||
|
.map(file => fileNameToSlug(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get metadata for all legal pages (for navigation/footer)
|
||||||
|
export function getAllLegalPagesMeta(locale: string = 'en'): LegalPageMeta[] {
|
||||||
|
const legalDir = getLegalDir();
|
||||||
|
|
||||||
|
if (!fs.existsSync(legalDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(legalDir);
|
||||||
|
return files
|
||||||
|
.filter(file => file.endsWith('.md'))
|
||||||
|
.map(file => {
|
||||||
|
const slug = fileNameToSlug(file);
|
||||||
|
const baseFileName = file.replace('.md', '');
|
||||||
|
const titles = titleMap[baseFileName];
|
||||||
|
const title = titles ? titles[locale as 'en' | 'es'] || titles.en : baseFileName.replace(/_/g, ' ');
|
||||||
|
|
||||||
|
return { slug, title };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a specific legal page content
|
||||||
|
export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | null {
|
||||||
|
const legalDir = getLegalDir();
|
||||||
|
const fileName = slugToFileName(slug);
|
||||||
|
const filePath = path.join(legalDir, fileName);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const baseFileName = fileName.replace('.md', '');
|
||||||
|
const titles = titleMap[baseFileName];
|
||||||
|
const title = titles ? titles[locale as 'en' | 'es'] || titles.en : baseFileName.replace(/_/g, ' ');
|
||||||
|
|
||||||
|
// Try to extract last updated date from content
|
||||||
|
const lastUpdatedMatch = content.match(/Last updated:\s*(.+)/i);
|
||||||
|
const lastUpdated = lastUpdatedMatch ? lastUpdatedMatch[1].trim() : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
lastUpdated,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user