This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
name: Build and Push Lunchtime Images (Kaniko)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.sgruber.at
|
||||
IMAGE_NAMESPACE: lunchtime
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute image tags and auth
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
SHORT_SHA=$(echo $GITHUB_SHA | cut -c1-7)
|
||||
IMAGE_REPO="$REGISTRY/$IMAGE_NAMESPACE/lunchtime-web"
|
||||
|
||||
# Prepare Kaniko destination arguments
|
||||
FRONTEND_DESTS="--destination $IMAGE_REPO:frontend-$SHORT_SHA"
|
||||
BACKEND_DESTS="--destination $IMAGE_REPO:backend-$SHORT_SHA"
|
||||
|
||||
if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
|
||||
FRONTEND_DESTS="$FRONTEND_DESTS --destination $IMAGE_REPO:frontend-${GITHUB_REF_NAME}"
|
||||
BACKEND_DESTS="$BACKEND_DESTS --destination $IMAGE_REPO:backend-${GITHUB_REF_NAME}"
|
||||
fi
|
||||
|
||||
if [[ "$GITHUB_REF_NAME" == "main" || "$GITHUB_REF_NAME" == "master" ]]; then
|
||||
FRONTEND_DESTS="$FRONTEND_DESTS --destination $IMAGE_REPO:frontend-latest"
|
||||
BACKEND_DESTS="$BACKEND_DESTS --destination $IMAGE_REPO:backend-latest"
|
||||
fi
|
||||
|
||||
# Create the auth string
|
||||
AUTH_B64=$(echo -n "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" | base64 -w 0)
|
||||
CONFIG_JSON="{\"auths\":{\"$REGISTRY\":{\"auth\":\"$AUTH_B64\"}}}"
|
||||
CONFIG_B64=$(echo -n "$CONFIG_JSON" | base64 -w 0)
|
||||
|
||||
echo "frontend_dests=$FRONTEND_DESTS" >> "$GITHUB_OUTPUT"
|
||||
echo "backend_dests=$BACKEND_DESTS" >> "$GITHUB_OUTPUT"
|
||||
echo "config_b64=$CONFIG_B64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and Push Frontend
|
||||
uses: docker://gcr.io/kaniko-project/executor:debug
|
||||
env:
|
||||
DOCKER_CONFIG_B64: ${{ steps.meta.outputs.config_b64 }}
|
||||
with:
|
||||
entrypoint: /busybox/sh
|
||||
args: >-
|
||||
-c "mkdir -p /kaniko/.docker &&
|
||||
echo $DOCKER_CONFIG_B64 | base64 -d > /kaniko/.docker/config.json &&
|
||||
/kaniko/executor
|
||||
--context=$GITHUB_WORKSPACE/src/frontend
|
||||
--dockerfile=$GITHUB_WORKSPACE/src/frontend/Containerfile
|
||||
${{ steps.meta.outputs.frontend_dests }}"
|
||||
|
||||
- name: Build and Push Backend
|
||||
uses: docker://gcr.io/kaniko-project/executor:debug
|
||||
env:
|
||||
DOCKER_CONFIG_B64: ${{ steps.meta.outputs.config_b64 }}
|
||||
with:
|
||||
entrypoint: /busybox/sh
|
||||
args: >-
|
||||
-c "mkdir -p /kaniko/.docker &&
|
||||
echo $DOCKER_CONFIG_B64 | base64 -d > /kaniko/.docker/config.json &&
|
||||
/kaniko/executor
|
||||
--context=$GITHUB_WORKSPACE/src/backend
|
||||
--dockerfile=$GITHUB_WORKSPACE/src/backend/Containerfile
|
||||
${{ steps.meta.outputs.backend_dests }}"
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.sqlite3
|
||||
*.db
|
||||
.venv/
|
||||
.env
|
||||
.DS_Store
|
||||
.data/
|
||||
@@ -0,0 +1,62 @@
|
||||
# Lunchtime
|
||||
|
||||
Lunchtime is a self-hosted lunch order app with a React frontend, a Python backend, and nginx for reverse proxying.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Copy and adjust `config.yaml` for your environment.
|
||||
2. Create a writable data folder (for SQLite and uploads), for example `.data`.
|
||||
3. Start the stack with Docker Compose.
|
||||
|
||||
Example `compose.yml` (inspired by `src/compose.yml`) using images from the Gitea registry:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
image: git.sgruber.at/lunchtime/lunchtime-api:latest
|
||||
volumes:
|
||||
- ./.data:/app/data
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
environment:
|
||||
- APP_ENV=production
|
||||
- LOG_LEVEL=info
|
||||
- DB_PATH=/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: git.sgruber.at/lunchtime/lunchtime-web:latest
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `http://localhost:8080`.
|
||||
|
||||
## CI/CD Registry Publish
|
||||
|
||||
The workflow in `.gitea/workflows/build-and-push-images.yaml` builds and pushes:
|
||||
|
||||
- `git.sgruber.at/lunchtime/lunchtime-web`
|
||||
- `git.sgruber.at/lunchtime/lunchtime-api`
|
||||
|
||||
It tags images with the commit SHA and also `latest` on `main`/`master`.
|
||||
|
||||
Required repository secrets:
|
||||
|
||||
- `GITEA_REGISTRY_USER`
|
||||
- `GITEA_REGISTRY_TOKEN`
|
||||
@@ -0,0 +1,10 @@
|
||||
Add admin view:
|
||||
|
||||
Users tab:
|
||||
- Show registered users and their orders (tree view? table with expandable groupings?) meaning what they created and what they participated in
|
||||
- Allow deleting users (and their orders), also add switch to make them admin (popconfirm)
|
||||
- View and set user email and confirmed state
|
||||
|
||||
E-Mail configuration tab:
|
||||
- View email configuration (without password)
|
||||
- Ability to send test email
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
app:
|
||||
public_base_url: "http://localhost:8080/"
|
||||
|
||||
email:
|
||||
enabled: true
|
||||
smtp_host: "mail.example.com"
|
||||
smtp_port: 465
|
||||
smtp_username: "lunchtime@example.com"
|
||||
smtp_password: ""
|
||||
from_address: "lunchtime@example.com"
|
||||
use_tls: false
|
||||
use_ssl: true
|
||||
|
||||
announcements:
|
||||
# - type: info
|
||||
# title: Info
|
||||
# message: Welcome to Lunchtime!
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
upstream backend {
|
||||
server backend:5000;
|
||||
}
|
||||
|
||||
upstream frontend {
|
||||
server frontend:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://frontend/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.db
|
||||
*.sqlite3
|
||||
.venv/
|
||||
.env
|
||||
.DS_Store
|
||||
.git/
|
||||
.github/
|
||||
*.md
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
RUN mkdir -p /app/data && chmod 755 /app/data && \
|
||||
chmod +x /app/entrypoint.sh 2>/dev/null || true
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:5000/api/config || exit 1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5000"]
|
||||
@@ -0,0 +1,147 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
data_dir = Path(os.getenv("DB_PATH", "/app/data"))
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
DB_PATH = data_dir / "burger_orders.db"
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with get_connection() as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS group_orders (
|
||||
id TEXT PRIMARY KEY,
|
||||
admin_token TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
image_url TEXT,
|
||||
closed INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS submissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
group_order_id TEXT NOT NULL,
|
||||
submission_token TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL,
|
||||
choices_json TEXT NOT NULL,
|
||||
accepted INTEGER NOT NULL DEFAULT 0,
|
||||
paid INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(group_order_id) REFERENCES group_orders(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_order_tokens (
|
||||
user_id TEXT NOT NULL,
|
||||
group_order_id TEXT NOT NULL,
|
||||
admin_token TEXT,
|
||||
submission_token TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, group_order_id),
|
||||
FOREIGN KEY(group_order_id) REFERENCES group_orders(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(admin_token) REFERENCES group_orders(admin_token) ON DELETE SET NULL,
|
||||
FOREIGN KEY(submission_token) REFERENCES submissions(submission_token) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
email_confirmed INTEGER NOT NULL DEFAULT 0,
|
||||
pending_email TEXT,
|
||||
pending_email_old_confirmed INTEGER NOT NULL DEFAULT 0,
|
||||
pending_email_new_confirmed INTEGER NOT NULL DEFAULT 0,
|
||||
pending_user_id TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_confirmation_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
process_id TEXT,
|
||||
email TEXT,
|
||||
new_email TEXT,
|
||||
new_user_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_account_confirmation_tokens_user_id
|
||||
ON account_confirmation_tokens(user_id)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_account_confirmation_tokens_process_id
|
||||
ON account_confirmation_tokens(process_id)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def row_to_submission(row: sqlite3.Row) -> dict:
|
||||
choices: dict = {}
|
||||
raw_choices = row["choices_json"]
|
||||
if raw_choices:
|
||||
try:
|
||||
parsed = json.loads(raw_choices)
|
||||
if isinstance(parsed, dict):
|
||||
choices = parsed
|
||||
except json.JSONDecodeError:
|
||||
choices = {}
|
||||
|
||||
return {
|
||||
"id": row["id"],
|
||||
"group_order_id": row["group_order_id"],
|
||||
"submission_token": row["submission_token"],
|
||||
"email": row["email"],
|
||||
"choices": choices,
|
||||
"accepted": bool(row["accepted"]),
|
||||
"paid": bool(row["paid"]),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def row_to_my_order(row: sqlite3.Row) -> dict:
|
||||
submission_choices: dict = {}
|
||||
raw_choices = row["choices_json"]
|
||||
if raw_choices:
|
||||
try:
|
||||
parsed = json.loads(raw_choices)
|
||||
if isinstance(parsed, dict):
|
||||
submission_choices = parsed
|
||||
except json.JSONDecodeError:
|
||||
submission_choices = {}
|
||||
|
||||
return {
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
"description": row["description"],
|
||||
"image_url": row["image_url"],
|
||||
"closed": bool(row["closed"]),
|
||||
"created_at": row["created_at"],
|
||||
"is_owner": bool(row["admin_token"]),
|
||||
"is_participant": bool(row["submission_token"]),
|
||||
"submission": {
|
||||
"choices": submission_choices,
|
||||
"accepted": bool(row["accepted"]) if row["accepted"] is not None else False,
|
||||
"paid": bool(row["paid"]) if row["paid"] is not None else False,
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
||||
import secrets
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .common_service import clean_user_email, clean_user_id, now_iso
|
||||
from ..db import row_to_submission
|
||||
|
||||
|
||||
def upsert_user_order_tokens(
|
||||
conn: sqlite3.Connection,
|
||||
user_id: str,
|
||||
order_id: str,
|
||||
*,
|
||||
admin_token: str | None = None,
|
||||
submission_token: str | None = None,
|
||||
) -> None:
|
||||
timestamp = now_iso()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO user_order_tokens (
|
||||
user_id, group_order_id, admin_token, submission_token, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, group_order_id)
|
||||
DO UPDATE SET
|
||||
admin_token = COALESCE(excluded.admin_token, user_order_tokens.admin_token),
|
||||
submission_token = COALESCE(excluded.submission_token, user_order_tokens.submission_token),
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(user_id, order_id, admin_token, submission_token, timestamp, timestamp),
|
||||
)
|
||||
|
||||
|
||||
def get_user_order_tokens(conn: sqlite3.Connection, user_id: str, order_id: str) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT user_id, group_order_id, admin_token, submission_token FROM user_order_tokens WHERE user_id = ? AND group_order_id = ?",
|
||||
(user_id, order_id),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def upsert_user_profile_email(conn: sqlite3.Connection, user_id: str, email: str) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO user_profiles (user_id, email, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id)
|
||||
DO UPDATE SET
|
||||
email = excluded.email,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(user_id, email, now_iso()),
|
||||
)
|
||||
|
||||
|
||||
def get_user_profile_email(conn: sqlite3.Connection, user_id: str) -> str | None:
|
||||
row = conn.execute(
|
||||
"SELECT email FROM user_profiles WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return str(row["email"]).strip().lower() if row and row["email"] else None
|
||||
|
||||
|
||||
def get_user_profile(conn: sqlite3.Connection, user_id: str) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
user_id,
|
||||
email,
|
||||
email_confirmed,
|
||||
pending_email,
|
||||
pending_email_old_confirmed,
|
||||
pending_email_new_confirmed,
|
||||
pending_user_id,
|
||||
updated_at
|
||||
FROM user_profiles
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def create_confirmation_token(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
user_id: str,
|
||||
action: str,
|
||||
email: str | None = None,
|
||||
new_email: str | None = None,
|
||||
new_user_id: str | None = None,
|
||||
process_id: str | None = None,
|
||||
expires_in_hours: int = 24,
|
||||
) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
created_at = now_iso()
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO account_confirmation_tokens (
|
||||
token, user_id, action, process_id, email, new_email, new_user_id, created_at, expires_at, consumed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||
""",
|
||||
(token, user_id, action, process_id, email, new_email, new_user_id, created_at, expires_at),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def consume_confirmation_token(conn: sqlite3.Connection, token: str) -> sqlite3.Row:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT token, user_id, action, process_id, email, new_email, new_user_id, created_at, expires_at, consumed_at
|
||||
FROM account_confirmation_tokens
|
||||
WHERE token = ?
|
||||
""",
|
||||
(token,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Confirmation token not found")
|
||||
if row["consumed_at"]:
|
||||
raise HTTPException(status_code=409, detail="Confirmation token already used")
|
||||
|
||||
expires_at = datetime.fromisoformat(str(row["expires_at"]))
|
||||
if datetime.now(timezone.utc) > expires_at:
|
||||
raise HTTPException(status_code=410, detail="Confirmation token has expired")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE account_confirmation_tokens SET consumed_at = ? WHERE token = ?",
|
||||
(now_iso(), token),
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def choose_new_user_id(conn: sqlite3.Connection, requested_user_id: str | None) -> str:
|
||||
if requested_user_id:
|
||||
candidate = clean_user_id(requested_user_id)
|
||||
existing = conn.execute(
|
||||
"SELECT user_id FROM user_profiles WHERE user_id = ?",
|
||||
(candidate,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Requested user ID is already in use")
|
||||
return candidate
|
||||
|
||||
for _ in range(5):
|
||||
candidate = str(uuid4())
|
||||
existing = conn.execute(
|
||||
"SELECT user_id FROM user_profiles WHERE user_id = ?",
|
||||
(candidate,),
|
||||
).fetchone()
|
||||
if not existing:
|
||||
return candidate
|
||||
|
||||
raise HTTPException(status_code=500, detail="Could not generate a unique user ID")
|
||||
|
||||
|
||||
def migrate_user_id(conn: sqlite3.Connection, old_user_id: str, new_user_id: str) -> None:
|
||||
exists = conn.execute(
|
||||
"SELECT user_id FROM user_profiles WHERE user_id = ?",
|
||||
(new_user_id,),
|
||||
).fetchone()
|
||||
if exists:
|
||||
raise HTTPException(status_code=409, detail="New user ID already exists")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE user_profiles SET user_id = ?, pending_user_id = NULL, updated_at = ? WHERE user_id = ?",
|
||||
(new_user_id, now_iso(), old_user_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE user_order_tokens SET user_id = ?, updated_at = ? WHERE user_id = ?",
|
||||
(new_user_id, now_iso(), old_user_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE account_confirmation_tokens SET user_id = ? WHERE user_id = ? AND consumed_at IS NULL",
|
||||
(new_user_id, old_user_id),
|
||||
)
|
||||
|
||||
|
||||
def resolve_submission_email(conn: sqlite3.Connection, user_id: str, user_email_header: str | None) -> str:
|
||||
header_email = clean_user_email(user_email_header)
|
||||
if header_email:
|
||||
upsert_user_profile_email(conn, user_id, header_email)
|
||||
return header_email
|
||||
|
||||
saved_email = get_user_profile_email(conn, user_id)
|
||||
if saved_email:
|
||||
return saved_email
|
||||
|
||||
raise HTTPException(status_code=422, detail="No email associated with current user token")
|
||||
|
||||
|
||||
def ensure_owner_access(conn: sqlite3.Connection, user_id: str, order_id: str) -> sqlite3.Row:
|
||||
row = get_user_order_tokens(conn, user_id, order_id)
|
||||
if not row or not row["admin_token"]:
|
||||
raise HTTPException(status_code=404, detail="Admin order view not found")
|
||||
return row
|
||||
|
||||
|
||||
def build_submission_payload(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
return row_to_submission(row) if row else None
|
||||
|
||||
|
||||
def get_order_creator_info(conn: sqlite3.Connection, order_id: str) -> tuple[str | None, str | None]:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT uot.user_id AS creator_user_id, up.email AS creator_email
|
||||
FROM group_orders go
|
||||
LEFT JOIN user_order_tokens uot
|
||||
ON uot.group_order_id = go.id
|
||||
AND uot.admin_token = go.admin_token
|
||||
LEFT JOIN user_profiles up
|
||||
ON up.user_id = uot.user_id
|
||||
WHERE go.id = ?
|
||||
ORDER BY uot.created_at ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(order_id,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return None, None
|
||||
|
||||
creator_user_id = str(row["creator_user_id"]).strip() if row["creator_user_id"] else None
|
||||
creator_email = str(row["creator_email"]).strip().lower() if row["creator_email"] else None
|
||||
return creator_user_id, creator_email
|
||||
@@ -0,0 +1,51 @@
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import EmailStr, TypeAdapter
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def clean_optional(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
trimmed = value.strip()
|
||||
return trimmed if trimmed else None
|
||||
|
||||
|
||||
def clean_user_id(raw_user_id: str | None) -> str:
|
||||
clean_value = (raw_user_id or "").strip()
|
||||
if not clean_value:
|
||||
raise HTTPException(status_code=401, detail="Missing user ID")
|
||||
if len(clean_value) > 200:
|
||||
raise HTTPException(status_code=422, detail="User ID is too long")
|
||||
return clean_value
|
||||
|
||||
|
||||
def clean_user_email(raw_user_email: str | None) -> str | None:
|
||||
clean_value = clean_optional(raw_user_email)
|
||||
if not clean_value:
|
||||
return None
|
||||
|
||||
try:
|
||||
validated = TypeAdapter(EmailStr).validate_python(clean_value)
|
||||
except Exception as exc: # pragma: no cover - defensive against validator changes
|
||||
raise HTTPException(status_code=422, detail="User email header is invalid") from exc
|
||||
|
||||
return str(validated).lower()
|
||||
|
||||
|
||||
def clean_image_url(value: str | None) -> str | None:
|
||||
clean_url = clean_optional(value)
|
||||
if not clean_url:
|
||||
return None
|
||||
|
||||
if clean_url.startswith("http"):
|
||||
parsed = urlparse(clean_url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise HTTPException(status_code=422, detail="image_url must be a valid http(s) URL or relative path")
|
||||
|
||||
return clean_url
|
||||
@@ -0,0 +1,83 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from fastapi import HTTPException
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
CONFIG_PATH = BASE_DIR / "config.yaml"
|
||||
|
||||
|
||||
def normalize_announcement(item: Any) -> dict[str, str] | None:
|
||||
allowed_types = {"info", "warning", "error", "success"}
|
||||
|
||||
if isinstance(item, str):
|
||||
message = item.strip()
|
||||
if not message:
|
||||
return None
|
||||
return {"type": "info", "message": message}
|
||||
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
|
||||
message = str(item.get("message", "")).strip()
|
||||
if not message:
|
||||
return None
|
||||
|
||||
announcement_type = str(item.get("type", "info")).strip().lower()
|
||||
if announcement_type not in allowed_types:
|
||||
announcement_type = "info"
|
||||
|
||||
title = str(item.get("title", "")).strip()
|
||||
normalized = {"type": announcement_type, "message": message}
|
||||
if title:
|
||||
normalized["title"] = title
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_announcements(items: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
|
||||
normalized: list[dict[str, str]] = []
|
||||
for item in items:
|
||||
clean_item = normalize_announcement(item)
|
||||
if clean_item:
|
||||
normalized.append(clean_item)
|
||||
return normalized
|
||||
|
||||
|
||||
def load_raw_config() -> dict[str, Any]:
|
||||
if not CONFIG_PATH.exists():
|
||||
raise HTTPException(status_code=500, detail="config.yaml is missing")
|
||||
|
||||
with CONFIG_PATH.open("r", encoding="utf-8") as file:
|
||||
return yaml.safe_load(file) or {}
|
||||
|
||||
|
||||
def load_config() -> dict[str, Any]:
|
||||
data = load_raw_config()
|
||||
return {
|
||||
"announcements": normalize_announcements(data.get("announcements", [])),
|
||||
}
|
||||
|
||||
|
||||
def get_email_settings() -> dict[str, Any]:
|
||||
data = load_raw_config()
|
||||
email = data.get("email") if isinstance(data.get("email"), dict) else {}
|
||||
app = data.get("app") if isinstance(data.get("app"), dict) else {}
|
||||
|
||||
return {
|
||||
"enabled": bool(email.get("enabled", False)),
|
||||
"smtp_host": str(email.get("smtp_host", "") or "").strip(),
|
||||
"smtp_port": int(email.get("smtp_port", 587) or 587),
|
||||
"smtp_username": str(email.get("smtp_username", "") or "").strip(),
|
||||
"smtp_password": str(email.get("smtp_password", "") or ""),
|
||||
"from_address": str(email.get("from_address", "") or "").strip(),
|
||||
"use_tls": bool(email.get("use_tls", True)),
|
||||
"use_ssl": bool(email.get("use_ssl", False)),
|
||||
"confirmation_link_base": str(
|
||||
email.get("confirmation_link_base", app.get("public_base_url", "")) or ""
|
||||
).strip(),
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import logging
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .config_service import get_email_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_confirmation_link(base_url: str, token: str) -> str:
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=500, detail="Missing confirmation link base URL in config.yaml")
|
||||
|
||||
parsed = urlparse(base_url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise HTTPException(status_code=500, detail="confirmation_link_base must be a valid http(s) URL")
|
||||
|
||||
sep = "&" if "?" in base_url else "?"
|
||||
return f"{base_url}{sep}{urlencode({'token': token})}"
|
||||
|
||||
|
||||
def mask_email_for_log(value: str) -> str:
|
||||
local, sep, domain = value.partition("@")
|
||||
if not sep:
|
||||
return "***"
|
||||
if len(local) <= 1:
|
||||
return f"*@{domain}"
|
||||
return f"{local[0]}***@{domain}"
|
||||
|
||||
|
||||
def send_email_message(*, to_email: str, subject: str, body: str) -> None:
|
||||
settings = get_email_settings()
|
||||
masked_recipient = mask_email_for_log(to_email)
|
||||
|
||||
if not settings["enabled"]:
|
||||
logger.warning("Email send skipped because email delivery is disabled")
|
||||
raise HTTPException(status_code=503, detail="Email delivery is disabled")
|
||||
|
||||
required = ["smtp_host", "smtp_port", "from_address"]
|
||||
missing = [name for name in required if not settings.get(name)]
|
||||
if missing:
|
||||
logger.error("Email send blocked by missing email settings: %s", ", ".join(missing))
|
||||
raise HTTPException(status_code=500, detail=f"Missing email settings: {', '.join(missing)}")
|
||||
|
||||
logger.info(
|
||||
"Sending email to=%s subject=%s smtp_host=%s smtp_port=%s tls=%s ssl=%s",
|
||||
masked_recipient,
|
||||
subject,
|
||||
settings["smtp_host"],
|
||||
settings["smtp_port"],
|
||||
settings["use_tls"],
|
||||
settings["use_ssl"],
|
||||
)
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["From"] = settings["from_address"]
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = f"[Lunchtime] {subject}".strip()
|
||||
msg.set_content(body)
|
||||
|
||||
try:
|
||||
if settings["use_ssl"]:
|
||||
server = smtplib.SMTP_SSL(settings["smtp_host"], settings["smtp_port"], timeout=15)
|
||||
else:
|
||||
server = smtplib.SMTP(settings["smtp_host"], settings["smtp_port"], timeout=15)
|
||||
|
||||
with server:
|
||||
if settings["use_tls"] and not settings["use_ssl"]:
|
||||
server.starttls()
|
||||
|
||||
username = settings["smtp_username"]
|
||||
password = settings["smtp_password"]
|
||||
if username:
|
||||
server.login(username, password)
|
||||
|
||||
server.send_message(msg)
|
||||
|
||||
logger.info("Email sent successfully to=%s subject=%s", masked_recipient, subject)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - external SMTP/network dependency
|
||||
logger.exception(
|
||||
"Email send failed to=%s subject=%s smtp_host=%s smtp_port=%s",
|
||||
masked_recipient,
|
||||
subject,
|
||||
settings["smtp_host"],
|
||||
settings["smtp_port"],
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Failed to send email") from exc
|
||||
|
||||
|
||||
def send_registration_email(user_id: str, email: str, token: str) -> None:
|
||||
settings = get_email_settings()
|
||||
link = build_confirmation_link(settings["confirmation_link_base"], token)
|
||||
send_email_message(
|
||||
to_email=email,
|
||||
subject="Setup your Lunchtime account",
|
||||
body=(
|
||||
"Welcome to Lunchtime!\n\n"
|
||||
"Click this link to confirm your email address:\n"
|
||||
f"{link}\n\n"
|
||||
"If this wasn't you, you can safely ignore this email."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def send_user_id_change_email(current_email: str, new_user_id: str, token: str) -> None:
|
||||
settings = get_email_settings()
|
||||
link = build_confirmation_link(settings["confirmation_link_base"], token)
|
||||
send_email_message(
|
||||
to_email=current_email,
|
||||
subject="Confirm your user ID change",
|
||||
body=(
|
||||
"You requested a user ID change.\n\n"
|
||||
"Confirm this change by clicking this link:\n"
|
||||
f"{link}\n\n"
|
||||
"If this wasn't you, you can safely ignore this email."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def send_email_change_new_email_confirmation(new_email: str, token: str) -> None:
|
||||
settings = get_email_settings()
|
||||
link = build_confirmation_link(settings["confirmation_link_base"], token)
|
||||
send_email_message(
|
||||
to_email=new_email,
|
||||
subject="Confirm your new email address",
|
||||
body=(
|
||||
"You requested to change your account email address.\n\n"
|
||||
"Confirm ownership of this new email by clicking this link:\n"
|
||||
f"{link}\n\n"
|
||||
"If this wasn't you, you can safely ignore this email."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def send_migration_email(email: str, user_id: str, token: str) -> None:
|
||||
settings = get_email_settings()
|
||||
link = build_confirmation_link(settings["confirmation_link_base"], token)
|
||||
send_email_message(
|
||||
to_email=email,
|
||||
subject="Confirm account migration",
|
||||
body=(
|
||||
"You requested account migration on a new device.\n\n"
|
||||
"Confirm migration by clicking this link:\n"
|
||||
f"{link}\n\n"
|
||||
"If this wasn't you, you can safely ignore this email."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
IMAGES_DIR = BASE_DIR / "data" / "images"
|
||||
CONFIGS_DIR = BASE_DIR / "data" / "configs"
|
||||
DESCRIPTIONS_DIR = BASE_DIR / "data" / "descriptions"
|
||||
|
||||
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
DESCRIPTIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def load_order_config(admin_token: str) -> dict[str, Any]:
|
||||
config_path = CONFIGS_DIR / f"{admin_token}.json"
|
||||
if not config_path.exists():
|
||||
return {
|
||||
"categories": [],
|
||||
}
|
||||
|
||||
try:
|
||||
with config_path.open("r", encoding="utf-8") as file:
|
||||
return json.load(file)
|
||||
except Exception:
|
||||
return {"categories": []}
|
||||
|
||||
|
||||
def save_order_config(admin_token: str, config: dict[str, Any]) -> None:
|
||||
config_path = CONFIGS_DIR / f"{admin_token}.json"
|
||||
try:
|
||||
with config_path.open("w", encoding="utf-8") as file:
|
||||
json.dump(config, file, indent=2, ensure_ascii=False)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save configuration: {str(exc)}")
|
||||
|
||||
|
||||
def load_order_description(admin_token: str) -> str:
|
||||
desc_path = DESCRIPTIONS_DIR / f"{admin_token}.md"
|
||||
if not desc_path.exists():
|
||||
return ""
|
||||
|
||||
try:
|
||||
with desc_path.open("r", encoding="utf-8") as file:
|
||||
return file.read()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def save_order_description(admin_token: str, description: str) -> None:
|
||||
desc_path = DESCRIPTIONS_DIR / f"{admin_token}.md"
|
||||
try:
|
||||
with desc_path.open("w", encoding="utf-8") as file:
|
||||
file.write(description)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save description: {str(exc)}")
|
||||
|
||||
|
||||
def delete_order_description(admin_token: str) -> None:
|
||||
desc_path = DESCRIPTIONS_DIR / f"{admin_token}.md"
|
||||
try:
|
||||
if desc_path.exists():
|
||||
desc_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def delete_order_config(admin_token: str) -> None:
|
||||
config_path = CONFIGS_DIR / f"{admin_token}.json"
|
||||
try:
|
||||
if config_path.exists():
|
||||
config_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def delete_order_images(admin_token: str, image_url: str | None) -> None:
|
||||
try:
|
||||
if image_url and not image_url.startswith("http"):
|
||||
file_path = IMAGES_DIR / image_url
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
|
||||
order_images_dir = IMAGES_DIR / admin_token
|
||||
if order_images_dir.exists():
|
||||
shutil.rmtree(order_images_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def save_uploaded_image(file: UploadFile, order_id: str, admin_token: str) -> str:
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=422, detail="No filename provided")
|
||||
|
||||
allowed_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
file_ext = Path(file.filename).suffix.lower()
|
||||
|
||||
if file_ext not in allowed_extensions:
|
||||
raise HTTPException(status_code=422, detail=f"File type not allowed. Allowed: {', '.join(allowed_extensions)}")
|
||||
|
||||
order_images_dir = IMAGES_DIR / admin_token
|
||||
order_images_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
new_filename = f"{timestamp}_{file.filename.replace(' ', '_')}"
|
||||
file_path = order_images_dir / new_filename
|
||||
|
||||
try:
|
||||
contents = file.file.read()
|
||||
|
||||
if not contents:
|
||||
raise HTTPException(status_code=422, detail="Uploaded file is empty")
|
||||
|
||||
with open(file_path, "wb") as output_file:
|
||||
output_file.write(contents)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save image: {str(exc)}")
|
||||
|
||||
return f"{admin_token}/{new_filename}"
|
||||
@@ -0,0 +1,23 @@
|
||||
import sqlite3
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..db import get_connection
|
||||
|
||||
|
||||
def ensure_order_exists(order_id: str) -> sqlite3.Row:
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, admin_token, title, description, image_url, closed, created_at FROM group_orders WHERE id = ?",
|
||||
(order_id,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
return row
|
||||
|
||||
|
||||
def ensure_order_open(order: sqlite3.Row) -> None:
|
||||
if bool(order["closed"]):
|
||||
raise HTTPException(status_code=409, detail="Order is closed for submissions")
|
||||
@@ -0,0 +1,178 @@
|
||||
from math import isfinite
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from .common_service import clean_optional
|
||||
|
||||
def get_category_snippet(category: dict[str, Any]) -> str:
|
||||
raw_snippet = str(category.get("formattedSnippet", "") or "").strip()
|
||||
if not raw_snippet or "{name}" not in raw_snippet:
|
||||
return "{label}: {name}"
|
||||
return raw_snippet
|
||||
|
||||
|
||||
def get_multiple_separator(category: dict[str, Any]) -> str:
|
||||
raw_separator = category.get("multipleSeparator")
|
||||
if isinstance(raw_separator, str):
|
||||
return raw_separator
|
||||
return ", "
|
||||
|
||||
|
||||
def normalize_submission_choices(choices: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
|
||||
categories = config.get("categories") or []
|
||||
normalized: dict[str, Any] = {}
|
||||
|
||||
for category in categories:
|
||||
category_id = str(category.get("id", "")).strip()
|
||||
if not category_id:
|
||||
continue
|
||||
|
||||
raw_value = choices.get(category_id)
|
||||
if bool(category.get("multiple")):
|
||||
raw_values = raw_value if isinstance(raw_value, list) else []
|
||||
normalized_values = sorted(
|
||||
{
|
||||
value
|
||||
for value in [clean_optional(str(entry)) for entry in raw_values]
|
||||
if value
|
||||
}
|
||||
)
|
||||
normalized[category_id] = normalized_values
|
||||
continue
|
||||
|
||||
single_raw = raw_value[0] if isinstance(raw_value, list) and raw_value else raw_value
|
||||
normalized[category_id] = clean_optional(str(single_raw)) if single_raw is not None else None
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_required_submission_choices(choices: dict[str, Any], config: dict[str, Any]) -> None:
|
||||
categories = config.get("categories") or []
|
||||
for category in categories:
|
||||
if not bool(category.get("required")):
|
||||
continue
|
||||
|
||||
category_id = str(category.get("id", "")).strip()
|
||||
if not category_id:
|
||||
continue
|
||||
|
||||
value = choices.get(category_id)
|
||||
if isinstance(value, list):
|
||||
if value:
|
||||
continue
|
||||
elif isinstance(value, str):
|
||||
if value.strip():
|
||||
continue
|
||||
elif value:
|
||||
continue
|
||||
|
||||
label = str(category.get("label", category_id)).strip() or category_id
|
||||
raise HTTPException(status_code=422, detail=f"{label} is required")
|
||||
|
||||
|
||||
def resolve_category_item_display_name(item_name: str, category: dict[str, Any]) -> str:
|
||||
for item in category.get("items") or []:
|
||||
if str(item.get("name", "")) != item_name:
|
||||
continue
|
||||
|
||||
return item_name
|
||||
|
||||
return item_name
|
||||
|
||||
|
||||
def calculate_estimated_submission_total(choices: dict[str, Any], config: dict[str, Any]) -> float | None:
|
||||
categories = config.get("categories") or []
|
||||
total = 0.0
|
||||
has_priced_items = False
|
||||
|
||||
for category in categories:
|
||||
category_id = str(category.get("id", "")).strip()
|
||||
if not category_id:
|
||||
continue
|
||||
|
||||
value = choices.get(category_id)
|
||||
items = category.get("items") or []
|
||||
|
||||
if bool(category.get("multiple")):
|
||||
selected_values = value if isinstance(value, list) else []
|
||||
for selected in selected_values:
|
||||
selected_name = str(selected)
|
||||
for item in items:
|
||||
if str(item.get("name", "")) != selected_name:
|
||||
continue
|
||||
|
||||
price = item.get("price")
|
||||
if isinstance(price, (int, float)) and isfinite(float(price)):
|
||||
total += float(price)
|
||||
has_priced_items = True
|
||||
break
|
||||
continue
|
||||
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
continue
|
||||
|
||||
selected_name = value.strip()
|
||||
for item in items:
|
||||
if str(item.get("name", "")) != selected_name:
|
||||
continue
|
||||
|
||||
price = item.get("price")
|
||||
if isinstance(price, (int, float)) and isfinite(float(price)):
|
||||
total += float(price)
|
||||
has_priced_items = True
|
||||
break
|
||||
|
||||
if not has_priced_items:
|
||||
return None
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def build_formatted_submission_string(choices: dict[str, Any], config: dict[str, Any]) -> str:
|
||||
categories = config.get("categories") or []
|
||||
parts: list[str] = []
|
||||
|
||||
for category in categories:
|
||||
category_id = str(category.get("id", "")).strip()
|
||||
if not category_id:
|
||||
continue
|
||||
|
||||
value = choices.get(category_id)
|
||||
category_part = ""
|
||||
|
||||
if bool(category.get("multiple")):
|
||||
values = value if isinstance(value, list) else []
|
||||
if values:
|
||||
labels = [resolve_category_item_display_name(str(entry), category) for entry in values]
|
||||
category_part = get_multiple_separator(category).join(labels)
|
||||
elif isinstance(value, str) and value.strip():
|
||||
category_part = resolve_category_item_display_name(value.strip(), category)
|
||||
|
||||
if category_part:
|
||||
snippet = get_category_snippet(category)
|
||||
label = str(category.get("label", category_id))
|
||||
parts.append(snippet.replace("{name}", category_part).replace("{label}", label))
|
||||
continue
|
||||
|
||||
if not bool(category.get("required")):
|
||||
fallback = str(category.get("optionalFallback", "") or "").strip()
|
||||
if fallback:
|
||||
parts.append(fallback)
|
||||
|
||||
if parts:
|
||||
return " ".join(parts)
|
||||
|
||||
return "No items selected"
|
||||
|
||||
|
||||
def with_submission_display(submission: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
|
||||
submission_with_display = dict(submission)
|
||||
submission_with_display["formatted_string"] = build_formatted_submission_string(
|
||||
submission_with_display.get("choices") or {},
|
||||
config,
|
||||
)
|
||||
submission_with_display["estimated_total"] = calculate_estimated_submission_total(
|
||||
submission_with_display.get("choices") or {},
|
||||
config,
|
||||
)
|
||||
return submission_with_display
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"categories": []
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.0
|
||||
PyYAML==6.0.2
|
||||
python-multipart==0.0.20
|
||||
email-validator==2.2.0
|
||||
@@ -0,0 +1,47 @@
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: lunchtime-nginx
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ../nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- burger-network
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: backend
|
||||
dockerfile: Containerfile
|
||||
container_name: lunchtime-backend
|
||||
volumes:
|
||||
- ../.data:/app/data
|
||||
- ../config.yaml:/app/config.yaml:ro
|
||||
environment:
|
||||
- APP_ENV=production
|
||||
- LOG_LEVEL=info
|
||||
- DB_PATH=/app/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
burger-network:
|
||||
aliases:
|
||||
- backend
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: frontend
|
||||
dockerfile: Containerfile
|
||||
container_name: lunchtime-frontend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
burger-network:
|
||||
aliases:
|
||||
- frontend
|
||||
|
||||
networks:
|
||||
burger-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM node:20-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . ./
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g http-server
|
||||
|
||||
COPY --from=build /app/dist ./
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget -q -O- http://localhost:8000/index.html || exit 1
|
||||
|
||||
CMD ["http-server", ".", "-p", "8000", "-c-1", "--gzip", "-P", "http://localhost:8000?"]
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="background-color: #323232;">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<title>Lunchtime</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2992
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "lunchtime-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 3000",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"antd": "^5.27.3",
|
||||
"dompurify": "^3.3.3",
|
||||
"marked": "^16.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-copy-to-clipboard": "^5.1.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 773 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
@@ -0,0 +1,420 @@
|
||||
import React, {
|
||||
memo,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useSyncExternalStore,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
ConfigProvider,
|
||||
Divider,
|
||||
Layout,
|
||||
Typography,
|
||||
Result,
|
||||
Space,
|
||||
message,
|
||||
theme as antdTheme,
|
||||
} from "antd";
|
||||
import Announcements from "./components/common/Announcements";
|
||||
import TopNav from "./components/common/TopNav";
|
||||
import WelcomeOnboardingModal from "./components/modals/WelcomeOnboardingModal";
|
||||
import { navigateTo, parseRoute, subscribeToRouteChange } from "./lib/routing";
|
||||
import { apiService } from "./lib/services";
|
||||
import {
|
||||
ensureUserId,
|
||||
hasStoredUserId,
|
||||
updateUserId,
|
||||
} from "./lib/userIdentity";
|
||||
import { THEME_MODE_KEY } from "./lib/constants";
|
||||
import { getStoredValue, setStoredValue } from "./lib/storage";
|
||||
import AdminView from "./views/AdminView";
|
||||
import CreateOrderView from "./views/CreateOrderView";
|
||||
import HomeView from "./views/HomeView";
|
||||
import ParticipantView from "./views/ParticipantView";
|
||||
|
||||
const { defaultAlgorithm, darkAlgorithm } = antdTheme;
|
||||
const { Content, Footer } = Layout;
|
||||
const { Text, Link, Paragraph } = Typography;
|
||||
|
||||
type ThemeMode = "light" | "dark";
|
||||
|
||||
type AppAnnouncement = {
|
||||
message?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
const warmLightTokens = {
|
||||
colorPrimary: "#c84f2a",
|
||||
colorInfo: "#d97706",
|
||||
colorSuccess: "#5f8f2d",
|
||||
colorWarning: "#c87b16",
|
||||
colorError: "#b63d26",
|
||||
colorLink: "#a85a1c",
|
||||
colorBgLayout: "#f8f1e6",
|
||||
colorBgContainer: "#fffaf2",
|
||||
colorBgElevated: "#fff6ea",
|
||||
colorText: "#3c2c1f",
|
||||
colorTextSecondary: "#6a5643",
|
||||
colorBorder: "#e7d3ba",
|
||||
borderRadius: 12,
|
||||
wireframe: false,
|
||||
};
|
||||
|
||||
const warmDarkTokens = {
|
||||
colorPrimary: "#e28743",
|
||||
colorInfo: "#f59e0b",
|
||||
colorSuccess: "#9bc067",
|
||||
colorWarning: "#e9ad4c",
|
||||
colorError: "#df6a4f",
|
||||
colorLink: "#f2a65a",
|
||||
colorBgLayout: "#21160f",
|
||||
colorBgContainer: "#2a1d14",
|
||||
colorBgElevated: "#322419",
|
||||
colorText: "#f4e8d7",
|
||||
colorTextSecondary: "#d9c3ab",
|
||||
colorBorder: "#5c422d",
|
||||
borderRadius: 12,
|
||||
wireframe: false,
|
||||
};
|
||||
|
||||
const warmComponentTokens = {
|
||||
Button: {
|
||||
borderRadius: 999,
|
||||
controlHeight: 40,
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 16,
|
||||
headerBg: "transparent",
|
||||
},
|
||||
Input: {
|
||||
borderRadius: 10,
|
||||
},
|
||||
InputNumber: {
|
||||
borderRadius: 10,
|
||||
},
|
||||
Select: {
|
||||
borderRadius: 10,
|
||||
},
|
||||
Layout: {
|
||||
bodyBg: "transparent",
|
||||
headerBg: "transparent",
|
||||
siderBg: "transparent",
|
||||
triggerBg: "transparent",
|
||||
},
|
||||
};
|
||||
|
||||
function RouteOutlet({ isLocked }: { isLocked: boolean }) {
|
||||
if (isLocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathname = useSyncExternalStore(
|
||||
subscribeToRouteChange,
|
||||
() => window.location.pathname,
|
||||
() => "/",
|
||||
);
|
||||
const route = useMemo(() => parseRoute(pathname), [pathname]);
|
||||
|
||||
if (route.type === "home") {
|
||||
return <HomeView />;
|
||||
}
|
||||
|
||||
if (route.type === "create") {
|
||||
return <CreateOrderView />;
|
||||
}
|
||||
|
||||
if (route.type === "order") {
|
||||
return <ParticipantView orderId={route.orderId} />;
|
||||
}
|
||||
|
||||
if (route.type === "admin") {
|
||||
return <AdminView orderId={route.orderId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Result
|
||||
status="404"
|
||||
title="Page not found"
|
||||
subTitle="The page you are looking for does not exist."
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
navigateTo("/");
|
||||
}}
|
||||
>
|
||||
Back Home
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const MemoTopNav = memo(TopNav);
|
||||
const MemoAnnouncements = memo(Announcements);
|
||||
|
||||
function AppContent({
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
announcements,
|
||||
isLocked,
|
||||
userId,
|
||||
userEmail,
|
||||
onUserEmailChange,
|
||||
}: {
|
||||
themeMode: ThemeMode;
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
announcements: AppAnnouncement[];
|
||||
isLocked: boolean;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
|
||||
}) {
|
||||
const appBackground =
|
||||
themeMode === "dark"
|
||||
? "radial-gradient(1200px 600px at -10% -10%, #5b3118 0%, rgba(91, 49, 24, 0) 60%), radial-gradient(900px 480px at 110% 0%, #4b2b1a 0%, rgba(75, 43, 26, 0) 55%), #21160f"
|
||||
: "radial-gradient(1200px 600px at -10% -10%, #f8d5a7 0%, rgba(248, 213, 167, 0) 60%), radial-gradient(900px 480px at 110% 0%, #f3c389 0%, rgba(243, 195, 137, 0) 55%), #f8f1e6";
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh", background: appBackground }}>
|
||||
<Content
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "24px 16px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{ width: "100%", maxWidth: 980 }}
|
||||
size={16}
|
||||
>
|
||||
<MemoTopNav
|
||||
themeMode={themeMode}
|
||||
onThemeChange={setThemeMode}
|
||||
onHome={() => {
|
||||
navigateTo("/");
|
||||
}}
|
||||
userEmail={userEmail}
|
||||
onUserEmailChange={onUserEmailChange}
|
||||
/>
|
||||
|
||||
<MemoAnnouncements announcements={announcements} />
|
||||
<RouteOutlet isLocked={isLocked} key={userId} />
|
||||
</Space>
|
||||
</Content>
|
||||
<Footer
|
||||
style={{
|
||||
padding: "8px 16px 16px",
|
||||
textAlign: "center",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
<div style={{ width: "100%", maxWidth: 980, margin: "0 auto" }}>
|
||||
<Divider style={{ margin: "8px 0 12px" }} />
|
||||
<Paragraph type="secondary">
|
||||
<Link href="mailto:lunchtime@sgruber.at">Feedback</Link> •{" "}
|
||||
<Link href="https://git.sgruber.at/lunchtime" target="_blank">
|
||||
Source Code
|
||||
</Link>
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Footer>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function getInitialThemeMode(): ThemeMode {
|
||||
const stored = getStoredValue(THEME_MODE_KEY);
|
||||
if (stored === "light" || stored === "dark") {
|
||||
return stored;
|
||||
}
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [userId, setUserId] = useState(() => ensureUserId());
|
||||
const [isOnboardingOpen, setIsOnboardingOpen] = useState(
|
||||
() => !hasStoredUserId(),
|
||||
);
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>(() =>
|
||||
getInitialThemeMode(),
|
||||
);
|
||||
const [userEmail, setUserEmail] = useState("");
|
||||
|
||||
const [announcements, setAnnouncements] = useState<AppAnnouncement[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme-mode", themeMode);
|
||||
setStoredValue(THEME_MODE_KEY, themeMode);
|
||||
}, [themeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function loadUserProfileEmail() {
|
||||
if (!userId) {
|
||||
setUserEmail("");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await apiService.profile.getMine();
|
||||
if (mounted) {
|
||||
setUserEmail(
|
||||
String(profile?.email || "")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
);
|
||||
}
|
||||
} catch (_error) {
|
||||
if (mounted) {
|
||||
setUserEmail("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadUserProfileEmail();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const config = (await apiService.config.get()) as {
|
||||
announcements?: AppAnnouncement[];
|
||||
};
|
||||
if (mounted) {
|
||||
setAnnouncements(config.announcements || []);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail if config cannot be loaded
|
||||
console.error("Failed to load config:", error);
|
||||
}
|
||||
}
|
||||
|
||||
loadConfig();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function handleConfirmationFromLink() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = String(params.get("token") || "").trim();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await apiService.account.confirm(token);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.user_id) {
|
||||
const normalized = updateUserId(result.user_id);
|
||||
setUserId(normalized);
|
||||
setIsOnboardingOpen(false);
|
||||
}
|
||||
|
||||
if (result?.action === "email_change_new_confirm") {
|
||||
message.success("New email address confirmed and updated.");
|
||||
} else {
|
||||
message.success("Account confirmation succeeded.");
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
message.error(error?.message || "Could not confirm account action.");
|
||||
}
|
||||
} finally {
|
||||
params.delete("token");
|
||||
const nextQuery = params.toString();
|
||||
const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ""}${window.location.hash}`;
|
||||
window.history.replaceState({}, "", nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
void handleConfirmationFromLink();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const themeConfig = useMemo(
|
||||
() => ({
|
||||
algorithm: themeMode === "dark" ? darkAlgorithm : defaultAlgorithm,
|
||||
token: themeMode === "dark" ? warmDarkTokens : warmLightTokens,
|
||||
components: warmComponentTokens,
|
||||
}),
|
||||
[themeMode],
|
||||
);
|
||||
|
||||
const handleUserEmailChange = async (nextUserEmail: string) => {
|
||||
await apiService.account.requestEmailChange(nextUserEmail);
|
||||
message.info("Check your new email to confirm this change.");
|
||||
return userEmail;
|
||||
};
|
||||
|
||||
const completeCreateAccount = async (email: string) => {
|
||||
try {
|
||||
await apiService.account.register(email);
|
||||
message.success("Registration email sent. Open the link in your inbox to finish setup.");
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || "Could not create account.");
|
||||
}
|
||||
};
|
||||
|
||||
const checkAccountExists = async (email: string) => {
|
||||
const result = await apiService.account.lookupByEmail(email);
|
||||
return !!result.exists;
|
||||
};
|
||||
|
||||
const completeMigrateAccount = async (email: string) => {
|
||||
try {
|
||||
await apiService.account.requestMigration(email);
|
||||
message.success("Migration email sent. Open the link in your inbox to complete migration.");
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || "Could not migrate account.");
|
||||
}
|
||||
};
|
||||
|
||||
const isAppLocked = isOnboardingOpen || !userId;
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={themeConfig}>
|
||||
<AppContent
|
||||
themeMode={themeMode}
|
||||
setThemeMode={setThemeMode}
|
||||
announcements={announcements}
|
||||
isLocked={isAppLocked}
|
||||
userId={userId}
|
||||
userEmail={userEmail}
|
||||
onUserEmailChange={handleUserEmailChange}
|
||||
/>
|
||||
<WelcomeOnboardingModal
|
||||
open={isOnboardingOpen}
|
||||
themeMode={themeMode}
|
||||
onThemeChange={setThemeMode}
|
||||
onCheckAccountExists={checkAccountExists}
|
||||
onCreateAccount={completeCreateAccount}
|
||||
onMigrateAccount={completeMigrateAccount}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useState } from "react";
|
||||
import { UserOutlined } from "@ant-design/icons";
|
||||
import { Button, Card, Flex, Popover, Space, Tooltip, Typography } from "antd";
|
||||
import AccountSettingsPopoverContent from "./AccountSettingsPopoverContent";
|
||||
import ThemeModeToggle from "./utils/ThemeModeToggle";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function TopNav({
|
||||
themeMode,
|
||||
onThemeChange,
|
||||
onHome,
|
||||
userEmail,
|
||||
onUserEmailChange,
|
||||
}: {
|
||||
themeMode: "light" | "dark";
|
||||
onThemeChange: (mode: "light" | "dark") => void;
|
||||
onHome: () => void;
|
||||
userEmail: string;
|
||||
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
|
||||
}) {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={12}>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={onHome}
|
||||
aria-label="Go to home"
|
||||
style={{ paddingInline: 0 }}
|
||||
>
|
||||
<Text style={{ fontSize: 18 }} strong>
|
||||
Lunchtime
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
<Space size={8} align="center">
|
||||
<ThemeModeToggle
|
||||
themeMode={themeMode}
|
||||
onThemeChange={onThemeChange}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
content={
|
||||
<AccountSettingsPopoverContent
|
||||
userEmail={userEmail}
|
||||
onUserEmailChange={onUserEmailChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tooltip title="Account settings">
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={<UserOutlined />}
|
||||
aria-label="User settings"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Flex, Form, Input, Modal, Space, Typography } from "antd";
|
||||
import ThemeModeToggle from "./utils/ThemeModeToggle";
|
||||
|
||||
type ThemeMode = "light" | "dark";
|
||||
type EmailLookupState = "idle" | "checking" | "exists" | "new";
|
||||
|
||||
export default function WelcomeOnboardingModal({
|
||||
open,
|
||||
themeMode,
|
||||
onThemeChange,
|
||||
onCheckAccountExists,
|
||||
onCreateAccount,
|
||||
onMigrateAccount,
|
||||
}: {
|
||||
open: boolean;
|
||||
themeMode: ThemeMode;
|
||||
onThemeChange: (mode: ThemeMode) => void;
|
||||
onCheckAccountExists: (email: string) => Promise<boolean>;
|
||||
onCreateAccount: (email: string) => void | Promise<void>;
|
||||
onMigrateAccount: (email: string) => void | Promise<void>;
|
||||
}) {
|
||||
const [form] = Form.useForm<{ email: string }>();
|
||||
const [lookupState, setLookupState] = useState<EmailLookupState>("idle");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const lookupTimerRef = useRef<number | null>(null);
|
||||
const lookupRequestIdRef = useRef(0);
|
||||
|
||||
const normalizeEmail = (email: string) => email.trim().toLowerCase();
|
||||
|
||||
const isValidEmail = (email: string) =>
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
|
||||
const resolveAccountExists = async (email: string) => {
|
||||
const normalized = normalizeEmail(email);
|
||||
if (!normalized || !isValidEmail(normalized)) {
|
||||
setLookupState("idle");
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestId = ++lookupRequestIdRef.current;
|
||||
setLookupState("checking");
|
||||
|
||||
try {
|
||||
const exists = await onCheckAccountExists(normalized);
|
||||
if (requestId !== lookupRequestIdRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setLookupState(exists ? "exists" : "new");
|
||||
return exists;
|
||||
} catch {
|
||||
if (requestId === lookupRequestIdRef.current) {
|
||||
setLookupState("idle");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.resetFields();
|
||||
setLookupState("idle");
|
||||
setIsSubmitting(false);
|
||||
lookupRequestIdRef.current += 1;
|
||||
if (lookupTimerRef.current) {
|
||||
window.clearTimeout(lookupTimerRef.current);
|
||||
lookupTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [form, open]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (lookupTimerRef.current) {
|
||||
window.clearTimeout(lookupTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const buttonLabel = useMemo(() => {
|
||||
if (lookupState === "exists") {
|
||||
return "Migrate account";
|
||||
}
|
||||
if (lookupState === "checking") {
|
||||
return "Checking account...";
|
||||
}
|
||||
if (lookupState === "new") {
|
||||
return "Create new account";
|
||||
}
|
||||
return "Continue";
|
||||
}, [lookupState]);
|
||||
|
||||
const helperText = useMemo(() => {
|
||||
if (lookupState === "exists") {
|
||||
return "An account was found for this email. We'll send a migration confirmation link.";
|
||||
}
|
||||
if (lookupState === "new") {
|
||||
return "No account found for this email. We'll create a new account.";
|
||||
}
|
||||
return "";
|
||||
}, [lookupState]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
closable={false}
|
||||
footer={null}
|
||||
centered
|
||||
>
|
||||
<Space direction="vertical" size={14} style={{ width: "100%" }}>
|
||||
<Flex align="center" justify="space-between" style={{ width: "100%" }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
Welcome to Lunchtime
|
||||
</Typography.Title>
|
||||
<ThemeModeToggle themeMode={themeMode} onThemeChange={onThemeChange} />
|
||||
</Flex>
|
||||
|
||||
<Typography.Paragraph style={{ marginBottom: 6 }}>
|
||||
Enter your account email to continue. We'll automatically detect whether to create a new account or migrate an existing one.
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={(_changedValues, values) => {
|
||||
const currentEmail = String(values.email || "");
|
||||
if (lookupTimerRef.current) {
|
||||
window.clearTimeout(lookupTimerRef.current);
|
||||
lookupTimerRef.current = null;
|
||||
}
|
||||
|
||||
lookupTimerRef.current = window.setTimeout(() => {
|
||||
void resolveAccountExists(currentEmail);
|
||||
}, 350);
|
||||
}}
|
||||
onFinish={async (values) => {
|
||||
const normalizedEmail = normalizeEmail(values.email);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let exists = lookupState === "exists";
|
||||
if (lookupState !== "exists" && lookupState !== "new") {
|
||||
const checked = await resolveAccountExists(normalizedEmail);
|
||||
exists = checked === true;
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
await onMigrateAccount(normalizedEmail);
|
||||
} else {
|
||||
await onCreateAccount(normalizedEmail);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: "Email cannot be empty." },
|
||||
{ type: "email", message: "Enter a valid email address." },
|
||||
]}
|
||||
extra={helperText || undefined}
|
||||
>
|
||||
<Input placeholder="alex@example.com" autoFocus maxLength={320} />
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
loading={isSubmitting || lookupState === "checking"}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</Form>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { CheckOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
|
||||
export default function AccountSettingsPopoverContent({
|
||||
userEmail,
|
||||
onUserEmailChange,
|
||||
}: {
|
||||
userEmail: string;
|
||||
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
|
||||
}) {
|
||||
const [form] = Form.useForm<{ email: string }>();
|
||||
const watchedEmail = Form.useWatch("email", form) || "";
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({ email: userEmail });
|
||||
}, [form, userEmail]);
|
||||
|
||||
const hasChanges = watchedEmail.trim().toLowerCase() !== userEmail;
|
||||
|
||||
const saveAccountSettings = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
const nextEmail = values.email.trim().toLowerCase();
|
||||
|
||||
if (nextEmail !== userEmail) {
|
||||
await onUserEmailChange(nextEmail);
|
||||
}
|
||||
|
||||
message.success("Account settings saved");
|
||||
} catch (error: any) {
|
||||
if (error?.errorFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
message.error(error?.message || "Could not save account settings.");
|
||||
// Validation errors are shown by Form.Item rules.
|
||||
}
|
||||
};
|
||||
|
||||
const discardAccountChanges = () => {
|
||||
form.setFieldsValue({ email: userEmail });
|
||||
message.info("Changes discarded");
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={10} style={{ maxWidth: 340 }}>
|
||||
<Typography.Title level={4}>Account Settings</Typography.Title>
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={saveAccountSettings}>
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: "Email cannot be empty." },
|
||||
{ type: "email", message: "Enter a valid email address." },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="alex@example.com" maxLength={320} />
|
||||
</Form.Item>
|
||||
|
||||
{watchedEmail.trim().toLowerCase() !== userEmail && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="A confirmation email will be sent to the new address before the change is applied."
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex gap={8}>
|
||||
<Button
|
||||
size="large"
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
htmlType="submit"
|
||||
block
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
block
|
||||
onClick={discardAccountChanges}
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { memo, useMemo, useState } from "react";
|
||||
import { Alert, Space } from "antd";
|
||||
|
||||
const ALERT_TYPES = new Set(["success", "info", "warning", "error"]);
|
||||
|
||||
type Announcement = {
|
||||
type?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function toAlertType(rawType: unknown) {
|
||||
const normalized = String(rawType || "info").trim().toLowerCase();
|
||||
|
||||
if (ALERT_TYPES.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (normalized === "warn") {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
if (normalized === "danger" || normalized === "critical") {
|
||||
return "error";
|
||||
}
|
||||
|
||||
return "info";
|
||||
}
|
||||
|
||||
export default memo(function Announcements({ announcements }: { announcements?: Announcement[] }) {
|
||||
const [closedIds, setClosedIds] = useState(() => new Set<string>());
|
||||
|
||||
const normalizedAnnouncements = useMemo(
|
||||
() =>
|
||||
(announcements || [])
|
||||
.map((announcement, idx) => {
|
||||
if (!announcement || typeof announcement !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = String(announcement.message || "").trim();
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = toAlertType(announcement.type);
|
||||
const title = String(announcement.title || "").trim();
|
||||
|
||||
return {
|
||||
id: `${type}-${title}-${message}-${idx}`,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
};
|
||||
})
|
||||
.filter(Boolean),
|
||||
[announcements],
|
||||
);
|
||||
|
||||
const visibleAnnouncements = useMemo(
|
||||
() => normalizedAnnouncements.filter((announcement) => !closedIds.has((announcement as any).id)),
|
||||
[closedIds, normalizedAnnouncements],
|
||||
);
|
||||
|
||||
if (visibleAnnouncements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
{visibleAnnouncements.map((announcement: any) => (
|
||||
<Alert
|
||||
key={announcement.id}
|
||||
type={announcement.type}
|
||||
message={announcement.title || announcement.type.charAt(0).toUpperCase() + announcement.type.slice(1)}
|
||||
description={announcement.message}
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => {
|
||||
setClosedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(announcement.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useState } from "react";
|
||||
import { UserOutlined } from "@ant-design/icons";
|
||||
import { Button, Card, Flex, Popover, Space, Tooltip, Typography } from "antd";
|
||||
import AccountSettingsPopoverContent from "../account/AccountSettingsPopoverContent";
|
||||
import ThemeModeToggle from "../utils/ThemeModeToggle";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function TopNav({
|
||||
themeMode,
|
||||
onThemeChange,
|
||||
onHome,
|
||||
userEmail,
|
||||
onUserEmailChange,
|
||||
}: {
|
||||
themeMode: "light" | "dark";
|
||||
onThemeChange: (mode: "light" | "dark") => void;
|
||||
onHome: () => void;
|
||||
userEmail: string;
|
||||
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
|
||||
}) {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={12}>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={onHome}
|
||||
aria-label="Go to home"
|
||||
style={{ paddingInline: 0 }}
|
||||
>
|
||||
<Text style={{ fontSize: 18 }} strong>
|
||||
Lunchtime
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
<Space size={8} align="center">
|
||||
<ThemeModeToggle
|
||||
themeMode={themeMode}
|
||||
onThemeChange={onThemeChange}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
content={
|
||||
<AccountSettingsPopoverContent
|
||||
userEmail={userEmail}
|
||||
onUserEmailChange={onUserEmailChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tooltip title="Account settings">
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={<UserOutlined />}
|
||||
aria-label="User settings"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Button, message, Space } from "antd";
|
||||
import { ExportOutlined, ImportOutlined } from "@ant-design/icons";
|
||||
import type { OrderFormConfig } from "./OrderFormConfigBuilder";
|
||||
import ExportSelectionModal, { type ExportSelectionState } from "../modals/ExportSelectionModal";
|
||||
|
||||
type MenuConfigImportExportProps = {
|
||||
config?: OrderFormConfig | null;
|
||||
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
||||
showImport?: boolean;
|
||||
showExport?: boolean;
|
||||
buttonType?: "link" | "text" | "default" | "primary" | "dashed";
|
||||
fileNameBase?: string;
|
||||
};
|
||||
|
||||
function normalizeConfig(value: unknown): OrderFormConfig {
|
||||
if (value && typeof value === "object" && Array.isArray((value as any).categories)) {
|
||||
return { categories: (value as any).categories };
|
||||
}
|
||||
|
||||
return { categories: [] };
|
||||
}
|
||||
|
||||
export default function MenuConfigImportExport({
|
||||
config,
|
||||
onImportConfig,
|
||||
showImport = true,
|
||||
showExport = true,
|
||||
buttonType = "link",
|
||||
fileNameBase = "order",
|
||||
}: MenuConfigImportExportProps) {
|
||||
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
const [exportSelection, setExportSelection] = useState<ExportSelectionState>({
|
||||
title: true,
|
||||
menu: true,
|
||||
description: true,
|
||||
});
|
||||
|
||||
const handleImportJson = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const content = e.target?.result;
|
||||
if (typeof content !== "string") {
|
||||
message.error("Failed to read import file");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content) as {
|
||||
menu?: unknown;
|
||||
categories?: unknown;
|
||||
};
|
||||
|
||||
const nextConfig = normalizeConfig(
|
||||
Object.prototype.hasOwnProperty.call(parsed, "menu")
|
||||
? parsed.menu
|
||||
: parsed,
|
||||
);
|
||||
|
||||
if (!Array.isArray(nextConfig.categories)) {
|
||||
message.error("Invalid menu configuration in import file");
|
||||
return;
|
||||
}
|
||||
|
||||
await onImportConfig?.(nextConfig);
|
||||
message.success("Imported menu configuration");
|
||||
} catch (_error) {
|
||||
message.error("Failed to import JSON file");
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
|
||||
if (importFileInputRef.current) {
|
||||
importFileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportJson = () => {
|
||||
if (!exportSelection.title && !exportSelection.menu && !exportSelection.description) {
|
||||
message.warning("Select at least one section to export");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
menu?: OrderFormConfig;
|
||||
} = {};
|
||||
|
||||
if (exportSelection.title) {
|
||||
payload.title = fileNameBase || "";
|
||||
}
|
||||
|
||||
if (exportSelection.description) {
|
||||
payload.description = "";
|
||||
}
|
||||
|
||||
if (exportSelection.menu) {
|
||||
payload.menu = normalizeConfig(config);
|
||||
}
|
||||
|
||||
const safeBase = String(fileNameBase || "order")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase();
|
||||
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${safeBase || "order"}-menu.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
setIsExportModalOpen(false);
|
||||
message.success("Exported menu configuration");
|
||||
};
|
||||
|
||||
const openExport = () => {
|
||||
setIsExportModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Space size="small">
|
||||
{showImport && (
|
||||
<Button
|
||||
type={buttonType}
|
||||
icon={<ImportOutlined />}
|
||||
onClick={() => importFileInputRef.current?.click()}
|
||||
disabled={!onImportConfig}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
)}
|
||||
{showExport && (
|
||||
<Button type={buttonType} icon={<ExportOutlined />} onClick={openExport}>
|
||||
Export
|
||||
</Button>
|
||||
)}
|
||||
<input
|
||||
ref={importFileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={handleImportJson}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
<ExportSelectionModal
|
||||
open={isExportModalOpen}
|
||||
title="Export Order Data"
|
||||
description="Select which sections of the order you want to include in the export:"
|
||||
options={[
|
||||
{ key: "title", label: "Title" },
|
||||
{ key: "menu", label: "Menu" },
|
||||
{ key: "description", label: "Description" },
|
||||
]}
|
||||
selected={exportSelection}
|
||||
onCancel={() => setIsExportModalOpen(false)}
|
||||
onConfirm={handleExportJson}
|
||||
onSelectedChange={setExportSelection}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Form,
|
||||
Input,
|
||||
List,
|
||||
Space,
|
||||
Tooltip,
|
||||
InputNumber,
|
||||
message,
|
||||
Popconfirm,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Typography,
|
||||
Alert,
|
||||
} from "antd";
|
||||
import {
|
||||
buildExampleFormattedOrderString,
|
||||
DEFAULT_CATEGORY_SNIPPET,
|
||||
DEFAULT_MULTIPLE_SEPARATOR,
|
||||
ORDER_FORMAT_VALUE_PLACEHOLDER,
|
||||
} from "../../lib/orderFormatting";
|
||||
import type {
|
||||
OrderFormItem,
|
||||
OrderFormCategory,
|
||||
OrderFormConfig,
|
||||
} from "../../lib/types";
|
||||
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
CopyOutlined,
|
||||
CloseOutlined,
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import MenuConfigImportExport from "./MenuConfigImportExport";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface OrderFormConfigBuilderProps {
|
||||
config: OrderFormConfig;
|
||||
onChange: (config: OrderFormConfig) => void;
|
||||
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
||||
editable?: boolean;
|
||||
showFormatPreview?: boolean;
|
||||
showImportExport?: boolean;
|
||||
categoriesExpandedByDefault?: boolean;
|
||||
}
|
||||
|
||||
// Re-export types for backward compatibility
|
||||
export type { OrderFormItem, OrderFormCategory, OrderFormConfig };
|
||||
|
||||
export default function OrderFormConfigBuilder({
|
||||
config,
|
||||
onChange,
|
||||
onImportConfig,
|
||||
editable = true,
|
||||
showFormatPreview = true,
|
||||
showImportExport = editable,
|
||||
categoriesExpandedByDefault = true,
|
||||
}: OrderFormConfigBuilderProps) {
|
||||
const [form] = Form.useForm<OrderFormConfig>();
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({ categories: config.categories ?? [] });
|
||||
}, [config, form]);
|
||||
|
||||
const handleValuesChange = (_: unknown, allValues: OrderFormConfig) => {
|
||||
onChange({ categories: allValues.categories ?? [] });
|
||||
};
|
||||
|
||||
const createNewCategory = (): OrderFormCategory => {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: `cat_${now}`,
|
||||
label: "New Category",
|
||||
required: false,
|
||||
multiple: false,
|
||||
custom: false,
|
||||
formattedSnippet: DEFAULT_CATEGORY_SNIPPET,
|
||||
multipleSeparator: DEFAULT_MULTIPLE_SEPARATOR,
|
||||
optionalFallback: "",
|
||||
items: [],
|
||||
};
|
||||
};
|
||||
|
||||
const createNewItem = (): OrderFormItem => {
|
||||
return {
|
||||
name: "",
|
||||
price: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const buildDuplicatedItem = (item?: OrderFormItem): OrderFormItem => {
|
||||
const now = Date.now();
|
||||
return {
|
||||
name: item?.name ? `${item.name}_copy` : `item_${now}`,
|
||||
price: item?.price,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={{ categories: config.categories ?? [] }}
|
||||
onValuesChange={handleValuesChange}
|
||||
autoComplete="off"
|
||||
disabled={!editable}
|
||||
>
|
||||
<Form.List name="categories">
|
||||
{(categoryFields, categoryOps) => (
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="middle">
|
||||
{showImportExport && (
|
||||
<MenuConfigImportExport
|
||||
config={config}
|
||||
onImportConfig={
|
||||
editable
|
||||
? async (nextConfig) => {
|
||||
form.setFieldsValue({ categories: nextConfig.categories ?? [] });
|
||||
onChange({ categories: nextConfig.categories ?? [] });
|
||||
await onImportConfig?.(nextConfig);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
fileNameBase="order"
|
||||
buttonType="default"
|
||||
/>
|
||||
)}
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="small">
|
||||
{categoryFields.map((categoryField) => (
|
||||
<Collapse
|
||||
key={categoryField.key}
|
||||
bordered={false}
|
||||
defaultActiveKey={
|
||||
categoriesExpandedByDefault
|
||||
? [String(categoryField.key)]
|
||||
: []
|
||||
}
|
||||
items={[
|
||||
{
|
||||
key: String(categoryField.key),
|
||||
label: (
|
||||
<Space size="small" wrap>
|
||||
<Form.Item
|
||||
name={[categoryField.name, "label"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Category label is required",
|
||||
},
|
||||
]}
|
||||
noStyle
|
||||
>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="Category name"
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
),
|
||||
extra: editable ? (
|
||||
<Space
|
||||
size="small"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Tooltip title="Move category up">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<UpOutlined />}
|
||||
disabled={categoryField.name === 0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
categoryOps.move(
|
||||
categoryField.name,
|
||||
categoryField.name - 1,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Move category down">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<DownOutlined />}
|
||||
disabled={
|
||||
categoryField.name === categoryFields.length - 1
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
categoryOps.move(
|
||||
categoryField.name,
|
||||
categoryField.name + 1,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Delete category?"
|
||||
description="This will remove the category and all its items."
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={(event) => {
|
||||
event?.stopPropagation?.();
|
||||
categoryOps.remove(categoryField.name);
|
||||
}}
|
||||
onCancel={(event) => event?.stopPropagation?.()}
|
||||
>
|
||||
<Tooltip title="Delete category">
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) : null,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
style={{ display: "none" }}
|
||||
name={[categoryField.name, "id"]}
|
||||
hidden
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{/* Flags Section */}
|
||||
<div>
|
||||
<Title level={5} style={{ marginBottom: 12 }}>
|
||||
Options
|
||||
</Title>
|
||||
<Space
|
||||
size={0}
|
||||
direction="vertical"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Form.Item
|
||||
name={[categoryField.name, "required"]}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Checkbox>
|
||||
Require at least one selection
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={[categoryField.name, "multiple"]}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Checkbox>Allow multiple entries</Checkbox>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={[categoryField.name, "custom"]}
|
||||
valuePropName="checked"
|
||||
initialValue={false}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Checkbox>Allow custom entries</Checkbox>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</div>
|
||||
<Divider style={{ margin: "12px 0" }} />
|
||||
|
||||
{/* Format Section */}
|
||||
<div>
|
||||
<Title level={5} style={{ marginBottom: 12 }}>
|
||||
Format
|
||||
</Title>
|
||||
<Form.Item
|
||||
label="Format string"
|
||||
name={[categoryField.name, "formattedSnippet"]}
|
||||
extra="Use {name} where selected name(s) should appear. You can also use {label}."
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Format is required",
|
||||
},
|
||||
{
|
||||
validator: (_rule, value) => {
|
||||
const snippet = String(
|
||||
value || "",
|
||||
).trim();
|
||||
if (
|
||||
!snippet.includes(
|
||||
ORDER_FORMAT_VALUE_PLACEHOLDER,
|
||||
)
|
||||
) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`Snippet must include {name}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<Input placeholder="Example: {label}: {name}" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Separator & Fallback Section */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, nextValues) => {
|
||||
const prevCategory =
|
||||
prevValues?.categories?.[categoryField.name];
|
||||
const nextCategory =
|
||||
nextValues?.categories?.[categoryField.name];
|
||||
return (
|
||||
prevCategory?.multiple !==
|
||||
nextCategory?.multiple ||
|
||||
prevCategory?.required !==
|
||||
nextCategory?.required
|
||||
);
|
||||
}}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const isMultiple = !!getFieldValue([
|
||||
"categories",
|
||||
categoryField.name,
|
||||
"multiple",
|
||||
]);
|
||||
const isRequired = !!getFieldValue([
|
||||
"categories",
|
||||
categoryField.name,
|
||||
"required",
|
||||
]);
|
||||
|
||||
if (!isMultiple && isRequired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isMultiple && (
|
||||
<Form.Item
|
||||
label="Multiple entries separator"
|
||||
name={[
|
||||
categoryField.name,
|
||||
"multipleSeparator",
|
||||
]}
|
||||
extra="Text used between selected entries."
|
||||
initialValue=", "
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<Input allowClear placeholder=", " />
|
||||
</Form.Item>
|
||||
)}
|
||||
{!isRequired && (
|
||||
<Form.Item
|
||||
label="Fallback text (optional)"
|
||||
name={[
|
||||
categoryField.name,
|
||||
"optionalFallback",
|
||||
]}
|
||||
extra="Used when no selection is made and this category is not required."
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="Example: No drink selected"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Divider style={{ margin: "12px 0" }} />
|
||||
|
||||
{/* Items Section */}
|
||||
<Title level={5} style={{ marginBottom: 12 }}>
|
||||
Items
|
||||
</Title>
|
||||
|
||||
<Form.List name={[categoryField.name, "items"]}>
|
||||
{(itemFields, itemOps) => (
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{ width: "100%" }}
|
||||
size="small"
|
||||
>
|
||||
<List
|
||||
dataSource={itemFields}
|
||||
locale={{ emptyText: "No items yet" }}
|
||||
renderItem={(itemField) => (
|
||||
<List.Item
|
||||
key={itemField.key}
|
||||
actions={
|
||||
editable
|
||||
? [
|
||||
<Tooltip
|
||||
key="up"
|
||||
title="Move item up"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UpOutlined />}
|
||||
disabled={
|
||||
itemField.name === 0
|
||||
}
|
||||
onClick={() => {
|
||||
itemOps.move(
|
||||
itemField.name,
|
||||
itemField.name - 1,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>,
|
||||
<Tooltip
|
||||
key="down"
|
||||
title="Move item down"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownOutlined />}
|
||||
disabled={
|
||||
itemField.name ===
|
||||
itemFields.length - 1
|
||||
}
|
||||
onClick={() => {
|
||||
itemOps.move(
|
||||
itemField.name,
|
||||
itemField.name + 1,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>,
|
||||
<Tooltip
|
||||
key="duplicate"
|
||||
title="Duplicate item"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
const itemValue =
|
||||
form.getFieldValue([
|
||||
"categories",
|
||||
categoryField.name,
|
||||
"items",
|
||||
itemField.name,
|
||||
]) as
|
||||
| OrderFormItem
|
||||
| undefined;
|
||||
|
||||
itemOps.add(
|
||||
buildDuplicatedItem(
|
||||
itemValue,
|
||||
),
|
||||
itemField.name + 1,
|
||||
);
|
||||
message.success(
|
||||
"Item duplicated",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>,
|
||||
<Tooltip
|
||||
key="delete"
|
||||
title="Delete item"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() =>
|
||||
itemOps.remove(
|
||||
itemField.name,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Tooltip>,
|
||||
]
|
||||
: []
|
||||
}
|
||||
>
|
||||
<Space size="middle" wrap>
|
||||
<Form.Item
|
||||
label="Name"
|
||||
name={[itemField.name, "name"]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Name is required",
|
||||
},
|
||||
]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="Item name"
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Price"
|
||||
name={[itemField.name, "price"]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
placeholder="0.00"
|
||||
style={{ width: 140 }}
|
||||
min={0}
|
||||
step={0.5}
|
||||
precision={2}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
{editable && (
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
itemOps.add(createNewItem())
|
||||
}
|
||||
>
|
||||
Add Item
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Form.List>
|
||||
</Space>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
{editable && (
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => categoryOps.add(createNewCategory())}
|
||||
block
|
||||
>
|
||||
Add Category
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showFormatPreview && (
|
||||
<Form.Item noStyle shouldUpdate={true}>
|
||||
{({ getFieldValue }) => {
|
||||
const categories = getFieldValue("categories") || [];
|
||||
const exampleOutput = buildExampleFormattedOrderString({ categories });
|
||||
|
||||
return (
|
||||
<Alert
|
||||
style={{ marginBottom: 12 }}
|
||||
message="Example order message"
|
||||
description={
|
||||
<Text code copyable>
|
||||
{exampleOutput}
|
||||
</Text>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
import { Checkbox, Modal, Space, Typography } from "antd";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export type ExportSelectionState = Record<string, boolean>;
|
||||
|
||||
export type ExportSelectionOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export default function ExportSelectionModal({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
options,
|
||||
selected,
|
||||
okText = "Export",
|
||||
onCancel,
|
||||
onConfirm,
|
||||
onSelectedChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
options: ExportSelectionOption[];
|
||||
selected: ExportSelectionState;
|
||||
okText?: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
onSelectedChange: (next: ExportSelectionState) => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onConfirm}
|
||||
okText={okText}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{description ? <Text>{description}</Text> : null}
|
||||
|
||||
{options.map((option) => (
|
||||
<Checkbox
|
||||
key={option.key}
|
||||
checked={!!selected[option.key]}
|
||||
onChange={(event) =>
|
||||
onSelectedChange({
|
||||
...selected,
|
||||
[option.key]: event.target.checked,
|
||||
})
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Flex, Form, Input, Modal, Space, Typography } from "antd";
|
||||
import ThemeModeToggle from "../utils/ThemeModeToggle";
|
||||
|
||||
type ThemeMode = "light" | "dark";
|
||||
type EmailLookupState = "idle" | "checking" | "exists" | "new";
|
||||
|
||||
export default function WelcomeOnboardingModal({
|
||||
open,
|
||||
themeMode,
|
||||
onThemeChange,
|
||||
onCheckAccountExists,
|
||||
onCreateAccount,
|
||||
onMigrateAccount,
|
||||
}: {
|
||||
open: boolean;
|
||||
themeMode: ThemeMode;
|
||||
onThemeChange: (mode: ThemeMode) => void;
|
||||
onCheckAccountExists: (email: string) => Promise<boolean>;
|
||||
onCreateAccount: (email: string) => void | Promise<void>;
|
||||
onMigrateAccount: (email: string) => void | Promise<void>;
|
||||
}) {
|
||||
const [form] = Form.useForm<{ email: string }>();
|
||||
const [lookupState, setLookupState] = useState<EmailLookupState>("idle");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const lookupTimerRef = useRef<number | null>(null);
|
||||
const lookupRequestIdRef = useRef(0);
|
||||
|
||||
const normalizeEmail = (email: string) => email.trim().toLowerCase();
|
||||
|
||||
const isValidEmail = (email: string) =>
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
|
||||
const resolveAccountExists = async (email: string) => {
|
||||
const normalized = normalizeEmail(email);
|
||||
if (!normalized || !isValidEmail(normalized)) {
|
||||
setLookupState("idle");
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestId = ++lookupRequestIdRef.current;
|
||||
setLookupState("checking");
|
||||
|
||||
try {
|
||||
const exists = await onCheckAccountExists(normalized);
|
||||
if (requestId !== lookupRequestIdRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setLookupState(exists ? "exists" : "new");
|
||||
return exists;
|
||||
} catch {
|
||||
if (requestId === lookupRequestIdRef.current) {
|
||||
setLookupState("idle");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.resetFields();
|
||||
setLookupState("idle");
|
||||
setIsSubmitting(false);
|
||||
lookupRequestIdRef.current += 1;
|
||||
if (lookupTimerRef.current) {
|
||||
window.clearTimeout(lookupTimerRef.current);
|
||||
lookupTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [form, open]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (lookupTimerRef.current) {
|
||||
window.clearTimeout(lookupTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const buttonLabel = useMemo(() => {
|
||||
if (lookupState === "exists") {
|
||||
return "Migrate account";
|
||||
}
|
||||
if (lookupState === "checking") {
|
||||
return "Checking account...";
|
||||
}
|
||||
if (lookupState === "new") {
|
||||
return "Create new account";
|
||||
}
|
||||
return "Continue";
|
||||
}, [lookupState]);
|
||||
|
||||
const helperText = useMemo(() => {
|
||||
if (lookupState === "exists") {
|
||||
return "An account was found for this email. We'll send a migration confirmation link.";
|
||||
}
|
||||
if (lookupState === "new") {
|
||||
return "No account found for this email. We'll create a new account.";
|
||||
}
|
||||
return "";
|
||||
}, [lookupState]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
closable={false}
|
||||
footer={null}
|
||||
centered
|
||||
>
|
||||
<Space direction="vertical" size={14} style={{ width: "100%" }}>
|
||||
<Flex align="center" justify="space-between" style={{ width: "100%" }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
Welcome to Lunchtime
|
||||
</Typography.Title>
|
||||
<ThemeModeToggle themeMode={themeMode} onThemeChange={onThemeChange} />
|
||||
</Flex>
|
||||
|
||||
<Typography.Paragraph style={{ marginBottom: 6 }}>
|
||||
Enter your account email to continue. We'll automatically detect whether to create a new account or migrate an existing one.
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={(_changedValues, values) => {
|
||||
const currentEmail = String(values.email || "");
|
||||
if (lookupTimerRef.current) {
|
||||
window.clearTimeout(lookupTimerRef.current);
|
||||
lookupTimerRef.current = null;
|
||||
}
|
||||
|
||||
lookupTimerRef.current = window.setTimeout(() => {
|
||||
void resolveAccountExists(currentEmail);
|
||||
}, 350);
|
||||
}}
|
||||
onFinish={async (values) => {
|
||||
const normalizedEmail = normalizeEmail(values.email);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let exists = lookupState === "exists";
|
||||
if (lookupState !== "exists" && lookupState !== "new") {
|
||||
const checked = await resolveAccountExists(normalizedEmail);
|
||||
exists = checked === true;
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
await onMigrateAccount(normalizedEmail);
|
||||
} else {
|
||||
await onCreateAccount(normalizedEmail);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: "Email cannot be empty." },
|
||||
{ type: "email", message: "Enter a valid email address." },
|
||||
]}
|
||||
extra={helperText || undefined}
|
||||
>
|
||||
<Input placeholder="alex@example.com" autoFocus maxLength={320} />
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
loading={isSubmitting || lookupState === "checking"}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</Form>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import ReadOnlyOrderOverviewCard from "./ReadOnlyOrderOverviewCard";
|
||||
|
||||
export default ReadOnlyOrderOverviewCard;
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from "react";
|
||||
import { Card, Col, Empty, Flex, Image, Row, Space, Tabs, Typography } from "antd";
|
||||
import { markdownToHtml } from "../../lib/markdown";
|
||||
import MenuConfigImportExport from "../forms/MenuConfigImportExport";
|
||||
import OrderFormConfigBuilder, {
|
||||
OrderFormConfig,
|
||||
} from "../forms/OrderFormConfigBuilder";
|
||||
|
||||
type Order = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
image_url?: string | null;
|
||||
created_at?: string | null;
|
||||
creator_user_id?: string | null;
|
||||
creator_email?: string | null;
|
||||
};
|
||||
|
||||
function formatTimestamp(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function getImageUrl(imageUrl: string | null | undefined): string {
|
||||
if (!imageUrl) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (imageUrl.startsWith("http")) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
return `/api/images/${imageUrl}`;
|
||||
}
|
||||
|
||||
export default function ReadOnlyOrderOverviewCard({
|
||||
order,
|
||||
config,
|
||||
onImportConfig,
|
||||
}: {
|
||||
order: Order | null;
|
||||
config?: OrderFormConfig | null;
|
||||
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
||||
}) {
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentDescription = order.description || "";
|
||||
const currentImageUrl = order.image_url || "";
|
||||
const currentConfig: OrderFormConfig = {
|
||||
categories: Array.isArray(config?.categories) ? config.categories : [],
|
||||
};
|
||||
const creatorLabel =
|
||||
order.creator_email || order.creator_user_id || "Unknown";
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Flex vertical gap={0}>
|
||||
{order.title}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, fontWeight: "normal" }}>
|
||||
by {creatorLabel} ({formatTimestamp(order.created_at)})
|
||||
</Typography.Text>
|
||||
|
||||
</Flex>
|
||||
}
|
||||
extra={
|
||||
<MenuConfigImportExport
|
||||
config={currentConfig}
|
||||
showImport={false}
|
||||
onImportConfig={onImportConfig}
|
||||
fileNameBase={order.title || "order"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
defaultActiveKey="description"
|
||||
items={[
|
||||
{
|
||||
key: "description",
|
||||
label: "Description",
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={order.image_url ? 14 : 24}>
|
||||
{!!currentDescription ? (
|
||||
<Typography.Paragraph>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: markdownToHtml(currentDescription),
|
||||
}}
|
||||
/>
|
||||
</Typography.Paragraph>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="No description provided"
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
{order.image_url && (
|
||||
<Col xs={24} md={10}>
|
||||
{currentImageUrl ? (
|
||||
<Image
|
||||
src={getImageUrl(currentImageUrl)}
|
||||
alt={`${order.title} image`}
|
||||
style={{
|
||||
width: "100%",
|
||||
borderRadius: 8,
|
||||
cursor: "zoom-in",
|
||||
}}
|
||||
preview={{
|
||||
getContainer: () => document.body,
|
||||
zIndex: 3000,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="No image provided"
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "menu",
|
||||
label: "Menu",
|
||||
children: (
|
||||
<OrderFormConfigBuilder
|
||||
editable={false}
|
||||
showFormatPreview={false}
|
||||
showImportExport={false}
|
||||
config={currentConfig}
|
||||
categoriesExpandedByDefault={false}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
></Tabs>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import ErrorResult from "./ErrorResult";
|
||||
import LoadingSkeleton from "./LoadingSkeleton";
|
||||
|
||||
export default function AsyncContent({
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
errorTitle = "Something went wrong",
|
||||
errorSubtitle,
|
||||
loadingRows = 4,
|
||||
loadingSections = 2,
|
||||
isEmpty = false,
|
||||
emptyState = null,
|
||||
children,
|
||||
}: {
|
||||
loading: boolean;
|
||||
error?: string | null;
|
||||
onRetry?: () => void;
|
||||
errorTitle?: string;
|
||||
errorSubtitle?: string;
|
||||
loadingRows?: number;
|
||||
loadingSections?: number;
|
||||
isEmpty?: boolean;
|
||||
emptyState?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
if (loading) {
|
||||
return <LoadingSkeleton rows={loadingRows} sections={loadingSections} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorResult
|
||||
title={errorTitle}
|
||||
subtitle={errorSubtitle || error}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return emptyState;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import { Button, Result } from "antd";
|
||||
|
||||
export default function ErrorResult({
|
||||
title = "Something went wrong",
|
||||
subtitle = "Please try again in a moment.",
|
||||
onRetry,
|
||||
}: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title={title}
|
||||
subTitle={subtitle}
|
||||
extra={
|
||||
onRetry ? (
|
||||
<Button type="primary" onClick={onRetry}>
|
||||
Try Again
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import { Card, Skeleton, Space } from "antd";
|
||||
|
||||
export default function LoadingSkeleton({ rows = 4, sections = 2 }: { rows?: number; sections?: number }) {
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: "100%" }} size={16}>
|
||||
{Array.from({ length: sections }).map((_, index) => (
|
||||
<Card key={`loading-skeleton-${index}`}>
|
||||
<Skeleton active title paragraph={{ rows }} />
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import { MoonOutlined, SunOutlined } from "@ant-design/icons";
|
||||
import { Button, Tooltip } from "antd";
|
||||
|
||||
type ThemeMode = "light" | "dark";
|
||||
|
||||
export default function ThemeModeToggle({
|
||||
themeMode,
|
||||
onThemeChange,
|
||||
}: {
|
||||
themeMode: ThemeMode;
|
||||
onThemeChange: (mode: ThemeMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip title="Toggle theme">
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
aria-label="Toggle light and dark theme"
|
||||
icon={themeMode === "dark" ? <SunOutlined /> : <MoonOutlined />}
|
||||
onClick={() =>
|
||||
onThemeChange(themeMode === "dark" ? "light" : "dark")
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
StopOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
List,
|
||||
Popconfirm,
|
||||
Typography,
|
||||
} from "antd";
|
||||
|
||||
export default function AdminControlCenterCard({
|
||||
isClosed,
|
||||
updatingOrderStatus,
|
||||
deletingOrder,
|
||||
onToggleClosed,
|
||||
onDeleteOrder,
|
||||
}: {
|
||||
isClosed: boolean;
|
||||
updatingOrderStatus: boolean;
|
||||
deletingOrder: boolean;
|
||||
onToggleClosed: (nextClosed: boolean) => void;
|
||||
onDeleteOrder: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card title="Control center">
|
||||
<List
|
||||
size="small"
|
||||
dataSource={[{ key: "availability", isClosed }, { key: "delete" }]}
|
||||
renderItem={(item: { key: string; isClosed?: boolean }) => (
|
||||
<List.Item
|
||||
style={{ paddingInline: 0 }}
|
||||
actions={
|
||||
item.key === "availability"
|
||||
? [
|
||||
<Button
|
||||
key="toggle-order-status"
|
||||
type="default"
|
||||
danger={!item.isClosed}
|
||||
icon={item.isClosed ? <CheckCircleOutlined /> : <StopOutlined />}
|
||||
loading={updatingOrderStatus}
|
||||
onClick={() => onToggleClosed(!item.isClosed)}
|
||||
>
|
||||
{item.isClosed ? "Reopen order" : "Close order"}
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Popconfirm
|
||||
key="delete-order-confirm"
|
||||
title="Delete this order?"
|
||||
description="This will permanently remove the order and its submissions."
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
okButtonProps={{ danger: true, loading: deletingOrder }}
|
||||
onConfirm={onDeleteOrder}
|
||||
>
|
||||
<Button type="primary" danger loading={deletingOrder}>
|
||||
Delete order
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
]
|
||||
}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
item.key === "availability" ? (
|
||||
<Typography.Text strong>Order availability</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text strong>Order deletion</Typography.Text>
|
||||
)
|
||||
}
|
||||
description={
|
||||
item.key === "availability" ? (
|
||||
<Typography.Text type={item.isClosed ? "warning" : "secondary"}>
|
||||
{item.isClosed
|
||||
? "Order is currently closed to new submissions."
|
||||
: "Order is currently open for participant submissions."}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="danger">
|
||||
Deleting an order is permanent and cannot be undone.
|
||||
</Typography.Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { Alert, Typography } from "antd";
|
||||
import { navigateTo } from "../../lib/routing";
|
||||
|
||||
const { Text, Link } = Typography;
|
||||
|
||||
export default function AdminParticipantShareAlert({
|
||||
orderId,
|
||||
}: {
|
||||
orderId: string;
|
||||
}) {
|
||||
const participantUrl = `${window.location.origin}/order/${orderId}`;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={
|
||||
<Text>
|
||||
<Text strong>Participant page:</Text>{" "}
|
||||
Share{" "}
|
||||
<Link
|
||||
href={`/order/${orderId}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateTo(`/order/${orderId}`);
|
||||
}}
|
||||
>
|
||||
this page
|
||||
</Link>{" "}
|
||||
so people can submit their order:{" "}
|
||||
<Text code copyable={{ text: participantUrl }}>
|
||||
{participantUrl}
|
||||
</Text>
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import React from "react";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import {
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Input,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import { formatEstimatedTotal, stripPriceDecorations } from "../../lib/orderFormatting";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function getEstimatedTotalText(rawValue: unknown): string | null {
|
||||
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
|
||||
return formatEstimatedTotal(rawValue);
|
||||
}
|
||||
|
||||
if (typeof rawValue === "string") {
|
||||
const parsed = Number(rawValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return formatEstimatedTotal(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AdminSubmissionsCard({
|
||||
searchQuery,
|
||||
selectedSubmissionState,
|
||||
onSearchQueryChange,
|
||||
onSelectedSubmissionStateChange,
|
||||
onReload,
|
||||
copyText,
|
||||
onCopy,
|
||||
selectedRowKeys,
|
||||
onSelectedRowKeysChange,
|
||||
pagedSubmissions,
|
||||
deletingId,
|
||||
savingStatusKey,
|
||||
onDeleteSubmission,
|
||||
onUpdateSubmissionStatus,
|
||||
totalEstimatedText,
|
||||
selectedCount,
|
||||
submissionsPage,
|
||||
pageSize,
|
||||
totalCount,
|
||||
onSubmissionsPageChange,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
selectedSubmissionState: "all" | "pending" | "unpaid" | "paid";
|
||||
onSearchQueryChange: (value: string) => void;
|
||||
onSelectedSubmissionStateChange: (value: "all" | "pending" | "unpaid" | "paid") => void;
|
||||
onReload: () => void;
|
||||
copyText: string;
|
||||
onCopy: (text: string, didCopy: boolean) => void;
|
||||
selectedRowKeys: Array<string | number>;
|
||||
onSelectedRowKeysChange: (keys: Array<string | number>) => void;
|
||||
pagedSubmissions: any[];
|
||||
deletingId: string | number | null;
|
||||
savingStatusKey: string | null;
|
||||
onDeleteSubmission: (id: string | number) => void;
|
||||
onUpdateSubmissionStatus: (submission: any, changes: { accepted?: boolean; paid?: boolean }) => void;
|
||||
totalEstimatedText: string | null;
|
||||
selectedCount: number;
|
||||
submissionsPage: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
onSubmissionsPageChange: (page: number) => void;
|
||||
}) {
|
||||
const columns = [
|
||||
{
|
||||
title: "Submission",
|
||||
dataIndex: "formatted_string",
|
||||
key: "submission",
|
||||
render: (value: string, record: any) => {
|
||||
const cleanValue = stripPriceDecorations(String(value || "")).trim();
|
||||
const estimateText = getEstimatedTotalText(record.estimated_total);
|
||||
const emailText = String(record.email || "").trim() || "-";
|
||||
const secondaryText = `${emailText} • ${estimateText || "-"}`;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text code copyable={{ text: cleanValue }}>{cleanValue || "-"}</Text>
|
||||
<Tooltip title="Rough estimate based on menu prices. Final amount may differ.">
|
||||
<Text type="secondary">{secondaryText}</Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Accepted",
|
||||
key: "accepted",
|
||||
width: 120,
|
||||
render: (_: any, record: any) => {
|
||||
const isSaving = savingStatusKey === `${record.id}:accepted`;
|
||||
return (
|
||||
<Switch
|
||||
checked={!!record.accepted}
|
||||
loading={isSaving}
|
||||
onChange={(checked) => onUpdateSubmissionStatus(record, { accepted: checked })}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Paid",
|
||||
key: "paid",
|
||||
width: 100,
|
||||
render: (_: any, record: any) => {
|
||||
const isSaving = savingStatusKey === `${record.id}:paid`;
|
||||
return (
|
||||
<Switch
|
||||
checked={!!record.paid}
|
||||
loading={isSaving}
|
||||
onChange={(checked) => onUpdateSubmissionStatus(record, { paid: checked })}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Actions",
|
||||
key: "actions",
|
||||
width: 1,
|
||||
render: (_: any, record: any) => (
|
||||
<Popconfirm
|
||||
title="Delete this submission?"
|
||||
description="This cannot be undone."
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true, loading: deletingId === record.id }}
|
||||
onConfirm={() => onDeleteSubmission(record.id)}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deletingId === record.id}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Submissions"
|
||||
extra={
|
||||
<Space>
|
||||
<Tooltip
|
||||
title={
|
||||
selectedRowKeys.length > 0
|
||||
? "Copy selected submissions as list to clipboard"
|
||||
: "Copy submissions as list to clipboard"
|
||||
}
|
||||
>
|
||||
<CopyToClipboard text={copyText} onCopy={onCopy}>
|
||||
<Button icon={<CopyOutlined />}>
|
||||
{selectedRowKeys.length > 0 ? "Copy (selected)" : "Copy"}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
<Tooltip title="Reload latest order details">
|
||||
<Button icon={<ReloadOutlined />} onClick={onReload} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space style={{ marginBottom: 12 }} wrap>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="Filter by email or order text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => onSearchQueryChange(event.target.value)}
|
||||
style={{ minWidth: 260 }}
|
||||
/>
|
||||
<Select
|
||||
value={selectedSubmissionState}
|
||||
style={{ minWidth: 180 }}
|
||||
options={[
|
||||
{ label: "All statuses", value: "all" },
|
||||
{ label: "Pending", value: "pending" },
|
||||
{ label: "Unpaid", value: "unpaid" },
|
||||
{ label: "Paid", value: "paid" },
|
||||
]}
|
||||
onChange={(value) => onSelectedSubmissionStateChange(value)}
|
||||
/>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={pagedSubmissions}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (nextSelectedRowKeys) =>
|
||||
onSelectedRowKeysChange(nextSelectedRowKeys as Array<string | number>),
|
||||
}}
|
||||
pagination={false}
|
||||
scroll={{ x: true }}
|
||||
/>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
gap={12}
|
||||
wrap
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
{totalEstimatedText ? (
|
||||
<Tooltip title="Rough estimate based on menu prices. Final amount may differ.">
|
||||
<Text type="secondary">
|
||||
Total {selectedCount > 0 ? "(selected)" : ""}: {totalEstimatedText}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Pagination
|
||||
current={submissionsPage}
|
||||
pageSize={pageSize}
|
||||
total={totalCount}
|
||||
showSizeChanger={false}
|
||||
hideOnSinglePage
|
||||
onChange={onSubmissionsPageChange}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import {
|
||||
ExportOutlined,
|
||||
ImportOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Popconfirm, Space } from "antd";
|
||||
|
||||
export default function CreateOrderHeaderActions({
|
||||
importFileInputRef,
|
||||
onImportJson,
|
||||
onOpenExport,
|
||||
}: {
|
||||
importFileInputRef: React.RefObject<HTMLInputElement>;
|
||||
onImportJson: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onOpenExport: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Space size="small">
|
||||
<Popconfirm
|
||||
title="Import order data?"
|
||||
description="Import will overwrite all current content in this form."
|
||||
okText="Import"
|
||||
cancelText="Cancel"
|
||||
onConfirm={() => importFileInputRef.current?.click()}
|
||||
>
|
||||
<Button type="link" icon={<ImportOutlined />}>
|
||||
Import
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button type="link" icon={<ExportOutlined />} onClick={onOpenExport}>
|
||||
Export
|
||||
</Button>
|
||||
<input
|
||||
ref={importFileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={onImportJson}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { Pagination, Select, Space } from "antd";
|
||||
|
||||
const ownerTypeFilters = [
|
||||
{ label: "All", value: "" },
|
||||
{ label: "Owner", value: "owner" },
|
||||
{ label: "Participant", value: "participant" },
|
||||
];
|
||||
|
||||
const stateTypeFilters = [
|
||||
{ label: "All", value: "" },
|
||||
{ label: "Pending", value: "pending" },
|
||||
{ label: "Unpaid", value: "unpaid" },
|
||||
{ label: "Paid", value: "paid" },
|
||||
];
|
||||
|
||||
export default function HomeOrdersFilters({
|
||||
currentPage,
|
||||
pageSize,
|
||||
total,
|
||||
selectedOwnerType,
|
||||
selectedState,
|
||||
onOwnerTypeChange,
|
||||
onStateChange,
|
||||
onPageChange,
|
||||
}: {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
selectedOwnerType: string;
|
||||
selectedState: string;
|
||||
onOwnerTypeChange: (value: string) => void;
|
||||
onStateChange: (value: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Space size={12} wrap>
|
||||
<Select
|
||||
value={selectedOwnerType}
|
||||
onChange={onOwnerTypeChange}
|
||||
options={ownerTypeFilters}
|
||||
style={{ minWidth: 170 }}
|
||||
placeholder="Owner type"
|
||||
aria-label="Owner type"
|
||||
/>
|
||||
<Select
|
||||
value={selectedState}
|
||||
onChange={onStateChange}
|
||||
options={stateTypeFilters}
|
||||
style={{ minWidth: 160 }}
|
||||
placeholder="State type"
|
||||
aria-label="State type"
|
||||
/>
|
||||
</Space>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger={false}
|
||||
hideOnSinglePage
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from "react";
|
||||
import { EditOutlined } from "@ant-design/icons";
|
||||
import { Button, Space, Table, Tag, Tooltip, Typography } from "antd";
|
||||
import { navigateTo } from "../../lib/routing";
|
||||
import {
|
||||
formatEstimatedTotal,
|
||||
stripPriceDecorations,
|
||||
} from "../../lib/orderFormatting";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function getOrderState(order: any): "pending" | "unpaid" | "paid" | "" {
|
||||
if (!order?.is_participant) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (order.submission?.paid) {
|
||||
return "paid";
|
||||
}
|
||||
|
||||
if (order.submission?.accepted) {
|
||||
return "unpaid";
|
||||
}
|
||||
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function getEstimatedTotalText(rawValue: unknown): string | null {
|
||||
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
|
||||
return formatEstimatedTotal(rawValue);
|
||||
}
|
||||
|
||||
if (typeof rawValue === "string") {
|
||||
const parsed = Number(rawValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return formatEstimatedTotal(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function HomeOrdersTable({
|
||||
orders,
|
||||
selectedState,
|
||||
selectedOwnerType,
|
||||
onPageFromTable,
|
||||
}: {
|
||||
orders: any[];
|
||||
selectedState: string;
|
||||
selectedOwnerType: string;
|
||||
onPageFromTable: (page: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={orders}
|
||||
showHeader={false}
|
||||
onChange={(tablePagination) =>
|
||||
onPageFromTable(Number(tablePagination?.current || 1))
|
||||
}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText:
|
||||
selectedState || selectedOwnerType
|
||||
? "No orders match your filters."
|
||||
: "Once you create an order or submit to one, it will appear here.",
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: "Order",
|
||||
dataIndex: "title",
|
||||
key: "title",
|
||||
render: (_value: unknown, item: any) => {
|
||||
const state = getOrderState(item);
|
||||
const rawFormatted = String(
|
||||
item.submission?.formatted_string || "",
|
||||
);
|
||||
const formatted = stripPriceDecorations(rawFormatted).trim();
|
||||
const estimateText = getEstimatedTotalText(
|
||||
item.submission?.estimated_total,
|
||||
);
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space size={8} align="center">
|
||||
<Text strong>{item.title || item.id}</Text>
|
||||
{item.is_owner ? <Tag color="geekblue">Owner</Tag> : null}
|
||||
{state === "paid" ? <Tag color="green">Paid</Tag> : null}
|
||||
{state === "unpaid" ? (
|
||||
<Tag color="volcano">Unpaid</Tag>
|
||||
) : null}
|
||||
{state === "pending" ? <Tag color="blue">Pending</Tag> : null}
|
||||
</Space>
|
||||
<Text type="secondary">
|
||||
{item.created_at
|
||||
? new Date(item.created_at).toLocaleString()
|
||||
: "Unknown creation time"}
|
||||
{item.is_participant && formatted && ` • ${formatted}`}
|
||||
{item.is_participant && estimateText && (
|
||||
<>
|
||||
{" • "}
|
||||
<Tooltip title="Rough estimate based on menu prices. Final amount may differ.">
|
||||
{estimateText}
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (_value: unknown, item: any) =>
|
||||
item.is_owner ? (
|
||||
<Button
|
||||
key="admin-edit-btn"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
navigateTo(`/order/${item.id}/admin`);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
]}
|
||||
onRow={(item: any) => ({
|
||||
onClick: () => navigateTo(`/order/${item.id}`),
|
||||
style: { cursor: "pointer" },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
import { Col, Form, Select } from "antd";
|
||||
import { OrderFormCategory } from "../forms/OrderFormConfigBuilder";
|
||||
|
||||
function cleanOptional(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function toCategoryOptions(category: OrderFormCategory) {
|
||||
return category.items.map((item) => {
|
||||
const hasPrice = Number.isFinite(item.price);
|
||||
return {
|
||||
value: item.name,
|
||||
label: hasPrice ? `${item.name} (€${item.price!.toFixed(2)})` : item.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getCategoryRules(category: OrderFormCategory) {
|
||||
if (!category.required) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
validator: (_: unknown, value: unknown) => {
|
||||
if (Array.isArray(value)) {
|
||||
const cleaned = value.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
return cleaned.length > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error(`${category.label} is required`));
|
||||
}
|
||||
|
||||
const singleValue = cleanOptional(value);
|
||||
return singleValue
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error(`${category.label} is required`));
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function ParticipantCategoryFields({
|
||||
categories,
|
||||
isClosed,
|
||||
}: {
|
||||
categories: OrderFormCategory[];
|
||||
isClosed: boolean;
|
||||
}) {
|
||||
return categories.map((category) => {
|
||||
const options = toCategoryOptions(category);
|
||||
const selectPlaceholder = `Select ${category.label.toLowerCase()}`;
|
||||
const rules = getCategoryRules(category);
|
||||
const extra = category.custom
|
||||
? `Your ${category.label.toLowerCase()} of choice. (Custom entries allowed)`
|
||||
: `Your ${category.label.toLowerCase()} of choice.`;
|
||||
|
||||
return (
|
||||
<Col xs={24} md={12} key={category.id}>
|
||||
<Form.Item extra={extra} label={category.label} name={category.id} rules={rules}>
|
||||
<Select
|
||||
mode={category.multiple ? "multiple" : undefined}
|
||||
allowClear={!category.required}
|
||||
showSearch
|
||||
placeholder={selectPlaceholder}
|
||||
options={options}
|
||||
disabled={isClosed}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import { Alert, Space, Tooltip, Typography } from "antd";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function ParticipantDraftSummaryAlert({
|
||||
hasExistingSubmission,
|
||||
hasActiveChanges,
|
||||
savedOrderString,
|
||||
savedEstimatedTotal,
|
||||
draftOrderString,
|
||||
draftEstimatedTotal,
|
||||
}: {
|
||||
hasExistingSubmission: boolean;
|
||||
hasActiveChanges: boolean;
|
||||
savedOrderString: string;
|
||||
savedEstimatedTotal: string | null;
|
||||
draftOrderString: string;
|
||||
draftEstimatedTotal: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Alert
|
||||
type={hasExistingSubmission && hasActiveChanges ? "warning" : "info"}
|
||||
message={
|
||||
<Space direction="vertical" size={0}>
|
||||
{hasExistingSubmission && (
|
||||
<Text>
|
||||
Current order: <Text code>{savedOrderString}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{hasExistingSubmission && savedEstimatedTotal && (
|
||||
<Tooltip title="Rough estimate based on configured item prices. Final amount may differ.">
|
||||
<Text type="secondary">Current estimated total: {savedEstimatedTotal}</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(!hasExistingSubmission || hasActiveChanges) && (
|
||||
<Text>
|
||||
Order preview: <Text code>{draftOrderString}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{(!hasExistingSubmission || hasActiveChanges) && draftEstimatedTotal && (
|
||||
<Tooltip title="Rough estimate based on configured item prices. Final amount may differ.">
|
||||
<Text type="secondary">Preview estimated total: {draftEstimatedTotal}</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
import { Checkbox, Result, Space, Tooltip, Typography } from "antd";
|
||||
import type { Submission } from "../../lib/types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function formatTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const timestamp = new Date(value);
|
||||
if (Number.isNaN(timestamp.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return timestamp.toLocaleString();
|
||||
}
|
||||
|
||||
export default function ParticipantSubmissionSummary({
|
||||
submission,
|
||||
savedOrderString,
|
||||
savedEstimatedTotal,
|
||||
isClosed,
|
||||
}: {
|
||||
submission: Submission | null;
|
||||
savedOrderString: string;
|
||||
savedEstimatedTotal: string | null;
|
||||
isClosed: boolean;
|
||||
}) {
|
||||
if (!submission) {
|
||||
return (
|
||||
<Result
|
||||
status="info"
|
||||
title="No order submitted"
|
||||
subTitle={
|
||||
isClosed
|
||||
? "This order is now closed, and no submission was recorded for you."
|
||||
: "You have not submitted an order yet. Fill in the form below when you're ready."
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Result
|
||||
status="success"
|
||||
title="Order submitted"
|
||||
subTitle={
|
||||
<Space direction="vertical">
|
||||
<Tooltip title="Your submitted order">
|
||||
<Text code copyable>
|
||||
{savedOrderString}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{savedEstimatedTotal && (
|
||||
<Tooltip title="Rough estimate based on configured item prices. Final amount may differ.">
|
||||
<Text type="secondary">Estimated total: {savedEstimatedTotal}</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isClosed && <Text>Review or adjust at any time while the order is open</Text>}
|
||||
</Space>
|
||||
}
|
||||
extra={[
|
||||
<Space key="submission-status" direction="vertical" size={4}>
|
||||
<Text strong>Submission status</Text>
|
||||
<Space size={20} wrap>
|
||||
<Checkbox checked={submission.accepted} style={{ pointerEvents: "none" }}>
|
||||
Accepted
|
||||
</Checkbox>
|
||||
<Checkbox checked={submission.paid} style={{ pointerEvents: "none" }}>
|
||||
Paid
|
||||
</Checkbox>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Updated {formatTimestamp(submission.updated_at)}
|
||||
</Text>
|
||||
</Space>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { UseApiRequestOptions } from "../lib/types";
|
||||
|
||||
export function useApiRequest<T = any>(requestFactory: () => Promise<T>, options: UseApiRequestOptions<T> = {}) {
|
||||
const {
|
||||
immediate = true,
|
||||
deps = [],
|
||||
initialData = null,
|
||||
onError,
|
||||
onSuccess,
|
||||
} = options;
|
||||
|
||||
const [data, setData] = useState<T | null>(initialData);
|
||||
const [loading, setLoading] = useState(immediate);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
const currentRequestId = ++requestIdRef.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = await requestFactory();
|
||||
if (currentRequestId !== requestIdRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setData(payload);
|
||||
if (typeof onSuccess === "function") {
|
||||
onSuccess(payload);
|
||||
}
|
||||
return payload;
|
||||
} catch (requestError: unknown) {
|
||||
if (currentRequestId !== requestIdRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorMessage = requestError instanceof Error ? requestError.message : "Request failed.";
|
||||
setError(errorMessage);
|
||||
if (typeof onError === "function") {
|
||||
onError(requestError as Error);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (currentRequestId === requestIdRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [onError, onSuccess, requestFactory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!immediate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
run();
|
||||
return () => {
|
||||
requestIdRef.current += 1;
|
||||
};
|
||||
// deps are intentionally user-controlled so each consumer decides refresh triggers.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
return {
|
||||
data,
|
||||
setData,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
run,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ensureUserId } from "./userIdentity";
|
||||
import type { ApiError } from "./types";
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
function createApiError(message: string, options: { status?: number; isNetworkError?: boolean } = {}): ApiError {
|
||||
const error = new Error(message) as ApiError;
|
||||
error.name = "ApiError";
|
||||
error.status = options.status ?? null;
|
||||
error.isNetworkError = !!options.isNetworkError;
|
||||
error.isNotFound = error.status === 404;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function isNotFoundError(error: unknown) {
|
||||
const maybeError = error as Partial<ApiError> | null;
|
||||
return !!(maybeError && (maybeError.isNotFound || maybeError.status === 404));
|
||||
}
|
||||
|
||||
export async function apiFetch<T = any>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const fullUrl = url.startsWith("/api") ? API_BASE + url.slice(4) : url;
|
||||
const userId = ensureUserId();
|
||||
const authHeader = userId ? { "X-User-Id": userId } : {};
|
||||
|
||||
// Determine Content-Type: don't set it for FormData (let browser set it), use application/json for JSON
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const contentTypeHeader = isFormData ? {} : { "Content-Type": "application/json" };
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(fullUrl, {
|
||||
headers: {
|
||||
...contentTypeHeader,
|
||||
...authHeader,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
} catch (_error) {
|
||||
throw createApiError("Network request failed. Please try again.", {
|
||||
isNetworkError: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({} as { detail?: string }));
|
||||
throw createApiError(payload.detail || `Request failed (${response.status})`, {
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
return (response.status === 204 ? null : response.json()) as Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { apiService } from "./services";
|
||||
@@ -0,0 +1,2 @@
|
||||
export const USER_TOKEN_KEY = "lunchtime_token";
|
||||
export const THEME_MODE_KEY = "lunchtime_theme_mode";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export function markdownToHtml(markdownText: string | null | undefined) {
|
||||
if (!markdownText) {
|
||||
return "";
|
||||
}
|
||||
const html = marked.parse(markdownText) as string;
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { OrderFormCategory, OrderFormConfig, OrderFormItem } from "./types";
|
||||
|
||||
export const ORDER_FORMAT_VALUE_PLACEHOLDER = "{name}";
|
||||
export const ORDER_FORMAT_LABEL_PLACEHOLDER = "{label}";
|
||||
export const DEFAULT_CATEGORY_SNIPPET = "{label}: {name}";
|
||||
export const DEFAULT_MULTIPLE_SEPARATOR = ", ";
|
||||
export const DEFAULT_EMPTY_ORDER_TEXT = "No items selected";
|
||||
|
||||
export type NormalizedOrderSelection = string | null | string[];
|
||||
export type NormalizedOrderSelections = Record<string, NormalizedOrderSelection>;
|
||||
|
||||
type RawOrderFormValues = Record<string, unknown>;
|
||||
|
||||
function cleanOptional(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function resolveItemLabel(name: string, item?: OrderFormItem) {
|
||||
if (!item) {
|
||||
return name;
|
||||
}
|
||||
|
||||
return item.name;
|
||||
}
|
||||
|
||||
function resolveItemPrice(item?: OrderFormItem) {
|
||||
if (!item || !Number.isFinite(item.price)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number(item.price);
|
||||
}
|
||||
|
||||
function getMultipleSeparator(category: OrderFormCategory) {
|
||||
if (typeof category.multipleSeparator === "string") {
|
||||
return category.multipleSeparator;
|
||||
}
|
||||
|
||||
return DEFAULT_MULTIPLE_SEPARATOR;
|
||||
}
|
||||
|
||||
function getCategorySnippet(category: OrderFormCategory) {
|
||||
const rawSnippet = String(category.formattedSnippet || "").trim();
|
||||
if (!rawSnippet || !rawSnippet.includes(ORDER_FORMAT_VALUE_PLACEHOLDER)) {
|
||||
return DEFAULT_CATEGORY_SNIPPET;
|
||||
}
|
||||
|
||||
return rawSnippet;
|
||||
}
|
||||
|
||||
function applyCategorySnippet(category: OrderFormCategory, value: string) {
|
||||
const snippet = getCategorySnippet(category);
|
||||
return snippet
|
||||
.split(ORDER_FORMAT_VALUE_PLACEHOLDER)
|
||||
.join(value)
|
||||
.split(ORDER_FORMAT_LABEL_PLACEHOLDER)
|
||||
.join(category.label);
|
||||
}
|
||||
|
||||
export function normalizeOrderSelections(
|
||||
values: RawOrderFormValues,
|
||||
config: OrderFormConfig,
|
||||
): NormalizedOrderSelections {
|
||||
const normalized: NormalizedOrderSelections = {};
|
||||
|
||||
for (const category of config.categories) {
|
||||
const categoryValue = values[category.id];
|
||||
|
||||
if (category.multiple) {
|
||||
normalized[category.id] = (Array.isArray(categoryValue) ? categoryValue : [])
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawSingleValue = Array.isArray(categoryValue)
|
||||
? categoryValue[0]
|
||||
: categoryValue;
|
||||
normalized[category.id] = cleanOptional(rawSingleValue);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function buildFormattedOrderString(
|
||||
values: NormalizedOrderSelections,
|
||||
config: OrderFormConfig,
|
||||
options?: {
|
||||
categoryJoiner?: string;
|
||||
emptyText?: string;
|
||||
},
|
||||
) {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const category of config.categories) {
|
||||
const categoryValue = values[category.id];
|
||||
let categoryPart = "";
|
||||
|
||||
if (category.multiple) {
|
||||
const selectedItems = Array.isArray(categoryValue) ? categoryValue : [];
|
||||
if (selectedItems.length > 0) {
|
||||
const labels = selectedItems.map((itemName) => {
|
||||
const item = category.items.find((entry) => entry.name === itemName);
|
||||
return resolveItemLabel(itemName, item);
|
||||
});
|
||||
categoryPart = labels.join(getMultipleSeparator(category));
|
||||
}
|
||||
} else if (typeof categoryValue === "string" && categoryValue.trim()) {
|
||||
const item = category.items.find((entry) => entry.name === categoryValue);
|
||||
categoryPart = resolveItemLabel(categoryValue, item);
|
||||
}
|
||||
|
||||
if (categoryPart) {
|
||||
parts.push(applyCategorySnippet(category, categoryPart));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!category.required) {
|
||||
const fallback = String(category.optionalFallback || "").trim();
|
||||
if (fallback) {
|
||||
parts.push(fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const categoryJoiner = options?.categoryJoiner || " ";
|
||||
const emptyText = options?.emptyText || DEFAULT_EMPTY_ORDER_TEXT;
|
||||
return parts.length > 0 ? parts.join(categoryJoiner) : emptyText;
|
||||
}
|
||||
|
||||
export function buildExampleFormattedOrderString(config: OrderFormConfig): string {
|
||||
const sampleSelections: NormalizedOrderSelections = {};
|
||||
|
||||
for (const category of config.categories) {
|
||||
if (category.multiple) {
|
||||
sampleSelections[category.id] = category.items.slice(0, 3).map((item) => item.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstItem = category.items[0];
|
||||
sampleSelections[category.id] = firstItem ? firstItem.name : null;
|
||||
}
|
||||
|
||||
return buildFormattedOrderString(sampleSelections, config, {
|
||||
categoryJoiner: " ",
|
||||
emptyText: "(Add items to see example)",
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateEstimatedOrderTotal(
|
||||
values: NormalizedOrderSelections,
|
||||
config: OrderFormConfig,
|
||||
): number | null {
|
||||
let total = 0;
|
||||
let hasPricedItems = false;
|
||||
|
||||
for (const category of config.categories) {
|
||||
const categoryValue = values[category.id];
|
||||
|
||||
if (category.multiple) {
|
||||
const selectedItems = Array.isArray(categoryValue) ? categoryValue : [];
|
||||
for (const itemName of selectedItems) {
|
||||
const item = category.items.find((entry) => entry.name === itemName);
|
||||
const price = resolveItemPrice(item);
|
||||
if (price === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
total += price;
|
||||
hasPricedItems = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof categoryValue !== "string" || !categoryValue.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = category.items.find((entry) => entry.name === categoryValue);
|
||||
const price = resolveItemPrice(item);
|
||||
if (price === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
total += price;
|
||||
hasPricedItems = true;
|
||||
}
|
||||
|
||||
if (!hasPricedItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
export function formatEstimatedTotal(total: number | null): string | null {
|
||||
if (total === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `~ €${total.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function stripPriceDecorations(value: string): string {
|
||||
return value.replace(/\s*\(€\s*\d+(?:[.,]\d{1,2})?\)/g, "");
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export type AppRoute =
|
||||
| { type: "home" }
|
||||
| { type: "create" }
|
||||
| { type: "order"; orderId: string }
|
||||
| { type: "admin"; orderId: string };
|
||||
|
||||
export function parseRoute(pathname: string): AppRoute {
|
||||
if (pathname === "/create") {
|
||||
return { type: "create" };
|
||||
}
|
||||
|
||||
const adminMatch = pathname.match(/^\/order\/([^/]+)\/admin$/);
|
||||
if (adminMatch) {
|
||||
return { type: "admin", orderId: adminMatch[1] };
|
||||
}
|
||||
|
||||
const orderMatch = pathname.match(/^\/order\/([^/]+)$/);
|
||||
if (orderMatch) {
|
||||
return { type: "order", orderId: orderMatch[1] };
|
||||
}
|
||||
|
||||
return { type: "home" };
|
||||
}
|
||||
|
||||
const ROUTE_CHANGE_EVENT = "app:routechange";
|
||||
|
||||
function notifyRouteChange() {
|
||||
window.dispatchEvent(new Event(ROUTE_CHANGE_EVENT));
|
||||
}
|
||||
|
||||
export function navigateTo(pathname: string) {
|
||||
if (window.location.pathname === pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.pushState({}, "", pathname);
|
||||
notifyRouteChange();
|
||||
}
|
||||
|
||||
export function replaceRoute(pathname: string) {
|
||||
if (window.location.pathname === pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.replaceState({}, "", pathname);
|
||||
notifyRouteChange();
|
||||
}
|
||||
|
||||
export function subscribeToRouteChange(onRouteChange: () => void) {
|
||||
const handleRouteChange = () => {
|
||||
onRouteChange();
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", handleRouteChange);
|
||||
window.addEventListener(ROUTE_CHANGE_EVENT, handleRouteChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("popstate", handleRouteChange);
|
||||
window.removeEventListener(ROUTE_CHANGE_EVENT, handleRouteChange);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { apiFetch } from "../api";
|
||||
import type { RegisterAccountResult, ConfirmAccountResult, AccountLookupResult } from "../types";
|
||||
|
||||
export const accountService = {
|
||||
lookupByEmail: (email: string) =>
|
||||
apiFetch<AccountLookupResult>(`/api/account/lookup?email=${encodeURIComponent(email)}`),
|
||||
register: (email: string) =>
|
||||
apiFetch<RegisterAccountResult>("/api/account/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
confirm: (token: string) =>
|
||||
apiFetch<ConfirmAccountResult>(`/api/account/confirm?token=${encodeURIComponent(token)}`),
|
||||
requestUserIdChange: (requestedUserId?: string) =>
|
||||
apiFetch<RegisterAccountResult>("/api/me/user-id/change/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ requested_user_id: requestedUserId || null }),
|
||||
}),
|
||||
requestEmailChange: (newEmail: string) =>
|
||||
apiFetch<RegisterAccountResult>("/api/me/email/change/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ new_email: newEmail }),
|
||||
}),
|
||||
requestMigration: (email: string) =>
|
||||
apiFetch<RegisterAccountResult>("/api/account/migrate/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { apiFetch } from "../api";
|
||||
import type { AppConfig } from "../types";
|
||||
|
||||
export const configService = {
|
||||
get: () => apiFetch<AppConfig>("/api/config"),
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { accountService } from "./accountService";
|
||||
import { configService } from "./configService";
|
||||
import { ordersService } from "./ordersService";
|
||||
import { profileService } from "./profileService";
|
||||
import { submissionsService } from "./submissionsService";
|
||||
|
||||
export const apiService = {
|
||||
account: accountService,
|
||||
config: configService,
|
||||
orders: ordersService,
|
||||
profile: profileService,
|
||||
submissions: submissionsService,
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { apiFetch } from "../api";
|
||||
import type {
|
||||
Order,
|
||||
OrderFormConfig,
|
||||
GetAdminViewResponse,
|
||||
OrderAccessInfo,
|
||||
GetOrdersResponse,
|
||||
UploadImageResponse,
|
||||
} from "../types";
|
||||
|
||||
type CreateOrderPayload = {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
config: OrderFormConfig;
|
||||
};
|
||||
|
||||
type UpdateOrderDetailsPayload = {
|
||||
description?: string | null;
|
||||
image_url?: string | null;
|
||||
};
|
||||
|
||||
export const ordersService = {
|
||||
create: (payload: CreateOrderPayload) =>
|
||||
apiFetch<Order>("/api/orders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
getMine: (params?: { skip?: number; limit?: number; state?: string; role?: string }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.skip !== undefined) query.append("skip", params.skip.toString());
|
||||
if (params?.limit !== undefined) query.append("limit", params.limit.toString());
|
||||
if (params?.state) query.append("state", params.state);
|
||||
if (params?.role) query.append("role", params.role);
|
||||
|
||||
const url = `/api/orders/me${query.toString() ? "?" + query.toString() : ""}`;
|
||||
return apiFetch<GetOrdersResponse>(url);
|
||||
},
|
||||
getMyAccess: (orderId: string) => apiFetch<OrderAccessInfo>(`/api/orders/${orderId}/me`),
|
||||
get: (orderId: string) => apiFetch<Order>(`/api/orders/${orderId}`),
|
||||
getConfig: (orderId: string) => apiFetch<OrderFormConfig>(`/api/orders/${orderId}/config`),
|
||||
getAdminView: (orderId: string) => apiFetch<GetAdminViewResponse>(`/api/orders/${orderId}/admin`),
|
||||
delete: (orderId: string) =>
|
||||
apiFetch<{ status: string }>(`/api/orders/${orderId}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
updateClosedStatus: (orderId: string, closed: boolean) =>
|
||||
apiFetch<Order>(`/api/orders/${orderId}/admin/status`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ closed }),
|
||||
}),
|
||||
updateDetails: (orderId: string, payload: UpdateOrderDetailsPayload) =>
|
||||
apiFetch<Order>(`/api/orders/${orderId}/admin/description`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateConfig: (orderId: string, config: OrderFormConfig) =>
|
||||
apiFetch<Order>(`/api/orders/${orderId}/admin/config`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
uploadImage: (orderId: string, file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return apiFetch<UploadImageResponse>(`/api/orders/${orderId}/admin/image`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
},
|
||||
deleteImage: (orderId: string) =>
|
||||
apiFetch<{ status: string }>(`/api/orders/${orderId}/admin/image`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { apiFetch } from "../api";
|
||||
import type { UserProfile } from "../types";
|
||||
|
||||
export const profileService = {
|
||||
getMine: () => apiFetch<UserProfile>("/api/me/profile"),
|
||||
updateMine: (email: string) =>
|
||||
apiFetch<UserProfile>("/api/me/profile", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { apiFetch } from "../api";
|
||||
import type { Submission, SubmissionInput, SubmissionStatusUpdate } from "../types";
|
||||
|
||||
export const submissionsService = {
|
||||
create: (orderId: string, payload: SubmissionInput) =>
|
||||
apiFetch<Submission>(`/api/orders/${orderId}/submissions/me`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
createMine: (orderId: string, payload: SubmissionInput) =>
|
||||
apiFetch<Submission>(`/api/orders/${orderId}/submissions/me`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
getMine: (orderId: string) => apiFetch<Submission | null>(`/api/orders/${orderId}/submissions/me`),
|
||||
update: (orderId: string, payload: SubmissionInput) =>
|
||||
apiFetch<Submission>(`/api/orders/${orderId}/submissions/me`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateMine: (orderId: string, payload: SubmissionInput) =>
|
||||
apiFetch<Submission>(`/api/orders/${orderId}/submissions/me`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
delete: (orderId: string) =>
|
||||
apiFetch<{ status: string }>(`/api/orders/${orderId}/submissions/me`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
removeMine: (orderId: string) =>
|
||||
apiFetch<{ status: string }>(`/api/orders/${orderId}/submissions/me`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
removeAsAdmin: (orderId: string, submissionId: string | number) =>
|
||||
apiFetch<{ status: string }>(`/api/orders/${orderId}/admin/submissions/${submissionId}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
updateStatusAsAdmin: (orderId: string, submissionId: string | number, payload: SubmissionStatusUpdate) =>
|
||||
apiFetch<Submission>(`/api/orders/${orderId}/admin/submissions/${submissionId}/status`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
const inMemoryFallback = new Map<string, string>();
|
||||
|
||||
function canUseLocalStorage() {
|
||||
return typeof window !== "undefined" && !!window.localStorage;
|
||||
}
|
||||
|
||||
export function getStoredValue(key?: string | null) {
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (canUseLocalStorage()) {
|
||||
try {
|
||||
const value = window.localStorage.getItem(key);
|
||||
if (value !== null) {
|
||||
return value;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Fall back to in-memory value when storage access is blocked.
|
||||
}
|
||||
}
|
||||
|
||||
return inMemoryFallback.get(key) ?? null;
|
||||
}
|
||||
|
||||
export function setStoredValue(key: string | null | undefined, value: unknown) {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = String(value);
|
||||
|
||||
if (canUseLocalStorage()) {
|
||||
try {
|
||||
window.localStorage.setItem(key, normalized);
|
||||
inMemoryFallback.delete(key);
|
||||
return;
|
||||
} catch (_error) {
|
||||
// Keep app functional even when persistent storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
inMemoryFallback.set(key, normalized);
|
||||
}
|
||||
|
||||
export function removeStoredValue(key?: string | null) {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canUseLocalStorage()) {
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch (_error) {
|
||||
// Ignore remove failures and still clear fallback state.
|
||||
}
|
||||
}
|
||||
|
||||
inMemoryFallback.delete(key);
|
||||
}
|
||||
|
||||
export function getStoredJSON<T = unknown>(key: string) {
|
||||
const raw = getStoredValue(key);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch (_error) {
|
||||
removeStoredValue(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredJSON(key: string, value: unknown) {
|
||||
setStoredValue(key, JSON.stringify(value));
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Core Domain Models and API Types
|
||||
* This file contains all TypeScript types and interfaces for the entire application
|
||||
*/
|
||||
|
||||
/* ==================== Account & Authentication ==================== */
|
||||
|
||||
export type RegisterAccountResult = {
|
||||
status: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ConfirmAccountResult = {
|
||||
status: string;
|
||||
action: string;
|
||||
user_id: string;
|
||||
};
|
||||
|
||||
export type AccountLookupResult = {
|
||||
exists: boolean;
|
||||
};
|
||||
|
||||
export type UserProfile = {
|
||||
email: string | null;
|
||||
email_confirmed?: boolean;
|
||||
pending_email?: string | null;
|
||||
pending_email_old_confirmed?: boolean;
|
||||
pending_email_new_confirmed?: boolean;
|
||||
pending_user_id?: string | null;
|
||||
};
|
||||
|
||||
export type AppConfig = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/* ==================== Order Models ==================== */
|
||||
|
||||
export type OrderFormItem = {
|
||||
name: string;
|
||||
price?: number;
|
||||
};
|
||||
|
||||
export type OrderFormCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
multiple: boolean;
|
||||
custom?: boolean;
|
||||
formattedSnippet?: string;
|
||||
multipleSeparator?: string;
|
||||
optionalFallback?: string;
|
||||
items: OrderFormItem[];
|
||||
};
|
||||
|
||||
export type OrderFormConfig = {
|
||||
categories: OrderFormCategory[];
|
||||
};
|
||||
|
||||
export type OrderBase = {
|
||||
id: string;
|
||||
admin_token: string;
|
||||
admin_user_id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
image_url?: string | null;
|
||||
created_at: string;
|
||||
closed: boolean;
|
||||
config: OrderFormConfig;
|
||||
};
|
||||
|
||||
export type Order = OrderBase & {
|
||||
access?: {
|
||||
is_owner: boolean;
|
||||
is_participant: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type OrderAdminView = OrderBase & {
|
||||
submissions: Submission[];
|
||||
formatted_total?: string;
|
||||
estimated_total?: number;
|
||||
total_submitted: number;
|
||||
stats?: {
|
||||
pending: number;
|
||||
unpaid: number;
|
||||
paid: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type OrderAccessInfo = {
|
||||
is_owner: boolean;
|
||||
is_participant: boolean;
|
||||
};
|
||||
|
||||
/* ==================== Submission Models ==================== */
|
||||
|
||||
export type SubmissionStatus = "pending" | "unpaid" | "paid";
|
||||
|
||||
export type OrderChoices = Record<string, string | string[] | null | undefined>;
|
||||
|
||||
export type Submission = {
|
||||
id: string | number;
|
||||
order_id?: string;
|
||||
group_order_id?: string;
|
||||
user_id?: string;
|
||||
email?: string;
|
||||
choices?: OrderChoices;
|
||||
choices_json?: string | OrderChoices;
|
||||
formatted_string?: string;
|
||||
estimated_total?: number | string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
accepted: boolean;
|
||||
paid: boolean;
|
||||
};
|
||||
|
||||
export type SubmissionInput = {
|
||||
choices: OrderChoices;
|
||||
};
|
||||
|
||||
export type SubmissionStatusUpdate = {
|
||||
accepted?: boolean;
|
||||
paid?: boolean;
|
||||
};
|
||||
|
||||
/* ==================== API Response Models ==================== */
|
||||
|
||||
export type PaginationInfo = {
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
export type GetOrdersResponse = {
|
||||
orders: Order[];
|
||||
pagination: PaginationInfo;
|
||||
};
|
||||
|
||||
export type GetAdminViewResponse = {
|
||||
order: Order;
|
||||
submissions: Submission[];
|
||||
};
|
||||
|
||||
export type UploadImageResponse = {
|
||||
status: string;
|
||||
image_url?: string;
|
||||
};
|
||||
|
||||
/* ==================== Normalized/Computed Models ==================== */
|
||||
|
||||
export type NormalizedOrderSelections = Record<string, string | string[] | null | undefined>;
|
||||
|
||||
export type FormattedOrderString = string;
|
||||
|
||||
/* ==================== Component Prop Types ==================== */
|
||||
|
||||
export type AsyncContentProps = {
|
||||
loading: boolean;
|
||||
error: Error | string | null;
|
||||
onRetry?: () => void | Promise<void>;
|
||||
loadingRows?: number;
|
||||
loadingSections?: number;
|
||||
errorTitle?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export type LoadingSkeletonProps = {
|
||||
rows?: number;
|
||||
sections?: number;
|
||||
};
|
||||
|
||||
export type ErrorResultProps = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
onRetry?: () => void;
|
||||
size?: "default" | "large";
|
||||
};
|
||||
|
||||
export type ThemeModeToggleProps = {
|
||||
value?: "light" | "dark" | string;
|
||||
onChange?: (mode: string) => void;
|
||||
};
|
||||
|
||||
export type AnnouncementsProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
/* ==================== Hook Types ==================== */
|
||||
|
||||
export type UseApiRequestOptions<T> = {
|
||||
immediate?: boolean;
|
||||
deps?: unknown[];
|
||||
initialData?: T | null;
|
||||
onError?: (error: Error) => void;
|
||||
onSuccess?: (data: T) => void;
|
||||
};
|
||||
|
||||
export type UseApiRequestResult<T> = {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
setData: (data: T | ((prev: T | null) => T | null)) => void;
|
||||
run: () => Promise<T | null>;
|
||||
};
|
||||
|
||||
/* ==================== API Error Model ==================== */
|
||||
|
||||
export type ApiError = Error & {
|
||||
status: number | null;
|
||||
isNetworkError: boolean;
|
||||
isNotFound: boolean;
|
||||
};
|
||||
|
||||
/* ==================== Storage Models ==================== */
|
||||
|
||||
export type StorageUserIdentity = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
/* ==================== UI State Models ==================== */
|
||||
|
||||
export type SubmissionState = SubmissionStatus | "all";
|
||||
|
||||
export type OrderStateFilter = "all" | "open" | "closed";
|
||||
|
||||
export type OrderOwnerRoleFilter = "all" | "admin" | "participant";
|
||||
@@ -0,0 +1,74 @@
|
||||
import { USER_TOKEN_KEY } from "./constants";
|
||||
import { getStoredValue, setStoredValue } from "./storage";
|
||||
|
||||
const emailByUserId = new Map<string, string>();
|
||||
|
||||
function generateGuid() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Fallback GUID-like value for older environments without randomUUID.
|
||||
const randomHex = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).slice(1);
|
||||
return `${randomHex()}${randomHex()}-${randomHex()}-${randomHex()}-${randomHex()}-${randomHex()}${randomHex()}${randomHex()}`;
|
||||
}
|
||||
|
||||
function normalizeUserId(rawUserId: unknown) {
|
||||
return String(rawUserId || "").trim();
|
||||
}
|
||||
|
||||
function normalizeUserEmail(rawUserEmail: unknown) {
|
||||
return String(rawUserEmail || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function ensureUserId() {
|
||||
return normalizeUserId(getStoredValue(USER_TOKEN_KEY));
|
||||
}
|
||||
|
||||
export function hasStoredUserId() {
|
||||
return !!ensureUserId();
|
||||
}
|
||||
|
||||
export function createUserId() {
|
||||
const generated = generateGuid();
|
||||
setStoredValue(USER_TOKEN_KEY, generated);
|
||||
return generated;
|
||||
}
|
||||
|
||||
export function updateUserId(nextUserId: string) {
|
||||
const normalized = normalizeUserId(nextUserId);
|
||||
if (!normalized) {
|
||||
throw new Error("User ID is required");
|
||||
}
|
||||
|
||||
setStoredValue(USER_TOKEN_KEY, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getUserEmailForUserId(userId: string) {
|
||||
const normalizedUserId = normalizeUserId(userId);
|
||||
if (!normalizedUserId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return emailByUserId.get(normalizedUserId) || "";
|
||||
}
|
||||
|
||||
export function updateUserEmail(nextUserEmail: string, userId: string) {
|
||||
const normalizedUserId = normalizeUserId(userId);
|
||||
if (!normalizedUserId) {
|
||||
throw new Error("User ID is required");
|
||||
}
|
||||
|
||||
const normalized = normalizeUserEmail(nextUserEmail);
|
||||
if (!normalized) {
|
||||
throw new Error("Email is required");
|
||||
}
|
||||
|
||||
emailByUserId.set(normalizedUserId, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function regenerateUserId() {
|
||||
return createUserId();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "antd/dist/reset.css";
|
||||
import "./styles.css";
|
||||
import App from "./App";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element");
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
:root {
|
||||
--scrollbar-track: #efe3d4;
|
||||
--scrollbar-thumb: #c8874e;
|
||||
--scrollbar-thumb-hover: #a66631;
|
||||
--scrollbar-thumb-active: #8c4f22;
|
||||
}
|
||||
|
||||
:root[data-theme-mode="dark"] {
|
||||
--scrollbar-track: #2f2016;
|
||||
--scrollbar-thumb: #b7743f;
|
||||
--scrollbar-thumb-hover: #d08a4f;
|
||||
--scrollbar-thumb-active: #e8a261;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, var(--scrollbar-thumb), var(--scrollbar-thumb-active));
|
||||
border: 2px solid var(--scrollbar-track);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, var(--scrollbar-thumb-hover), var(--scrollbar-thumb));
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:active {
|
||||
background: var(--scrollbar-thumb-active);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: var(--scrollbar-track);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Space,
|
||||
message,
|
||||
} from "antd";
|
||||
import AsyncContent from "../components/utils/AsyncContent";
|
||||
import ReadOnlyOrderOverviewCard from "../components/orders/ReadOnlyOrderOverviewCard";
|
||||
import { useApiRequest } from "../hooks/useApiRequest";
|
||||
import { apiService } from "../lib/services";
|
||||
import { navigateTo } from "../lib/routing";
|
||||
import type {
|
||||
GetAdminViewResponse,
|
||||
Order,
|
||||
OrderFormConfig,
|
||||
Submission,
|
||||
SubmissionStatus,
|
||||
} from "../lib/types";
|
||||
import { formatEstimatedTotal, stripPriceDecorations } from "../lib/orderFormatting";
|
||||
import AdminParticipantShareAlert from "../components/views/AdminParticipantShareAlert";
|
||||
import AdminSubmissionsCard from "../components/views/AdminSubmissionsCard";
|
||||
import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
|
||||
|
||||
type AdminViewData = Order & {
|
||||
submissions: Submission[];
|
||||
config: OrderFormConfig;
|
||||
};
|
||||
|
||||
function getEstimatedTotalNumber(rawValue: unknown): number | null {
|
||||
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
if (typeof rawValue === "string") {
|
||||
const parsed = Number(rawValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AdminView({ orderId }: { orderId: string }) {
|
||||
const [deletingId, setDeletingId] = useState<string | number | null>(null);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
|
||||
const [savingStatusKey, setSavingStatusKey] = useState<string | null>(null);
|
||||
const [updatingOrderStatus, setUpdatingOrderStatus] = useState(false);
|
||||
const [deletingOrder, setDeletingOrder] = useState(false);
|
||||
const [submissionsPage, setSubmissionsPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedSubmissionState, setSelectedSubmissionState] = useState<"all" | "pending" | "unpaid" | "paid">("all");
|
||||
const SUBMISSIONS_PAGE_SIZE = 8;
|
||||
|
||||
const getSubmissionState = (submission: Submission): SubmissionStatus => {
|
||||
if (submission?.paid) {
|
||||
return "paid";
|
||||
}
|
||||
|
||||
if (submission?.accepted) {
|
||||
return "unpaid";
|
||||
}
|
||||
|
||||
return "pending";
|
||||
};
|
||||
|
||||
const loadAdminData = useCallback(
|
||||
async (): Promise<AdminViewData> => {
|
||||
const [adminPayload, configPayload] = await Promise.all([
|
||||
apiService.orders.getAdminView(orderId),
|
||||
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
||||
]);
|
||||
|
||||
const typedPayload = adminPayload as GetAdminViewResponse;
|
||||
const orderPayload = typedPayload?.order || ({} as Order);
|
||||
|
||||
return {
|
||||
...orderPayload,
|
||||
submissions: Array.isArray(typedPayload?.submissions)
|
||||
? typedPayload.submissions
|
||||
: [],
|
||||
config: {
|
||||
categories: Array.isArray((configPayload as OrderFormConfig)?.categories)
|
||||
? (configPayload as OrderFormConfig).categories
|
||||
: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
[orderId],
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
setData,
|
||||
loading,
|
||||
error,
|
||||
run: loadAdmin,
|
||||
} = useApiRequest(loadAdminData, {
|
||||
deps: [loadAdminData],
|
||||
onSuccess: () => {
|
||||
setSelectedRowKeys([]);
|
||||
},
|
||||
onError: (requestError) => {
|
||||
message.error(requestError?.message || "Admin view could not be loaded.");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAsAdmin = async (submissionId: string | number) => {
|
||||
setDeletingId(submissionId);
|
||||
try {
|
||||
await apiService.submissions.removeAsAdmin(orderId, submissionId);
|
||||
message.success("Submission deleted");
|
||||
await loadAdmin();
|
||||
} catch (error: any) {
|
||||
message.error(error.message);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSubmissionStatus = async (submission: any, changes: any) => {
|
||||
const nextAccepted =
|
||||
typeof changes.accepted === "boolean"
|
||||
? changes.accepted
|
||||
: !!submission.accepted;
|
||||
const nextPaid =
|
||||
typeof changes.paid === "boolean" ? changes.paid : !!submission.paid;
|
||||
|
||||
const statusKey = `${submission.id}:${Object.keys(changes).join(",")}`;
|
||||
setSavingStatusKey(statusKey);
|
||||
|
||||
try {
|
||||
const updated: any = await apiService.submissions.updateStatusAsAdmin(
|
||||
orderId,
|
||||
submission.id,
|
||||
{
|
||||
accepted: nextAccepted,
|
||||
paid: nextPaid,
|
||||
},
|
||||
);
|
||||
|
||||
setData((previous: any) => {
|
||||
if (!previous) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
submissions: previous.submissions.map((item: any) =>
|
||||
item.id === updated.id ? updated : item,
|
||||
),
|
||||
};
|
||||
});
|
||||
} catch (error: any) {
|
||||
message.error(error.message);
|
||||
} finally {
|
||||
setSavingStatusKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateOrderClosedStatus = async (closed: boolean) => {
|
||||
setUpdatingOrderStatus(true);
|
||||
try {
|
||||
const updatedOrder = await apiService.orders.updateClosedStatus(orderId, closed);
|
||||
|
||||
setData((previous: AdminViewData | null) => {
|
||||
if (!previous) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
...updatedOrder,
|
||||
};
|
||||
});
|
||||
|
||||
message.success(closed ? "Order closed" : "Order reopened");
|
||||
} catch (error: any) {
|
||||
message.error(error.message);
|
||||
} finally {
|
||||
setUpdatingOrderStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteOrder = async () => {
|
||||
setDeletingOrder(true);
|
||||
try {
|
||||
await apiService.orders.delete(orderId);
|
||||
message.success("Order deleted");
|
||||
navigateTo("/");
|
||||
} catch (error: any) {
|
||||
message.error(error.message);
|
||||
} finally {
|
||||
setDeletingOrder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pageError = error || (!data ? "Admin view could not be loaded." : null);
|
||||
|
||||
const handleImportConfig = async (nextConfig: OrderFormConfig) => {
|
||||
await apiService.orders.updateConfig(orderId, nextConfig);
|
||||
setData((previous: any) => {
|
||||
if (!previous) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
config: {
|
||||
categories: Array.isArray(nextConfig.categories)
|
||||
? nextConfig.categories
|
||||
: [],
|
||||
},
|
||||
};
|
||||
});
|
||||
message.success("Menu configuration updated");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(data.submissions.length / SUBMISSIONS_PAGE_SIZE));
|
||||
if (submissionsPage > maxPage) {
|
||||
setSubmissionsPage(maxPage);
|
||||
}
|
||||
}, [data, submissionsPage]);
|
||||
|
||||
const filteredSubmissions = useMemo(() => {
|
||||
if (!data?.submissions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||
|
||||
return data.submissions.filter((submission: any) => {
|
||||
if (selectedSubmissionState !== "all") {
|
||||
if (getSubmissionState(submission) !== selectedSubmissionState) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cleanOrderText = stripPriceDecorations(
|
||||
String(submission.formatted_string || ""),
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const haystack = [
|
||||
String(submission.email || "").toLowerCase(),
|
||||
cleanOrderText,
|
||||
].join(" ");
|
||||
|
||||
return haystack.includes(normalizedQuery);
|
||||
});
|
||||
}, [data?.submissions, searchQuery, selectedSubmissionState]);
|
||||
|
||||
useEffect(() => {
|
||||
const visibleIds = new Set(filteredSubmissions.map((submission: any) => submission.id));
|
||||
setSelectedRowKeys((previous) => previous.filter((id) => visibleIds.has(id)));
|
||||
setSubmissionsPage(1);
|
||||
}, [searchQuery, selectedSubmissionState, filteredSubmissions]);
|
||||
|
||||
if (pageError || loading) {
|
||||
return (
|
||||
<AsyncContent
|
||||
loading={loading}
|
||||
error={pageError}
|
||||
onRetry={loadAdmin}
|
||||
errorTitle="Admin page unavailable"
|
||||
loadingRows={4}
|
||||
loadingSections={2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selected = filteredSubmissions.filter((submission: any) =>
|
||||
selectedRowKeys.includes(submission.id),
|
||||
);
|
||||
const rowsToCopy = selected.length > 0 ? selected : filteredSubmissions;
|
||||
const copyText = rowsToCopy
|
||||
.map((submission: any) =>
|
||||
stripPriceDecorations(String(submission.formatted_string || "")).trim(),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const totalEstimatedValue = rowsToCopy.reduce((sum: number, submission: any) => {
|
||||
const value = getEstimatedTotalNumber(submission.estimated_total);
|
||||
return value === null ? sum : sum + value;
|
||||
}, 0);
|
||||
const hasAnyEstimatedValue = rowsToCopy.some(
|
||||
(submission: any) => getEstimatedTotalNumber(submission.estimated_total) !== null,
|
||||
);
|
||||
const totalEstimatedText = hasAnyEstimatedValue
|
||||
? formatEstimatedTotal(totalEstimatedValue)
|
||||
: null;
|
||||
const pagedSubmissions = filteredSubmissions.slice(
|
||||
(submissionsPage - 1) * SUBMISSIONS_PAGE_SIZE,
|
||||
submissionsPage * SUBMISSIONS_PAGE_SIZE,
|
||||
);
|
||||
|
||||
const copySubmissionList = (_text: string, didCopy: boolean) => {
|
||||
if (!copyText) {
|
||||
message.warning("Nothing to copy.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (didCopy === false) {
|
||||
message.error("Could not copy to clipboard.");
|
||||
return;
|
||||
}
|
||||
|
||||
message.success(
|
||||
`Copied ${rowsToCopy.length} ${rowsToCopy.length === 1 ? "entry" : "entries"}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: "100%" }} size={16}>
|
||||
<AdminParticipantShareAlert orderId={orderId} />
|
||||
|
||||
<ReadOnlyOrderOverviewCard
|
||||
order={data}
|
||||
config={data?.config}
|
||||
onImportConfig={handleImportConfig}
|
||||
/>
|
||||
|
||||
<AdminSubmissionsCard
|
||||
searchQuery={searchQuery}
|
||||
selectedSubmissionState={selectedSubmissionState}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onSelectedSubmissionStateChange={setSelectedSubmissionState}
|
||||
onReload={loadAdmin}
|
||||
copyText={copyText}
|
||||
onCopy={copySubmissionList}
|
||||
selectedRowKeys={selectedRowKeys}
|
||||
onSelectedRowKeysChange={setSelectedRowKeys}
|
||||
pagedSubmissions={pagedSubmissions}
|
||||
deletingId={deletingId}
|
||||
savingStatusKey={savingStatusKey}
|
||||
onDeleteSubmission={deleteAsAdmin}
|
||||
onUpdateSubmissionStatus={updateSubmissionStatus}
|
||||
totalEstimatedText={totalEstimatedText}
|
||||
selectedCount={selected.length}
|
||||
submissionsPage={submissionsPage}
|
||||
pageSize={SUBMISSIONS_PAGE_SIZE}
|
||||
totalCount={filteredSubmissions.length}
|
||||
onSubmissionsPageChange={setSubmissionsPage}
|
||||
/>
|
||||
|
||||
<AdminControlCenterCard
|
||||
isClosed={!!data?.closed}
|
||||
updatingOrderStatus={updatingOrderStatus}
|
||||
deletingOrder={deletingOrder}
|
||||
onToggleClosed={updateOrderClosedStatus}
|
||||
onDeleteOrder={deleteOrder}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import {
|
||||
PlusCircleOutlined,
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Tooltip,
|
||||
message,
|
||||
Space,
|
||||
Upload,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import type { UploadFile, UploadProps } from "antd";
|
||||
import { apiService } from "../lib/services";
|
||||
import { navigateTo } from "../lib/routing";
|
||||
import { ORDER_FORMAT_VALUE_PLACEHOLDER } from "../lib/orderFormatting";
|
||||
import type { OrderFormConfig } from "../lib/types";
|
||||
import OrderFormConfigBuilder from "../components/forms/OrderFormConfigBuilder";
|
||||
import CreateOrderHeaderActions from "../components/views/CreateOrderHeaderActions";
|
||||
import ExportSelectionModal, { type ExportSelectionState } from "../components/modals/ExportSelectionModal";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type CreateOrderValues = {
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: OrderFormConfig = {
|
||||
categories: [],
|
||||
};
|
||||
|
||||
function validateFormConfigRecursively(config: OrderFormConfig): string[] {
|
||||
const errors: string[] = [];
|
||||
const seenCategoryLabels = new Set<string>();
|
||||
|
||||
if (!config || !Array.isArray(config.categories)) {
|
||||
return ["Menu configuration is invalid"];
|
||||
}
|
||||
|
||||
for (let categoryIndex = 0; categoryIndex < config.categories.length; categoryIndex += 1) {
|
||||
const category = config.categories[categoryIndex];
|
||||
const categoryPath = `Category ${categoryIndex + 1}`;
|
||||
|
||||
if (!category || typeof category !== "object") {
|
||||
errors.push(`${categoryPath}: category is invalid`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryLabel = String(category.label || "").trim();
|
||||
const categoryId = String(category.id || "").trim();
|
||||
const formattedSnippet = String(category.formattedSnippet || "").trim();
|
||||
|
||||
if (!categoryLabel) {
|
||||
errors.push(`${categoryPath}: label is required`);
|
||||
} else {
|
||||
const normalizedCategoryLabel = categoryLabel.toLowerCase();
|
||||
if (seenCategoryLabels.has(normalizedCategoryLabel)) {
|
||||
errors.push(`Duplicate category name \"${categoryLabel}\"`);
|
||||
}
|
||||
seenCategoryLabels.add(normalizedCategoryLabel);
|
||||
}
|
||||
|
||||
if (!categoryId) {
|
||||
errors.push(`${categoryPath}: id is required`);
|
||||
}
|
||||
|
||||
if (!formattedSnippet) {
|
||||
errors.push(`${categoryPath}: formatted snippet is required`);
|
||||
} else if (!formattedSnippet.includes(ORDER_FORMAT_VALUE_PLACEHOLDER)) {
|
||||
errors.push(
|
||||
`${categoryPath}: formatted snippet must include {name}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(category.items)) {
|
||||
errors.push(`${categoryPath}: items must be a list`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!category.custom && category.items.length === 0) {
|
||||
errors.push(`${categoryPath}: add at least one item or enable custom entries`);
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
for (let itemIndex = 0; itemIndex < category.items.length; itemIndex += 1) {
|
||||
const item = category.items[itemIndex];
|
||||
const itemPath = `${categoryPath} > Item ${itemIndex + 1}`;
|
||||
|
||||
if (!item || typeof item !== "object") {
|
||||
errors.push(`${itemPath}: item is invalid`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemName = String(item.name || "").trim();
|
||||
if (!itemName) {
|
||||
errors.push(`${itemPath}: name is required`);
|
||||
} else {
|
||||
const normalizedName = itemName.toLowerCase();
|
||||
if (seenNames.has(normalizedName)) {
|
||||
errors.push(`${categoryPath}: duplicate item name \"${itemName}\"`);
|
||||
}
|
||||
seenNames.add(normalizedName);
|
||||
}
|
||||
|
||||
if (item.price !== undefined && item.price !== null) {
|
||||
const price = Number(item.price);
|
||||
if (!Number.isFinite(price) || price < 0) {
|
||||
errors.push(`${itemPath}: price must be a non-negative number`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(errors));
|
||||
}
|
||||
|
||||
export default function CreateOrderView() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm<CreateOrderValues>();
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [activeTabKey, setActiveTabKey] = useState<string>("details");
|
||||
const [imageFileList, setImageFileList] = useState<UploadFile[]>([]);
|
||||
const [formConfig, setFormConfig] = useState<OrderFormConfig>(DEFAULT_CONFIG);
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
const [exportOptions, setExportOptions] = useState<ExportSelectionState>({
|
||||
title: true,
|
||||
description: true,
|
||||
menu: true,
|
||||
});
|
||||
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const beforeImageUpload: UploadProps["beforeUpload"] = (file) => {
|
||||
const allowed = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
|
||||
const fileExt = "." + (file.name.split(".").pop() || "").toLowerCase();
|
||||
|
||||
if (!allowed.has(fileExt)) {
|
||||
message.error(
|
||||
"File type not allowed. Allowed types: jpg, jpeg, png, gif, webp",
|
||||
);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
message.error("File size must be less than 10MB");
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
setImageFileList([file]);
|
||||
return false;
|
||||
};
|
||||
|
||||
const getNativeFile = (uploadFile?: UploadFile): File | null => {
|
||||
if (!uploadFile) return null;
|
||||
if (uploadFile.originFileObj instanceof File) {
|
||||
return uploadFile.originFileObj;
|
||||
}
|
||||
if (uploadFile instanceof File) {
|
||||
return uploadFile;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const onFinish = async (values: CreateOrderValues) => {
|
||||
const configErrors = validateFormConfigRecursively(formConfig);
|
||||
if (configErrors.length > 0) {
|
||||
setValidationErrors(configErrors);
|
||||
setActiveTabKey("form");
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationErrors([]);
|
||||
setLoading(true);
|
||||
try {
|
||||
const order = await apiService.orders.create({
|
||||
title: values.title,
|
||||
description: values.description || "",
|
||||
config: formConfig,
|
||||
});
|
||||
|
||||
const selectedImage = getNativeFile(imageFileList[0]);
|
||||
|
||||
// Upload image if provided
|
||||
if (selectedImage) {
|
||||
try {
|
||||
await apiService.orders.uploadImage(order.id, selectedImage);
|
||||
message.success("Order created and image uploaded");
|
||||
} catch (error: any) {
|
||||
message.warning(
|
||||
"Order created but image upload failed: " + error.message,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
message.success("Group order created");
|
||||
}
|
||||
|
||||
// Save form configuration if provided
|
||||
if (formConfig.categories.length > 0) {
|
||||
try {
|
||||
await apiService.orders.updateConfig(order.id, formConfig);
|
||||
message.success("Form configuration saved");
|
||||
} catch (error: any) {
|
||||
message.warning(
|
||||
"Order created but config save failed: " + error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setImageFileList([]);
|
||||
setFormConfig(DEFAULT_CONFIG);
|
||||
|
||||
navigateTo(`/order/${order.id}/admin`);
|
||||
} catch (error: any) {
|
||||
message.error(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onFinishFailed = (errorInfo: {
|
||||
errorFields: Array<{ errors?: string[] }>;
|
||||
}) => {
|
||||
const formMessages = errorInfo.errorFields
|
||||
.flatMap((field) => field.errors || [])
|
||||
.filter(Boolean);
|
||||
|
||||
const configErrors = validateFormConfigRecursively(formConfig);
|
||||
if (configErrors.length > 0) {
|
||||
setActiveTabKey("form");
|
||||
}
|
||||
|
||||
setValidationErrors(Array.from(new Set([...formMessages, ...configErrors])));
|
||||
};
|
||||
|
||||
const handleCreateClick = async () => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
} catch (_error) {
|
||||
// onFinishFailed handles form validation errors.
|
||||
return;
|
||||
}
|
||||
|
||||
const configErrors = validateFormConfigRecursively(formConfig);
|
||||
if (configErrors.length > 0) {
|
||||
setValidationErrors(configErrors);
|
||||
setActiveTabKey("form");
|
||||
return;
|
||||
}
|
||||
|
||||
form.submit();
|
||||
};
|
||||
|
||||
const handleImportJson = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result;
|
||||
if (typeof content !== "string") {
|
||||
message.error("Failed to read import file");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content) as {
|
||||
title?: unknown;
|
||||
description?: unknown;
|
||||
menu?: unknown;
|
||||
};
|
||||
|
||||
const importedSections: string[] = [];
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, "title")) {
|
||||
form.setFieldValue(
|
||||
"title",
|
||||
typeof parsed.title === "string" ? parsed.title : "",
|
||||
);
|
||||
importedSections.push("title");
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, "description")) {
|
||||
form.setFieldValue(
|
||||
"description",
|
||||
typeof parsed.description === "string" ? parsed.description : "",
|
||||
);
|
||||
importedSections.push("description");
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, "menu")) {
|
||||
const menu = parsed.menu as Partial<OrderFormConfig> | null;
|
||||
setFormConfig({
|
||||
categories: Array.isArray(menu?.categories) ? menu.categories : [],
|
||||
});
|
||||
importedSections.push("menu");
|
||||
}
|
||||
|
||||
if (importedSections.length === 0) {
|
||||
message.warning("No supported sections found in import file");
|
||||
return;
|
||||
}
|
||||
|
||||
message.success(`Imported: ${importedSections.join(", ")}`);
|
||||
} catch (error) {
|
||||
message.error("Failed to import JSON file");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
// Reset input so same file can be selected again
|
||||
if (importFileInputRef.current) {
|
||||
importFileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportJson = () => {
|
||||
const payload: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
menu?: OrderFormConfig;
|
||||
} = {};
|
||||
|
||||
if (exportOptions.title) {
|
||||
payload.title = form.getFieldValue("title") || "";
|
||||
}
|
||||
|
||||
if (exportOptions.description) {
|
||||
payload.description = form.getFieldValue("description") || "";
|
||||
}
|
||||
|
||||
if (exportOptions.menu) {
|
||||
payload.menu = formConfig;
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
message.warning("Select at least one section to export");
|
||||
return;
|
||||
}
|
||||
|
||||
const safeTitle = (form.getFieldValue("title") || "order")
|
||||
.toString()
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase();
|
||||
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${safeTitle || "order"}-export.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setIsExportModalOpen(false);
|
||||
message.success("Exported selected sections as JSON");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title="Create Order"
|
||||
extra={
|
||||
<CreateOrderHeaderActions
|
||||
importFileInputRef={importFileInputRef}
|
||||
onImportJson={handleImportJson}
|
||||
onOpenExport={() => setIsExportModalOpen(true)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
onFinishFailed={onFinishFailed}
|
||||
initialValues={undefined}
|
||||
>
|
||||
<Form.Item
|
||||
label="Title"
|
||||
name="title"
|
||||
extra="The title of your group order."
|
||||
rules={[{ required: true, message: "Title is required" }]}
|
||||
>
|
||||
<Input maxLength={200} placeholder="Wednesday Lunch" />
|
||||
</Form.Item>
|
||||
<Tabs
|
||||
activeKey={activeTabKey}
|
||||
onChange={setActiveTabKey}
|
||||
items={[
|
||||
{
|
||||
key: "details",
|
||||
label: "Details",
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
label="Description"
|
||||
name="description"
|
||||
extra="Add notes about order deadline, notes, and pickup details."
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
placeholder="You can use markdown to format the description."
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Image"
|
||||
extra="Add an image alongside the description."
|
||||
>
|
||||
<Upload
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,image/*"
|
||||
listType="picture"
|
||||
maxCount={1}
|
||||
beforeUpload={beforeImageUpload}
|
||||
fileList={imageFileList}
|
||||
onRemove={() => {
|
||||
setImageFileList([]);
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>Upload</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "form",
|
||||
label: "Menu",
|
||||
children: (
|
||||
<OrderFormConfigBuilder
|
||||
config={formConfig}
|
||||
onChange={setFormConfig}
|
||||
showImportExport={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="Please fix the following issues:"
|
||||
description={
|
||||
<Space direction="vertical" size={0}>
|
||||
{validationErrors.map((errorMessage) => (
|
||||
<Text key={errorMessage}>{errorMessage}</Text>
|
||||
))}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tooltip title="Create a new order">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleCreateClick}
|
||||
icon={<PlusCircleOutlined />}
|
||||
loading={loading}
|
||||
>
|
||||
Create order
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</Card>
|
||||
<ExportSelectionModal
|
||||
open={isExportModalOpen}
|
||||
title="Export Order Data"
|
||||
description="Select which sections of the order you want to include in the export:"
|
||||
options={[
|
||||
{ key: "title", label: "Title" },
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "menu", label: "Menu" },
|
||||
]}
|
||||
selected={exportOptions}
|
||||
onCancel={() => setIsExportModalOpen(false)}
|
||||
onConfirm={handleExportJson}
|
||||
onSelectedChange={setExportOptions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Space,
|
||||
message,
|
||||
} from "antd";
|
||||
import AsyncContent from "../components/utils/AsyncContent";
|
||||
import { useApiRequest } from "../hooks/useApiRequest";
|
||||
import { apiService } from "../lib/services";
|
||||
import { navigateTo } from "../lib/routing";
|
||||
import type { GetOrdersResponse } from "../lib/types";
|
||||
import HomeOrdersFilters from "../components/views/HomeOrdersFilters";
|
||||
import HomeOrdersTable from "../components/views/HomeOrdersTable";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export default function HomeView() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedOwnerType, setSelectedOwnerType] = useState("");
|
||||
const [selectedState, setSelectedState] = useState("");
|
||||
|
||||
const loadOrdersData = useCallback(async (): Promise<GetOrdersResponse> => {
|
||||
const skip = (currentPage - 1) * PAGE_SIZE;
|
||||
const queryParams = {
|
||||
skip,
|
||||
limit: PAGE_SIZE,
|
||||
...(selectedOwnerType && { role: selectedOwnerType }),
|
||||
...(selectedState && { state: selectedState }),
|
||||
};
|
||||
|
||||
const payload = await apiService.orders.getMine(queryParams);
|
||||
return payload;
|
||||
}, [currentPage, selectedOwnerType, selectedState]);
|
||||
|
||||
const {
|
||||
data: response,
|
||||
loading: loadingOrders,
|
||||
error: loadError,
|
||||
run: loadOrders,
|
||||
} = useApiRequest(loadOrdersData, {
|
||||
deps: [loadOrdersData],
|
||||
initialData: {
|
||||
orders: [],
|
||||
pagination: { total: 0, skip: 0, limit: PAGE_SIZE, hasMore: false },
|
||||
},
|
||||
onError: (requestError) => {
|
||||
message.error(requestError?.message || "Unable to load saved orders.");
|
||||
},
|
||||
});
|
||||
|
||||
const orders = response?.orders || [];
|
||||
const pagination = response?.pagination || {
|
||||
total: 0,
|
||||
skip: 0,
|
||||
limit: PAGE_SIZE,
|
||||
hasMore: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space direction="vertical" style={{ width: "100%" }} size={16}>
|
||||
<Card
|
||||
title="Your Orders"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigateTo("/create")}
|
||||
>
|
||||
Create Order
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<AsyncContent
|
||||
loading={loadingOrders}
|
||||
error={loadError}
|
||||
onRetry={loadOrders}
|
||||
errorTitle="Could not load your orders"
|
||||
loadingRows={3}
|
||||
loadingSections={1}
|
||||
>
|
||||
<HomeOrdersFilters
|
||||
currentPage={currentPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={pagination.total}
|
||||
selectedOwnerType={selectedOwnerType}
|
||||
selectedState={selectedState}
|
||||
onOwnerTypeChange={(value) => {
|
||||
setSelectedOwnerType(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onStateChange={(value) => {
|
||||
setSelectedState(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onPageChange={(page) => setCurrentPage(page)}
|
||||
/>
|
||||
<HomeOrdersTable
|
||||
orders={orders}
|
||||
selectedState={selectedState}
|
||||
selectedOwnerType={selectedOwnerType}
|
||||
onPageFromTable={(page) => setCurrentPage(page)}
|
||||
/>
|
||||
</AsyncContent>
|
||||
</Card>
|
||||
</Space>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { DeleteOutlined, SaveOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Space,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import AsyncContent from "../components/utils/AsyncContent";
|
||||
import { useApiRequest } from "../hooks/useApiRequest";
|
||||
import { isNotFoundError } from "../lib/api";
|
||||
import { navigateTo } from "../lib/routing";
|
||||
import { apiService } from "../lib/services";
|
||||
import LoadingSkeleton from "../components/utils/LoadingSkeleton";
|
||||
import ReadOnlyOrderOverviewCard from "../components/orders/ReadOnlyOrderOverviewCard";
|
||||
import {
|
||||
buildFormattedOrderString,
|
||||
calculateEstimatedOrderTotal,
|
||||
formatEstimatedTotal,
|
||||
type NormalizedOrderSelections,
|
||||
normalizeOrderSelections,
|
||||
} from "../lib/orderFormatting";
|
||||
import type {
|
||||
OrderFormConfig,
|
||||
Order,
|
||||
OrderAccessInfo,
|
||||
Submission,
|
||||
} from "../lib/types";
|
||||
import ParticipantCategoryFields from "../components/views/ParticipantCategoryFields";
|
||||
import ParticipantSubmissionSummary from "../components/views/ParticipantSubmissionSummary";
|
||||
import ParticipantDraftSummaryAlert from "../components/views/ParticipantDraftSummaryAlert";
|
||||
|
||||
const { Text, Link } = Typography;
|
||||
|
||||
type SubmissionValues = {
|
||||
choices: NormalizedOrderSelections;
|
||||
};
|
||||
|
||||
type FormModel = Record<string, unknown>;
|
||||
|
||||
function serializeSelections(values: NormalizedOrderSelections) {
|
||||
return JSON.stringify(values);
|
||||
}
|
||||
|
||||
function toFormValues(values: NormalizedOrderSelections): FormModel {
|
||||
const model: FormModel = {};
|
||||
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
model[key] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
model[key] = value ?? undefined;
|
||||
});
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
type ParticipantLoadData = {
|
||||
order: Order;
|
||||
config: OrderFormConfig;
|
||||
};
|
||||
|
||||
export default function ParticipantView({ orderId }: { orderId: string }) {
|
||||
const [form] = Form.useForm<FormModel>();
|
||||
const watchedValues = Form.useWatch([], form);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loadingSubmission, setLoadingSubmission] = useState(true);
|
||||
const [isEditingSubmittedOrder, setIsEditingSubmittedOrder] = useState(false);
|
||||
const [liveOrder, setLiveOrder] = useState<Order | null>(null);
|
||||
const [existingSubmission, setExistingSubmission] =
|
||||
useState<Submission | null>(null);
|
||||
const [orderAccess, setOrderAccess] = useState<OrderAccessInfo | null>(null);
|
||||
|
||||
const loadParticipantData =
|
||||
useCallback(async (): Promise<ParticipantLoadData> => {
|
||||
const [order, config] = await Promise.all([
|
||||
apiService.orders.get(orderId),
|
||||
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
||||
]);
|
||||
|
||||
return { order, config };
|
||||
}, [orderId]);
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
run: reload,
|
||||
} = useApiRequest(loadParticipantData, {
|
||||
deps: [loadParticipantData],
|
||||
onError: (requestError) => {
|
||||
message.error(requestError?.message || "Order could not be loaded.");
|
||||
},
|
||||
});
|
||||
|
||||
const order = data?.order || null;
|
||||
const config: OrderFormConfig = useMemo(
|
||||
() => ({ categories: data?.config?.categories || [] }),
|
||||
[data?.config?.categories],
|
||||
);
|
||||
|
||||
const pageError =
|
||||
error || (!order || !config ? "Order could not be loaded." : null);
|
||||
const isClosed = !!liveOrder?.closed;
|
||||
const normalizedDraftValues = normalizeOrderSelections(
|
||||
watchedValues || {},
|
||||
config,
|
||||
);
|
||||
const draftOrderString = buildFormattedOrderString(
|
||||
normalizedDraftValues,
|
||||
config,
|
||||
);
|
||||
const draftEstimatedTotal = useMemo(
|
||||
() =>
|
||||
formatEstimatedTotal(
|
||||
calculateEstimatedOrderTotal(normalizedDraftValues, config),
|
||||
),
|
||||
[normalizedDraftValues, config],
|
||||
);
|
||||
const parseSavedChoices = (
|
||||
submission: Submission | null,
|
||||
): Record<string, unknown> => {
|
||||
const rawChoices = submission?.choices_json ?? submission?.choices;
|
||||
|
||||
if (!rawChoices) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (typeof rawChoices === "string") {
|
||||
try {
|
||||
return JSON.parse(rawChoices);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
return rawChoices;
|
||||
};
|
||||
|
||||
const normalizedSavedValues = useMemo(
|
||||
() =>
|
||||
normalizeOrderSelections(parseSavedChoices(existingSubmission), config),
|
||||
[existingSubmission, config],
|
||||
);
|
||||
const savedOrderString = useMemo(
|
||||
() => buildFormattedOrderString(normalizedSavedValues, config),
|
||||
[normalizedSavedValues, config],
|
||||
);
|
||||
const savedEstimatedTotal = useMemo(
|
||||
() =>
|
||||
formatEstimatedTotal(
|
||||
calculateEstimatedOrderTotal(normalizedSavedValues, config),
|
||||
),
|
||||
[normalizedSavedValues, config],
|
||||
);
|
||||
const hasActiveChanges =
|
||||
!!existingSubmission &&
|
||||
serializeSelections(normalizedDraftValues) !==
|
||||
serializeSelections(normalizedSavedValues);
|
||||
|
||||
useEffect(() => {
|
||||
if (order) {
|
||||
setLiveOrder(order);
|
||||
}
|
||||
}, [order]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function loadExisting() {
|
||||
try {
|
||||
const [access, existing] = await Promise.all([
|
||||
apiService.orders.getMyAccess(orderId),
|
||||
apiService.submissions
|
||||
.getMine(orderId)
|
||||
.catch((submissionError: any) => {
|
||||
if (isNotFoundError(submissionError)) {
|
||||
return null;
|
||||
}
|
||||
throw submissionError;
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderAccess(access);
|
||||
setExistingSubmission(existing);
|
||||
|
||||
if (!existing) {
|
||||
form.resetFields();
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoadingSubmission(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadExisting();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [form, orderId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!existingSubmission) {
|
||||
setIsEditingSubmittedOrder(false);
|
||||
}
|
||||
}, [existingSubmission]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isClosed) {
|
||||
setIsEditingSubmittedOrder(false);
|
||||
}
|
||||
}, [isClosed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!existingSubmission || !isEditingSubmittedOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setFieldsValue(toFormValues(normalizedSavedValues));
|
||||
}, [
|
||||
existingSubmission,
|
||||
form,
|
||||
isEditingSubmittedOrder,
|
||||
normalizedSavedValues,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const refreshOrderStatus = async () => {
|
||||
try {
|
||||
const latestOrder: any = await apiService.orders.get(orderId);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLiveOrder((previous: any) => {
|
||||
if (!previous) {
|
||||
return latestOrder;
|
||||
}
|
||||
|
||||
const changed =
|
||||
previous.closed !== latestOrder.closed ||
|
||||
previous.updated_at !== latestOrder.updated_at;
|
||||
|
||||
return changed ? { ...previous, ...latestOrder } : previous;
|
||||
});
|
||||
} catch (_error) {
|
||||
// Keep current order state when polling fails.
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = setInterval(refreshOrderStatus, 5000);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [orderId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!existingSubmission) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const refreshSubmission = async () => {
|
||||
try {
|
||||
const latest: any = await apiService.submissions.getMine(orderId);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExistingSubmission((previous: any) => {
|
||||
if (!previous) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
const changed =
|
||||
previous.updated_at !== latest.updated_at ||
|
||||
previous.accepted !== latest.accepted ||
|
||||
previous.paid !== latest.paid ||
|
||||
JSON.stringify(parseSavedChoices(previous)) !==
|
||||
JSON.stringify(parseSavedChoices(latest));
|
||||
|
||||
return changed ? latest : previous;
|
||||
});
|
||||
} catch (submissionError) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNotFoundError(submissionError)) {
|
||||
setExistingSubmission(null);
|
||||
form.resetFields();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = setInterval(refreshSubmission, 5000);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [existingSubmission, form, orderId]);
|
||||
|
||||
const onFinish = async (values: FormModel) => {
|
||||
if (isClosed) {
|
||||
message.warning("This order is closed.");
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeOrderSelections(values, config);
|
||||
const payload: SubmissionValues = {
|
||||
choices: normalized,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (existingSubmission) {
|
||||
const updated: any = await apiService.submissions.updateMine(
|
||||
orderId,
|
||||
payload,
|
||||
);
|
||||
setExistingSubmission(updated);
|
||||
setIsEditingSubmittedOrder(false);
|
||||
form.resetFields();
|
||||
message.success("Submission updated");
|
||||
} else {
|
||||
const created: any = await apiService.submissions.createMine(
|
||||
orderId,
|
||||
payload,
|
||||
);
|
||||
setExistingSubmission(created);
|
||||
setIsEditingSubmittedOrder(false);
|
||||
form.resetFields();
|
||||
message.success("Submission saved");
|
||||
}
|
||||
} catch (submissionError: any) {
|
||||
message.error(submissionError.message || "Submission failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSubmission = async () => {
|
||||
if (!existingSubmission) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isClosed) {
|
||||
message.warning("This order is closed.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiService.submissions.removeMine(orderId);
|
||||
setExistingSubmission(null);
|
||||
setIsEditingSubmittedOrder(false);
|
||||
form.resetFields();
|
||||
message.success("Submission deleted");
|
||||
} catch (submissionError: any) {
|
||||
message.error(submissionError.message || "Delete failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingSubmission) {
|
||||
return <LoadingSkeleton rows={5} sections={2} />;
|
||||
}
|
||||
|
||||
const canShowSubmittedResult =
|
||||
!!existingSubmission && !isEditingSubmittedOrder;
|
||||
const showSummaryView = isClosed || canShowSubmittedResult;
|
||||
|
||||
return (
|
||||
<AsyncContent
|
||||
loading={loading}
|
||||
error={pageError}
|
||||
onRetry={reload}
|
||||
errorTitle="Order page unavailable"
|
||||
loadingRows={5}
|
||||
loadingSections={2}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }} size={16}>
|
||||
{orderAccess?.is_owner && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={
|
||||
<Text>
|
||||
{"You're the owner of this order! "}
|
||||
<Link
|
||||
href={`/order/${orderId}/admin`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateTo(`/order/${orderId}/admin`);
|
||||
}}
|
||||
>
|
||||
Edit order
|
||||
</Link>
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isClosed && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="This order is closed. Submissions can no longer be changed."
|
||||
/>
|
||||
)}
|
||||
|
||||
<ReadOnlyOrderOverviewCard order={liveOrder} config={config} />
|
||||
|
||||
<Card
|
||||
title="Your order"
|
||||
extra={
|
||||
existingSubmission && !isClosed ? (
|
||||
canShowSubmittedResult ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => setIsEditingSubmittedOrder(true)}
|
||||
>
|
||||
Edit Order
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsEditingSubmittedOrder(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
>
|
||||
Back to Summary
|
||||
</Button>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{showSummaryView ? (
|
||||
<ParticipantSubmissionSummary
|
||||
submission={existingSubmission}
|
||||
savedOrderString={savedOrderString}
|
||||
savedEstimatedTotal={savedEstimatedTotal}
|
||||
isClosed={isClosed}
|
||||
/>
|
||||
) : (
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
{config.categories.length === 0 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="No form configuration available for this order."
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
) : (
|
||||
<Row gutter={12}>
|
||||
<ParticipantCategoryFields
|
||||
categories={config.categories}
|
||||
isClosed={isClosed}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{config.categories.length > 0 && (
|
||||
<>
|
||||
<ParticipantDraftSummaryAlert
|
||||
hasExistingSubmission={!!existingSubmission}
|
||||
hasActiveChanges={hasActiveChanges}
|
||||
savedOrderString={savedOrderString}
|
||||
savedEstimatedTotal={savedEstimatedTotal}
|
||||
draftOrderString={draftOrderString}
|
||||
draftEstimatedTotal={draftEstimatedTotal}
|
||||
/>
|
||||
|
||||
<Space>
|
||||
<Tooltip
|
||||
title={
|
||||
existingSubmission
|
||||
? "Save updates to your submission"
|
||||
: "Submit your order"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<SaveOutlined />}
|
||||
loading={submitting}
|
||||
disabled={isClosed}
|
||||
>
|
||||
{existingSubmission ? "Update" : "Submit"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{existingSubmission && (
|
||||
<Popconfirm
|
||||
title="Delete your current submission?"
|
||||
description="This action cannot be undone."
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={deleteSubmission}
|
||||
disabled={isClosed}
|
||||
>
|
||||
<Tooltip title="Delete your current submission">
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={submitting}
|
||||
disabled={isClosed}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
</AsyncContent>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowJs": false,
|
||||
"strict": false,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
Reference in New Issue
Block a user