mirror of
https://github.com/etiennecollin/unifi-voucher-manager.git
synced 2025-10-23 00:02:10 +00:00
Initial commit
fix: double api call to get voucher details style: removed ugly tab background
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
README.md
|
||||
.env
|
||||
compose.yaml
|
||||
assets
|
||||
|
||||
Dockerfile
|
||||
.git
|
||||
.gitignore
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
.DS_Store
|
111
Dockerfile
Normal file
111
Dockerfile
Normal file
@@ -0,0 +1,111 @@
|
||||
# ==============================================================================
|
||||
# Base images
|
||||
# ==============================================================================
|
||||
FROM node:24.3-alpine AS node-base
|
||||
FROM rust:1.88-alpine AS rust-base
|
||||
|
||||
# ==============================================================================
|
||||
# Backend dependencies
|
||||
# ==============================================================================
|
||||
FROM rust-base AS rust-deps
|
||||
|
||||
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./backend/Cargo.toml ./backend/Cargo.lock ./
|
||||
# Create dummy src to satisfy cargo
|
||||
RUN mkdir ./src && echo "fn main() {}" > ./src/main.rs
|
||||
|
||||
ENV OPENSSL_STATIC=1
|
||||
|
||||
# Build dependencies only (cache them)
|
||||
RUN cargo build --release && rm -rf ./src/
|
||||
|
||||
# ==============================================================================
|
||||
# Backend build
|
||||
# ==============================================================================
|
||||
FROM rust-deps AS rust-builder
|
||||
|
||||
# Now copy real source
|
||||
COPY ./backend/src ./src
|
||||
|
||||
# Build only our code (dependencies already built)
|
||||
RUN cargo build --release
|
||||
|
||||
# ==============================================================================
|
||||
# Frontend dependencies
|
||||
# ==============================================================================
|
||||
FROM node-base AS node-deps
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY ./frontend/package.json ./frontend/yarn.lock* ./frontend/package-lock.json* ./frontend/pnpm-lock.yaml* ./frontend/.npmrc* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# ==============================================================================
|
||||
# Frontend build
|
||||
# ==============================================================================
|
||||
FROM node-base AS node-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=node-deps /app/node_modules ./node_modules
|
||||
COPY ./frontend ./
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# ==============================================================================
|
||||
# Runner
|
||||
# ==============================================================================
|
||||
FROM alpine:3.22 AS runtime
|
||||
RUN apk add --no-cache ca-certificates wget nodejs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup -g 1001 -S appgroup && \
|
||||
adduser -S appuser -u 1001 -G appgroup
|
||||
|
||||
# Copy run wrapper script
|
||||
COPY ./scripts/run_wrapper.sh ./
|
||||
RUN chmod +x run_wrapper.sh
|
||||
|
||||
# Create healthcheck script
|
||||
COPY ./scripts/healthcheck.sh /usr/local/bin/healthcheck.sh
|
||||
RUN chmod +x /usr/local/bin/healthcheck.sh
|
||||
|
||||
# Copy backend
|
||||
COPY --from=rust-builder --chown=appuser:appgroup /app/target/release/backend ./backend
|
||||
|
||||
# Copy frontend
|
||||
COPY --from=node-builder --chown=appuser:appgroup /app/public* ./frontend/public
|
||||
COPY --from=node-builder --chown=appuser:appgroup /app/.next/standalone ./frontend
|
||||
COPY --from=node-builder --chown=appuser:appgroup /app/.next/static ./frontend/.next/static
|
||||
|
||||
USER appuser
|
||||
|
||||
ENV FRONTEND_BIND_HOST="0.0.0.0"
|
||||
ENV FRONTEND_BIND_PORT="3000"
|
||||
ENV FRONTEND_TO_BACKEND_URL="http://127.0.0.1"
|
||||
ENV BACKEND_BIND_HOST="127.0.0.1"
|
||||
ENV BACKEND_BIND_PORT="8080"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD /usr/local/bin/healthcheck.sh
|
||||
|
||||
CMD ["./run_wrapper.sh"]
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Etienne Collin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
201
README.md
Normal file
201
README.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# WiFi Voucher Manager
|
||||
|
||||
[]
|
||||
|
||||
A modern, touch-friendly web application for managing WiFi vouchers on UniFi controllers.
|
||||
Perfect for businesses, cafes, hotels, and home networks that need to provide guest WiFi access.
|
||||
|
||||

|
||||
|
||||
<!-- vim-markdown-toc GFM -->
|
||||
|
||||
- [✨ Features](#-features)
|
||||
- [🎫 Voucher Management](#-voucher-management)
|
||||
- [🎨 Modern Interface](#-modern-interface)
|
||||
- [🔧 Technical Features](#-technical-features)
|
||||
- [🏗 Architecture](#-architecture)
|
||||
- [🚀 Quick Start](#-quick-start)
|
||||
- [🐳 Using Docker Compose (Recommended)](#-using-docker-compose-recommended)
|
||||
- [⚙️ Without Docker](#-without-docker)
|
||||
- [⚙️ Configuration](#-configuration)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Getting UniFi API Credentials](#getting-unifi-api-credentials)
|
||||
- [🤝 Contributing](#-contributing)
|
||||
- [Development Guidelines](#development-guidelines)
|
||||
- [Code Style](#code-style)
|
||||
- [🐛 Troubleshooting](#-troubleshooting)
|
||||
- [Common Issues](#common-issues)
|
||||
- [Vouchers not appearing or connection issue to UniFi controller](#vouchers-not-appearing-or-connection-issue-to-unifi-controller)
|
||||
- [Application won't start](#application-wont-start)
|
||||
- [Getting Help](#getting-help)
|
||||
|
||||
<!-- vim-markdown-toc -->
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 🎫 Voucher Management
|
||||
|
||||
- **Quick Create** - Generate guest vouchers with preset durations (1 hour to 1 week)
|
||||
- **Custom Create** - Full control over voucher parameters:
|
||||
- Custom name
|
||||
- Duration (minutes to days)
|
||||
- Guest count limits
|
||||
- Data usage limits
|
||||
- Upload/download speed limits
|
||||
- **View All Vouchers** - Browse and search existing vouchers by name
|
||||
- **Search Vouchers** - Search vouchers by name
|
||||
- **Bulk Operations** - Select and delete multiple vouchers
|
||||
- **Auto-cleanup** - Remove expired vouchers with a single click
|
||||
|
||||
### 🎨 Modern Interface
|
||||
|
||||
- **Touch-Friendly** – Optimized for tablet, mobile, and desktop.
|
||||
- **Dark/Light Mode** – Follows system preference, with manual override.
|
||||
- **Responsive Design** - Works seamlessly across all screen sizes
|
||||
- **Smooth Animations** – Semantic transitions for polished UX.
|
||||
- **Real-time Notifications** - Instant feedback for all operations
|
||||
|
||||
### 🔧 Technical Features
|
||||
|
||||
- **Docker Ready** - Easy deployment with Docker Compose and included healthcheck
|
||||
- **UniFi Integration** - Direct API connection to UniFi controllers
|
||||
|
||||
#### 🏗 Architecture
|
||||
|
||||
This application is built with a clear separation of concerns:
|
||||
|
||||
1. **Frontend**: A Next.js 14 application (TypeScript + Tailwind CSS) that provides a responsive, touch-friendly UI.
|
||||
2. **Backend**: A Rust service powered by [Axum](https://github.com/tokio-rs/axum) that exposes a JSON API.
|
||||
3. **UniFi Controller**: The Axum backend securely communicates with your UniFi controller’s API, isolating API keys from the user-facing frontend.
|
||||
|
||||
```text
|
||||
{ DOCKER }
|
||||
[User Browser] <––– HTTP/HTTPS –––> [Next.js Frontend] <––– HTTP/HTTPS –––> [Axum Rust Backend] <––– HTTPS –––> [UniFi Controller]
|
||||
```
|
||||
|
||||
- The frontend only knows about the backend API endpoint.
|
||||
- All UniFi credentials and site IDs are stored on the backend.
|
||||
- This isolation limits the scope of user actions and protects sensitive API keys.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 🐳 Using Docker Compose (Recommended)
|
||||
|
||||
1. **Create the configuration files**
|
||||
|
||||
```bash
|
||||
# Download the compose file
|
||||
curl -o compose.yaml https://raw.githubusercontent.com/etiennecollin/unifi-voucher-manager/main/compose.yaml
|
||||
```
|
||||
|
||||
2. **Configure your environment**
|
||||
|
||||
Set the required environment variables (see [Environment Variables](#environment-variables)) in the `compose.yaml` file.
|
||||
|
||||
3. **Start the application**
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate
|
||||
```
|
||||
|
||||
4. **Access the interface**
|
||||
|
||||
Open your browser to `http://localhost:3000`.
|
||||
|
||||
### ⚙️ Without Docker
|
||||
|
||||
1. **Install the dependencies**
|
||||
|
||||
- `rust >= 1.88.0`
|
||||
- `nodejs >= 24.3.0`
|
||||
- `npm >= 11.4.2`
|
||||
|
||||
2. **Configure your environment**
|
||||
|
||||
In your shell, set the required environment variables (see [Environment Variables](#environment-variables))
|
||||
|
||||
3. **Start the frontend and backend**
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && cargo run -r
|
||||
|
||||
# Frontend (development)
|
||||
cd frontend && npm install && npm run dev
|
||||
|
||||
# Frontend (release)
|
||||
cd frontend && npm ci && npm run build && npm run start
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Type | Description | Example (default if optional) |
|
||||
| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
||||
| `UNIFI_CONTROLLER_URL` | Required | URL to your UniFi controller with protocol. | `https://unifi.example.com:8443` |
|
||||
| `UNIFI_API_KEY` | Required | API Key for your UniFi controller. | `abc123...` |
|
||||
| `UNIFI_SITE_ID` | Optional | Site ID of your UniFi controller. Using the value `default`, the backend will try to fetch the ID of the default site. | `default` (default) |
|
||||
| `FRONTEND_BIND_HOST` | Optional | Address on which the frontend server binds. | `0.0.0.0` (default) |
|
||||
| `FRONTEND_BIND_PORT` | Optional | Port on which the frontend server binds. | `3000` (default) |
|
||||
| `FRONTEND_TO_BACKEND_URL` | Optional | URL where the frontend will make its API requests to the backend. | `http://127.0.0.1` (default) |
|
||||
| `BACKEND_BIND_HOST` | Optional | Address on which the server binds. | `127.0.0.1` (default) |
|
||||
| `BACKEND_BIND_PORT` | Optional | Port on which the backend server binds. | `8080` (default) |
|
||||
| `TIMEZONE` | Optional | Server [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List). | `UTC` (default) |
|
||||
| `RUST_LOG` | Optional | Log level of the Rust backend. | `info`(default) |
|
||||
|
||||
### Getting UniFi API Credentials
|
||||
|
||||
1. **Access your UniFi Controller**
|
||||
2. **Navigate to Settings -> Control Plane -> Integration**
|
||||
3. **Create a new API key** by giving it a name and an expiration.
|
||||
4. **Find your Site ID** in the controller URL or on [unifi.ui.com](https://unifi.ui.com)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
### Development Guidelines
|
||||
|
||||
1. **Fork the repository**
|
||||
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
|
||||
3. **Make your changes** with proper TypeScript types
|
||||
4. **Update documentation** as needed
|
||||
5. **Commit changes**: `git commit -m 'feat: Add amazing feature'`
|
||||
6. **Push to branch**: `git push origin feature/amazing-feature`
|
||||
7. **Open a Pull Request**
|
||||
|
||||
### Code Style
|
||||
|
||||
- Use TypeScript for all new code
|
||||
- Follow the existing Tailwind CSS semantic utility patterns
|
||||
- Ensure components are touch-friendly and responsive
|
||||
- Add proper error handling and user feedback
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Vouchers not appearing or connection issue to UniFi controller
|
||||
|
||||
- Verify `UNIFI_CONTROLLER_URL` is correct and accessible
|
||||
- Verify `UNIFI_SITE_ID` matches your controller's site
|
||||
- Check if UniFi controller is running and reachable
|
||||
- Ensure API key is valid
|
||||
- Ensure the site has hotspot/guest portal enabled
|
||||
|
||||
#### Application won't start
|
||||
|
||||
- Check all environment variables are set
|
||||
- Verify Docker container has network access to UniFi controller
|
||||
- Check logs: `docker compose logs unifi-voucher-manager`
|
||||
|
||||
### Getting Help
|
||||
|
||||
- Check the [Issues](https://github.com/etiennecollin/unifi-voucher-manager/issues) page
|
||||
- Create a new issue with detailed information about your problem
|
||||
- Include relevant logs and environment details (redact sensitive information)
|
||||
|
||||
---
|
||||
|
||||
**⭐ If this project helped you, please consider giving it a star!**
|
BIN
assets/frontend.png
Normal file
BIN
assets/frontend.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 311 KiB |
2
backend/.cargo/config.toml
Normal file
2
backend/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[build]
|
||||
rustflags = ["-C", "target-cpu=native"]
|
5
backend/.dockerignore
Normal file
5
backend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
target
|
||||
README.md
|
||||
|
||||
.dockerignore
|
||||
.gitignore
|
1
backend/.gitignore
vendored
Normal file
1
backend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target
|
2193
backend/Cargo.lock
generated
Normal file
2193
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
backend/Cargo.toml
Normal file
25
backend/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "backend"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8.4"
|
||||
chrono = { version = "0.4.41" }
|
||||
chrono-tz = "0.10.4"
|
||||
reqwest = { version = "0.12.22", features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.141"
|
||||
tokio = { version = "1.47.0", features = ["full"] }
|
||||
tower = "0.5.2" # Remove??
|
||||
tower-http = { version = "0.6.6", features = ["cors"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
codegen-units = 1
|
||||
lto = "fat"
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
|
90
backend/src/handlers.rs
Normal file
90
backend/src/handlers.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use crate::{models::*, unifi_api::*};
|
||||
use axum::{extract::Query, http::StatusCode, response::Json};
|
||||
use tracing::{debug, error};
|
||||
|
||||
pub async fn get_vouchers_handler() -> Result<Json<GetVouchersResponse>, StatusCode> {
|
||||
debug!("Received request to get vouchers");
|
||||
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
|
||||
match client.get_all_vouchers().await {
|
||||
Ok(response) => Ok(Json(response)),
|
||||
Err(e) => {
|
||||
error!("Failed to get vouchers: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_newest_voucher_handler() -> Result<Json<Option<Voucher>>, StatusCode> {
|
||||
debug!("Received request to get newest voucher");
|
||||
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
|
||||
match client.get_newest_voucher().await {
|
||||
Ok(voucher) => Ok(Json(voucher)),
|
||||
Err(e) => {
|
||||
error!("Failed to get newest voucher: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_voucher_details_handler(
|
||||
Query(params): Query<DetailsRequest>,
|
||||
) -> Result<Json<Voucher>, StatusCode> {
|
||||
debug!("Received request to get voucher details");
|
||||
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
|
||||
match client.get_voucher_details(params.id).await {
|
||||
Ok(voucher) => Ok(Json(voucher)),
|
||||
Err(e) => {
|
||||
error!("Failed to get voucher details: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_voucher_handler(
|
||||
Json(request): Json<CreateVoucherRequest>,
|
||||
) -> Result<Json<CreateVoucherResponse>, StatusCode> {
|
||||
debug!("Received request to create voucher");
|
||||
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
|
||||
match client.create_voucher(request.clone()).await {
|
||||
Ok(response) => Ok(Json(response)),
|
||||
Err(e) => {
|
||||
error!("Failed to create voucher: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_selected_handler(
|
||||
Query(params): Query<DeleteRequest>,
|
||||
) -> Result<Json<DeleteResponse>, StatusCode> {
|
||||
debug!("Received request to delete selected vouchers");
|
||||
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
|
||||
let ids = params.ids.split(',').map(|s| s.to_string()).collect();
|
||||
match client.delete_vouchers_by_ids(ids).await {
|
||||
Ok(response) => Ok(Json(response)),
|
||||
Err(e) => {
|
||||
error!("Failed to delete selected vouchers: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_expired_handler() -> Result<Json<DeleteResponse>, StatusCode> {
|
||||
debug!("Received request to delete expired vouchers");
|
||||
let client = UNIFI_API.get().expect("UnifiAPI not initialized");
|
||||
match client.delete_expired_vouchers().await {
|
||||
Ok(response) => Ok(Json(response)),
|
||||
Err(e) => {
|
||||
error!("Failed to delete expired vouchers: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn health_check_handler() -> Result<Json<HealthCheckResponse>, StatusCode> {
|
||||
debug!("Received health check request");
|
||||
let response = HealthCheckResponse {
|
||||
status: "ok".to_string(),
|
||||
};
|
||||
Ok(Json(response))
|
||||
}
|
70
backend/src/lib.rs
Normal file
70
backend/src/lib.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use std::{env, sync::OnceLock};
|
||||
|
||||
use chrono_tz::Tz;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
pub mod unifi_api;
|
||||
|
||||
const DEFAULT_BACKEND_BIND_HOST: &str = "127.0.0.1";
|
||||
const DEFAULT_BACKEND_BIND_PORT: u16 = 8080;
|
||||
const DEFAULT_UNIFI_SITE_ID: &str = "default";
|
||||
|
||||
pub static ENVIRONMENT: OnceLock<Environment> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Environment {
|
||||
pub unifi_controller_url: String,
|
||||
pub unifi_site_id: String,
|
||||
pub unifi_api_key: String,
|
||||
pub backend_bind_host: String,
|
||||
pub backend_bind_port: u16,
|
||||
pub timezone: Tz,
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
pub fn try_new() -> Result<Self, String> {
|
||||
let unifi_controller_url: String =
|
||||
env::var("UNIFI_CONTROLLER_URL").map_err(|e| format!("UNIFI_CONTROLLER_URL: {e}"))?;
|
||||
let unifi_api_key: String =
|
||||
env::var("UNIFI_API_KEY").map_err(|e| format!("UNIFI_API_KEY: {e}"))?;
|
||||
let unifi_site_id: String =
|
||||
env::var("UNIFI_SITE_ID").unwrap_or(DEFAULT_UNIFI_SITE_ID.to_owned());
|
||||
|
||||
let backend_bind_host: String =
|
||||
env::var("BACKEND_BIND_HOST").unwrap_or(DEFAULT_BACKEND_BIND_HOST.to_owned());
|
||||
let backend_bind_port: u16 = match env::var("BACKEND_BIND_PORT") {
|
||||
Ok(port_str) => port_str
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid BACKEND_BIND_PORT: {e}"))?,
|
||||
Err(_) => DEFAULT_BACKEND_BIND_PORT,
|
||||
};
|
||||
|
||||
let timezone: Tz = match env::var("TIMEZONE") {
|
||||
Ok(s) => match s.parse() {
|
||||
Ok(tz) => {
|
||||
info!("Using timezone: {}", s);
|
||||
tz
|
||||
}
|
||||
Err(_) => {
|
||||
error!("Using UTC, could not parse timezone: {}", s);
|
||||
Tz::UTC
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
info!("TIMEZONE environment variable not set, defaulting to UTC");
|
||||
Tz::UTC
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
unifi_controller_url,
|
||||
unifi_site_id,
|
||||
unifi_api_key,
|
||||
backend_bind_host,
|
||||
backend_bind_port,
|
||||
timezone,
|
||||
})
|
||||
}
|
||||
}
|
70
backend/src/main.rs
Normal file
70
backend/src/main.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use axum::{
|
||||
Router,
|
||||
http::{self, Method},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use backend::{
|
||||
ENVIRONMENT, Environment,
|
||||
handlers::*,
|
||||
unifi_api::{UNIFI_API, UnifiAPI},
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing::{error, info, level_filters::LevelFilter};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy(), // use RUST_LOG env variable when running
|
||||
)
|
||||
.init();
|
||||
|
||||
let env = match Environment::try_new() {
|
||||
Ok(env) => env,
|
||||
Err(e) => {
|
||||
error!("Failed to load environment variables: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
ENVIRONMENT
|
||||
.set(env)
|
||||
.expect("Failed to set environment variables");
|
||||
|
||||
let unifi_api = match UnifiAPI::new().await {
|
||||
Ok(api) => api,
|
||||
Err(_) => {
|
||||
error!("Failed to initialize UnifiAPI wrapper");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
UNIFI_API.set(unifi_api).expect("Failed to set UnifiAPI");
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_headers([http::header::CONTENT_TYPE])
|
||||
.allow_methods([Method::POST, Method::GET, Method::DELETE])
|
||||
.allow_origin(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/health", get(health_check_handler))
|
||||
.route("/api/vouchers", get(get_vouchers_handler))
|
||||
.route("/api/vouchers", post(create_voucher_handler))
|
||||
.route("/api/vouchers/details", get(get_voucher_details_handler))
|
||||
.route("/api/vouchers/expired", delete(delete_expired_handler))
|
||||
.route("/api/vouchers/newest", get(get_newest_voucher_handler))
|
||||
.route("/api/vouchers/selected", delete(delete_selected_handler))
|
||||
.layer(cors);
|
||||
|
||||
let environment = ENVIRONMENT.get().expect("Environment not set");
|
||||
let bind_address = format!(
|
||||
"{}:{}",
|
||||
environment.backend_bind_host, environment.backend_bind_port
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap();
|
||||
info!("Server running on http://{}", bind_address);
|
||||
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
108
backend/src/models.rs
Normal file
108
backend/src/models.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Voucher {
|
||||
id: String,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: String,
|
||||
name: String,
|
||||
code: String,
|
||||
#[serde(rename = "authorizedGuestLimit")]
|
||||
authorized_guest_limit: Option<u64>,
|
||||
#[serde(rename = "authorizedGuestCount")]
|
||||
authorized_guest_count: u64,
|
||||
#[serde(rename = "activatedAt")]
|
||||
pub activated_at: Option<String>,
|
||||
#[serde(rename = "expiresAt")]
|
||||
pub expires_at: Option<String>,
|
||||
expired: bool,
|
||||
#[serde(rename = "timeLimitMinutes")]
|
||||
time_limit_minutes: u64,
|
||||
#[serde(rename = "dataUsageLimitMBytes")]
|
||||
data_usage_limit_mbytes: Option<u64>,
|
||||
#[serde(rename = "rxRateLimitKbps")]
|
||||
rx_rate_limit_kbps: Option<u64>,
|
||||
#[serde(rename = "txRateLimitKbps")]
|
||||
tx_rate_limit_kbps: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateVoucherRequest {
|
||||
count: u32,
|
||||
name: String,
|
||||
#[serde(rename = "authorizedGuestLimit")]
|
||||
authorized_guest_limit: Option<u64>,
|
||||
#[serde(rename = "timeLimitMinutes")]
|
||||
time_limit_minutes: u64,
|
||||
#[serde(rename = "dataUsageLimitMBytes")]
|
||||
data_usage_limit_mbytes: Option<u64>,
|
||||
#[serde(rename = "rxRateLimitKbps")]
|
||||
rx_rate_limit_kbps: Option<u64>,
|
||||
#[serde(rename = "txRateLimitKbps")]
|
||||
tx_rate_limit_kbps: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateVoucherResponse {
|
||||
pub vouchers: Vec<Voucher>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GetVouchersResponse {
|
||||
pub data: Vec<Voucher>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeleteResponse {
|
||||
#[serde(rename = "vouchersDeleted")]
|
||||
pub vouchers_deleted: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HealthCheckResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeleteRequest {
|
||||
pub ids: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DetailsRequest {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Site {
|
||||
pub id: String,
|
||||
#[serde(rename = "internalReference")]
|
||||
internal_reference: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetSitesResponse {
|
||||
offset: u64,
|
||||
limit: u32,
|
||||
count: u32,
|
||||
#[serde(rename = "totalCount")]
|
||||
total_count: u32,
|
||||
pub data: Vec<Site>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ErrorResponse {
|
||||
#[serde(rename = "statusCode")]
|
||||
pub status_code: u32,
|
||||
#[serde(rename = "statusName")]
|
||||
pub status_name: String,
|
||||
pub message: String,
|
||||
timestamp: String,
|
||||
#[serde(rename = "requestPath")]
|
||||
request_path: String,
|
||||
#[serde(rename = "requestId")]
|
||||
request_id: String,
|
||||
}
|
278
backend/src/unifi_api.rs
Normal file
278
backend/src/unifi_api.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
use chrono::DateTime;
|
||||
use chrono_tz::Tz;
|
||||
use reqwest::{Client, ClientBuilder, StatusCode};
|
||||
use std::{sync::OnceLock, time::Duration};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
ENVIRONMENT, Environment,
|
||||
models::{
|
||||
CreateVoucherRequest, CreateVoucherResponse, DeleteResponse, ErrorResponse,
|
||||
GetSitesResponse, GetVouchersResponse, Voucher,
|
||||
},
|
||||
};
|
||||
|
||||
const UNIFI_API_ROUTE: &str = "proxy/network/integration/v1/sites";
|
||||
const DATE_TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
|
||||
|
||||
pub static UNIFI_API: OnceLock<UnifiAPI> = OnceLock::new();
|
||||
|
||||
enum RequestType {
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnifiAPI<'a> {
|
||||
client: Client,
|
||||
sites_api_url: String,
|
||||
voucher_api_url: String,
|
||||
environment: &'a Environment,
|
||||
}
|
||||
|
||||
impl<'a> UnifiAPI<'a> {
|
||||
pub async fn new() -> Result<Self, ()> {
|
||||
let environment: &Environment = ENVIRONMENT.get().expect("Environment not set");
|
||||
|
||||
let mut headers = reqwest::header::HeaderMap::with_capacity(2);
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
reqwest::header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
headers.insert(
|
||||
"X-API-Key",
|
||||
environment
|
||||
.unifi_api_key
|
||||
.parse()
|
||||
.expect("Could not parse API Key"),
|
||||
);
|
||||
|
||||
let client = ClientBuilder::new()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.default_headers(headers)
|
||||
.use_rustls_tls()
|
||||
.build()
|
||||
.expect("Failed to build UniFi reqwest client");
|
||||
|
||||
let mut unifi_api = Self {
|
||||
client,
|
||||
sites_api_url: format!("{}/{}", environment.unifi_controller_url, UNIFI_API_ROUTE),
|
||||
voucher_api_url: String::new(),
|
||||
environment,
|
||||
};
|
||||
|
||||
let site_id = match environment.unifi_site_id.to_lowercase().as_str() {
|
||||
"default" => {
|
||||
info!("Trying to fetch the default site ID from UniFi controller...");
|
||||
let id = match unifi_api.get_default_site_id().await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
error!("Failed to fetch default site ID: {}", e);
|
||||
return Err(());
|
||||
}
|
||||
};
|
||||
info!("Default site ID found: {}", id);
|
||||
id
|
||||
}
|
||||
_ => environment.unifi_site_id.clone(),
|
||||
};
|
||||
|
||||
unifi_api.voucher_api_url =
|
||||
format!("{}/{}/hotspot/vouchers", unifi_api.sites_api_url, site_id);
|
||||
|
||||
Ok(unifi_api)
|
||||
}
|
||||
|
||||
async fn make_request<
|
||||
T: serde::ser::Serialize + Sized,
|
||||
U: serde::de::DeserializeOwned + Sized,
|
||||
>(
|
||||
&self,
|
||||
request_type: RequestType,
|
||||
url: &str,
|
||||
body: Option<&T>,
|
||||
) -> Result<U, StatusCode> {
|
||||
// Make request
|
||||
let response_result = match request_type {
|
||||
RequestType::Get => self.client.get(url).send().await,
|
||||
RequestType::Post => {
|
||||
if let Some(b) = body {
|
||||
self.client.post(url).json(b).send().await
|
||||
} else {
|
||||
error!("Body is required for POST requests");
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
RequestType::Delete => self.client.delete(url).send().await,
|
||||
};
|
||||
|
||||
// Check if the request was successful
|
||||
let response = match response_result {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
let status = e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
error!("Request failed with status {}: {}", status, e.without_url());
|
||||
return Err(status);
|
||||
}
|
||||
};
|
||||
|
||||
// The request was successful, now check the status code
|
||||
let clean_response = match response.error_for_status_ref().is_ok() {
|
||||
true => response,
|
||||
false => {
|
||||
let status = response.status();
|
||||
error!("Request failed with status: {}", status);
|
||||
|
||||
if let Ok(body) = response.json::<ErrorResponse>().await {
|
||||
error!("Error response message: {}", body.message);
|
||||
} else {
|
||||
error!("Failed to parse error response body");
|
||||
}
|
||||
return Err(status);
|
||||
}
|
||||
};
|
||||
|
||||
// It's a successful response, now parse the JSON
|
||||
clean_response.json::<U>().await.map_err(|e| {
|
||||
error!("Failed to parse response JSON: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})
|
||||
}
|
||||
|
||||
fn process_voucher(&self, voucher: &mut Voucher) {
|
||||
voucher.created_at = format_unifi_date(&voucher.created_at, &self.environment.timezone);
|
||||
if let Some(activated_at) = &mut voucher.activated_at {
|
||||
*activated_at = format_unifi_date(activated_at, &self.environment.timezone);
|
||||
}
|
||||
if let Some(expires_at) = &mut voucher.expires_at {
|
||||
*expires_at = format_unifi_date(expires_at, &self.environment.timezone);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_vouchers(&self, mut vouchers: Vec<Voucher>) -> Vec<Voucher> {
|
||||
vouchers.iter_mut().for_each(|voucher| {
|
||||
self.process_voucher(voucher);
|
||||
});
|
||||
vouchers
|
||||
}
|
||||
|
||||
async fn get_default_site_id(&self) -> Result<String, StatusCode> {
|
||||
let url = format!(
|
||||
"{}?filter=or(internalReference.eq('default'),name.eq('Default'))",
|
||||
self.sites_api_url
|
||||
);
|
||||
let result: GetSitesResponse = self
|
||||
.make_request(RequestType::Get, &url, None::<&()>)
|
||||
.await?;
|
||||
|
||||
if result.data.is_empty() {
|
||||
error!("No default site found on the UniFi controller");
|
||||
error!(
|
||||
"Please manually set the `UNIFI_SITE_ID` environment variable to a valid site ID."
|
||||
);
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
let id = result.data[0].id.to_owned();
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn get_all_vouchers(&self) -> Result<GetVouchersResponse, StatusCode> {
|
||||
let mut result: GetVouchersResponse = self
|
||||
.make_request(RequestType::Get, &self.voucher_api_url, None::<&()>)
|
||||
.await?;
|
||||
result.data = self.process_vouchers(result.data);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn get_newest_voucher(&self) -> Result<Option<Voucher>, StatusCode> {
|
||||
let response = self.get_all_vouchers().await?;
|
||||
|
||||
if response.data.is_empty() {
|
||||
warn!("No vouchers found when fetching the newest voucher");
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// Find the newest voucher
|
||||
let newest = response
|
||||
.data
|
||||
.iter()
|
||||
.max_by_key(|voucher| {
|
||||
DateTime::parse_from_str(&voucher.created_at, DATE_TIME_FORMAT)
|
||||
.unwrap_or_else(|_| DateTime::UNIX_EPOCH.fixed_offset())
|
||||
})
|
||||
.cloned();
|
||||
|
||||
Ok(newest)
|
||||
}
|
||||
|
||||
pub async fn get_voucher_details(&self, id: String) -> Result<Voucher, StatusCode> {
|
||||
let url = format!("{}/{}", self.voucher_api_url, id);
|
||||
|
||||
let mut result: Voucher = self
|
||||
.make_request(RequestType::Get, &url, None::<&()>)
|
||||
.await?;
|
||||
|
||||
self.process_voucher(&mut result);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn create_voucher(
|
||||
&self,
|
||||
request: CreateVoucherRequest,
|
||||
) -> Result<CreateVoucherResponse, StatusCode> {
|
||||
let mut result: CreateVoucherResponse = self
|
||||
.make_request(RequestType::Post, &self.voucher_api_url, Some(&request))
|
||||
.await?;
|
||||
result.vouchers = self.process_vouchers(result.vouchers);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn delete_vouchers_by_ids(
|
||||
&self,
|
||||
ids: Vec<String>,
|
||||
) -> Result<DeleteResponse, StatusCode> {
|
||||
if ids.is_empty() || (ids.len() == 1 && ids[0].is_empty()) {
|
||||
return Ok(DeleteResponse {
|
||||
vouchers_deleted: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let filter_expr = ids
|
||||
.iter()
|
||||
.map(|id| format!("id.eq({id})"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
let url = if ids.len() == 1 {
|
||||
format!("{}?filter={}", self.voucher_api_url, filter_expr)
|
||||
} else {
|
||||
format!("{}?filter=or({})", self.voucher_api_url, filter_expr)
|
||||
};
|
||||
|
||||
self.make_request(RequestType::Delete, &url, None::<&()>)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_expired_vouchers(&self) -> Result<DeleteResponse, StatusCode> {
|
||||
let url = format!("{}?filter=expired.eq(true)", self.voucher_api_url);
|
||||
self.make_request(RequestType::Delete, &url, None::<&()>)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn format_unifi_date(rfc3339_string: &str, target_timezone: &Tz) -> String {
|
||||
match DateTime::parse_from_rfc3339(rfc3339_string) {
|
||||
Ok(dt) => {
|
||||
let local_time = dt.with_timezone(target_timezone);
|
||||
local_time.format(DATE_TIME_FORMAT).to_string()
|
||||
}
|
||||
Err(_) => {
|
||||
error!("Failed to parse RFC3339 date: {}", rfc3339_string);
|
||||
rfc3339_string.to_string()
|
||||
}
|
||||
}
|
||||
}
|
20
compose.yaml
Normal file
20
compose.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
services:
|
||||
unifi-voucher-manager:
|
||||
image: etiennecollin/unifi-voucher-manager
|
||||
# To build the image yourself
|
||||
# build:
|
||||
# context: "./"
|
||||
# target: "runtime"
|
||||
# dockerfile: "./Dockerfile"
|
||||
container_name: "unifi-voucher-manager"
|
||||
restart: "unless-stopped"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
# To put the environment variables in a `.env` file, uncomment the env_file
|
||||
# section and comment the environment section.
|
||||
# env_file:
|
||||
# - ".env"
|
||||
environment:
|
||||
UNIFI_CONTROLLER_URL: "URL to your Unifi controller with protocol. Example: 'https://unifi.example.com:8443' or 'http://192.168.0.1'"
|
||||
UNIFI_API_KEY: "API Key for your Unifi controller"
|
7
frontend/.dockerignore
Normal file
7
frontend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
|
||||
.dockerignore
|
||||
.gitignore
|
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
19
frontend/README.md
Normal file
19
frontend/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
The page auto-updates as you edit files.
|
7
frontend/next.config.ts
Normal file
7
frontend/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
1735
frontend/package-lock.json
generated
Normal file
1735
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "unifi-voucher-manager",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"next": "15.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4"
|
||||
}
|
||||
}
|
5
frontend/postcss.config.mjs
Normal file
5
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
BIN
frontend/src/app/apple-touch-icon.png
Normal file
BIN
frontend/src/app/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.3 KiB |
11
frontend/src/app/favicon.svg
Normal file
11
frontend/src/app/favicon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" >
|
||||
<path fill="url(#a)" d="M0 10c0-3.5 0-5.25.681-6.587A6.25 6.25 0 0 1 3.413.68C4.75 0 6.5 0 10 0h12c3.5 0 5.25 0 6.587.681a6.25 6.25 0 0 1 2.732 2.732C32 4.75 32 6.5 32 10v12c0 3.5 0 5.25-.681 6.587a6.25 6.25 0 0 1-2.732 2.732C27.25 32 25.5 32 22 32H10c-3.5 0-5.25 0-6.587-.681A6.25 6.25 0 0 1 .68 28.587C0 27.25 0 25.5 0 22V10Z"/>
|
||||
<path fill="#fff" d="M23.5 8.88h-1v1h1v-1Zm-3.499 7.003v-2.004h2v2H24v.634c0 .733-.062 1.601-.206 2.283-.08.38-.201.76-.344 1.123a7.834 7.834 0 0 1-1.335 2.241l-.017.02-.028.033c-.077.09-.153.18-.237.267a7.888 7.888 0 0 1-.302.302 7.95 7.95 0 0 1-4.69 2.179 11 11 0 0 1-.841.044 11.84 11.84 0 0 1-.841-.044 7.954 7.954 0 0 1-4.69-2.179 7.888 7.888 0 0 1-.302-.302c-.088-.091-.167-.184-.248-.279l-.034-.04a7.834 7.834 0 0 1-1.335-2.242 7.132 7.132 0 0 1-.345-1.123C8.062 18.113 8 17.246 8 16.513V9.005h3.999v6.877s0 .528.006.7l.002.04c.008.224.017.442.04.66.066.618.202 1.203.484 1.699.081.143.164.282.263.414a4.022 4.022 0 0 0 2.658 1.572c.136.02.41.037.548.037.137 0 .412-.017.547-.037a4.022 4.022 0 0 0 2.66-1.572c.099-.132.18-.27.262-.414.282-.496.418-1.081.484-1.699.024-.218.032-.437.04-.66l.002-.04c.006-.172.006-.7.006-.7Z"/>
|
||||
<path fill="#fff" d="M20.5 10.38H22v1.5h2v2h-2v-2h-1.5v-1.5Z"/>
|
||||
<defs>
|
||||
<radialGradient id="a" cx="0" cy="0" r="1" gradientTransform="matrix(0 32 -32 0 16 0)" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#006FFF"/>
|
||||
<stop offset="1" stop-color="#003C9E"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
391
frontend/src/app/globals.css
Normal file
391
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,391 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
/* Font Families */
|
||||
--font-system:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, ui-sans-serif,
|
||||
system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
|
||||
/* Primary Brand Colors */
|
||||
--color-primary-50: #eff6ff;
|
||||
--color-primary-100: #dbeafe;
|
||||
--color-primary-200: #bfdbfe;
|
||||
--color-primary-300: #93c5fd;
|
||||
--color-primary-400: #60a5fa;
|
||||
--color-primary-500: #3b82f6;
|
||||
--color-primary-600: #2563eb;
|
||||
--color-primary-700: #1d4ed8;
|
||||
--color-primary-800: #1e40af;
|
||||
--color-primary-900: #1e3a8a;
|
||||
|
||||
/* Secondary Colors */
|
||||
--color-secondary-50: #f0f9ff;
|
||||
--color-secondary-100: #e0f2fe;
|
||||
--color-secondary-200: #bae6fd;
|
||||
--color-secondary-300: #7dd3fc;
|
||||
--color-secondary-400: #38bdf8;
|
||||
--color-secondary-500: #0ea5e9;
|
||||
--color-secondary-600: #0284c7;
|
||||
--color-secondary-700: #0369a1;
|
||||
--color-secondary-800: #075985;
|
||||
--color-secondary-900: #0c4a6e;
|
||||
|
||||
/* Success Colors */
|
||||
--color-success-50: #f0fdf4;
|
||||
--color-success-100: #dcfce7;
|
||||
--color-success-200: #bbf7d0;
|
||||
--color-success-300: #86efac;
|
||||
--color-success-400: #4ade80;
|
||||
--color-success-500: #22c55e;
|
||||
--color-success-600: #16a34a;
|
||||
--color-success-700: #15803d;
|
||||
--color-success-800: #166534;
|
||||
--color-success-900: #14532d;
|
||||
|
||||
/* Danger Colors */
|
||||
--color-danger-50: #fef2f2;
|
||||
--color-danger-100: #fee2e2;
|
||||
--color-danger-200: #fecaca;
|
||||
--color-danger-300: #fca5a5;
|
||||
--color-danger-400: #f87171;
|
||||
--color-danger-500: #ef4444;
|
||||
--color-danger-600: #dc2626;
|
||||
--color-danger-700: #b91c1c;
|
||||
--color-danger-800: #991b1b;
|
||||
--color-danger-900: #7f1d1d;
|
||||
|
||||
/* Warning Colors */
|
||||
--color-warning-50: #fffbeb;
|
||||
--color-warning-100: #fef3c7;
|
||||
--color-warning-200: #fde68a;
|
||||
--color-warning-300: #fcd34d;
|
||||
--color-warning-400: #fbbf24;
|
||||
--color-warning-500: #f59e0b;
|
||||
--color-warning-600: #d97706;
|
||||
--color-warning-700: #b45309;
|
||||
--color-warning-800: #92400e;
|
||||
--color-warning-900: #78350f;
|
||||
|
||||
/* Custom Box Shadows */
|
||||
--shadow-soft: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
--shadow-soft-dark: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
--shadow-elevation: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
--shadow-elevation-dark: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* 0. Semantic Color Utilities - Theme System */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
/* Text Colors - Semantic */
|
||||
@utility text-primary {
|
||||
@apply text-neutral-900 dark:text-neutral-50;
|
||||
}
|
||||
@utility text-secondary {
|
||||
@apply text-neutral-600 dark:text-neutral-400;
|
||||
}
|
||||
@utility text-muted {
|
||||
@apply text-neutral-500 dark:text-neutral-500;
|
||||
}
|
||||
@utility text-brand {
|
||||
@apply text-primary-600 dark:text-primary-400;
|
||||
}
|
||||
@utility text-brand-emphasis {
|
||||
@apply text-primary-700 dark:text-primary-300;
|
||||
}
|
||||
|
||||
/* Status Text Colors */
|
||||
@utility text-status-success {
|
||||
@apply text-success-700 dark:text-success-400;
|
||||
}
|
||||
@utility text-status-danger {
|
||||
@apply text-danger-700 dark:text-danger-400;
|
||||
}
|
||||
@utility text-status-warning {
|
||||
@apply text-warning-700 dark:text-warning-400;
|
||||
}
|
||||
@utility text-status-info {
|
||||
@apply text-primary-700 dark:text-primary-400;
|
||||
}
|
||||
|
||||
/* Background Colors - Semantic */
|
||||
@utility bg-page {
|
||||
@apply bg-neutral-50 dark:bg-neutral-900;
|
||||
}
|
||||
@utility bg-surface {
|
||||
@apply bg-white dark:bg-neutral-800;
|
||||
}
|
||||
@utility bg-surface-elevated {
|
||||
@apply bg-white dark:bg-neutral-700;
|
||||
}
|
||||
@utility bg-overlay {
|
||||
@apply bg-black/50 dark:bg-black/70;
|
||||
}
|
||||
|
||||
/* Status Backgrounds */
|
||||
@utility bg-status-success {
|
||||
@apply bg-success-100 dark:bg-success-900/30;
|
||||
}
|
||||
@utility bg-status-danger {
|
||||
@apply bg-danger-100 dark:bg-danger-900/30;
|
||||
}
|
||||
@utility bg-status-warning {
|
||||
@apply bg-warning-100 dark:bg-warning-900/30;
|
||||
}
|
||||
@utility bg-status-info {
|
||||
@apply bg-primary-100 dark:bg-primary-900/30;
|
||||
}
|
||||
|
||||
/* Interactive Backgrounds */
|
||||
@utility bg-interactive {
|
||||
@apply bg-neutral-50 dark:bg-neutral-700;
|
||||
}
|
||||
@utility bg-interactive-hover {
|
||||
@apply hover:bg-neutral-100 dark:hover:bg-neutral-600;
|
||||
}
|
||||
@utility bg-interactive-active {
|
||||
@apply bg-neutral-200 dark:bg-neutral-600;
|
||||
}
|
||||
|
||||
/* Border Colors - Semantic */
|
||||
@utility border-default {
|
||||
@apply border-neutral-200 dark:border-neutral-700;
|
||||
}
|
||||
@utility border-subtle {
|
||||
@apply border-neutral-100 dark:border-neutral-800;
|
||||
}
|
||||
@utility border-accent {
|
||||
@apply border-primary-500 dark:border-primary-400;
|
||||
}
|
||||
@utility border-emphasis {
|
||||
@apply border-primary-600 dark:border-primary-300;
|
||||
}
|
||||
|
||||
/* Status Borders */
|
||||
@utility border-status-success {
|
||||
@apply border-success-600;
|
||||
}
|
||||
@utility border-status-danger {
|
||||
@apply border-danger-600;
|
||||
}
|
||||
@utility border-status-warning {
|
||||
@apply border-warning-600;
|
||||
}
|
||||
@utility border-status-info {
|
||||
@apply border-primary-600;
|
||||
}
|
||||
|
||||
/* Focus States */
|
||||
@utility focus-accent {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500;
|
||||
}
|
||||
@utility focus-danger {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-danger-500;
|
||||
}
|
||||
|
||||
/* Tab States */
|
||||
@utility tab-active {
|
||||
@apply text-brand font-semibold border-b-3 border-accent;
|
||||
}
|
||||
@utility tab-inactive {
|
||||
@apply text-secondary bg-interactive-hover border-b-3 border-transparent hover:border-default;
|
||||
}
|
||||
|
||||
/* Selection States */
|
||||
@utility selected-accent {
|
||||
@apply border-accent bg-primary-600;
|
||||
}
|
||||
@utility unselected-neutral {
|
||||
@apply border-default bg-surface;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* 1. Semantic Animation Utilities - Motion System */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
/* Hover Effects */
|
||||
@utility hover-lift {
|
||||
@apply hover:-translate-y-1 hover:shadow-elevation hover:dark:shadow-elevation-dark;
|
||||
}
|
||||
@utility hover-subtle {
|
||||
@apply hover:bg-interactive-hover;
|
||||
}
|
||||
@utility hover-scale {
|
||||
@apply hover:scale-105;
|
||||
}
|
||||
|
||||
/* Transition Speeds */
|
||||
@utility transition-fast {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
@utility transition-smooth {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
@utility transition-slow {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Slide Animations */
|
||||
@utility slide-in-right {
|
||||
@apply transform transition-transform duration-500 ease-out translate-x-full;
|
||||
}
|
||||
@utility slide-in-right-visible {
|
||||
@apply translate-x-0;
|
||||
}
|
||||
@utility slide-in-left {
|
||||
@apply transform transition-transform duration-300 ease-out -translate-x-full;
|
||||
}
|
||||
@utility slide-in-left-visible {
|
||||
@apply translate-x-0;
|
||||
}
|
||||
@utility slide-in-up {
|
||||
@apply transform transition-transform duration-300 ease-out translate-y-full;
|
||||
}
|
||||
@utility slide-in-up-visible {
|
||||
@apply translate-y-0;
|
||||
}
|
||||
|
||||
/* Fade Animations */
|
||||
@utility fade-in {
|
||||
@apply transition-opacity duration-300 ease-out opacity-0;
|
||||
}
|
||||
@utility fade-in-visible {
|
||||
@apply opacity-100;
|
||||
}
|
||||
@utility fade-out {
|
||||
@apply transition-opacity duration-200 ease-in opacity-100;
|
||||
}
|
||||
@utility fade-out-hidden {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
/* Interactive States */
|
||||
@utility interactive-element {
|
||||
@apply transition-smooth cursor-pointer focus-accent;
|
||||
}
|
||||
@utility card-interactive {
|
||||
@apply interactive-element hover-lift;
|
||||
}
|
||||
|
||||
/* Focus Animations */
|
||||
@utility focus-ring {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all duration-200;
|
||||
}
|
||||
@utility focus-ring-danger {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-danger-500 focus:ring-offset-2 transition-all duration-200;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* 2. Base layer: resets, defaults, theming, scrollbar */
|
||||
/* -------------------------------------------------- */
|
||||
@layer base {
|
||||
html {
|
||||
font-family: var(--font-system);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply transition duration-200 ease-in-out;
|
||||
}
|
||||
.transition-disabled * {
|
||||
@apply transition-none;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-page text-primary min-h-screen flex flex-col;
|
||||
}
|
||||
|
||||
/* touch feedback */
|
||||
button,
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
@apply appearance-none bg-surface border border-default cursor-pointer focus-accent px-3 py-2 rounded-lg w-full;
|
||||
}
|
||||
input {
|
||||
@apply bg-surface border border-default focus-accent px-3 py-2 rounded-lg w-full;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
@apply bg-neutral-300 dark:bg-neutral-700 rounded-lg;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* 3. Components layer: reusable patterns */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
@utility btn {
|
||||
@apply inline-flex items-center justify-center font-medium rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 cursor-pointer px-4 py-2 disabled:opacity-50 disabled:bg-neutral-500 disabled:hover:bg-neutral-600 text-white;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-500 hover:bg-primary-600 focus:ring-primary-300;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply btn bg-secondary-500 hover:bg-secondary-600 focus:ring-secondary-300;
|
||||
}
|
||||
.btn-success {
|
||||
@apply btn bg-success-500 hover:bg-success-600 focus:ring-success-300;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply btn bg-danger-500 hover:bg-danger-600 focus:ring-danger-300;
|
||||
}
|
||||
.btn-warning {
|
||||
@apply btn bg-warning-500 hover:bg-warning-600 focus:ring-warning-300;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
@apply bg-surface border border-default rounded-xl shadow-soft dark:shadow-soft-dark p-4 border-1 w-full;
|
||||
}
|
||||
|
||||
/* Custom styled select with arrow */
|
||||
.icon-button {
|
||||
@apply inline-flex items-center justify-center w-9 h-9 text-sm rounded-lg bg-surface bg-interactive-hover border border-default focus-accent;
|
||||
}
|
||||
|
||||
.voucher-code {
|
||||
@apply font-bold text-brand font-mono tracking-wider;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* 4. Utilities layer: small helpers */
|
||||
/* -------------------------------------------------- */
|
||||
@layer utilities {
|
||||
/* Flex center shortcuts */
|
||||
.flex-center {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
/* Safe area padding for iOS */
|
||||
.safe-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
24
frontend/src/app/layout.tsx
Normal file
24
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "UniFi Voucher Manager",
|
||||
description: "Manage WiFi vouchers with ease",
|
||||
authors: [{ name: "etiennecollin", url: "https://etiennecollin.com" }],
|
||||
creator: "Etienne Collin",
|
||||
robots: {
|
||||
index: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`antialiased`}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
13
frontend/src/app/page.tsx
Normal file
13
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import Header from "@/components/Header";
|
||||
import NotificationContainer from "@/components/notifications/NotificationContainer";
|
||||
import Tabs from "@/components/tabs/Tabs";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-page">
|
||||
<NotificationContainer />
|
||||
<Header />
|
||||
<Tabs />
|
||||
</div>
|
||||
);
|
||||
}
|
16
frontend/src/components/Header.tsx
Normal file
16
frontend/src/components/Header.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import ThemeSwitcher from "@/components/utils/ThemeSwitcher";
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="bg-surface border-b border-default safe-top sticky top-0 z-7000">
|
||||
<div className="max-w-95/100 mx-auto flex items-center justify-between px-4 py-4">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-brand">
|
||||
UniFi Voucher Manager
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
86
frontend/src/components/VoucherCard.tsx
Normal file
86
frontend/src/components/VoucherCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Voucher } from "@/types/voucher";
|
||||
import {
|
||||
formatCode,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
formatGuestUsage,
|
||||
} from "@/utils/format";
|
||||
import { memo } from "react";
|
||||
|
||||
type Props = {
|
||||
voucher: Voucher;
|
||||
selected: boolean;
|
||||
editMode: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const VoucherCard = ({ voucher, selected, editMode, onClick }: Props) => {
|
||||
const statusClass = voucher.expired
|
||||
? "bg-status-danger text-status-danger"
|
||||
: "bg-status-success text-status-success";
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`card card-interactive
|
||||
${selected ? "border-accent" : ""}
|
||||
${editMode ? "relative" : ""}`}
|
||||
>
|
||||
{editMode && (
|
||||
<div className="absolute top-3 right-3 z-1000">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center transition-smooth
|
||||
${selected ? "selected-accent" : "unselected-neutral"}`}
|
||||
>
|
||||
{selected && <div className="w-3 h-3 bg-white rounded-full" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary Information */}
|
||||
<div className="mb-2">
|
||||
<div className="text-xl voucher-code">{formatCode(voucher.code)}</div>
|
||||
<div className="text-lg font-semibold truncate">{voucher.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-secondary">
|
||||
<div className="flex justify-between">
|
||||
<span>Guests Used:</span>
|
||||
<span>
|
||||
{formatGuestUsage(
|
||||
voucher.authorizedGuestCount,
|
||||
voucher.authorizedGuestLimit,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span>Session Time:</span>
|
||||
<span>{formatDuration(voucher.timeLimitMinutes)}</span>
|
||||
</div>
|
||||
|
||||
{voucher.activatedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span>First Used:</span>
|
||||
<span className="text-xs">{formatDate(voucher.activatedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-lg text-xs font-semibold uppercase ${statusClass}`}
|
||||
>
|
||||
{voucher.expired ? "Expired" : "Active"}
|
||||
</span>
|
||||
{voucher.expiresAt && (
|
||||
<span className="text-xs">
|
||||
Expires: {formatDate(voucher.expiresAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(VoucherCard);
|
56
frontend/src/components/modals/Modal.tsx
Normal file
56
frontend/src/components/modals/Modal.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect } from "react";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
/** Extra classes for the content container */
|
||||
contentClassName?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function Modal({
|
||||
onClose,
|
||||
contentClassName = "",
|
||||
children,
|
||||
}: Props) {
|
||||
// lock scroll + handle Escape
|
||||
useEffect(() => {
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const onBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-overlay flex-center z-8000"
|
||||
onClick={onBackdropClick}
|
||||
>
|
||||
<div
|
||||
className={`bg-surface border border-default flex flex-col max-h-9/10 max-w-lg overflow-hidden relative rounded-xl shadow-2xl w-full ${contentClassName}`}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-0 right-2 text-secondary text-2xl hover:text-primary"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="overflow-y-auto mr-3 mt-8 mb-2 p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
20
frontend/src/components/modals/SuccessModal.tsx
Normal file
20
frontend/src/components/modals/SuccessModal.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import Modal from "@/components/modals/Modal";
|
||||
import CopyCode from "@/components/utils/CopyCode";
|
||||
|
||||
type Props = {
|
||||
code: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function SuccessModal({ code: rawCode, onClose }: Props) {
|
||||
return (
|
||||
<Modal onClose={onClose} contentClassName="max-w-sm">
|
||||
<h2 className="text-2xl font-bold text-primary mb-4 text-center">
|
||||
Voucher Created!
|
||||
</h2>
|
||||
<CopyCode rawCode={rawCode} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
105
frontend/src/components/modals/VoucherModal.tsx
Normal file
105
frontend/src/components/modals/VoucherModal.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import Modal from "@/components/modals/Modal";
|
||||
import Spinner from "@/components/utils/Spinner";
|
||||
import { api } from "@/utils/api";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
formatBytes,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
formatGuestUsage,
|
||||
formatSpeed,
|
||||
} from "@/utils/format";
|
||||
import CopyCode from "@/components/utils/CopyCode";
|
||||
import { Voucher } from "@/types/voucher";
|
||||
|
||||
type Props = {
|
||||
voucher: Voucher;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function VoucherModal({ voucher, onClose }: Props) {
|
||||
const [details, setDetails] = useState<Voucher | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const lastFetchedId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Only fetch if we haven't already fetched this voucher's details
|
||||
if (voucher.id === lastFetchedId.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
lastFetchedId.current = voucher.id;
|
||||
|
||||
try {
|
||||
const res = await api.getVoucherDetails(voucher.id);
|
||||
setDetails(res);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [voucher.id]);
|
||||
|
||||
const rawCode = details?.code ?? voucher.code;
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<CopyCode rawCode={rawCode} contentClassName="mb-8" />
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : error || details == null ? (
|
||||
<div className="card text-status-danger text-center">
|
||||
Failed to load detailed information
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{(
|
||||
[
|
||||
["Status", details.expired ? "Expired" : "Active"],
|
||||
["Name", details.name || "No note"],
|
||||
["Created", formatDate(details.createdAt)],
|
||||
...(details.activatedAt
|
||||
? [["Activated", formatDate(details.activatedAt)]]
|
||||
: []),
|
||||
...(details.expiresAt
|
||||
? [["Expires", formatDate(details.expiresAt)]]
|
||||
: []),
|
||||
["Duration", formatDuration(details.timeLimitMinutes)],
|
||||
[
|
||||
"Guest Usage",
|
||||
formatGuestUsage(
|
||||
details.authorizedGuestCount,
|
||||
details.authorizedGuestLimit,
|
||||
),
|
||||
],
|
||||
[
|
||||
"Data Limit",
|
||||
details.dataUsageLimitMBytes
|
||||
? formatBytes(details.dataUsageLimitMBytes * 1024 * 1024)
|
||||
: "Unlimited",
|
||||
],
|
||||
["Download Speed", formatSpeed(details.rxRateLimitKbps)],
|
||||
["Upload Speed", formatSpeed(details.txRateLimitKbps)],
|
||||
["ID", details.id],
|
||||
] as [string, any][]
|
||||
).map(([label, value]) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex justify-between items-center p-4 bg-interactive border border-subtle rounded-xl space-x-4"
|
||||
>
|
||||
<span className="font-semibold text-primary">{label}:</span>
|
||||
<span className="text-secondary">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import NotificationItem from "@/components/notifications/NotificationItem";
|
||||
import { NotificationPayload } from "@/utils/notifications";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
export default function NotificationContainer() {
|
||||
const [notes, setNotes] = useState<NotificationPayload[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<NotificationPayload>).detail;
|
||||
setNotes((prev) => [...prev, detail]);
|
||||
};
|
||||
window.addEventListener("notify", handler as any);
|
||||
return () => window.removeEventListener("notify", handler as any);
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setNotes((prev) => prev.filter((n) => n.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 flex flex-col space-y-2 z-9000 overflow-visible">
|
||||
{notes.map((n) => (
|
||||
<NotificationItem key={n.id} {...n} onDone={remove} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
54
frontend/src/components/notifications/NotificationItem.tsx
Normal file
54
frontend/src/components/notifications/NotificationItem.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { NotificationPayload } from "@/utils/notifications";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = NotificationPayload & {
|
||||
onDone: (id: string) => void;
|
||||
};
|
||||
|
||||
const NOTIFICATION_DURATION_MS = 4000;
|
||||
|
||||
export default function NotificationItem({ id, message, type, onDone }: Props) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// slide in next tick
|
||||
const showTimer = setTimeout(() => setVisible(true), 10);
|
||||
// slide out after 4s
|
||||
const hideTimer = setTimeout(
|
||||
() => setVisible(false),
|
||||
NOTIFICATION_DURATION_MS,
|
||||
);
|
||||
// remove after animation
|
||||
const removeTimer = setTimeout(
|
||||
() => onDone(id),
|
||||
NOTIFICATION_DURATION_MS + 500,
|
||||
);
|
||||
|
||||
return () => [showTimer, hideTimer, removeTimer].forEach(clearTimeout);
|
||||
}, [id, onDone]);
|
||||
|
||||
const getNotificationClasses = () => {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return "border-status-success text-status-success";
|
||||
case "error":
|
||||
return "border-status-danger text-status-danger";
|
||||
default:
|
||||
return "border-status-info text-status-info";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
slide-in-right ${visible ? "slide-in-right-visible" : ""}
|
||||
bg-surface shadow-lg border px-4 py-3 rounded-lg max-w-xs
|
||||
${getNotificationClasses()}
|
||||
`}
|
||||
>
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
112
frontend/src/components/tabs/CustomCreateTab.tsx
Normal file
112
frontend/src/components/tabs/CustomCreateTab.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import SuccessModal from "@/components/modals/SuccessModal";
|
||||
import { VoucherCreateData } from "@/types/voucher";
|
||||
import { api } from "@/utils/api";
|
||||
import { map } from "@/utils/functional";
|
||||
import { notify } from "@/utils/notifications";
|
||||
import { useCallback, useState, FormEvent } from "react";
|
||||
|
||||
export default function CustomCreateTab() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newCode, setNewCode] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
const parseNumber = (x: FormDataEntryValue) =>
|
||||
x !== "" ? Number(x) : null;
|
||||
|
||||
const form = e.currentTarget;
|
||||
const data = new FormData(form);
|
||||
|
||||
const payload: VoucherCreateData = {
|
||||
count: Number(data.get("count")),
|
||||
name: String(data.get("name")),
|
||||
timeLimitMinutes: Number(data.get("duration")),
|
||||
authorizedGuestLimit: map(data.get("guests"), parseNumber),
|
||||
dataUsageLimitMBytes: map(data.get("data"), parseNumber),
|
||||
rxRateLimitKbps: map(data.get("download"), parseNumber),
|
||||
txRateLimitKbps: map(data.get("upload"), parseNumber),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await api.createVoucher(payload);
|
||||
const code = res.vouchers?.[0]?.code;
|
||||
if (code) {
|
||||
setNewCode(code);
|
||||
form.reset();
|
||||
} else {
|
||||
notify("Voucher created, but code not available", "error");
|
||||
}
|
||||
} catch {
|
||||
notify("Failed to create voucher", "error");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setNewCode(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="card max-w-lg mx-auto space-y-6">
|
||||
{[
|
||||
{
|
||||
label: "Number",
|
||||
name: "count",
|
||||
type: "number",
|
||||
props: { required: true, min: 1, max: 10, defaultValue: 1 },
|
||||
},
|
||||
{
|
||||
label: "Name",
|
||||
name: "name",
|
||||
type: "text",
|
||||
props: { required: true, defaultValue: "Custom Voucher" },
|
||||
},
|
||||
{
|
||||
label: "Duration (min)",
|
||||
name: "duration",
|
||||
type: "number",
|
||||
props: { required: true, min: 1, max: 525600, defaultValue: 1440 },
|
||||
},
|
||||
{
|
||||
label: "Guest Limit",
|
||||
name: "guests",
|
||||
type: "number",
|
||||
props: { min: 1, max: 5, placeholder: "Unlimited" },
|
||||
},
|
||||
{
|
||||
label: "Data Limit (MB)",
|
||||
name: "data",
|
||||
type: "number",
|
||||
props: { min: 1, max: 1048576, placeholder: "Unlimited" },
|
||||
},
|
||||
{
|
||||
label: "Download Kbps",
|
||||
name: "download",
|
||||
type: "number",
|
||||
props: { min: 2, max: 100000, placeholder: "Unlimited" },
|
||||
},
|
||||
{
|
||||
label: "Upload Kbps",
|
||||
name: "upload",
|
||||
type: "number",
|
||||
props: { min: 2, max: 100000, placeholder: "Unlimited" },
|
||||
},
|
||||
].map(({ label, name, type, props }) => (
|
||||
<div key={name}>
|
||||
<label className="block font-medium mb-1">{label}</label>
|
||||
<input name={name} type={type} {...(props as any)} />
|
||||
</div>
|
||||
))}
|
||||
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||
{loading ? "Creating…" : "Create Custom Voucher"}
|
||||
</button>
|
||||
</form>
|
||||
{newCode && <SuccessModal code={newCode} onClose={closeModal} />}
|
||||
</div>
|
||||
);
|
||||
}
|
69
frontend/src/components/tabs/QuickCreateTab.tsx
Normal file
69
frontend/src/components/tabs/QuickCreateTab.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import SuccessModal from "@/components/modals/SuccessModal";
|
||||
import { VoucherCreateData } from "@/types/voucher";
|
||||
import { api } from "@/utils/api";
|
||||
import { notify } from "@/utils/notifications";
|
||||
import { useCallback, useState, FormEvent } from "react";
|
||||
|
||||
export default function QuickCreateTab() {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [newCode, setNewCode] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
const form = e.currentTarget;
|
||||
const data = new FormData(form);
|
||||
|
||||
const payload: VoucherCreateData = {
|
||||
count: 1,
|
||||
name: String(data.get("name")),
|
||||
timeLimitMinutes: Number(data.get("duration")),
|
||||
authorizedGuestLimit: 1,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await api.createVoucher(payload);
|
||||
const code = res.vouchers?.[0]?.code;
|
||||
if (code) {
|
||||
setNewCode(code);
|
||||
form.reset();
|
||||
} else {
|
||||
notify("Voucher created, but code not available", "error");
|
||||
}
|
||||
} catch {
|
||||
notify("Failed to create voucher", "error");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setNewCode(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="card max-w-lg mx-auto space-y-6">
|
||||
<label className="block font-medium mb-1">Duration</label>
|
||||
<select name="duration" defaultValue="1440" required>
|
||||
<option value={60}>1 Hour</option>
|
||||
<option value={240}>4 Hours</option>
|
||||
<option value={1440}>24 Hours</option>
|
||||
<option value={4320}>3 Days</option>
|
||||
<option value={10080}>1 Week</option>
|
||||
</select>
|
||||
|
||||
<label className="block font-medium mb-1">Name</label>
|
||||
<input name="name" defaultValue="Quick Voucher" required />
|
||||
|
||||
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||
{loading ? "Creating…" : "Create Voucher"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{newCode && <SuccessModal code={newCode} onClose={closeModal} />}
|
||||
</div>
|
||||
);
|
||||
}
|
74
frontend/src/components/tabs/Tabs.tsx
Normal file
74
frontend/src/components/tabs/Tabs.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import CustomCreateTab from "@/components/tabs/CustomCreateTab";
|
||||
import TestTab from "@/components/tabs/TestTab";
|
||||
import QuickCreateTab from "@/components/tabs/QuickCreateTab";
|
||||
import VouchersTab from "@/components/tabs/VouchersTab";
|
||||
import { useState } from "react";
|
||||
|
||||
const TAB_CONFIG = [
|
||||
{
|
||||
id: "vouchers",
|
||||
label: "View Vouchers",
|
||||
component: VouchersTab,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "quick",
|
||||
label: "Quick Create",
|
||||
component: QuickCreateTab,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "custom",
|
||||
label: "Custom Create",
|
||||
component: CustomCreateTab,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "test",
|
||||
label: "Test",
|
||||
component: TestTab,
|
||||
enabled: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
// Get enabled tabs and derive types
|
||||
const enabledTabs = TAB_CONFIG.filter((tab) => tab.enabled);
|
||||
const tabIds = enabledTabs.map((tab) => tab.id);
|
||||
type TabId = (typeof tabIds)[number];
|
||||
|
||||
export default function Tabs() {
|
||||
const [tab, setTab] = useState<TabId>(tabIds[0]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="bg-surface border-b border-default flex sticky top-16 z-2000 shadow-soft dark:shadow-soft-dark">
|
||||
{enabledTabs.map((tabConfig) => (
|
||||
<button
|
||||
key={tabConfig.id}
|
||||
className={`flex-1 px-4 py-3 ${
|
||||
tab === tabConfig.id ? "tab-active" : "tab-inactive"
|
||||
}`}
|
||||
onClick={() => setTab(tabConfig.id)}
|
||||
>
|
||||
{tabConfig.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-4 overflow-y-auto">
|
||||
{enabledTabs.map((tabConfig) => {
|
||||
const Component = tabConfig.component;
|
||||
return (
|
||||
<div
|
||||
key={tabConfig.id}
|
||||
className={tab === tabConfig.id ? "" : "hidden"}
|
||||
>
|
||||
<Component />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
43
frontend/src/components/tabs/TestTab.tsx
Normal file
43
frontend/src/components/tabs/TestTab.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { notify } from "@/utils/notifications";
|
||||
import Spinner from "@/components/utils/Spinner";
|
||||
|
||||
export default function TestTab() {
|
||||
const sendInfo = () => notify("This is an info notification", "info");
|
||||
const sendSuccess = () => notify("Operation succeeded!", "success");
|
||||
const sendError = () => notify("Something went wrong!", "error");
|
||||
const sendMultiple = () => {
|
||||
notify("First message", "info");
|
||||
setTimeout(() => notify("Second message", "success"), 500);
|
||||
setTimeout(() => notify("Third message", "error"), 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-center flex-col space-y-6">
|
||||
<div className="card max-w-lg space-y-4">
|
||||
<h2 className="text-lg font-semibold text-primary">
|
||||
Notification Tester
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button onClick={sendInfo} className="btn-primary">
|
||||
Send Info
|
||||
</button>
|
||||
<button onClick={sendSuccess} className="btn-success">
|
||||
Send Success
|
||||
</button>
|
||||
<button onClick={sendError} className="btn-danger">
|
||||
Send Error
|
||||
</button>
|
||||
<button onClick={sendMultiple} className="btn-primary">
|
||||
Send Multiple
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card max-w-lg">
|
||||
<h2 className="text-lg font-semibold text-primary">Spinner Example</h2>
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
216
frontend/src/components/tabs/VouchersTab.tsx
Normal file
216
frontend/src/components/tabs/VouchersTab.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import Spinner from "@/components/utils/Spinner";
|
||||
import VoucherCard from "@/components/VoucherCard";
|
||||
import VoucherModal from "@/components/modals/VoucherModal";
|
||||
import { Voucher } from "@/types/voucher";
|
||||
import { api } from "@/utils/api";
|
||||
import { notify } from "@/utils/notifications";
|
||||
import { useMemo, useEffect, useCallback, useState } from "react";
|
||||
|
||||
export default function VouchersTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [vouchers, setVouchers] = useState<Voucher[]>([]);
|
||||
const [viewVoucher, setViewVoucher] = useState<Voucher | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.getAllVouchers();
|
||||
setVouchers(res.data || []);
|
||||
} catch {
|
||||
notify("Failed to load vouchers", "error");
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const startEdit = () => {
|
||||
setSelected(new Set());
|
||||
setEditMode(true);
|
||||
};
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setSelected(new Set());
|
||||
setEditMode(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
window.addEventListener("vouchersUpdated", load);
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") cancelEdit();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("vouchersUpdated", load);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [load, cancelEdit]);
|
||||
|
||||
const filteredVouchers = useMemo(() => {
|
||||
if (!searchQuery.trim()) return vouchers;
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
return vouchers.filter((voucher) =>
|
||||
voucher.name?.toLowerCase().includes(query),
|
||||
);
|
||||
}, [vouchers, searchQuery]);
|
||||
|
||||
const expiredVouchers = useMemo(
|
||||
() => filteredVouchers.filter((v) => v.expired).map((v) => v.id),
|
||||
[filteredVouchers],
|
||||
);
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelected((p) => {
|
||||
const s = new Set(p);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
return s;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAll = () => {
|
||||
if (selected.size === filteredVouchers.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(filteredVouchers.map((v) => v.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setViewVoucher(null);
|
||||
}, []);
|
||||
|
||||
const deleteVouchers = useCallback(
|
||||
async (kind: "selected" | "expired") => {
|
||||
setBusy(true);
|
||||
const kind_word = kind === "selected" ? "" : "expired";
|
||||
try {
|
||||
const res =
|
||||
kind === "selected"
|
||||
? await api.deleteSelected([...selected])
|
||||
: await api.deleteSelected([...expiredVouchers]);
|
||||
const count = res.vouchersDeleted || 0;
|
||||
if (count > 0) {
|
||||
notify(
|
||||
`Successfully deleted ${count} ${kind_word} voucher${count === 1 ? "" : "s"}`,
|
||||
"success",
|
||||
);
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
notify(`No ${kind_word} vouchers were deleted`, "info");
|
||||
}
|
||||
} catch {
|
||||
notify(`Failed to delete ${kind_word} vouchers`, "error");
|
||||
}
|
||||
setBusy(false);
|
||||
},
|
||||
[selected],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<div className="mb-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search vouchers by name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary text-2xl hover:text-primary"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
{!editMode ? (
|
||||
<>
|
||||
<button onClick={startEdit} className="btn-primary">
|
||||
Edit Mode
|
||||
</button>
|
||||
<button onClick={load} className="btn-secondary">
|
||||
Refresh
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={selectAll}
|
||||
disabled={!filteredVouchers.length}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteVouchers("selected")}
|
||||
disabled={busy || !selected.size}
|
||||
className="btn-danger"
|
||||
>
|
||||
Delete Selected
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteVouchers("expired")}
|
||||
disabled={busy || !expiredVouchers.length}
|
||||
className="btn-warning"
|
||||
>
|
||||
Delete Expired
|
||||
</button>
|
||||
<button onClick={cancelEdit} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
{busy ? <Spinner /> : <></>}
|
||||
<span className="text-sm text-secondary font-bold ml-auto">
|
||||
{selected.size} selected
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{searchQuery && (
|
||||
<div className="mb-4 text-sm text-secondary">
|
||||
Showing {filteredVouchers.length} of {vouchers.length} vouchers
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : !filteredVouchers.length ? (
|
||||
<div className="text-center py-8 text-secondary">
|
||||
{searchQuery
|
||||
? "No vouchers found matching your search"
|
||||
: "No vouchers found"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filteredVouchers.map((v) => (
|
||||
<VoucherCard
|
||||
key={v.id}
|
||||
voucher={v}
|
||||
editMode={editMode}
|
||||
selected={selected.has(v.id)}
|
||||
onClick={() =>
|
||||
editMode ? toggleSelect(v.id) : setViewVoucher(v)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewVoucher && (
|
||||
<VoucherModal voucher={viewVoucher} onClose={closeModal} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
41
frontend/src/components/utils/CopyCode.tsx
Normal file
41
frontend/src/components/utils/CopyCode.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { copyText } from "@/utils/clipboard";
|
||||
import { formatCode } from "@/utils/format";
|
||||
import { notify } from "@/utils/notifications";
|
||||
import { useState } from "react";
|
||||
|
||||
type Props = {
|
||||
rawCode: string;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export default function CopyCode({ rawCode, contentClassName = "" }: Props) {
|
||||
const code = formatCode(rawCode);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (await copyText(rawCode)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
notify("Code copied to clipboard!", "success");
|
||||
} else {
|
||||
notify("Failed to copy code", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`text-center ${contentClassName}`}>
|
||||
<div
|
||||
onClick={handleCopy}
|
||||
className="cursor-pointer mb-4 text-3xl voucher-code"
|
||||
>
|
||||
{code}
|
||||
</div>
|
||||
|
||||
<button onClick={handleCopy} className="btn-success w-2/3">
|
||||
{copied ? "Copied" : "Copy Code"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
10
frontend/src/components/utils/Spinner.tsx
Normal file
10
frontend/src/components/utils/Spinner.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export default function Spinner() {
|
||||
return (
|
||||
<div className="flex-center py-4">
|
||||
<div className="relative w-8 h-8">
|
||||
<div className="absolute inset-0 rounded-full border-3 border-default" />
|
||||
<div className="absolute inset-0 rounded-full border-3 border-accent border-t-transparent animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
64
frontend/src/components/utils/ThemeSwitcher.tsx
Normal file
64
frontend/src/components/utils/ThemeSwitcher.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { notify } from "@/utils/notifications";
|
||||
|
||||
export default function ThemeSwitcher() {
|
||||
type themeType = "system" | "light" | "dark";
|
||||
const [theme, setTheme] = useState<themeType>("system");
|
||||
|
||||
// Load saved theme
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("theme") as themeType | null;
|
||||
setTheme(stored || "system");
|
||||
}, []);
|
||||
|
||||
// Apply theme class
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
const mql = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
const apply = () => {
|
||||
// Only disable transitions on Safari
|
||||
if (isSafari) {
|
||||
html.classList.add("transition-disabled");
|
||||
notify("Theme changed, reloading for Safari compatibility", "info");
|
||||
}
|
||||
|
||||
const isDark = theme === "dark" || (theme === "system" && mql.matches);
|
||||
html.classList.toggle("dark", isDark);
|
||||
localStorage.setItem("theme", theme);
|
||||
|
||||
// Re-enable transitions after a brief delay (Safari only)
|
||||
if (isSafari) {
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
html.classList.remove("transition-disabled");
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
// For system mode, listen to changes
|
||||
mql.addEventListener("change", apply);
|
||||
|
||||
return () => {
|
||||
mql.removeEventListener("change", apply);
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value as any)}
|
||||
className="text-sm"
|
||||
>
|
||||
<option value="system">⚙️ System</option>
|
||||
<option value="light">🌞 Light</option>
|
||||
<option value="dark">🌙 Dark</option>
|
||||
</select>
|
||||
);
|
||||
}
|
17
frontend/src/middleware.ts
Normal file
17
frontend/src/middleware.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
|
||||
export const config = {
|
||||
matcher: "/api/:path*",
|
||||
};
|
||||
|
||||
const DEFAULT_FRONTEND_TO_BACKEND_URL = "http://127.0.0.1";
|
||||
const DEFAULT_BACKEND_BIND_PORT = "8080";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
return NextResponse.rewrite(
|
||||
new URL(
|
||||
`${process.env.FRONTEND_TO_BACKEND_URL || DEFAULT_FRONTEND_TO_BACKEND_URL}:${process.env.BACKEND_BIND_PORT || DEFAULT_BACKEND_BIND_PORT}${request.nextUrl.pathname}${request.nextUrl.search}`,
|
||||
),
|
||||
{ request },
|
||||
);
|
||||
}
|
37
frontend/src/types/voucher.ts
Normal file
37
frontend/src/types/voucher.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface Voucher {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
name: string;
|
||||
code: string;
|
||||
authorizedGuestLimit?: number | null;
|
||||
authorizedGuestCount: number;
|
||||
activatedAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
expired: boolean;
|
||||
timeLimitMinutes: number;
|
||||
dataUsageLimitMBytes?: number | null;
|
||||
rxRateLimitKbps?: number | null;
|
||||
txRateLimitKbps?: number | null;
|
||||
}
|
||||
|
||||
export interface VoucherCreateData
|
||||
extends Omit<
|
||||
Voucher,
|
||||
| "id"
|
||||
| "createdAt"
|
||||
| "code"
|
||||
| "authorizedGuestCount"
|
||||
| "activatedAt"
|
||||
| "expiresAt"
|
||||
| "expired"
|
||||
> {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface VoucherDeletedResponse {
|
||||
vouchersDeleted: number;
|
||||
}
|
||||
|
||||
export interface VoucherCreatedResponse {
|
||||
vouchers: Voucher[];
|
||||
}
|
64
frontend/src/utils/api.ts
Normal file
64
frontend/src/utils/api.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Voucher,
|
||||
VoucherCreateData,
|
||||
VoucherCreatedResponse,
|
||||
VoucherDeletedResponse,
|
||||
} from "@/types/voucher";
|
||||
|
||||
function removeNullUndefined<T extends Record<string, any>>(obj: T): T {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(
|
||||
([_, value]) => value !== null && value !== undefined,
|
||||
),
|
||||
) as T;
|
||||
}
|
||||
|
||||
async function call<T>(endpoint: string, opts: RequestInit = {}) {
|
||||
const res = await fetch(`/api/${endpoint}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...opts,
|
||||
});
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function notifyVouchersUpdated() {
|
||||
window.dispatchEvent(new CustomEvent("vouchersUpdated"));
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getAllVouchers: () => call<{ data: Voucher[] }>("/vouchers"),
|
||||
|
||||
getVoucherDetails: (id: string) =>
|
||||
call<Voucher>(`/vouchers/details?id=${encodeURIComponent(id)}`),
|
||||
|
||||
createVoucher: async (data: VoucherCreateData) => {
|
||||
const filteredData = removeNullUndefined(data);
|
||||
const result = await call<VoucherCreatedResponse>("/vouchers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(filteredData),
|
||||
});
|
||||
notifyVouchersUpdated();
|
||||
return result;
|
||||
},
|
||||
|
||||
deleteExpired: async () => {
|
||||
const result = await call<VoucherDeletedResponse>("/vouchers/expired", {
|
||||
method: "DELETE",
|
||||
});
|
||||
notifyVouchersUpdated();
|
||||
return result;
|
||||
},
|
||||
|
||||
deleteSelected: async (ids: string[]) => {
|
||||
const qs = ids.map(encodeURIComponent).join(",");
|
||||
const result = await call<VoucherDeletedResponse>(
|
||||
`/vouchers/selected?ids=${qs}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
notifyVouchersUpdated();
|
||||
return result;
|
||||
},
|
||||
};
|
34
frontend/src/utils/clipboard.ts
Normal file
34
frontend/src/utils/clipboard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copy the given text to clipboard.
|
||||
* Tries the modern Clipboard API first; on failure, falls back to execCommand.
|
||||
* Returns true if the text was successfully copied.
|
||||
*/
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
// Try Clipboard API
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// fall through to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to textarea + execCommand
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "absolute";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
let success = false;
|
||||
try {
|
||||
success = document.execCommand("copy");
|
||||
} catch {
|
||||
success = false;
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
return success;
|
||||
}
|
49
frontend/src/utils/format.ts
Normal file
49
frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export function formatCode(code: string) {
|
||||
return code.length === 10 ? code.replace(/(.{5})(.{5})/, "$1-$2") : code;
|
||||
}
|
||||
|
||||
export function formatDate(d: string) {
|
||||
return new Date(d).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatDuration(m: number | null | undefined) {
|
||||
if (!m) return "Unlimited";
|
||||
const days = Math.floor(m / 1440),
|
||||
hours = Math.floor((m % 1440) / 60),
|
||||
mins = m % 60;
|
||||
return (
|
||||
[
|
||||
days > 0 ? days + "d" : "",
|
||||
hours > 0 ? hours + "h" : "",
|
||||
mins > 0 ? mins + "m" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ") || "0m"
|
||||
);
|
||||
}
|
||||
|
||||
export function formatBytes(b: number | null | undefined) {
|
||||
if (!b) return "Unlimited";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = b,
|
||||
i = 0;
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function formatSpeed(kbps: number | null | undefined) {
|
||||
if (!kbps) return "Unlimited";
|
||||
return kbps >= 1024
|
||||
? `${(kbps / 1024).toFixed(kbps < 10240 ? 1 : 0)} Mbps`
|
||||
: `${kbps} Kbps`;
|
||||
}
|
||||
|
||||
export function formatGuestUsage(
|
||||
usage: number,
|
||||
limit: number | null | undefined,
|
||||
) {
|
||||
return limit ? `${usage}/${limit}` : `${usage}/∞`;
|
||||
}
|
6
frontend/src/utils/functional.ts
Normal file
6
frontend/src/utils/functional.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function map<T, U>(value: T | null, fn: (v: T) => U): U | null {
|
||||
/**
|
||||
* Returns the result of applying fn to value if value is non-null, otherwise returns null.
|
||||
*/
|
||||
return value != null ? fn(value) : null;
|
||||
}
|
43
frontend/src/utils/notifications.ts
Normal file
43
frontend/src/utils/notifications.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type NotificationType = "success" | "error" | "info";
|
||||
|
||||
export interface NotificationPayload {
|
||||
id: string;
|
||||
message: string;
|
||||
type: NotificationType;
|
||||
}
|
||||
|
||||
/** Generate a RFC-4122 v4 UUID */
|
||||
function generateUUID(): string {
|
||||
// use crypto.randomUUID() when available
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
// @ts-ignore
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// fallback to crypto.getRandomValues
|
||||
let d = new Date().getTime();
|
||||
let d2 = (performance && performance.now && performance.now() * 1000) || 0;
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r =
|
||||
Math.random() * 16 +
|
||||
// use high-res entropy if available
|
||||
(crypto && crypto.getRandomValues
|
||||
? crypto.getRandomValues(new Uint8Array(1))[0]
|
||||
: 0);
|
||||
const v = c === "x" ? r % 16 | 0 : (r % 16 & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a notification event. Listeners (e.g. NotificationContainer)
|
||||
* will pick this up and render it.
|
||||
*/
|
||||
export function notify(message: string, type: NotificationType = "info") {
|
||||
const id = generateUUID();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<NotificationPayload>("notify", {
|
||||
detail: { id, message, type },
|
||||
}),
|
||||
);
|
||||
}
|
27
frontend/tsconfig.json
Normal file
27
frontend/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
19
scripts/healthcheck.sh
Normal file
19
scripts/healthcheck.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Check backend health
|
||||
echo "Checking backend on port $BACKEND_BIND_PORT..."
|
||||
wget --no-verbose --tries=1 --spider --timeout=5 "http://localhost:$BACKEND_BIND_PORT/api/health" 2>/dev/null || {
|
||||
echo "Backend health check failed on port $BACKEND_BIND_PORT"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check frontend health
|
||||
echo "Checking frontend on port $FRONTEND_BIND_PORT..."
|
||||
wget --no-verbose --tries=1 --spider --timeout=5 "http://localhost:$FRONTEND_BIND_PORT" 2>/dev/null || {
|
||||
echo "Frontend health check failed on port $FRONTEND_BIND_PORT"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Both services are healthy"
|
||||
exit 0
|
39
scripts/run_wrapper.sh
Normal file
39
scripts/run_wrapper.sh
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
echo "================================================================"
|
||||
echo "Starting services..."
|
||||
echo "Frontend will listen on: ${FRONTEND_BIND_HOST}:${FRONTEND_BIND_PORT}"
|
||||
echo "Backend will listen on: ${BACKEND_BIND_HOST}:${BACKEND_BIND_PORT}"
|
||||
echo "================================================================"
|
||||
|
||||
# Start backend in background
|
||||
echo "Starting backend..."
|
||||
./backend &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# Wait for backend to initialize
|
||||
sleep 3
|
||||
|
||||
# Start frontend in foreground
|
||||
echo "Starting frontend..."
|
||||
NEXT_TELEMETRY_DISABLED="1" NODE_ENV="production" HOSTNAME=${FRONTEND_BIND_HOST} PORT="${FRONTEND_BIND_PORT}" node ./frontend/server.js &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo "================================================================"
|
||||
echo "Shutting down services..."
|
||||
kill $BACKEND_PID $FRONTEND_PID 2>/dev/null
|
||||
wait $BACKEND_PID $FRONTEND_PID 2>/dev/null
|
||||
echo "Frontend and Backend services have been shut down."
|
||||
echo "================================================================"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Set up signal handlers
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
# Wait for any process to exit
|
||||
wait $BACKEND_PID $FRONTEND_PID
|
||||
|
||||
# Exit with status of process that exited first
|
||||
exit $?
|
Reference in New Issue
Block a user