init
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Has been cancelled

This commit is contained in:
Simon Gruber
2026-03-29 14:59:03 +02:00
commit 2e84c75035
87 changed files with 11133 additions and 0 deletions
@@ -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
View File
@@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
*.pyd
*.sqlite3
*.db
.venv/
.env
.DS_Store
.data/
+62
View File
@@ -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`
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
View File
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.pyc
*.pyo
*.db
*.sqlite3
.venv/
.env
.DS_Store
.git/
.github/
*.md
+22
View File
@@ -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"]
View File
+147
View File
@@ -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
+227
View File
@@ -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(),
}
+152
View File
@@ -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}"
+23
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
{
"categories": []
}
+5
View File
@@ -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
+47
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
dist/
node_modules/
+2
View File
@@ -0,0 +1,2 @@
dist/
node_modules/
+24
View File
@@ -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?"]
+17
View File
@@ -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>
+2992
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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

+1
View File
@@ -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"}
+420
View File
@@ -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>
);
}
+69
View File
@@ -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>,
]}
/>
);
}
+73
View File
@@ -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,
};
}
+54
View File
@@ -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>;
}
+1
View File
@@ -0,0 +1 @@
export { apiService } from "./services";
+2
View File
@@ -0,0 +1,2 @@
export const USER_TOKEN_KEY = "lunchtime_token";
export const THEME_MODE_KEY = "lunchtime_theme_mode";
+10
View File
@@ -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);
}
+212
View File
@@ -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, "");
}
+61
View File
@@ -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"),
};
+13
View File
@@ -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),
}),
};
+78
View File
@@ -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));
}
+227
View File
@@ -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";
+74
View File
@@ -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();
}
+17
View File
@@ -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>,
);
+45
View File
@@ -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);
}
+365
View File
@@ -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>
);
}
+495
View File
@@ -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}
/>
</>
);
}
+111
View File
@@ -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>
</>
);
}
+546
View File
@@ -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>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+18
View File
@@ -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" }]
}
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"skipLibCheck": true
},
"include": ["vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});