curieous commited on
Commit
50d0387
·
verified ·
1 Parent(s): c651ab6

Deploy Venue Manager Agent Modal spike

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ frontend/assets/floodlight-field-backdrop.png filter=lfs diff=lfs merge=lfs -text
37
+ frontend/assets/floodlight-ground-board.png filter=lfs diff=lfs merge=lfs -text
38
+ frontend/assets/floodlight-source-reference.png filter=lfs diff=lfs merge=lfs -text
.hfignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ **/__pycache__/
3
+ *.pyc
4
+ .DS_Store
5
+ **/.DS_Store
6
+ .env
7
+ **/.env
8
+ node_modules/
README.md CHANGED
@@ -1,13 +1,29 @@
1
  ---
2
  title: Venue Manager Agent
3
- emoji: 🏃
4
- colorFrom: purple
5
- colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Venue Manager Agent
3
+ emoji: 🏟️
4
+ colorFrom: green
5
+ colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.18.0
 
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # Venue Manager Agent
13
+
14
+ Custom Gradio `Server` app for sports venue booking operations.
15
+
16
+ Runtime path for this spike:
17
+
18
+ - App host: Hugging Face Space.
19
+ - Model runtime: Modal.
20
+ - Model: `nvidia/Nemotron-Cascade-2-30B-A3B`.
21
+ - Model role: extract a user's venue booking request into schema-checked JSON.
22
+ - No deterministic model fallback is used for extraction; blockers are surfaced.
23
+
24
+ Required Space configuration:
25
+
26
+ - `APP_MODAL_BASE_URL`
27
+ - `APP_MODAL_AUTH_TOKEN`
28
+ - `APP_MODAL_TIMEOUT_SECONDS`
29
+ - `APP_MODAL_MODEL_ID`
app.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from fastapi import Body
8
+ from fastapi.responses import FileResponse, JSONResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+ from gradio import Server
11
+
12
+ from floodlight_space.fixtures import build_bootstrap
13
+ from floodlight_space.modal_client import (
14
+ SAMPLE_USER_MESSAGE,
15
+ call_booking_extractor,
16
+ modal_status as get_modal_status,
17
+ )
18
+
19
+
20
+ ROOT = Path(__file__).resolve().parent
21
+ FRONTEND_ROOT = ROOT / "frontend"
22
+ ASSETS_ROOT = FRONTEND_ROOT / "assets"
23
+
24
+
25
+ def build_server() -> Server:
26
+ app = Server(
27
+ title="Floodlight Venue Ops Agent",
28
+ description="A custom Floodlight venue-ops UI served by a Gradio Server.",
29
+ docs_url=None,
30
+ redoc_url=None,
31
+ )
32
+
33
+ app.mount("/frontend", StaticFiles(directory=FRONTEND_ROOT), name="frontend")
34
+ app.mount("/assets", StaticFiles(directory=ASSETS_ROOT), name="assets")
35
+
36
+ @app.get("/")
37
+ async def homepage() -> FileResponse:
38
+ return FileResponse(FRONTEND_ROOT / "index.html")
39
+
40
+ @app.get("/health")
41
+ async def health() -> dict[str, str]:
42
+ return {
43
+ "status": "ok",
44
+ "app": "floodlight-venue-ops-agent",
45
+ "frontend": "fully-custom-ui",
46
+ "version": "v1-design-prototype",
47
+ "model_runtime": "modal",
48
+ "model_id": get_modal_status()["model_id"],
49
+ }
50
+
51
+ @app.get("/api/model-status")
52
+ async def model_status_endpoint() -> JSONResponse:
53
+ return JSONResponse(get_modal_status())
54
+
55
+ @app.get("/api/bootstrap")
56
+ async def bootstrap(scenario: str = "normal") -> JSONResponse:
57
+ return JSONResponse(build_bootstrap(scenario))
58
+
59
+ @app.post("/api/decision")
60
+ async def decision(payload: dict[str, Any] = Body(default_factory=dict)) -> JSONResponse:
61
+ scenario = str(payload.get("scenario") or "normal")
62
+ data = build_bootstrap(scenario)
63
+ action = str(payload.get("action") or "approve")
64
+ if action == "reject":
65
+ data["decision"]["state"] = "rejected"
66
+ data["trace"].append({"id": "rejected", "label": "Owner rejected the reply", "tone": "error", "timestamp_label": "now"})
67
+ elif action == "clarify":
68
+ data["decision"]["state"] = "ready"
69
+ data["trace"].append({"id": "clarify", "label": "Owner asked for clarification", "tone": "warning", "timestamp_label": "now"})
70
+ else:
71
+ data["decision"]["state"] = "sent"
72
+ data["trace"].append({"id": "sent", "label": "Reply marked sent via simulator", "tone": "success", "timestamp_label": "now"})
73
+ return JSONResponse(data)
74
+
75
+ @app.post("/api/extract-booking")
76
+ async def extract_booking(payload: dict[str, Any] = Body(default_factory=dict)) -> JSONResponse:
77
+ scenario = str(payload.get("scenario") or "normal")
78
+ message = str(payload.get("message") or SAMPLE_USER_MESSAGE)
79
+ trace_id = str(payload.get("trace_id") or "space-venue-sample-001")
80
+ context = build_bootstrap(scenario)
81
+ result = call_booking_extractor(
82
+ message=message,
83
+ venue_context={
84
+ "slots": context["slots"],
85
+ "venues": context["venues"],
86
+ "decision": context["decision"],
87
+ },
88
+ trace_id=trace_id,
89
+ )
90
+ return JSONResponse(result, status_code=200 if result.get("ok") else 502)
91
+
92
+ @app.api(
93
+ name="get-floodlight-fixture",
94
+ description="Return the static Floodlight venue-ops fixture with explicit proof boundaries.",
95
+ queue=False,
96
+ api_visibility="public",
97
+ )
98
+ def get_floodlight_fixture(scenario: str = "normal") -> dict[str, Any]:
99
+ return build_bootstrap(scenario)
100
+
101
+ @app.api(
102
+ name="extract-venue-booking-sample",
103
+ description="Send one booking message to the Modal Nemotron Cascade extractor and return schema-checked JSON.",
104
+ queue=False,
105
+ api_visibility="public",
106
+ )
107
+ def extract_venue_booking_sample(message: str = SAMPLE_USER_MESSAGE) -> dict[str, Any]:
108
+ context = build_bootstrap("normal")
109
+ return call_booking_extractor(
110
+ message=message,
111
+ venue_context={
112
+ "slots": context["slots"],
113
+ "venues": context["venues"],
114
+ "decision": context["decision"],
115
+ },
116
+ trace_id="gradio-api-venue-sample-001",
117
+ )
118
+
119
+ return app
120
+
121
+
122
+ app = build_server()
123
+
124
+
125
+ if __name__ == "__main__":
126
+ app.launch(
127
+ server_name=os.environ.get("HOST", "0.0.0.0"),
128
+ server_port=int(os.environ.get("PORT", "7860")),
129
+ footer_links=[],
130
+ show_error=True,
131
+ )
floodlight_space/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Floodlight venue ops demo package."""
2
+
floodlight_space/fixtures.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+
7
+ def build_bootstrap(scenario: str = "normal") -> dict[str, Any]:
8
+ payload = _base_payload()
9
+ return _apply_scenario(payload, scenario)
10
+
11
+
12
+ def _base_payload() -> dict[str, Any]:
13
+ return {
14
+ "runtime": {
15
+ "app": "floodlight-venue-ops-agent",
16
+ "version": "v1-design-prototype",
17
+ "channel_label": "WhatsApp simulator",
18
+ "channel_status": "simulator-connected",
19
+ "channel_copy": "Demo channel connected. No live WhatsApp send proof claimed.",
20
+ "pending_count": 7,
21
+ "confirmed_today": 12,
22
+ "occupancy_today": 68,
23
+ "proof_boundary": "Static fixture prototype; live WhatsApp, external scoring/admin, video, model, and HF deployment proof are not claimed."
24
+ },
25
+ "slots": [
26
+ {"id": "morning", "label": "8 AM - 12 PM", "period": "Morning"},
27
+ {"id": "afternoon", "label": "2 PM - 6 PM", "period": "Afternoon"}
28
+ ],
29
+ "venues": [
30
+ {
31
+ "id": "north-field",
32
+ "name": "North Field",
33
+ "surface": "Grass",
34
+ "grass_type": "Natural",
35
+ "slots": {
36
+ "morning": {"status": "available", "rate": 6000, "request_count": 0},
37
+ "afternoon": {"status": "partial", "rate": 6500, "request_count": 2}
38
+ }
39
+ },
40
+ {
41
+ "id": "south-field",
42
+ "name": "South Field",
43
+ "surface": "Grass",
44
+ "grass_type": "Astro turf",
45
+ "slots": {
46
+ "morning": {"status": "available", "rate": 5500, "request_count": 0},
47
+ "afternoon": {"status": "partial", "rate": 6000, "request_count": 1}
48
+ }
49
+ }
50
+ ],
51
+ "requests": [
52
+ {
53
+ "id": "aman-sharma",
54
+ "customer_name": "Aman Sharma",
55
+ "phone": "+91 98765 43210",
56
+ "received_label": "1 min ago",
57
+ "activity": "Cricket",
58
+ "date_label": "Tomorrow, 25 May 2025",
59
+ "requested_slot_id": "north-field:morning",
60
+ "players": 12,
61
+ "group_type": "Corporate",
62
+ "status": "new",
63
+ "tone": "ready",
64
+ "missing_fields": [],
65
+ "summary": "12 players · Corporate",
66
+ "reply_draft": "Hi Aman, North Field is available tomorrow from 8 AM - 12 PM. The slot is ₹6,000 for natural grass. Reply YES and we will hold it after owner approval."
67
+ },
68
+ {
69
+ "id": "rohit-verma",
70
+ "customer_name": "Rohit Verma",
71
+ "phone": "+91 90123 45678",
72
+ "received_label": "3 min ago",
73
+ "activity": "Cricket",
74
+ "date_label": "Tomorrow, 25 May 2025",
75
+ "requested_slot_id": "north-field:afternoon",
76
+ "players": 10,
77
+ "group_type": "Friends",
78
+ "status": "new",
79
+ "tone": "partial",
80
+ "missing_fields": [],
81
+ "summary": "10 players · Friends",
82
+ "reply_draft": "Hi Rohit, North Field has partial demand tomorrow from 2 PM - 6 PM at ₹6,500. We can confirm after owner approval, or offer South Field at ₹6,000."
83
+ },
84
+ {
85
+ "id": "karan-mehta",
86
+ "customer_name": "Karan Mehta",
87
+ "phone": "+91 99887 76655",
88
+ "received_label": "5 min ago",
89
+ "activity": "Cricket",
90
+ "date_label": "Tomorrow, 25 May 2025",
91
+ "requested_slot_id": "south-field:morning",
92
+ "players": 14,
93
+ "group_type": "League",
94
+ "status": "new",
95
+ "tone": "ready",
96
+ "missing_fields": [],
97
+ "summary": "14 players · League",
98
+ "reply_draft": "Hi Karan, South Field is available tomorrow from 8 AM - 12 PM. Astro turf surface, ₹5,500. I can hold it after owner approval."
99
+ },
100
+ {
101
+ "id": "vikas-singh",
102
+ "customer_name": "Vikas Singh",
103
+ "phone": "+91 91234 87650",
104
+ "received_label": "7 min ago",
105
+ "activity": "Cricket",
106
+ "date_label": "Today",
107
+ "requested_slot_id": "south-field:afternoon",
108
+ "players": 12,
109
+ "group_type": "Corporate",
110
+ "status": "conflict",
111
+ "tone": "conflict",
112
+ "missing_fields": [],
113
+ "summary": "12 players · Corporate",
114
+ "reply_draft": "Hi Vikas, the requested afternoon slot has other demand. North Field morning is open, or South Field morning is open. Which one should I hold for you?"
115
+ },
116
+ {
117
+ "id": "neha-iyer",
118
+ "customer_name": "Neha Iyer",
119
+ "phone": "+91 95555 45670",
120
+ "received_label": "9 min ago",
121
+ "activity": "Cricket",
122
+ "date_label": "Tomorrow, 25 May 2025",
123
+ "requested_slot_id": "south-field:afternoon",
124
+ "players": 8,
125
+ "group_type": "Friends",
126
+ "status": "clarification",
127
+ "tone": "clarification",
128
+ "missing_fields": ["format/overs"],
129
+ "summary": "8 players · Friends",
130
+ "reply_draft": "Hi Neha, South Field afternoon is available with one existing request. Could you confirm format or overs before I ask the owner to hold it?"
131
+ }
132
+ ],
133
+ "decision": {
134
+ "selected_request_id": "aman-sharma",
135
+ "selected_slot_id": "north-field:morning",
136
+ "selected_price": 6000,
137
+ "notes": "",
138
+ "state": "ready"
139
+ },
140
+ "integrations": [
141
+ {
142
+ "id": "video",
143
+ "label": "Video Telecast",
144
+ "state": "disabled",
145
+ "copy": "Disabled"
146
+ },
147
+ {
148
+ "id": "external-scoring",
149
+ "label": "External scoring/admin app",
150
+ "state": "not-connected",
151
+ "copy": "Not connected · Disabled"
152
+ }
153
+ ],
154
+ "trace": [
155
+ {"id": "intake", "label": "Intake received from simulator", "tone": "neutral", "timestamp_label": "now"},
156
+ {"id": "policy", "label": "Approval required before outbound reply", "tone": "warning", "timestamp_label": "now"},
157
+ {"id": "draft", "label": "Reply drafted and pending owner review", "tone": "neutral", "timestamp_label": "now"}
158
+ ],
159
+ "scenarios": [
160
+ {"id": "normal", "label": "Live queue"},
161
+ {"id": "clarification", "label": "Clarification"},
162
+ {"id": "conflict", "label": "Slot conflict"},
163
+ {"id": "provider-error", "label": "Provider error"},
164
+ {"id": "empty", "label": "Empty queue"}
165
+ ],
166
+ "active_scenario": "normal"
167
+ }
168
+
169
+
170
+ def _apply_scenario(payload: dict[str, Any], scenario: str) -> dict[str, Any]:
171
+ data = deepcopy(payload)
172
+ data["active_scenario"] = scenario
173
+
174
+ if scenario == "clarification":
175
+ data["decision"]["selected_request_id"] = "neha-iyer"
176
+ data["decision"]["selected_slot_id"] = "south-field:afternoon"
177
+ data["decision"]["selected_price"] = 6000
178
+ data["trace"].append({"id": "missing-fields", "label": "Missing field: format/overs", "tone": "warning", "timestamp_label": "now"})
179
+ elif scenario == "conflict":
180
+ data["decision"]["selected_request_id"] = "vikas-singh"
181
+ data["decision"]["selected_slot_id"] = "south-field:afternoon"
182
+ data["decision"]["selected_price"] = 6000
183
+ data["venues"][1]["slots"]["afternoon"]["status"] = "conflict"
184
+ data["trace"].append({"id": "conflict", "label": "Requested slot has competing demand; alternatives visible", "tone": "warning", "timestamp_label": "now"})
185
+ elif scenario == "provider-error":
186
+ data["runtime"]["channel_status"] = "provider-error"
187
+ data["runtime"]["channel_copy"] = "Simulator provider error. No outbound reply can be marked sent."
188
+ data["decision"]["state"] = "error"
189
+ data["trace"].append({"id": "provider-error", "label": "Provider error state active; retry required", "tone": "error", "timestamp_label": "now"})
190
+ elif scenario == "empty":
191
+ data["runtime"]["pending_count"] = 0
192
+ data["requests"] = []
193
+ data["decision"]["selected_request_id"] = ""
194
+ data["decision"]["selected_slot_id"] = "north-field:morning"
195
+ data["decision"]["state"] = "idle"
196
+ data["trace"] = [{"id": "empty", "label": "Queue empty; board remains reviewable", "tone": "neutral", "timestamp_label": "now"}]
197
+
198
+ return data
floodlight_space/modal_client.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any
9
+
10
+
11
+ SAMPLE_USER_MESSAGE = (
12
+ "Hi, this is Aman Sharma. Need a cricket ground tomorrow morning "
13
+ "8 AM to 12 PM for 12 players. Natural grass preferred, North Field if "
14
+ "available. Budget is around Rs 6000. My number is +91 98765 43210."
15
+ )
16
+
17
+
18
+ def modal_status() -> dict[str, Any]:
19
+ return {
20
+ "configured": bool(os.environ.get("APP_MODAL_BASE_URL") and os.environ.get("APP_MODAL_AUTH_TOKEN")),
21
+ "base_url_configured": bool(os.environ.get("APP_MODAL_BASE_URL")),
22
+ "auth_configured": bool(os.environ.get("APP_MODAL_AUTH_TOKEN")),
23
+ "model_id": os.environ.get("APP_MODAL_MODEL_ID", "nvidia/Nemotron-Cascade-2-30B-A3B"),
24
+ "timeout_seconds": float(os.environ.get("APP_MODAL_TIMEOUT_SECONDS", "90")),
25
+ }
26
+
27
+
28
+ def call_booking_extractor(
29
+ *,
30
+ message: str = SAMPLE_USER_MESSAGE,
31
+ venue_context: dict[str, Any] | None = None,
32
+ trace_id: str = "space-venue-sample-001",
33
+ ) -> dict[str, Any]:
34
+ status = modal_status()
35
+ if not status["configured"]:
36
+ return {
37
+ "ok": False,
38
+ "trace_id": trace_id,
39
+ "model_id": status["model_id"],
40
+ "backend": "modal_http",
41
+ "fallback_used": False,
42
+ "booking": None,
43
+ "blocker": "APP_MODAL_BASE_URL and APP_MODAL_AUTH_TOKEN must be configured on the Space.",
44
+ "validation": {"valid": False, "errors": ["modal_config_missing"]},
45
+ }
46
+
47
+ payload = json.dumps(
48
+ {
49
+ "trace_id": trace_id,
50
+ "message": message,
51
+ "venue_context": venue_context or {},
52
+ }
53
+ ).encode("utf-8")
54
+ headers = {
55
+ "Content-Type": "application/json",
56
+ "Authorization": f"Bearer {os.environ['APP_MODAL_AUTH_TOKEN']}",
57
+ }
58
+ started = time.time()
59
+ request = urllib.request.Request(
60
+ os.environ["APP_MODAL_BASE_URL"],
61
+ data=payload,
62
+ headers=headers,
63
+ method="POST",
64
+ )
65
+ try:
66
+ with urllib.request.urlopen(request, timeout=status["timeout_seconds"]) as response:
67
+ result = json.loads(response.read().decode("utf-8"))
68
+ except urllib.error.HTTPError as exc:
69
+ detail = exc.read().decode("utf-8", errors="replace")[:800]
70
+ return _blocked(trace_id, status["model_id"], f"Modal HTTP {exc.code}: {detail}")
71
+ except urllib.error.URLError as exc:
72
+ return _blocked(trace_id, status["model_id"], f"Modal request failed: {exc}")
73
+ except TimeoutError as exc:
74
+ return _blocked(trace_id, status["model_id"], f"Modal request timed out: {exc}")
75
+ except json.JSONDecodeError as exc:
76
+ return _blocked(trace_id, status["model_id"], f"Modal returned non-JSON response: {exc}")
77
+
78
+ result.setdefault("client_latency_ms", round((time.time() - started) * 1000))
79
+ result.setdefault("fallback_used", False)
80
+ result.setdefault("backend", "modal_http")
81
+ result.setdefault("model_id", status["model_id"])
82
+ return result
83
+
84
+
85
+ def _blocked(trace_id: str, model_id: str, blocker: str) -> dict[str, Any]:
86
+ return {
87
+ "ok": False,
88
+ "trace_id": trace_id,
89
+ "model_id": model_id,
90
+ "backend": "modal_http",
91
+ "fallback_used": False,
92
+ "booking": None,
93
+ "blocker": blocker,
94
+ "validation": {"valid": False, "errors": ["modal_request_blocked"]},
95
+ }
frontend/assets/floodlight-cricket-ball.png ADDED
frontend/assets/floodlight-field-backdrop.png ADDED

Git LFS Details

  • SHA256: 09b3d6f8ac81257336f1f2f1936e706c0a18445f45b93e96ecfd0e20462733f4
  • Pointer size: 131 Bytes
  • Size of remote file: 806 kB
frontend/assets/floodlight-ground-board.png ADDED

Git LFS Details

  • SHA256: 7612048e3bb445588399f68ba46a65e7a26dfcb9ba863698e62663aee1f66c19
  • Pointer size: 132 Bytes
  • Size of remote file: 1.04 MB
frontend/assets/floodlight-logo-lockup.png ADDED
frontend/assets/floodlight-source-reference.png ADDED

Git LFS Details

  • SHA256: c2f5836a5779b0fee9ede6b7ec6d13fd6d061430cbcc8255fc518dc21211e1aa
  • Pointer size: 132 Bytes
  • Size of remote file: 1.92 MB
frontend/index.html ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Floodlight Venue Ops Agent</title>
7
+ <link rel="stylesheet" href="/frontend/styles/tokens.css" />
8
+ <link rel="stylesheet" href="/frontend/styles/app.css" />
9
+ </head>
10
+ <body>
11
+ <div class="app-shell" data-design-id="floodlight-app">
12
+ <header class="topbar" data-design-id="brand-header">
13
+ <div class="brand-lockup">
14
+ <img class="brand-logo" src="/assets/floodlight-logo-lockup.png" alt="Floodlight Venue Ops Agent" />
15
+ </div>
16
+ <span class="agent-pill"><span></span>Agent Online</span>
17
+ <span class="top-separator"></span>
18
+ <span class="top-meta">Today: <strong id="todayLabel">25 May 2025, Sun</strong></span>
19
+ <span class="top-meta">11:42 AM</span>
20
+ <span class="top-spacer"></span>
21
+ <span class="top-meta channel" id="channelStatus">WhatsApp simulator</span>
22
+ <span class="owner-menu">Owner: Ankit Sharma</span>
23
+ <img class="ball-accent" src="/assets/floodlight-cricket-ball.png" alt="" aria-hidden="true" />
24
+ </header>
25
+
26
+ <nav class="scenario-bar" data-design-id="scenario-switcher" aria-label="Review scenarios">
27
+ <span class="scenario-label">Review state</span>
28
+ <div id="scenarioButtons" class="scenario-buttons"></div>
29
+ </nav>
30
+
31
+ <nav class="mobile-tabs" data-design-id="mobile-panel-tabs" aria-label="Mobile panels">
32
+ <button type="button" class="mobile-tab is-active" data-mobile-panel-target="phone">Chat</button>
33
+ <button type="button" class="mobile-tab" data-mobile-panel-target="agent">Agent</button>
34
+ <button type="button" class="mobile-tab" data-mobile-panel-target="owner">Owner</button>
35
+ <button type="button" class="mobile-tab" data-mobile-panel-target="decision">Decision</button>
36
+ </nav>
37
+
38
+ <main class="console-grid">
39
+ <section class="phone-panel" data-design-id="phone-chat-simulator" data-panel="phone">
40
+ <div class="phone-shell">
41
+ <div class="phone-status">
42
+ <span>11:42</span>
43
+ <span class="phone-signal">LTE</span>
44
+ </div>
45
+ <div class="chat-head">
46
+ <button type="button" class="icon-button" aria-label="Back to conversations">‹</button>
47
+ <span class="avatar-dot"></span>
48
+ <div>
49
+ <strong id="chatPhone">+91 98765 43210</strong>
50
+ <span>online</span>
51
+ </div>
52
+ <span class="chat-actions">Call · More</span>
53
+ </div>
54
+ <div id="chatThread" class="chat-thread"></div>
55
+ <div class="chat-composer">
56
+ <span>Type a message</span>
57
+ <button type="button" aria-label="Attach file">Attach</button>
58
+ <button type="button" aria-label="Voice note">Mic</button>
59
+ </div>
60
+ </div>
61
+ </section>
62
+
63
+ <section class="agent-panel panel-card" data-design-id="agent-understanding-panel" data-panel="agent">
64
+ <div class="panel-title-row">
65
+ <div>
66
+ <h1>Agent Panel</h1>
67
+ <span id="channelCopy" class="panel-subcopy">Demo channel connected</span>
68
+ </div>
69
+ <span class="live-badge"><span></span>Live</span>
70
+ </div>
71
+
72
+ <div class="intent-card">
73
+ <div>
74
+ <span class="section-title">Detected intent</span>
75
+ <strong id="detectedIntent" class="intent-chip">BOOK_SLOT</strong>
76
+ </div>
77
+ <div class="confidence-box">
78
+ <span class="section-title">Confidence</span>
79
+ <strong id="confidenceValue">0.92</strong>
80
+ <span class="confidence-meter"><span></span></span>
81
+ <em>High</em>
82
+ </div>
83
+ </div>
84
+
85
+ <div class="agent-columns">
86
+ <div class="extracted-card">
87
+ <span class="section-title">Extracted slots</span>
88
+ <dl id="extractedSlots" class="slot-list"></dl>
89
+ </div>
90
+ <div class="unclear-card">
91
+ <span class="section-title">Missing / unclear</span>
92
+ <ul id="missingList" class="missing-list"></ul>
93
+ </div>
94
+ </div>
95
+
96
+ <div class="timeline-card" data-design-id="trace-ledger">
97
+ <span class="section-title">Trace timeline</span>
98
+ <ol id="traceList" class="trace-list"></ol>
99
+ <span id="timelineState" class="ready-pill">Ready for Reply</span>
100
+ </div>
101
+ </section>
102
+
103
+ <aside class="owner-panel panel-card" data-design-id="owner-booking-panel" data-panel="owner">
104
+ <div class="panel-title-row">
105
+ <div>
106
+ <h2>Owner Booking Panel</h2>
107
+ <span id="bookingId" class="panel-subcopy">Booking ID: BK-51872</span>
108
+ </div>
109
+ <button type="button" class="ghost-button" id="resetDemoButton">Reset</button>
110
+ </div>
111
+
112
+ <section class="owner-section availability-section" data-design-id="availability-board">
113
+ <div class="owner-section-head">
114
+ <h3>Availability</h3>
115
+ <span id="pendingCount" class="count-chip">7 pending</span>
116
+ </div>
117
+ <div class="date-slot-row">
118
+ <select aria-label="Booking date">
119
+ <option>25 May 2025, Sun</option>
120
+ </select>
121
+ <button type="button" class="slot-filter is-active" data-slot-filter="morning">8 AM - 12 PM</button>
122
+ <button type="button" class="slot-filter" data-slot-filter="afternoon">2 PM - 6 PM</button>
123
+ </div>
124
+ <div id="availabilityBoard" class="venue-list"></div>
125
+ </section>
126
+
127
+ <section class="owner-section package-section">
128
+ <h3>Activity Packet <span>(Selected)</span></h3>
129
+ <div id="packetList" class="packet-list"></div>
130
+ <p id="suggestedPrice" class="packet-total">Total add-ons: ₹1,450</p>
131
+ </section>
132
+
133
+ <section class="owner-section group-section">
134
+ <div class="group-card">
135
+ <h3>Group A <span>(Existing)</span></h3>
136
+ <dl id="groupA"></dl>
137
+ </div>
138
+ <div class="group-card">
139
+ <h3>Group B <span>(New)</span></h3>
140
+ <dl id="groupB"></dl>
141
+ </div>
142
+ </section>
143
+
144
+ <section class="owner-section integrations" data-design-id="disabled-integrations">
145
+ <h3>Disabled elements</h3>
146
+ <div id="integrationsList"></div>
147
+ </section>
148
+ </aside>
149
+
150
+ <section class="decision-bar panel-card" data-design-id="approval-actions" data-panel="decision">
151
+ <div class="reply-editor" data-design-id="reply-draft">
152
+ <label for="replyDraft">AI Generated Reply <span>(Editable)</span></label>
153
+ <textarea id="replyDraft" rows="3"></textarea>
154
+ <div class="reply-meta">
155
+ <span id="charCount">Characters: 0</span>
156
+ <button type="button" class="ghost-button" id="suggestButton">Suggest variations</button>
157
+ </div>
158
+ </div>
159
+
160
+ <div class="decision-actions">
161
+ <span class="section-title">Owner Decision</span>
162
+ <p id="decisionState" class="decision-state">Pending owner approval</p>
163
+ <div class="action-grid">
164
+ <button type="button" class="approve-button" id="approveButton">Approve &amp; Send<span>Send reply to user</span></button>
165
+ <button type="button" class="edit-button" id="clarifyButton">Edit &amp; Send<span>Modify and send</span></button>
166
+ <button type="button" class="reject-button" id="rejectButton">Reject<span>Do not send</span></button>
167
+ </div>
168
+ </div>
169
+ </section>
170
+
171
+ <select id="requestQueue" class="request-select" aria-label="Select booking request"></select>
172
+ </main>
173
+ </div>
174
+
175
+ <script type="module" src="/frontend/scripts/app.js"></script>
176
+ </body>
177
+ </html>
frontend/scripts/app.js ADDED
@@ -0,0 +1,507 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const state = {
2
+ data: null,
3
+ selectedRequestId: "",
4
+ selectedSlotId: "",
5
+ selectedPrice: 0,
6
+ activeScenario: "normal",
7
+ activeSlotFilter: "morning",
8
+ mobilePanel: "phone",
9
+ variationIndex: 0,
10
+ };
11
+
12
+ const els = {
13
+ shell: document.querySelector(".app-shell"),
14
+ todayLabel: document.querySelector("#todayLabel"),
15
+ channelStatus: document.querySelector("#channelStatus"),
16
+ channelCopy: document.querySelector("#channelCopy"),
17
+ pendingCount: document.querySelector("#pendingCount"),
18
+ scenarioButtons: document.querySelector("#scenarioButtons"),
19
+ requestQueue: document.querySelector("#requestQueue"),
20
+ chatPhone: document.querySelector("#chatPhone"),
21
+ chatThread: document.querySelector("#chatThread"),
22
+ detectedIntent: document.querySelector("#detectedIntent"),
23
+ confidenceValue: document.querySelector("#confidenceValue"),
24
+ extractedSlots: document.querySelector("#extractedSlots"),
25
+ missingList: document.querySelector("#missingList"),
26
+ traceList: document.querySelector("#traceList"),
27
+ timelineState: document.querySelector("#timelineState"),
28
+ bookingId: document.querySelector("#bookingId"),
29
+ availabilityBoard: document.querySelector("#availabilityBoard"),
30
+ packetList: document.querySelector("#packetList"),
31
+ groupA: document.querySelector("#groupA"),
32
+ groupB: document.querySelector("#groupB"),
33
+ suggestedPrice: document.querySelector("#suggestedPrice"),
34
+ integrationsList: document.querySelector("#integrationsList"),
35
+ replyDraft: document.querySelector("#replyDraft"),
36
+ charCount: document.querySelector("#charCount"),
37
+ decisionState: document.querySelector("#decisionState"),
38
+ approveButton: document.querySelector("#approveButton"),
39
+ clarifyButton: document.querySelector("#clarifyButton"),
40
+ rejectButton: document.querySelector("#rejectButton"),
41
+ resetDemoButton: document.querySelector("#resetDemoButton"),
42
+ suggestButton: document.querySelector("#suggestButton"),
43
+ };
44
+
45
+ function formatPrice(value) {
46
+ return new Intl.NumberFormat("en-IN", {
47
+ style: "currency",
48
+ currency: "INR",
49
+ maximumFractionDigits: 0,
50
+ }).format(value);
51
+ }
52
+
53
+ function slotKey(venueId, slotId) {
54
+ return `${venueId}:${slotId}`;
55
+ }
56
+
57
+ function getSelectedRequest() {
58
+ return state.data?.requests.find((request) => request.id === state.selectedRequestId) || null;
59
+ }
60
+
61
+ function getSelectedSlot() {
62
+ if (!state.data || !state.selectedSlotId) return null;
63
+ const [venueId, slotId] = state.selectedSlotId.split(":");
64
+ const venue = state.data.venues.find((item) => item.id === venueId);
65
+ const slot = state.data.slots.find((item) => item.id === slotId);
66
+ const inventory = venue?.slots?.[slotId];
67
+ if (!venue || !slot || !inventory) return null;
68
+ return { venue, slot, inventory };
69
+ }
70
+
71
+ async function loadScenario(scenario = "normal") {
72
+ const response = await fetch(`/api/bootstrap?scenario=${encodeURIComponent(scenario)}`);
73
+ state.data = await response.json();
74
+ state.activeScenario = state.data.active_scenario;
75
+ state.selectedRequestId = state.data.decision.selected_request_id;
76
+ state.selectedSlotId = state.data.decision.selected_slot_id;
77
+ state.selectedPrice = state.data.decision.selected_price;
78
+ state.activeSlotFilter = state.selectedSlotId.split(":")[1] || "morning";
79
+ state.variationIndex = 0;
80
+ render();
81
+ }
82
+
83
+ function render() {
84
+ renderRuntime();
85
+ renderScenarios();
86
+ renderRequestSelect();
87
+ renderPhone();
88
+ renderAgentPanel();
89
+ renderOwnerPanel();
90
+ renderDecisionBar();
91
+ renderMobilePanel();
92
+ }
93
+
94
+ function renderRuntime() {
95
+ const runtime = state.data.runtime;
96
+ els.todayLabel.textContent = getSelectedRequest()?.date_label || "25 May 2025, Sun";
97
+ els.channelStatus.textContent = runtime.channel_label;
98
+ els.channelCopy.textContent = runtime.channel_copy;
99
+ els.pendingCount.textContent = `${runtime.pending_count} pending`;
100
+ els.shell.classList.toggle("is-provider-error", runtime.channel_status === "provider-error");
101
+ }
102
+
103
+ function renderScenarios() {
104
+ els.scenarioButtons.replaceChildren(
105
+ ...state.data.scenarios.map((scenario) => {
106
+ const button = document.createElement("button");
107
+ button.type = "button";
108
+ button.textContent = scenario.label;
109
+ button.className = scenario.id === state.activeScenario ? "is-active" : "";
110
+ button.addEventListener("click", () => loadScenario(scenario.id));
111
+ return button;
112
+ })
113
+ );
114
+ }
115
+
116
+ function renderRequestSelect() {
117
+ if (!state.data.requests.length) {
118
+ const option = new Option("No booking requests", "");
119
+ els.requestQueue.replaceChildren(option);
120
+ els.requestQueue.disabled = true;
121
+ return;
122
+ }
123
+
124
+ els.requestQueue.disabled = false;
125
+ els.requestQueue.replaceChildren(
126
+ ...state.data.requests.map((request) => {
127
+ const label = `${request.customer_name} · ${request.activity} · ${stateLabel(request.status)}`;
128
+ const option = new Option(label, request.id, false, request.id === state.selectedRequestId);
129
+ return option;
130
+ })
131
+ );
132
+ }
133
+
134
+ function renderPhone() {
135
+ const request = getSelectedRequest();
136
+ els.chatPhone.textContent = request?.phone || "No active conversation";
137
+ if (!request) {
138
+ els.chatThread.replaceChildren(emptyPanel("No active booking chat. Pick a scenario or wait for a new request."));
139
+ return;
140
+ }
141
+
142
+ const selected = getSelectedSlot();
143
+ const locationText = selected?.venue.name || "North Field or South Field";
144
+ const transcript = [
145
+ { who: "customer", text: `Hi, need a ground for ${request.activity.toLowerCase()} ${request.date_label.toLowerCase()}.`, time: "11:34 AM" },
146
+ { who: "agent", text: "Hi! Sure, what time are you looking for?", time: "11:35 AM" },
147
+ { who: "customer", text: `${selected?.slot.label || "8 AM - 12 PM"}. For ${request.players} players.`, time: "11:35 AM" },
148
+ { who: "agent", text: "Which location?", time: "11:36 AM" },
149
+ { who: "customer", text: `${locationText} works.`, time: "11:36 AM" },
150
+ { who: "agent", text: "Any surface preference?", time: "11:37 AM" },
151
+ { who: "customer", text: `${selected?.venue.surface || "Grass"} is fine. Budget around ${formatPrice(state.selectedPrice || selected?.inventory.rate || 6000)}.`, time: "11:38 AM" },
152
+ { who: "agent", text: "Got it. Let me check availability and send options.", time: "11:38 AM" },
153
+ ];
154
+
155
+ const day = document.createElement("div");
156
+ day.className = "chat-day";
157
+ day.textContent = "Today";
158
+ els.chatThread.replaceChildren(day, ...transcript.map(chatBubble));
159
+ }
160
+
161
+ function chatBubble(message) {
162
+ const div = document.createElement("div");
163
+ div.className = `bubble ${message.who === "agent" ? "agent" : ""}`;
164
+ div.innerHTML = `${message.text}<time>${message.time}</time>`;
165
+ return div;
166
+ }
167
+
168
+ function renderAgentPanel() {
169
+ const request = getSelectedRequest();
170
+ const selected = getSelectedSlot();
171
+ const confidence = confidenceFor(request);
172
+ els.detectedIntent.textContent = request ? "BOOK_SLOT" : "IDLE";
173
+ els.confidenceValue.textContent = confidence.toFixed(2);
174
+ document.querySelector(".confidence-meter span").style.width = `${Math.round(confidence * 100)}%`;
175
+
176
+ if (!request) {
177
+ els.extractedSlots.replaceChildren();
178
+ els.missingList.replaceChildren(emptyListItem("Waiting for the next booking request."));
179
+ els.traceList.replaceChildren(...state.data.trace.map(traceItem));
180
+ els.timelineState.textContent = "Idle";
181
+ return;
182
+ }
183
+
184
+ const extracted = [
185
+ ["date", request.date_label],
186
+ ["time", selected?.slot.label || "Not selected"],
187
+ ["players", String(request.players)],
188
+ ["activity", request.activity],
189
+ ["location", selected?.venue.name || "Any venue"],
190
+ ["surface", selected ? `${selected.venue.surface} · ${selected.venue.grass_type}` : "Grass"],
191
+ ["budget", `${formatPrice(state.selectedPrice || selected?.inventory.rate || 0)} approx`],
192
+ ];
193
+
194
+ els.extractedSlots.replaceChildren(...extracted.flatMap(([label, value]) => slotRow(label, value)));
195
+ const missing = request.missing_fields.length
196
+ ? request.missing_fields
197
+ : ["Backup slot preference", "Add-ons final confirmation"];
198
+ els.missingList.replaceChildren(...missing.map((item) => listItem(item)));
199
+ els.traceList.replaceChildren(...buildTimeline(request).map(traceItem));
200
+ els.timelineState.textContent = state.data.decision.state === "sent" ? "Sent" : timelineCopy(request);
201
+ }
202
+
203
+ function renderOwnerPanel() {
204
+ const request = getSelectedRequest();
205
+ els.bookingId.textContent = request ? `Booking ID: BK-${request.id.slice(0, 2).toUpperCase()}51872` : "Booking ID: none";
206
+ renderAvailabilityBoard();
207
+ renderPacket();
208
+ renderGroups(request);
209
+ renderIntegrations();
210
+ }
211
+
212
+ function renderAvailabilityBoard() {
213
+ const slot = state.data.slots.find((item) => item.id === state.activeSlotFilter) || state.data.slots[0];
214
+ document.querySelectorAll("[data-slot-filter]").forEach((button) => {
215
+ button.classList.toggle("is-active", button.dataset.slotFilter === slot.id);
216
+ });
217
+
218
+ const rows = state.data.venues.map((venue) => {
219
+ const inventory = venue.slots[slot.id];
220
+ const key = slotKey(venue.id, slot.id);
221
+ const button = document.createElement("button");
222
+ button.type = "button";
223
+ button.className = `venue-row ${key === state.selectedSlotId ? "is-selected" : ""}`;
224
+ button.innerHTML = `
225
+ <span>
226
+ <strong>${venue.name}</strong>
227
+ <small>${venue.surface} · ${venue.grass_type}</small>
228
+ </span>
229
+ <span class="venue-rate">${formatPrice(inventory.rate)}</span>
230
+ <span class="surface-badge">${venue.grass_type}</span>
231
+ <span class="slot-count ${inventory.status}">${inventoryLabel(inventory)}</span>
232
+ `;
233
+ button.addEventListener("click", () => {
234
+ state.selectedSlotId = key;
235
+ state.selectedPrice = inventory.rate;
236
+ render();
237
+ });
238
+ return button;
239
+ });
240
+ els.availabilityBoard.replaceChildren(...rows);
241
+ }
242
+
243
+ function renderPacket() {
244
+ const selected = getSelectedSlot();
245
+ const packet = [
246
+ ["Umpire", "₹700"],
247
+ ["Scorekeeper", "₹300"],
248
+ ["Match ball", "₹250"],
249
+ ["Water", "₹200"],
250
+ ];
251
+ els.packetList.replaceChildren(
252
+ ...packet.map(([label, price]) => {
253
+ const item = document.createElement("div");
254
+ item.className = "packet-item";
255
+ item.innerHTML = `<strong>${label}</strong><span>${price}</span>`;
256
+ return item;
257
+ })
258
+ );
259
+ const base = selected?.inventory.rate || 0;
260
+ els.suggestedPrice.textContent = `Slot ${formatPrice(base)} · Total add-ons: ${formatPrice(1450)}`;
261
+ }
262
+
263
+ function renderGroups(request) {
264
+ const groupA = request
265
+ ? [
266
+ ["Group name", request.group_type],
267
+ ["Primary contact", request.phone],
268
+ ["Activity", request.activity],
269
+ ["Players", String(request.players)],
270
+ ]
271
+ : [["State", "No request selected"]];
272
+ const groupB = request
273
+ ? [
274
+ ["Group name", "To be confirmed"],
275
+ ["Status", request.missing_fields.length ? "Needs clarification" : "Ready"],
276
+ ["Venue", getSelectedSlot()?.venue.name || "Not selected"],
277
+ ["Slot", getSelectedSlot()?.slot.label || "Not selected"],
278
+ ]
279
+ : [["State", "No request selected"]];
280
+ els.groupA.replaceChildren(...definitionRows(groupA));
281
+ els.groupB.replaceChildren(...definitionRows(groupB));
282
+ }
283
+
284
+ function renderIntegrations() {
285
+ els.integrationsList.replaceChildren(
286
+ ...state.data.integrations.map((integration) => {
287
+ const row = document.createElement("div");
288
+ row.className = "integration-row";
289
+ row.innerHTML = `<span>${integration.label}</span><span>${integration.copy}</span>`;
290
+ return row;
291
+ })
292
+ );
293
+ }
294
+
295
+ function renderDecisionBar() {
296
+ const request = getSelectedRequest();
297
+ const providerError = state.data.runtime.channel_status === "provider-error";
298
+ if (document.activeElement !== els.replyDraft) {
299
+ els.replyDraft.value = request?.reply_draft || "Pick a request to review the reply draft.";
300
+ }
301
+ updateCharCount();
302
+ els.decisionState.textContent = decisionCopy(state.data.decision.state, request);
303
+
304
+ const disabled = !request || providerError;
305
+ els.approveButton.disabled = disabled;
306
+ els.clarifyButton.disabled = !request;
307
+ els.rejectButton.disabled = !request;
308
+ }
309
+
310
+ function slotRow(label, value) {
311
+ const dt = document.createElement("dt");
312
+ const dd = document.createElement("dd");
313
+ const ok = document.createElement("span");
314
+ dt.textContent = label;
315
+ dd.textContent = value;
316
+ ok.className = "ok-mark";
317
+ ok.textContent = "✓";
318
+ return [dt, dd, ok];
319
+ }
320
+
321
+ function definitionRows(rows) {
322
+ return rows.flatMap(([label, value]) => {
323
+ const dt = document.createElement("dt");
324
+ const dd = document.createElement("dd");
325
+ dt.textContent = label;
326
+ dd.textContent = value;
327
+ return [dt, dd];
328
+ });
329
+ }
330
+
331
+ function listItem(text) {
332
+ const li = document.createElement("li");
333
+ li.textContent = text;
334
+ return li;
335
+ }
336
+
337
+ function emptyListItem(text) {
338
+ return listItem(text);
339
+ }
340
+
341
+ function emptyPanel(text) {
342
+ const div = document.createElement("div");
343
+ div.className = "empty-state";
344
+ div.textContent = text;
345
+ return div;
346
+ }
347
+
348
+ function traceItem(event) {
349
+ const li = document.createElement("li");
350
+ li.className = event.tone;
351
+ li.innerHTML = `<time>${event.timestamp_label || "now"}</time><span>${event.label}</span>`;
352
+ return li;
353
+ }
354
+
355
+ function buildTimeline(request) {
356
+ const selected = getSelectedSlot();
357
+ return [
358
+ { id: "hello", label: `Message received from ${request.customer_name}`, tone: "neutral", timestamp_label: "11:34:12" },
359
+ { id: "time", label: `Extracted ${selected?.slot.label || "requested slot"}`, tone: "success", timestamp_label: "11:35:04" },
360
+ { id: "players", label: `User: ${request.players} players`, tone: "success", timestamp_label: "11:35:16" },
361
+ { id: "venue", label: `Venue candidate: ${selected?.venue.name || "Any venue"}`, tone: "success", timestamp_label: "11:36:02" },
362
+ ...state.data.trace,
363
+ ];
364
+ }
365
+
366
+ function confidenceFor(request) {
367
+ if (!request) return 0;
368
+ if (state.data.runtime.channel_status === "provider-error") return 0.64;
369
+ if (request.status === "clarification") return 0.78;
370
+ if (request.status === "conflict") return 0.86;
371
+ return 0.92;
372
+ }
373
+
374
+ function timelineCopy(request) {
375
+ if (!request) return "Idle";
376
+ if (request.status === "conflict") return "Alternatives Needed";
377
+ if (request.missing_fields.length) return "Clarify First";
378
+ return "Ready for Reply";
379
+ }
380
+
381
+ function stateLabel(status) {
382
+ const labels = {
383
+ new: "New",
384
+ clarification: "Clarify",
385
+ conflict: "Conflict",
386
+ sent: "Sent",
387
+ rejected: "Rejected",
388
+ };
389
+ return labels[status] || status;
390
+ }
391
+
392
+ function inventoryLabel(inventory) {
393
+ if (inventory.status === "partial") return `${inventory.request_count} holds`;
394
+ if (inventory.status === "conflict") return "Conflict";
395
+ if (inventory.status === "booked") return "Booked";
396
+ return "Available";
397
+ }
398
+
399
+ function decisionCopy(decisionState, request) {
400
+ if (!request) return "Idle. No outbound reply can be sent.";
401
+ if (decisionState === "sent") return "Sent via simulator. No live WhatsApp proof claimed.";
402
+ if (decisionState === "rejected") return "Rejected by owner.";
403
+ if (decisionState === "error") return "Provider error. Retry before sending.";
404
+ if (request.missing_fields.length) return "Clarification needed before confirmation.";
405
+ if (request.status === "conflict") return "Conflict detected. Offer alternatives.";
406
+ return "Pending owner approval.";
407
+ }
408
+
409
+ function renderMobilePanel() {
410
+ document.querySelectorAll("[data-panel]").forEach((panel) => {
411
+ panel.classList.toggle("is-mobile-active", panel.dataset.panel === state.mobilePanel);
412
+ });
413
+ document.querySelectorAll("[data-mobile-panel-target]").forEach((tab) => {
414
+ tab.classList.toggle("is-active", tab.dataset.mobilePanelTarget === state.mobilePanel);
415
+ });
416
+ }
417
+
418
+ function updateCharCount() {
419
+ els.charCount.textContent = `Characters: ${els.replyDraft.value.length}`;
420
+ }
421
+
422
+ function attachEvents() {
423
+ document.querySelectorAll("[data-mobile-panel-target]").forEach((tab) => {
424
+ tab.addEventListener("click", () => {
425
+ state.mobilePanel = tab.dataset.mobilePanelTarget;
426
+ renderMobilePanel();
427
+ });
428
+ });
429
+
430
+ document.querySelectorAll("[data-slot-filter]").forEach((button) => {
431
+ button.addEventListener("click", () => {
432
+ state.activeSlotFilter = button.dataset.slotFilter;
433
+ renderAvailabilityBoard();
434
+ });
435
+ });
436
+
437
+ els.requestQueue.addEventListener("change", () => {
438
+ state.selectedRequestId = els.requestQueue.value;
439
+ const request = getSelectedRequest();
440
+ if (request) {
441
+ state.selectedSlotId = request.requested_slot_id;
442
+ state.activeSlotFilter = state.selectedSlotId.split(":")[1] || "morning";
443
+ const selected = getSelectedSlot();
444
+ state.selectedPrice = selected?.inventory.rate || 0;
445
+ }
446
+ render();
447
+ });
448
+
449
+ els.replyDraft.addEventListener("input", updateCharCount);
450
+
451
+ els.suggestButton.addEventListener("click", () => {
452
+ const request = getSelectedRequest();
453
+ const selected = getSelectedSlot();
454
+ if (!request || !selected) return;
455
+ const variants = [
456
+ `Great, ${selected.venue.name} is available for ${request.activity.toLowerCase()} on ${request.date_label} from ${selected.slot.label}. The slot is ${formatPrice(state.selectedPrice)}. Should I hold it after owner approval?`,
457
+ `I can offer ${selected.venue.name}, ${selected.slot.label}, ${selected.venue.grass_type} surface at ${formatPrice(state.selectedPrice)}. Please confirm and we will proceed after owner approval.`,
458
+ `Available option: ${selected.venue.name} on ${request.date_label}, ${selected.slot.label}, for ${request.players} players. Estimated slot cost is ${formatPrice(state.selectedPrice)} plus selected add-ons.`,
459
+ ];
460
+ state.variationIndex = (state.variationIndex + 1) % variants.length;
461
+ els.replyDraft.value = variants[state.variationIndex];
462
+ updateCharCount();
463
+ });
464
+
465
+ els.approveButton.addEventListener("click", () => {
466
+ if (!state.data || state.data.runtime.channel_status === "provider-error") return;
467
+ const request = getSelectedRequest();
468
+ if (!request) return;
469
+ state.data.decision.state = "sending";
470
+ state.data.trace.push({ id: "sending", label: "Owner approved; simulator send in progress", tone: "neutral", timestamp_label: "now" });
471
+ renderDecisionBar();
472
+ renderAgentPanel();
473
+ setTimeout(() => {
474
+ state.data.decision.state = "sent";
475
+ request.status = "sent";
476
+ request.tone = "ready";
477
+ request.reply_draft = els.replyDraft.value;
478
+ state.data.trace.push({ id: "sent", label: "Reply marked sent via simulator", tone: "success", timestamp_label: "now" });
479
+ render();
480
+ }, 650);
481
+ });
482
+
483
+ els.clarifyButton.addEventListener("click", () => {
484
+ const request = getSelectedRequest();
485
+ if (!request) return;
486
+ request.status = "clarification";
487
+ request.tone = "clarification";
488
+ request.reply_draft = els.replyDraft.value || `Hi ${request.customer_name.split(" ")[0]}, could you confirm the missing details before I ask the owner to hold this slot?`;
489
+ state.data.trace.push({ id: "edited-send", label: "Edited reply prepared for simulator send", tone: "warning", timestamp_label: "now" });
490
+ render();
491
+ });
492
+
493
+ els.rejectButton.addEventListener("click", () => {
494
+ const request = getSelectedRequest();
495
+ if (!request) return;
496
+ request.status = "rejected";
497
+ request.tone = "rejected";
498
+ state.data.decision.state = "rejected";
499
+ state.data.trace.push({ id: "rejected", label: "Owner rejected the outbound reply", tone: "error", timestamp_label: "now" });
500
+ render();
501
+ });
502
+
503
+ els.resetDemoButton.addEventListener("click", () => loadScenario("normal"));
504
+ }
505
+
506
+ attachEvents();
507
+ loadScenario("normal");
frontend/styles/app.css ADDED
@@ -0,0 +1,1077 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * {
2
+ box-sizing: border-box;
3
+ }
4
+
5
+ html {
6
+ min-height: 100%;
7
+ background: #071012;
8
+ }
9
+
10
+ body {
11
+ margin: 0;
12
+ min-height: 100%;
13
+ color: #10171a;
14
+ font-family: var(--fl-font-body);
15
+ background:
16
+ linear-gradient(180deg, rgba(7, 16, 18, 0.92), rgba(7, 16, 18, 0.48) 58%, rgba(7, 16, 18, 0.92)),
17
+ url("/assets/floodlight-field-backdrop.png") center / cover fixed no-repeat,
18
+ #071012;
19
+ }
20
+
21
+ button,
22
+ select,
23
+ textarea {
24
+ font: inherit;
25
+ }
26
+
27
+ button {
28
+ cursor: pointer;
29
+ }
30
+
31
+ button:disabled {
32
+ cursor: not-allowed;
33
+ filter: grayscale(0.35);
34
+ opacity: 0.62;
35
+ }
36
+
37
+ button:focus-visible,
38
+ select:focus-visible,
39
+ textarea:focus-visible {
40
+ outline: 2px solid var(--fl-lime);
41
+ outline-offset: 2px;
42
+ }
43
+
44
+ .app-shell {
45
+ min-height: 100vh;
46
+ }
47
+
48
+ .topbar {
49
+ min-height: 60px;
50
+ display: flex;
51
+ align-items: center;
52
+ gap: 20px;
53
+ padding: 10px 18px;
54
+ color: #f7faf8;
55
+ background: linear-gradient(180deg, #11191d, #090e11);
56
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
57
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
58
+ }
59
+
60
+ .brand-lockup {
61
+ width: 154px;
62
+ display: flex;
63
+ align-items: center;
64
+ }
65
+
66
+ .brand-logo {
67
+ width: 146px;
68
+ height: auto;
69
+ display: block;
70
+ }
71
+
72
+ .agent-pill {
73
+ min-height: 30px;
74
+ display: inline-flex;
75
+ align-items: center;
76
+ gap: 9px;
77
+ padding: 0 14px;
78
+ border: 1px solid rgba(255, 255, 255, 0.18);
79
+ border-radius: 6px;
80
+ font-weight: 800;
81
+ white-space: nowrap;
82
+ }
83
+
84
+ .agent-pill span,
85
+ .live-badge span {
86
+ width: 10px;
87
+ height: 10px;
88
+ border-radius: 999px;
89
+ background: #25d366;
90
+ box-shadow: 0 0 18px rgba(37, 211, 102, 0.5);
91
+ }
92
+
93
+ .top-separator {
94
+ width: 1px;
95
+ height: 18px;
96
+ background: rgba(255, 255, 255, 0.28);
97
+ }
98
+
99
+ .top-meta,
100
+ .owner-menu {
101
+ color: rgba(255, 255, 255, 0.9);
102
+ font-size: 14px;
103
+ white-space: nowrap;
104
+ }
105
+
106
+ .top-meta strong {
107
+ color: #fff;
108
+ }
109
+
110
+ .top-meta.channel {
111
+ color: var(--fl-lime);
112
+ font-weight: 800;
113
+ }
114
+
115
+ .top-spacer {
116
+ flex: 1;
117
+ }
118
+
119
+ .ball-accent {
120
+ width: 46px;
121
+ height: auto;
122
+ filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.4));
123
+ }
124
+
125
+ .scenario-bar {
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 14px;
129
+ padding: 10px 18px;
130
+ color: #eaf0ec;
131
+ background: rgba(12, 20, 23, 0.9);
132
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
133
+ }
134
+
135
+ .scenario-label,
136
+ .section-title {
137
+ font-size: 12px;
138
+ font-weight: 800;
139
+ color: #68747a;
140
+ letter-spacing: 0.04em;
141
+ text-transform: uppercase;
142
+ }
143
+
144
+ .scenario-label {
145
+ color: #d8e3df;
146
+ }
147
+
148
+ .scenario-buttons {
149
+ display: flex;
150
+ flex-wrap: wrap;
151
+ gap: 8px;
152
+ }
153
+
154
+ .scenario-buttons button,
155
+ .ghost-button {
156
+ min-height: 34px;
157
+ border: 1px solid rgba(255, 255, 255, 0.16);
158
+ border-radius: 6px;
159
+ color: #edf5f0;
160
+ background: rgba(255, 255, 255, 0.05);
161
+ padding: 0 12px;
162
+ }
163
+
164
+ .scenario-buttons button.is-active,
165
+ .slot-filter.is-active {
166
+ color: #081012;
167
+ background: var(--fl-lime);
168
+ border-color: var(--fl-lime);
169
+ font-weight: 900;
170
+ }
171
+
172
+ .mobile-tabs {
173
+ display: none;
174
+ }
175
+
176
+ .console-grid {
177
+ display: grid;
178
+ grid-template-columns: minmax(280px, 0.8fr) minmax(430px, 1.12fr) minmax(390px, 1fr);
179
+ grid-template-rows: minmax(650px, calc(100vh - 244px)) minmax(150px, auto);
180
+ gap: 12px;
181
+ padding: 12px 18px 18px;
182
+ }
183
+
184
+ .panel-card {
185
+ background: rgba(255, 255, 255, 0.95);
186
+ border: 1px solid rgba(210, 218, 222, 0.88);
187
+ border-radius: 12px;
188
+ box-shadow: 0 18px 44px rgba(8, 16, 18, 0.2);
189
+ overflow: hidden;
190
+ }
191
+
192
+ .phone-panel {
193
+ min-width: 0;
194
+ }
195
+
196
+ .phone-shell {
197
+ height: 100%;
198
+ min-height: 640px;
199
+ display: flex;
200
+ flex-direction: column;
201
+ overflow: hidden;
202
+ color: #11191d;
203
+ background: #f4f0e9;
204
+ border: 9px solid #10161a;
205
+ border-radius: 22px;
206
+ box-shadow: 0 24px 56px rgba(0, 0, 0, 0.3);
207
+ }
208
+
209
+ .phone-status,
210
+ .chat-head {
211
+ color: #fff;
212
+ background: linear-gradient(180deg, #151d21, #0b1114);
213
+ }
214
+
215
+ .phone-status {
216
+ min-height: 44px;
217
+ display: flex;
218
+ align-items: center;
219
+ justify-content: space-between;
220
+ padding: 0 18px;
221
+ font-weight: 800;
222
+ }
223
+
224
+ .phone-signal {
225
+ font-size: 12px;
226
+ letter-spacing: 0.08em;
227
+ }
228
+
229
+ .chat-head {
230
+ min-height: 64px;
231
+ display: grid;
232
+ grid-template-columns: 34px 42px 1fr auto;
233
+ align-items: center;
234
+ gap: 10px;
235
+ padding: 0 12px;
236
+ }
237
+
238
+ .icon-button {
239
+ width: 32px;
240
+ height: 32px;
241
+ border: 0;
242
+ color: #fff;
243
+ background: transparent;
244
+ font-size: 30px;
245
+ line-height: 1;
246
+ }
247
+
248
+ .avatar-dot {
249
+ width: 40px;
250
+ height: 40px;
251
+ border-radius: 999px;
252
+ background:
253
+ linear-gradient(135deg, rgba(198, 226, 73, 0.72), rgba(85, 97, 34, 0.65)),
254
+ url("/assets/floodlight-ground-board.png") center / cover no-repeat;
255
+ border: 1px solid rgba(255, 255, 255, 0.22);
256
+ }
257
+
258
+ .chat-head strong,
259
+ .chat-head span {
260
+ display: block;
261
+ }
262
+
263
+ .chat-head strong {
264
+ font-size: 16px;
265
+ }
266
+
267
+ .chat-head div span {
268
+ color: #bbd0c5;
269
+ font-size: 12px;
270
+ }
271
+
272
+ .chat-actions {
273
+ color: #dce5e0;
274
+ font-size: 12px;
275
+ white-space: nowrap;
276
+ }
277
+
278
+ .chat-thread {
279
+ flex: 1;
280
+ overflow-y: auto;
281
+ padding: 12px;
282
+ background:
283
+ linear-gradient(rgba(244, 240, 233, 0.92), rgba(244, 240, 233, 0.92)),
284
+ url("/assets/floodlight-field-backdrop.png") center / cover no-repeat;
285
+ }
286
+
287
+ .chat-day {
288
+ width: max-content;
289
+ margin: 0 auto 10px;
290
+ padding: 4px 10px;
291
+ border-radius: 999px;
292
+ color: #667179;
293
+ background: #fff;
294
+ font-size: 12px;
295
+ }
296
+
297
+ .bubble {
298
+ width: fit-content;
299
+ max-width: 78%;
300
+ margin-bottom: 8px;
301
+ padding: 9px 10px 7px;
302
+ border-radius: 8px;
303
+ background: #fff;
304
+ box-shadow: 0 1px 2px rgba(10, 20, 24, 0.13);
305
+ line-height: 1.3;
306
+ font-size: 14px;
307
+ }
308
+
309
+ .bubble.agent {
310
+ margin-left: auto;
311
+ background: #d8f9cc;
312
+ }
313
+
314
+ .bubble time {
315
+ display: block;
316
+ margin-top: 4px;
317
+ color: #728078;
318
+ text-align: right;
319
+ font-size: 10px;
320
+ }
321
+
322
+ .chat-composer {
323
+ min-height: 58px;
324
+ display: grid;
325
+ grid-template-columns: 1fr auto auto;
326
+ align-items: center;
327
+ gap: 8px;
328
+ padding: 8px;
329
+ background: #f4f0e9;
330
+ }
331
+
332
+ .chat-composer span {
333
+ min-height: 42px;
334
+ display: flex;
335
+ align-items: center;
336
+ padding: 0 14px;
337
+ border-radius: 999px;
338
+ color: #8a9297;
339
+ background: #fff;
340
+ }
341
+
342
+ .chat-composer button {
343
+ min-width: 44px;
344
+ height: 42px;
345
+ border: 0;
346
+ border-radius: 999px;
347
+ color: #fff;
348
+ background: #0e9d62;
349
+ font-size: 12px;
350
+ }
351
+
352
+ .empty-state {
353
+ margin: 14px;
354
+ padding: 16px;
355
+ border: 1px dashed #cfd9dd;
356
+ border-radius: 7px;
357
+ color: #637077;
358
+ background: rgba(255, 255, 255, 0.78);
359
+ line-height: 1.45;
360
+ }
361
+
362
+ .panel-title-row {
363
+ min-height: 70px;
364
+ display: flex;
365
+ justify-content: space-between;
366
+ align-items: center;
367
+ gap: 16px;
368
+ padding: 14px 16px;
369
+ border-bottom: 1px solid #dde5e8;
370
+ }
371
+
372
+ h1,
373
+ h2,
374
+ h3,
375
+ p {
376
+ margin: 0;
377
+ }
378
+
379
+ h1,
380
+ h2,
381
+ h3 {
382
+ letter-spacing: 0;
383
+ }
384
+
385
+ h1 {
386
+ font-size: 22px;
387
+ }
388
+
389
+ h2 {
390
+ font-size: 20px;
391
+ }
392
+
393
+ h3 {
394
+ font-size: 16px;
395
+ }
396
+
397
+ .panel-subcopy {
398
+ display: block;
399
+ margin-top: 5px;
400
+ color: #68747a;
401
+ font-size: 12px;
402
+ }
403
+
404
+ .live-badge,
405
+ .ready-pill,
406
+ .count-chip {
407
+ display: inline-flex;
408
+ align-items: center;
409
+ gap: 8px;
410
+ padding: 5px 10px;
411
+ border-radius: 6px;
412
+ color: #077932;
413
+ background: #dff7e7;
414
+ font-size: 12px;
415
+ font-weight: 900;
416
+ }
417
+
418
+ .intent-card,
419
+ .agent-columns,
420
+ .timeline-card {
421
+ margin: 14px 16px;
422
+ border: 1px solid #dbe3e7;
423
+ border-radius: 7px;
424
+ background: #fff;
425
+ }
426
+
427
+ .intent-card {
428
+ min-height: 100px;
429
+ display: grid;
430
+ grid-template-columns: 1fr 1fr;
431
+ }
432
+
433
+ .intent-card > div {
434
+ padding: 18px;
435
+ }
436
+
437
+ .intent-card > div + div {
438
+ border-left: 1px solid #dbe3e7;
439
+ }
440
+
441
+ .intent-chip {
442
+ width: max-content;
443
+ display: block;
444
+ margin-top: 12px;
445
+ padding: 8px 12px;
446
+ border-radius: 6px;
447
+ color: #047b2d;
448
+ background: #cff5dc;
449
+ font-size: 20px;
450
+ }
451
+
452
+ .confidence-box strong {
453
+ display: inline-block;
454
+ margin: 12px 14px 0 0;
455
+ font-size: 20px;
456
+ }
457
+
458
+ .confidence-box em {
459
+ color: #06983e;
460
+ font-style: normal;
461
+ font-weight: 900;
462
+ }
463
+
464
+ .confidence-meter {
465
+ width: 34%;
466
+ height: 7px;
467
+ display: inline-block;
468
+ overflow: hidden;
469
+ border-radius: 999px;
470
+ background: #e3ece8;
471
+ vertical-align: 2px;
472
+ }
473
+
474
+ .confidence-meter span {
475
+ width: 92%;
476
+ height: 100%;
477
+ display: block;
478
+ background: linear-gradient(90deg, #16ad4f, var(--fl-lime));
479
+ }
480
+
481
+ .agent-columns {
482
+ min-height: 240px;
483
+ display: grid;
484
+ grid-template-columns: 1.45fr 1fr;
485
+ margin-top: 0;
486
+ }
487
+
488
+ .extracted-card,
489
+ .unclear-card {
490
+ padding: 18px;
491
+ }
492
+
493
+ .unclear-card {
494
+ border-left: 1px solid #dbe3e7;
495
+ }
496
+
497
+ .slot-list {
498
+ display: grid;
499
+ grid-template-columns: minmax(82px, auto) 1fr 20px;
500
+ gap: 12px 14px;
501
+ margin: 18px 0 0;
502
+ }
503
+
504
+ .slot-list dt,
505
+ .slot-list dd {
506
+ margin: 0;
507
+ font-size: 14px;
508
+ }
509
+
510
+ .slot-list dt {
511
+ color: #3e4b50;
512
+ }
513
+
514
+ .slot-list dd {
515
+ color: #10171a;
516
+ }
517
+
518
+ .ok-mark {
519
+ width: 17px;
520
+ height: 17px;
521
+ display: grid;
522
+ place-items: center;
523
+ border-radius: 999px;
524
+ color: #fff;
525
+ background: #16a34a;
526
+ font-size: 11px;
527
+ font-weight: 900;
528
+ }
529
+
530
+ .missing-list {
531
+ margin: 18px 0 0;
532
+ padding: 0;
533
+ list-style: none;
534
+ }
535
+
536
+ .missing-list li {
537
+ display: grid;
538
+ grid-template-columns: 10px 1fr;
539
+ gap: 10px;
540
+ margin-bottom: 14px;
541
+ color: #3f4c52;
542
+ font-size: 14px;
543
+ }
544
+
545
+ .missing-list li::before {
546
+ content: "";
547
+ width: 8px;
548
+ height: 8px;
549
+ margin-top: 5px;
550
+ border-radius: 999px;
551
+ background: #9aa5aa;
552
+ }
553
+
554
+ .timeline-card {
555
+ position: relative;
556
+ min-height: 230px;
557
+ padding: 18px;
558
+ }
559
+
560
+ .trace-list {
561
+ list-style: none;
562
+ margin: 18px 0 0;
563
+ padding: 0 0 0 12px;
564
+ }
565
+
566
+ .trace-list li {
567
+ position: relative;
568
+ display: grid;
569
+ grid-template-columns: 74px 1fr;
570
+ gap: 16px;
571
+ padding: 0 0 18px 18px;
572
+ font-size: 14px;
573
+ }
574
+
575
+ .trace-list li::before {
576
+ content: "";
577
+ position: absolute;
578
+ left: -1px;
579
+ top: 2px;
580
+ width: 12px;
581
+ height: 12px;
582
+ border: 2px solid #18a84f;
583
+ border-radius: 999px;
584
+ background: #fff;
585
+ }
586
+
587
+ .trace-list li::after {
588
+ content: "";
589
+ position: absolute;
590
+ left: 5px;
591
+ top: 18px;
592
+ bottom: 0;
593
+ width: 1px;
594
+ background: #18a84f;
595
+ }
596
+
597
+ .trace-list li:last-child::after {
598
+ display: none;
599
+ }
600
+
601
+ .trace-list time {
602
+ color: #28353a;
603
+ }
604
+
605
+ .trace-list span {
606
+ color: #172126;
607
+ }
608
+
609
+ .ready-pill {
610
+ position: absolute;
611
+ right: 18px;
612
+ bottom: 14px;
613
+ color: #057a31;
614
+ background: #ecfff0;
615
+ border: 1px solid #22aa54;
616
+ }
617
+
618
+ .owner-panel {
619
+ overflow-y: auto;
620
+ }
621
+
622
+ .owner-section {
623
+ margin: 10px 14px;
624
+ padding: 12px;
625
+ border: 1px solid #dbe3e7;
626
+ border-radius: 7px;
627
+ background: #fff;
628
+ }
629
+
630
+ .owner-section-head {
631
+ display: flex;
632
+ align-items: center;
633
+ justify-content: space-between;
634
+ gap: 10px;
635
+ margin-bottom: 12px;
636
+ }
637
+
638
+ .date-slot-row {
639
+ display: grid;
640
+ grid-template-columns: minmax(140px, 1fr) auto auto;
641
+ gap: 8px;
642
+ margin-bottom: 12px;
643
+ }
644
+
645
+ .date-slot-row select,
646
+ .slot-filter {
647
+ min-height: 36px;
648
+ border: 1px solid #d7e0e4;
649
+ border-radius: 6px;
650
+ color: #1a252a;
651
+ background: #fff;
652
+ padding: 0 10px;
653
+ }
654
+
655
+ .venue-list {
656
+ display: grid;
657
+ gap: 8px;
658
+ }
659
+
660
+ .venue-row {
661
+ width: 100%;
662
+ min-height: 58px;
663
+ display: grid;
664
+ grid-template-columns: 1fr auto auto auto;
665
+ align-items: center;
666
+ gap: 12px;
667
+ border: 1px solid #dfe7ea;
668
+ border-radius: 6px;
669
+ color: #172126;
670
+ background: #fff;
671
+ padding: 10px 12px;
672
+ text-align: left;
673
+ }
674
+
675
+ .venue-row.is-selected {
676
+ border-color: #25b656;
677
+ box-shadow: 0 0 0 2px rgba(37, 182, 86, 0.12);
678
+ }
679
+
680
+ .venue-row strong,
681
+ .venue-row span {
682
+ display: block;
683
+ }
684
+
685
+ .venue-row small {
686
+ color: #59686e;
687
+ }
688
+
689
+ .venue-rate {
690
+ font-weight: 900;
691
+ }
692
+
693
+ .surface-badge,
694
+ .slot-count,
695
+ .integration-row span:last-child {
696
+ border-radius: 5px;
697
+ padding: 5px 9px;
698
+ white-space: nowrap;
699
+ font-size: 12px;
700
+ font-weight: 800;
701
+ }
702
+
703
+ .surface-badge {
704
+ color: #2b3a40;
705
+ background: #edf4f1;
706
+ }
707
+
708
+ .slot-count.available {
709
+ color: #087a34;
710
+ background: #def8e7;
711
+ }
712
+
713
+ .slot-count.partial {
714
+ color: #6f4b00;
715
+ background: #fff1ce;
716
+ }
717
+
718
+ .slot-count.conflict,
719
+ .slot-count.booked {
720
+ color: #a72a1f;
721
+ background: #ffe0db;
722
+ }
723
+
724
+ .package-section h3,
725
+ .integrations h3 {
726
+ margin-bottom: 10px;
727
+ }
728
+
729
+ .package-section h3 span,
730
+ .group-card h3 span,
731
+ .reply-editor label span {
732
+ color: #59686e;
733
+ font-weight: 500;
734
+ }
735
+
736
+ .packet-list {
737
+ display: grid;
738
+ grid-template-columns: repeat(4, minmax(0, 1fr));
739
+ gap: 8px;
740
+ }
741
+
742
+ .packet-item {
743
+ min-height: 56px;
744
+ border: 1px solid #dfe7ea;
745
+ border-radius: 6px;
746
+ padding: 8px;
747
+ font-size: 12px;
748
+ }
749
+
750
+ .packet-item strong,
751
+ .packet-item span {
752
+ display: block;
753
+ }
754
+
755
+ .packet-item span {
756
+ margin-top: 4px;
757
+ color: #59686e;
758
+ }
759
+
760
+ .packet-total {
761
+ margin-top: 8px;
762
+ color: #3e4b50;
763
+ text-align: right;
764
+ font-size: 13px;
765
+ }
766
+
767
+ .group-section {
768
+ display: grid;
769
+ grid-template-columns: 1fr 1fr;
770
+ padding: 0;
771
+ overflow: hidden;
772
+ }
773
+
774
+ .group-card {
775
+ padding: 12px;
776
+ }
777
+
778
+ .group-card + .group-card {
779
+ border-left: 1px solid #dbe3e7;
780
+ }
781
+
782
+ .group-card h3 {
783
+ padding-bottom: 8px;
784
+ border-bottom: 2px solid #22aa54;
785
+ font-size: 14px;
786
+ }
787
+
788
+ .group-card dl {
789
+ display: grid;
790
+ grid-template-columns: 1fr;
791
+ gap: 2px;
792
+ margin: 10px 0 0;
793
+ font-size: 12px;
794
+ }
795
+
796
+ .group-card dt {
797
+ color: #59686e;
798
+ }
799
+
800
+ .group-card dd {
801
+ margin: 0 0 7px;
802
+ color: #182328;
803
+ }
804
+
805
+ .integrations {
806
+ margin-bottom: 14px;
807
+ }
808
+
809
+ .integration-row {
810
+ min-height: 38px;
811
+ display: grid;
812
+ grid-template-columns: 1fr auto;
813
+ align-items: center;
814
+ gap: 12px;
815
+ padding: 8px 0;
816
+ border-top: 1px solid #edf2f4;
817
+ color: #172126;
818
+ }
819
+
820
+ .integration-row span:last-child {
821
+ color: #8a3500;
822
+ background: #fff1ce;
823
+ }
824
+
825
+ .decision-bar {
826
+ grid-column: 1 / -1;
827
+ display: grid;
828
+ grid-template-columns: minmax(0, 1.35fr) minmax(420px, 1fr);
829
+ }
830
+
831
+ .reply-editor {
832
+ padding: 14px 16px;
833
+ }
834
+
835
+ .reply-editor label {
836
+ display: block;
837
+ margin-bottom: 8px;
838
+ font-weight: 900;
839
+ }
840
+
841
+ textarea {
842
+ width: 100%;
843
+ resize: vertical;
844
+ border: 1px solid #cdd8dd;
845
+ border-radius: 6px;
846
+ color: #172126;
847
+ background: #fff;
848
+ padding: 12px;
849
+ line-height: 1.35;
850
+ }
851
+
852
+ .reply-meta {
853
+ display: flex;
854
+ justify-content: space-between;
855
+ align-items: center;
856
+ gap: 12px;
857
+ margin-top: 8px;
858
+ color: #7a878c;
859
+ font-size: 12px;
860
+ }
861
+
862
+ .reply-meta .ghost-button {
863
+ color: #415057;
864
+ border-color: #cfd9dd;
865
+ background: #fff;
866
+ }
867
+
868
+ .decision-actions {
869
+ padding: 14px 16px;
870
+ border-left: 1px solid #dbe3e7;
871
+ }
872
+
873
+ .decision-state {
874
+ min-height: 18px;
875
+ margin: 4px 0 12px;
876
+ color: #68747a;
877
+ font-size: 13px;
878
+ }
879
+
880
+ .action-grid {
881
+ display: grid;
882
+ grid-template-columns: repeat(3, 1fr);
883
+ gap: 14px;
884
+ }
885
+
886
+ .approve-button,
887
+ .edit-button,
888
+ .reject-button {
889
+ min-height: 82px;
890
+ border: 0;
891
+ border-radius: 6px;
892
+ color: #fff;
893
+ font-size: 17px;
894
+ font-weight: 900;
895
+ }
896
+
897
+ .approve-button {
898
+ background: linear-gradient(180deg, #28b43d, #14902e);
899
+ }
900
+
901
+ .edit-button {
902
+ background: linear-gradient(180deg, #ff563e, #ea321f);
903
+ }
904
+
905
+ .reject-button {
906
+ background: linear-gradient(180deg, #ff3e4d, #e62231);
907
+ }
908
+
909
+ .approve-button span,
910
+ .edit-button span,
911
+ .reject-button span {
912
+ display: block;
913
+ margin-top: 6px;
914
+ font-size: 12px;
915
+ font-weight: 500;
916
+ }
917
+
918
+ .request-select {
919
+ position: fixed;
920
+ right: 18px;
921
+ bottom: 18px;
922
+ z-index: 4;
923
+ min-height: 36px;
924
+ max-width: 260px;
925
+ border: 1px solid rgba(255, 255, 255, 0.2);
926
+ border-radius: 6px;
927
+ color: #edf5f0;
928
+ background: #11191d;
929
+ padding: 0 10px;
930
+ }
931
+
932
+ .is-provider-error .top-meta.channel,
933
+ .is-provider-error .decision-state {
934
+ color: var(--fl-red-strong);
935
+ }
936
+
937
+ @media (prefers-reduced-motion: no-preference) {
938
+ button,
939
+ .venue-row {
940
+ transition:
941
+ border-color var(--fl-duration-fast) var(--fl-ease-out),
942
+ background var(--fl-duration-fast) var(--fl-ease-out),
943
+ transform var(--fl-duration-fast) var(--fl-ease-out);
944
+ }
945
+
946
+ button:hover {
947
+ transform: translateY(-1px);
948
+ }
949
+ }
950
+
951
+ @media (max-width: 1180px) {
952
+ .topbar {
953
+ flex-wrap: wrap;
954
+ }
955
+
956
+ .console-grid {
957
+ grid-template-columns: 1fr;
958
+ grid-template-rows: auto;
959
+ }
960
+
961
+ .mobile-tabs {
962
+ display: grid;
963
+ grid-template-columns: repeat(4, 1fr);
964
+ background: rgba(12, 20, 23, 0.92);
965
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
966
+ }
967
+
968
+ .mobile-tab {
969
+ min-height: 44px;
970
+ border: 0;
971
+ border-right: 1px solid rgba(255, 255, 255, 0.09);
972
+ color: #eef5f0;
973
+ background: transparent;
974
+ }
975
+
976
+ .mobile-tab.is-active {
977
+ color: #081012;
978
+ background: var(--fl-lime);
979
+ font-weight: 900;
980
+ }
981
+
982
+ [data-panel] {
983
+ display: none;
984
+ }
985
+
986
+ [data-panel].is-mobile-active {
987
+ display: block;
988
+ grid-column: 1;
989
+ grid-row: 1;
990
+ }
991
+
992
+ .decision-bar.is-mobile-active {
993
+ display: grid;
994
+ grid-column: 1;
995
+ grid-row: 1;
996
+ }
997
+
998
+ .phone-shell {
999
+ min-height: 680px;
1000
+ }
1001
+
1002
+ .decision-bar {
1003
+ grid-column: auto;
1004
+ grid-template-columns: 1fr;
1005
+ }
1006
+
1007
+ .decision-actions {
1008
+ border-left: 0;
1009
+ border-top: 1px solid #dbe3e7;
1010
+ }
1011
+
1012
+ .request-select {
1013
+ position: static;
1014
+ margin: 0 18px 18px;
1015
+ color: #10171a;
1016
+ background: #fff;
1017
+ }
1018
+ }
1019
+
1020
+ @media (max-width: 680px) {
1021
+ .topbar {
1022
+ gap: 10px;
1023
+ padding: 10px 12px;
1024
+ }
1025
+
1026
+ .brand-lockup {
1027
+ width: 128px;
1028
+ }
1029
+
1030
+ .brand-logo {
1031
+ width: 122px;
1032
+ }
1033
+
1034
+ .owner-menu,
1035
+ .top-separator {
1036
+ display: none;
1037
+ }
1038
+
1039
+ .scenario-bar {
1040
+ align-items: flex-start;
1041
+ flex-direction: column;
1042
+ padding: 10px 12px;
1043
+ }
1044
+
1045
+ .console-grid {
1046
+ padding: 12px;
1047
+ }
1048
+
1049
+ .phone-shell {
1050
+ min-height: 620px;
1051
+ }
1052
+
1053
+ .intent-card,
1054
+ .agent-columns,
1055
+ .date-slot-row,
1056
+ .group-section,
1057
+ .decision-bar,
1058
+ .action-grid {
1059
+ grid-template-columns: 1fr;
1060
+ }
1061
+
1062
+ .intent-card > div + div,
1063
+ .unclear-card,
1064
+ .group-card + .group-card {
1065
+ border-left: 0;
1066
+ border-top: 1px solid #dbe3e7;
1067
+ }
1068
+
1069
+ .packet-list {
1070
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1071
+ }
1072
+
1073
+ .venue-row {
1074
+ grid-template-columns: 1fr;
1075
+ align-items: start;
1076
+ }
1077
+ }
frontend/styles/tokens.css ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: dark;
3
+
4
+ --fl-font-display: "Barlow Condensed", "Roboto Condensed", "Arial Narrow", system-ui, sans-serif;
5
+ --fl-font-body: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
6
+ --fl-font-mono: "Roboto Mono", "SFMono-Regular", Consolas, monospace;
7
+
8
+ --fl-bg: #081012;
9
+ --fl-bg-elevated: #10181b;
10
+ --fl-panel: #121d22;
11
+ --fl-panel-strong: #162126;
12
+ --fl-panel-soft: #1c2a30;
13
+ --fl-line: #344148;
14
+ --fl-line-strong: #52585c;
15
+
16
+ --fl-text: #f2f5f6;
17
+ --fl-text-soft: #c8d0d3;
18
+ --fl-text-muted: #8c999f;
19
+ --fl-text-dim: #647178;
20
+
21
+ --fl-lime: #c6e249;
22
+ --fl-lime-strong: #d7ff39;
23
+ --fl-lime-soft: rgba(198, 226, 73, 0.16);
24
+ --fl-field: #556122;
25
+ --fl-field-bright: #778727;
26
+ --fl-amber: #ffbd4a;
27
+ --fl-red: #b94335;
28
+ --fl-red-strong: #ff5848;
29
+ --fl-blue-gray: #627586;
30
+
31
+ --fl-shadow-deep: 0 24px 80px rgba(0, 0, 0, 0.44);
32
+ --fl-shadow-panel: 0 16px 44px rgba(0, 0, 0, 0.28);
33
+ --fl-glow-lime: 0 0 0 1px rgba(198, 226, 73, 0.65), 0 0 28px rgba(198, 226, 73, 0.18);
34
+ --fl-glow-white: 0 0 32px rgba(242, 245, 246, 0.22);
35
+
36
+ --fl-radius-xs: 4px;
37
+ --fl-radius-sm: 6px;
38
+ --fl-radius-md: 8px;
39
+ --fl-radius-lg: 12px;
40
+ --fl-radius-pill: 999px;
41
+
42
+ --fl-space-1: 4px;
43
+ --fl-space-2: 8px;
44
+ --fl-space-3: 12px;
45
+ --fl-space-4: 16px;
46
+ --fl-space-5: 20px;
47
+ --fl-space-6: 24px;
48
+ --fl-space-7: 32px;
49
+ --fl-space-8: 40px;
50
+
51
+ --fl-duration-fast: 140ms;
52
+ --fl-duration-med: 240ms;
53
+ --fl-ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
54
+ }
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ gradio>=6.18.0