first commit

This commit is contained in:
Michaël
2026-01-29 14:13:11 -03:00
commit 2302748c87
105 changed files with 93301 additions and 0 deletions

56
.gitignore vendored Normal file
View 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
View 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
View 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
View 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: Lets 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
View 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
View 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: 4856px
H2: 3640px
H3: 2428px
Body: 1618px
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: 1014px
* Padding: 12px 24px
Secondary Button:
* Border: 1px solid #111111
* Background: Transparent
Hover:
* Slight darkening
* Soft shadow
---
### 9.2 Cards
* White background
* Rounded corners (1620px)
* 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
View 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

File diff suppressed because it is too large Load Diff

264
about/overview.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

248
backend/src/lib/auth.ts Normal file
View 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
View 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;

View 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>&copy; {{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
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

284
backend/src/routes/admin.ts Normal file
View 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
View 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;

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

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

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

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

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

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

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

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

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

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

View 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 users 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
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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} &lt;{selectedLog.recipientEmail}&gt;</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>
);
}

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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: '🇧🇷' };

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

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