feat: integrate Just Works app and native business setup

Preserve hosted website UX inside Archipelago, add isolated Business and owner-authorized native wallet setup, and include deployment handoff. Based on main with native signer import already accepted.
This commit is contained in:
yaya
2026-10-08 19:39:38 +01:00
parent 8d17597202
commit 94899e69d3
152 changed files with 22873 additions and 2 deletions
+12
View File
@@ -673,6 +673,18 @@
"tier": "optional",
"icon": "/assets/img/app-icons/angor-green.png",
"repoUrl": "https://github.com/hoytech/strfry"
},
{
"id": "justworks",
"title": "Just Works",
"version": "0.1.0",
"description": "Turn your existing business links into a website with Just Works. Open your website editor and business tools from one lightweight launcher. Uses the hosted Just Works services; an internet connection is required.",
"icon": "/assets/img/app-icons/justworks.svg",
"author": "Just Works contributors",
"category": "business",
"tier": "optional",
"repoUrl": "https://github.com/bencoin21/justworks.cash",
"dockerImage": "localhost/archipelago-justworks:0.1.0"
}
]
}
+62
View File
@@ -0,0 +1,62 @@
app:
id: justworks
name: Just Works
version: 0.1.0
description: >-
Turn your existing business links into a website with Just Works.
Open your website editor and business tools from one lightweight launcher.
Uses the hosted Just Works services; an internet connection is required.
category: business
upstream:
kind: manual
url: https://github.com/bencoin21/justworks-business
container:
build:
context: /opt/archipelago/docker/justworks
dockerfile: Dockerfile
tag: localhost/archipelago-justworks:0.1.0
network: archy-net
resources:
cpu_limit: 1
memory_limit: 256Mi
disk_limit: 128Mi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: isolated
ports:
- host: 8340
container: 8340
protocol: tcp
bind: 127.0.0.1
auth: gated
session_passthrough: true
volumes:
- type: bind
source: /var/lib/archipelago/justworks
target: /data
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:8340
path: /healthz
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Just Works
description: Website and business tools
type: ui
port: 8340
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/justworks.svg
author: Just Works contributors
repo: https://github.com/bencoin21/justworks.cash
tier: optional
launch:
requires_host_frame: true
open_in_new_tab: false
+1
View File
@@ -33,6 +33,7 @@ pub const APP_LAUNCH_PORTS: &[u16] = &[
8334,
8336,
8337,
8340,
8888,
8998,
8999,
+2
View File
@@ -0,0 +1,2 @@
__pycache__
test_*.py
+11
View File
@@ -0,0 +1,11 @@
FROM docker.io/library/python:3.13-alpine@sha256:2d9aefe2fef018a7eb2c13064c89c71929800fd2e5dccdbf52ea5da5bb8d929a
RUN apk add --no-cache nodejs
WORKDIR /app
COPY server.py payments.py boot.py ./
COPY business ./business
COPY public ./public
COPY UPSTREAM-LICENSE /usr/share/licenses/justworks/LICENSE
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
USER 65534:65534
EXPOSE 8340
CMD ["python3", "boot.py"]
+113
View File
@@ -0,0 +1,113 @@
Just Works for Archipelago — app and demo
Scope
One optional Archipelago app. First launch embeds the original Just Works home.
After a published website is connected, show Website and Business tools.
Dashboard > Setup > Instant business site contains the native OS guided flow.
The split launcher appears after the first connected website. A small Business
tools switch is always available at bottom-left, with a Website return path.
It uses the shared ring-button style, building/person icons, and icon-only
layout on phones to leave room for the upstream menu at bottom-right.
No changes to, publication in, or deployment of the existing Just Works repos.
Native wallet keys never leave the node. Website owner keys can be imported
into a separate native business identity. Reuse requires the node password and
explicit key-sharing approval; only its nsec is forwarded to Just Works for
the selected login/payment action. No secrets are saved in browser storage.
Real-service mode
The original hosted creator/editor and an isolated Business copy load inside
the app iframe. Business data persists in /var/lib/archipelago/justworks.
The same wrapper is used when Archipelago opens the app in its own tab.
Owner authorization is verified by Just Works. Connecting a public link is not
ownership proof.
The app's GET /api/site checks the existing Core public API for published status.
It returns only a bounded display name, public identity and fixed-origin links.
Outbound reads reject redirects and are bounded in size/time; there is no generic
proxy or client-controlled upstream host. Only the page slug is saved in browser
storage. Another browser must connect its own link. Removing the app/link does
not delete the hosted business or its data. Internet access is required.
Isolated Business discovery resolves published sites directly through Core.
It uses the connected npub automatically and preserves the upstream UI.
No demo recipient fallback is enabled; legacy hardcoded order checkout is
blocked. The website payment page and merchant-address quick-pay are separate.
The local proxy has a fixed loopback destination and requires same-origin writes.
Demo mode
The dashboard demo shows the native guide and embeds the original hosted site.
It does not simulate a replacement website editor or Business interface.
Connecting a published page requires the installed adapter on a real node.
Setup completion
The native guide checks the published page through a hidden app-origin bridge.
Requests are correlated by random IDs; replies require the exact iframe window
and origin. Merely opening the app never completes the guide. The bridge saves
only the public slug; storage failure does not mark setup complete.
The launcher rechecks publication before revealing Business tools.
Native wallet connection
The third OS guide step calls existing wallet.ecash-lnaddress; no LND/Bitcoin
dependency, additional wallet app or custom mint is installed. The native
@minibits.cash address is displayed automatically, not typed by the merchant.
Owner login/key authorization is entered for Connect wallet & publish. Core
verifies it; the adapter changes only the payment field using existing APIs.
POST /api/payment-connect requires exact same-origin JSON and a bounded body;
fixed-origin requests refuse redirects. No credentials are logged or stored.
Success requires Core's verified artifact, published status and a fresh public
record whose lud16 exactly matches the native address. Partial save/publish
failures remain incomplete and offer retry. No payment is sent by setup.
Hosted website tip flow reads this public record. Legacy Business order flows
with hardcoded destinations are not changed or certified by this integration.
Wallet Receive > Ecash uses the existing claim/retry flow. Node ecash still
depends on its existing hosted address/mint service; this is not self-hosting
the mint or receiving into LND. Existing Business merchant discovery applies.
Runtime
Python adapter and isolated Business service, non-root, read-only root, no
capabilities, 256 MiB limit.
Port 8340 is loopback-bound behind Archipelago's app gate. The manifest carries
the icon, UI classification, build context and host-frame integration.
Run locally: JW_HOST=127.0.0.1 JW_PORT=18340 python3 server.py
Build: docker build -t localhost/archipelago-justworks:0.1.0 docker/justworks
Design-system provenance
Vendored from bencoin21/justworks-business at
0aec5c44fb90f8642afab8f2e9d4c13364af1ae0 (2026-09-20), MIT.
The original tokens, design-system.css imports, logo renderer, base/components,
layout presets and mobile chrome are retained. The only vendor adaptation is
ds/frame.js configureNavigation(): local launcher destinations replace
the upstream customer menu. launcher.css owns the new page composition.
The dashboard SVG uses the official portal paths in white,
normalized with scripts/normalize-app-icon.py (12% margin). Archipelago adds
its tile plate. No replacement brand glyph or external artwork is used.
Just Works MIT notice: UPSTREAM-LICENSE and public/licenses/JustWorks-LICENSE.
Syne and Geist font licenses: public/fonts/*-LICENSE.
Verification
python3 -m unittest discover -s docker/justworks -p 'test_*.py'
From neode-ui: npm test -- src/views/goals/__tests__/justworksCompletion.test.ts
src/views/goals/__tests__/goalStepActions.test.ts src/stores/__tests__/goals.test.ts
From neode-ui: npm run build
bash scripts/validate-app-manifest.sh apps/justworks/manifest.yml
python3 scripts/check-app-catalog-drift.py --release --strict
Local shared browser scenarios/reports live outside the repository, under
~/.local/share/browser-check/projects/archy-justworks/ and /tmp/justworks-*.
Browser fixture tests perform no production writes. A container read-only check
used the existing public barreirinha-bar-cafe-3 site; no credentials were used.
Deployment boundary
Deployed and verified as an Archipelago demo on yaya. Not an OTA, catalog
publication or change to the hosted Just Works deployment. Shared-platform release/integration remains with the
existing platform owner; preserve their concurrent work when integrating.
Native key rollout
Backend additions: identity.capabilities and password-protected identity.import-nostr.
Import verifies the expected npub, creates a separate business identity, reuses
an existing matching business identity, and never replaces another identity.
The native guide checks capabilities before collecting an import key. Nodes
without the new RPC retain direct owner-key/login access and a clear update note.
Export uses existing identity.export-keys after node-password verification;
other exported secret fields are never forwarded to the app.
The original hosted CMS has no session injection mechanism. Its own sign-in
remains separate. Saved credentials support payment setup and local Business.
Source: isolated justworks-business-archipelago worktree, upstream 0aec5c4.
Business LICENSE and bundled upstream assets retained in business/.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Napplet contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17
View File
@@ -0,0 +1,17 @@
"""Run the app adapter and isolated Business service with shared lifecycle."""
import os
import subprocess
import time
children = []
try:
children.append(subprocess.Popen(['python3', 'business/server.py'], env={**os.environ,
'JWB_HOST':'127.0.0.1', 'JWB_PORT':'4310', 'JWB_BASE_PATH':'/business-demo',
'JWB_DATA_DIR':'/data/business', 'JWB_AUTH_MODE':'enforce',
'JWB_COOKIE_SECURE':'0', 'JWB_ARCHIPELAGO':'1'}))
children.append(subprocess.Popen(['python3', 'server.py']))
while all(child.poll() is None for child in children): time.sleep(0.5)
finally:
for child in children:
if child.poll() is None: child.terminate()
for child in children: child.wait()
raise SystemExit(1)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Napplet contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,275 @@
"""Operational rollout and browser-bound Business SSO, not a new identity provider."""
import hashlib
import json
import os
import re
import secrets
import time
from http.cookies import SimpleCookie
from urllib.parse import urlsplit, urlencode
import business_security as security
GROUPS = ("merchant_config", "staff_reads", "staff_mutations")
DEFAULT_ORIGINS = ("https://business.justworks.cash", "https://pos.justworks.cash")
SSO_COOKIE = "__Host-jwb_exchange"
TABLES = ("products", "tables", "orders", "payments", "quick_payments", "bookings", "merchant_modules")
def migrate(conn):
conn.executescript("""
CREATE TABLE IF NOT EXISTS business_session_audiences (
token_hash TEXT PRIMARY KEY, audience TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS business_identity_verifications (
npub TEXT PRIMARY KEY, slug TEXT NOT NULL, verified_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS business_merchant_aliases (
alias TEXT PRIMARY KEY, npub TEXT NOT NULL UNIQUE, verified_slug TEXT NOT NULL,
migrated_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS business_rollout_approvals (
route_group TEXT NOT NULL, npub TEXT NOT NULL, approved_at INTEGER NOT NULL,
PRIMARY KEY(route_group,npub));
CREATE TABLE IF NOT EXISTS business_exchanges (
request_hash TEXT PRIMARY KEY, binder_hash TEXT NOT NULL, npub TEXT NOT NULL,
audience TEXT NOT NULL, return_path TEXT NOT NULL, expires_at INTEGER NOT NULL,
code_hash TEXT UNIQUE, source_session TEXT, consumed INTEGER NOT NULL DEFAULT 0);
CREATE INDEX IF NOT EXISTS business_exchanges_expiry ON business_exchanges(expires_at);
""")
def legacy_merchant(conn):
row = conn.execute("SELECT npub FROM business_merchant_aliases WHERE alias='localpub-demo'").fetchone()
return row[0] if row else "localpub-demo"
def group_for(method, path):
if method == "GET" and path in security.PRIVILEGED_GET | {"/api/business"}:
return "staff_reads"
if method == "POST" and path in security.PRIVILEGED_POST:
return "merchant_config"
if method == "POST" and re.fullmatch(r"/api/(orders|payments|bookings)/[^/]+/(status|acknowledge)", path):
return "staff_mutations"
return None
def policy():
groups = json.loads(os.environ.get("JWB_AUTH_GROUPS", "{}"))
if not isinstance(groups, dict) or any(k not in GROUPS or v not in ("shadow", "enforce") for k, v in groups.items()):
raise ValueError("invalid JWB_AUTH_GROUPS")
canaries = set(filter(None, (x.strip() for x in os.environ.get("JWB_AUTH_CANARIES", "").split(","))))
for merchant in canaries:
security.canonical_npub(merchant)
return groups, canaries
def effective_mode(conn, method, path, merchant):
global_mode = security.mode()
# Existing global enforce is retained for regression/local tests only.
# Operators use shadow + groups; force-shadow overrides all group settings.
if global_mode == "off" or os.environ.get("JWB_AUTH_FORCE_SHADOW", "0") == "1":
return "off" if global_mode == "off" else "shadow"
if global_mode == "enforce":
return "enforce"
groups, canaries = policy()
group = group_for(method, path)
if groups.get(group) != "enforce" or merchant not in canaries:
return "shadow"
approved = conn.execute("SELECT 1 FROM business_rollout_approvals WHERE route_group=? AND npub=?", (group, merchant)).fetchone()
return "enforce" if approved else "shadow"
def verification(conn, npub, slug):
conn.execute("INSERT OR REPLACE INTO business_identity_verifications VALUES (?,?,?)", (security.canonical_npub(npub), slug, int(time.time())))
conn.commit()
def migration_plan(conn, npub, slug):
npub = security.canonical_npub(npub)
existing = conn.execute("SELECT * FROM business_merchant_aliases WHERE alias='localpub-demo'").fetchone()
if existing:
if existing["npub"] != npub or existing["verified_slug"] != slug:
raise ValueError("legacy merchant already mapped to a different identity")
return {"already_applied": True, "source": "localpub-demo", "target": npub, "counts": {}}
verified = conn.execute("SELECT slug,verified_at FROM business_identity_verifications WHERE npub=?", (npub,)).fetchone()
if not verified or verified[0] != slug or verified[1] < int(time.time()) - 86400:
raise ValueError("recent Core-verified owner login and matching slug required")
if not conn.execute("SELECT 1 FROM merchant WHERE id='localpub-demo'").fetchone():
raise ValueError("legacy merchant missing")
counts = {table: conn.execute(f"SELECT COUNT(*) FROM {table} WHERE merchant_id='localpub-demo'").fetchone()[0] for table in TABLES}
# Never merge unrelated tenant records or choose which configuration wins.
if conn.execute("SELECT 1 FROM merchant WHERE id=?", (npub,)).fetchone() or any(
conn.execute(f"SELECT 1 FROM {table} WHERE merchant_id=? LIMIT 1", (npub,)).fetchone() for table in TABLES
):
raise ValueError("target has existing data; explicit reconciliation required before migration")
return {"already_applied": False, "source": "localpub-demo", "target": npub, "counts": counts}
def migrate_legacy(conn, npub, slug, apply=False):
conn.execute("BEGIN IMMEDIATE")
try:
report = migration_plan(conn, npub, slug)
if apply and not report["already_applied"]:
conn.execute("UPDATE merchant SET id=? WHERE id='localpub-demo'", (npub,))
for table in TABLES:
conn.execute(f"UPDATE {table} SET merchant_id=? WHERE merchant_id='localpub-demo'", (npub,))
conn.execute("INSERT INTO business_merchant_aliases VALUES ('localpub-demo',?,?,?)", (npub, slug, int(time.time())))
conn.execute("INSERT INTO business_audit(occurred_at,event,route,merchant,actor,reason) VALUES (?,'merchant_migrated','operator',?,?,'explicit_verified_mapping')", (int(time.time()), npub, npub))
conn.commit()
else:
conn.rollback()
return {**report, "applied": apply}
except Exception:
conn.rollback()
raise
def origins():
values = tuple(x.strip() for x in os.environ.get("JWB_SSO_ORIGINS", ",".join(DEFAULT_ORIGINS)).split(",") if x.strip())
for value in values:
p = urlsplit(value)
if p.scheme != "https" or not p.hostname or p.username or p.password or p.port or p.path or p.query or p.fragment or value != f"https://{p.hostname}" or not p.hostname.endswith(".justworks.cash"):
raise ValueError("SSO origins must be exact HTTPS JustWorks subdomains")
if DEFAULT_ORIGINS[0] not in values:
raise ValueError("business.justworks.cash must remain the Business session hub")
return values
def request_origin(headers):
host = headers.get("Host", "").lower()
origin = "https://" + host
if origin not in origins():
raise ValueError("SSO host not allowed")
if headers.get("Origin") != origin or headers.get("Sec-Fetch-Site") == "cross-site":
raise ValueError("explicit same-origin request required")
return origin
def enabled():
return os.environ.get("JWB_SSO_ENABLED", "0") == "1"
def safe_return(value):
if not isinstance(value, str) or len(value) > 2000 or not value.startswith("/") or value.startswith("//") or "\\" in value or any(ord(c) < 32 for c in value):
raise ValueError("invalid return path")
p = urlsplit(value)
if p.scheme or p.netloc or p.fragment or "%" in p.path:
raise ValueError("invalid return path")
return value
def opaque(value):
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_-]{43}", value):
raise ValueError("invalid exchange")
return value
def exchange_cookie(token="", ttl=0):
return f"{SSO_COOKIE}={token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age={ttl}"
def start(conn, values, headers):
audience = request_origin(headers)
npub = security.canonical_npub(values.get("npub"))
target = safe_return(values.get("return_path", "/"))
request_id, binder = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
timestamp = int(time.time())
conn.execute("DELETE FROM business_exchanges WHERE expires_at<=? OR consumed=1", (timestamp,))
if conn.execute("SELECT COUNT(*) FROM business_exchanges").fetchone()[0] >= 1000:
raise ValueError("exchange capacity reached")
conn.execute("INSERT INTO business_exchanges(request_hash,binder_hash,npub,audience,return_path,expires_at) VALUES (?,?,?,?,?,?)", (security.token_hash(request_id), security.token_hash(binder), npub, audience, target, timestamp + 180))
conn.commit()
return {"request_id": request_id, "authorize_url": DEFAULT_ORIGINS[0] + "/business-sso?" + urlencode({"npub": npub}) + "#" + request_id, "source_origin": DEFAULT_ORIGINS[0]}, exchange_cookie(binder, 180)
def pending(conn, request_id):
row = conn.execute("SELECT * FROM business_exchanges WHERE request_hash=? AND expires_at>? AND consumed=0", (security.token_hash(opaque(request_id)), int(time.time()))).fetchone()
if not row:
raise ValueError("exchange expired or used")
return row
def source_details(conn, values, headers, approve=False):
if request_origin(headers) != DEFAULT_ORIGINS[0]:
raise ValueError("only Business session hub may approve")
row = pending(conn, values.get("request_id"))
current = security.session(conn, headers)
if not current or current["npub"] != row["npub"]:
raise ValueError("matching verified Business login required")
if row["audience"] not in origins():
raise ValueError("audience no longer allowed")
result = {"audience": row["audience"], "npub": row["npub"]}
if approve:
if row["code_hash"]:
raise ValueError("exchange already approved")
code = secrets.token_urlsafe(32)
cookie = SimpleCookie(); cookie.load(headers.get("Cookie", ""))
source_hash = security.token_hash(cookie[security.COOKIE].value)
conn.execute("UPDATE business_exchanges SET code_hash=?,source_session=?,expires_at=? WHERE request_hash=?", (security.token_hash(code), source_hash, min(row["expires_at"], int(time.time()) + 60), row["request_hash"]))
conn.commit()
result["code"] = code
security.audit(conn, "exchange_approved", "/api/business-sso/approve", row["npub"], current["npub"])
return result
def complete(conn, values, headers):
audience = request_origin(headers)
row = pending(conn, values.get("request_id"))
cookie = SimpleCookie(); cookie.load(headers.get("Cookie", ""))
binder = cookie[SSO_COOKIE].value if SSO_COOKIE in cookie else ""
code_hash = security.token_hash(opaque(values.get("code")))
source = conn.execute("SELECT s.npub FROM business_sessions s JOIN business_session_audiences a ON a.token_hash=s.token_hash WHERE s.token_hash=? AND s.expires_at>? AND a.audience=?", (row["source_session"], int(time.time()), DEFAULT_ORIGINS[0][8:])).fetchone()
if row["audience"] != audience or not secrets.compare_digest(row["binder_hash"], security.token_hash(binder)) or not secrets.compare_digest(row["code_hash"] or "", code_hash) or not source or source[0] != row["npub"]:
raise ValueError("exchange binding mismatch")
# The caller serializes HTTP operations with the application lock. This UPDATE
# also makes consumption conditional for other operator/SQLite clients.
updated = conn.execute("UPDATE business_exchanges SET consumed=1 WHERE request_hash=? AND consumed=0", (row["request_hash"],))
if updated.rowcount != 1:
raise ValueError("exchange already used")
conn.commit()
session_cookie, expires = security.issue(conn, row["npub"], headers)
security.audit(conn, "exchange_completed", "/api/business-sso/complete", row["npub"], row["npub"])
return {"ok": True, "npub": row["npub"], "expires_at": expires, "return_path": row["return_path"]}, session_cookie
def readiness(conn, npub, group):
npub = security.canonical_npub(npub)
if group not in GROUPS:
raise ValueError("unknown route group")
blockers = []
if not conn.execute("SELECT 1 FROM business_identity_verifications WHERE npub=?", (npub,)).fetchone():
blockers.append("no_verified_owner_login")
hosts = {row[0] for row in conn.execute("SELECT DISTINCT a.audience FROM business_sessions s JOIN business_session_audiences a ON a.token_hash=s.token_hash WHERE s.npub=? AND s.expires_at>?", (npub, int(time.time())))}
if not all(urlsplit(origin).hostname in hosts for origin in origins()):
blockers.append("missing_verified_session_on_configured_hosts")
if not enabled() or not conn.execute("SELECT 1 FROM business_audit WHERE event='exchange_completed' AND merchant=?", (npub,)).fetchone():
blockers.append("subdomain_exchange_not_verified")
if legacy_merchant(conn) == "localpub-demo":
blockers.append("legacy_owner_mapping_unresolved")
return blockers
def approve_group(conn, npub, group):
blockers = readiness(conn, npub, group)
if blockers:
raise ValueError("readiness blocked: " + ", ".join(blockers))
conn.execute("INSERT OR REPLACE INTO business_rollout_approvals VALUES (?,?,?)", (group, npub, int(time.time())))
conn.commit()
security.audit(conn, "rollout_approved", "operator", npub, npub, group)
def audit_report(conn, since=0, reveal=False):
def identity(value):
return value if reveal or value == "localpub-demo" else "merchant:" + hashlib.sha256(value.encode()).hexdigest()[:12] if value else "unknown"
rows = conn.execute("SELECT event,route,merchant,reason,COUNT(*) AS events,MIN(occurred_at) AS first_seen,MAX(occurred_at) AS last_seen FROM business_audit WHERE occurred_at>=? AND event IN ('would_deny','denied') GROUP BY event,route,merchant,reason ORDER BY events DESC", (since,))
observations = [{**dict(row), "merchant": identity(row["merchant"]), "group": group_for("GET" if row["route"] in security.PRIVILEGED_GET else "POST", row["route"])} for row in rows]
groups, canaries = policy()
active_sessions = conn.execute("SELECT COUNT(*) FROM business_sessions WHERE expires_at>?", (int(time.time()),)).fetchone()[0]
blockers = []
if not active_sessions: blockers.append("no_active_business_sessions")
if legacy_merchant(conn) == 'localpub-demo': blockers.append("legacy_owner_mapping_unresolved")
if not enabled(): blockers.append("subdomain_exchange_disabled")
if not canaries: blockers.append("no_explicit_canary_merchants")
return {"mode": security.mode(), "force_shadow": os.environ.get("JWB_AUTH_FORCE_SHADOW") == "1", "groups": groups,
"canaries": [identity(x) for x in sorted(canaries)], "legacy_mapping": identity(legacy_merchant(conn)),
"active_sessions": active_sessions, "blockers": blockers,
"observations": observations, "coalesced_events_not_request_counts": True,
"canary_readiness": [{"merchant": identity(x), "group": group, "blockers": readiness(conn, x, group), "approved": bool(conn.execute("SELECT 1 FROM business_rollout_approvals WHERE route_group=? AND npub=?", (group, x)).fetchone())} for x in sorted(canaries) for group in GROUPS]}
@@ -0,0 +1,226 @@
"""Additive Business authorization; never an identity provider or key store."""
import hashlib
import ipaddress
import os
import re
import secrets
import time
from http.cookies import SimpleCookie, CookieError
from collections import OrderedDict
COOKIE = "jwb_session"
PRIVILEGED_GET = {"/api/dashboard", "/api/pos"}
PRIVILEGED_POST = {"/api/config", "/api/business-config", "/api/business-profile"}
RATE_BUCKETS = OrderedDict()
def canonical_npub(value):
"""Validate NIP-19 bech32 checksum and the 32-byte public key length."""
alphabet = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
if not isinstance(value, str) or len(value) != 63 or not value.startswith("npub1"):
raise ValueError("invalid npub")
try:
data = [alphabet.index(char) for char in value[5:]]
except ValueError:
raise ValueError("invalid npub") from None
checksum = 1
expanded = [ord(c) >> 5 for c in "npub"] + [0] + [ord(c) & 31 for c in "npub"]
for item in expanded + data:
high = checksum >> 25
checksum = ((checksum & 0x1ffffff) << 5) ^ item
for bit, generator in enumerate((0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)):
if (high >> bit) & 1: checksum ^= generator
if checksum != 1 or data[-7] & 15:
raise ValueError("invalid npub")
return value
def client_ip(remote, headers):
"""Trust one Nginx-overwritten X-Real-IP only from explicitly enabled loopback."""
def normalize(value):
if not isinstance(value, str) or '%' in value:
raise ValueError("invalid address")
address = ipaddress.ip_address(value.strip())
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
return address.ipv4_mapped
return address
try:
peer = normalize(remote)
except ValueError:
return "unknown"
if os.environ.get("JWB_TRUST_PROXY", "0") != "1" or not peer.is_loopback:
return str(peer)
# HTTPMessage permits duplicate headers; a normal dict is used by unit tests.
values = headers.get_all("X-Real-IP", []) if hasattr(headers, "get_all") else [headers.get("X-Real-IP", "")]
if len(values) == 1:
try:
return str(normalize(values[0]))
except ValueError:
pass
return str(peer)
def rate_scope(path, values):
# Table flows ignore caller-supplied npub; opaque tokens never enter bucket keys.
if path in {"/api/orders", "/api/payments"}:
for field in ("token", "order_id", "payment_id"):
value = values.get(field)
if isinstance(value, str) and value:
return field + ":" + token_hash(value)
return "unscoped"
try:
return "merchant:" + canonical_npub(values.get("npub", ""))
except ValueError:
return "unscoped"
def rate_allowed(remote, path, values=None, headers=None):
# Caller holds the process lock. Keep only a digest of the normalized peer.
timestamp = int(time.time()) // 60
peer = client_ip(remote, headers or {})
key = (token_hash(peer), path, rate_scope(path, values or {}))
prior_window, count = RATE_BUCKETS.pop(key, (timestamp, 0))
count = count + 1 if prior_window == timestamp else 1
RATE_BUCKETS[key] = (timestamp, count)
while len(RATE_BUCKETS) > 2048: RATE_BUCKETS.popitem(last=False)
return count <= (20 if path == "/api/business-owner-login" else 120)
def mode():
value = os.environ.get("JWB_AUTH_MODE", "shadow")
if value not in {"off", "shadow", "enforce"}:
raise RuntimeError("JWB_AUTH_MODE must be off, shadow or enforce")
return value
def migrate(conn):
conn.executescript("""
CREATE TABLE IF NOT EXISTS business_sessions (
token_hash TEXT PRIMARY KEY, npub TEXT NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS business_sessions_expiry ON business_sessions(expires_at);
CREATE TABLE IF NOT EXISTS business_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at INTEGER NOT NULL,
event TEXT NOT NULL, route TEXT NOT NULL, merchant TEXT NOT NULL,
actor TEXT NOT NULL, reason TEXT NOT NULL);
""")
import business_rollout
business_rollout.migrate(conn)
def audit(conn, event, route="", merchant="", actor="", reason=""):
# Only fixed event/reason codes and public identities; never bodies, cookies or exceptions.
timestamp = int(time.time())
route = re.sub(r"/api/(orders|payments|bookings)/[^/]+/", r"/api/\1/:id/", route)
merchant = merchant if re.fullmatch(r"npub1[023456789acdefghjklmnpqrstuvwxyz]{58}|localpub-demo", merchant) else ""
actor = actor if re.fullmatch(r"npub1[023456789acdefghjklmnpqrstuvwxyz]{58}", actor) else ""
if event == "would_deny" and conn.execute(
"SELECT 1 FROM business_audit WHERE event=? AND route=? AND merchant=? AND reason=? AND occurred_at>? LIMIT 1",
(event, route, merchant, reason, timestamp - 60)).fetchone():
return
conn.execute("INSERT INTO business_audit(occurred_at,event,route,merchant,actor,reason) VALUES (?,?,?,?,?,?)",
(timestamp, event, route, merchant, actor, reason))
conn.execute("DELETE FROM business_audit WHERE id NOT IN (SELECT id FROM business_audit ORDER BY id DESC LIMIT 10000)")
conn.execute("DELETE FROM business_sessions WHERE expires_at<=?", (timestamp,))
conn.execute("DELETE FROM business_session_audiences WHERE token_hash NOT IN (SELECT token_hash FROM business_sessions)")
conn.commit()
def token_hash(token):
return hashlib.sha256(token.encode()).hexdigest()
def session(conn, headers):
try:
cookie = SimpleCookie()
cookie.load(headers.get("Cookie", ""))
token = cookie[COOKIE].value
if not re.fullmatch(r"[A-Za-z0-9_-]{43}", token):
return None
row = conn.execute("SELECT s.npub,s.expires_at FROM business_sessions s LEFT JOIN business_session_audiences a ON a.token_hash=s.token_hash WHERE s.token_hash=? AND s.expires_at>? AND (a.audience=? OR a.audience IS NULL)",
(token_hash(token), int(time.time()), headers.get("Host", "").lower())).fetchone()
return dict(row) if row else None
except (KeyError, ValueError, CookieError):
return None
def revoke(conn, headers, all_sessions=False):
current = session(conn, headers)
if all_sessions and current:
conn.execute("DELETE FROM business_sessions WHERE npub=?", (current["npub"],))
else:
cookie = SimpleCookie()
try:
cookie.load(headers.get("Cookie", ""))
conn.execute("DELETE FROM business_sessions WHERE token_hash=?", (token_hash(cookie[COOKIE].value),))
except (KeyError, ValueError, CookieError):
pass
conn.execute("DELETE FROM business_session_audiences WHERE token_hash NOT IN (SELECT token_hash FROM business_sessions)")
conn.commit()
return current
def cookie_header(token="", max_age=0):
# Local HTTP opt-out is operator configuration, never inferred from attacker-controlled Host.
secure = "" if os.environ.get("JWB_COOKIE_SECURE", "1") == "0" else "; Secure"
return f"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}"
def issue(conn, npub, headers):
npub = canonical_npub(npub)
revoke(conn, headers)
token = secrets.token_urlsafe(32)
ttl = max(60, min(86400, int(os.environ.get("JWB_SESSION_TTL", "28800"))))
expires = int(time.time()) + ttl
conn.execute("INSERT INTO business_sessions(token_hash,npub,created_at,expires_at) VALUES (?,?,?,?)", (token_hash(token), npub, int(time.time()), expires))
conn.execute("INSERT INTO business_session_audiences VALUES (?,?)", (token_hash(token), headers.get("Host", "").lower()))
conn.execute("DELETE FROM business_sessions WHERE npub=? AND token_hash NOT IN (SELECT token_hash FROM business_sessions WHERE npub=? ORDER BY created_at DESC,rowid DESC LIMIT 10)", (npub, npub))
conn.execute("DELETE FROM business_session_audiences WHERE token_hash NOT IN (SELECT token_hash FROM business_sessions)")
conn.commit()
return cookie_header(token, ttl), expires
def target(conn, method, path, values):
from business_rollout import legacy_merchant
if method == "GET" and path in PRIVILEGED_GET:
return legacy_merchant(conn) if path == "/api/dashboard" else str(values.get("npub", "")).strip()
if method == "POST" and path in PRIVILEGED_POST:
return legacy_merchant(conn) if path == "/api/config" else str(values.get("npub", "")).strip()
match = re.fullmatch(r"/api/(orders|payments|bookings)/([^/]+)/(status|acknowledge)", path)
if method == "POST" and match:
table, identity, _ = match.groups()
row = conn.execute(f"SELECT merchant_id FROM {table} WHERE id=?", (identity,)).fetchone()
return row[0] if row else ""
return None
def same_origin(headers):
from urllib.parse import urlparse
origin = headers.get("Origin")
if headers.get("Sec-Fetch-Site") == "cross-site":
return False
if not origin:
return True # non-browser clients; browser JSON requests require CORS preflight
parsed = urlparse(origin)
return parsed.scheme in {"http", "https"} and parsed.netloc.lower() == headers.get("Host", "").lower()
def authorize(conn, method, path, values, headers):
merchant = target(conn, method, path, values)
if merchant is None:
return True, None
current = session(conn, headers)
reason = ""
if not current:
reason = "missing_session"
elif current["npub"] != merchant:
reason = "tenant_mismatch"
elif method != "GET" and not same_origin(headers):
reason = "cross_origin"
from business_rollout import effective_mode
selected = effective_mode(conn, method, path, merchant)
if reason and selected != "off":
audit(conn, "denied" if selected == "enforce" else "would_deny", path, merchant,
current["npub"] if current else "", reason)
return not reason or selected != "enforce", (merchant, current["npub"] if current else "")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
.app-mode{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;color:#f7f7f4;font:500 12px/1.4 var(--jw-font-sans,system-ui,sans-serif)}
.app-mode>button,.app-help-close{appearance:none;border:1px solid #ffffff45;border-radius:999px;background:#101613d9;color:#f7f7f4;min-height:40px;padding:8px 14px;font:inherit;cursor:pointer;white-space:nowrap}
.app-mode button:hover{border-color:#ff996f;background:#242c27}.app-mode button:focus-visible{outline:3px solid #ff996f;outline-offset:3px}.app-mode button[hidden]{display:none}.app-mode button:disabled{opacity:.6;cursor:wait}
.app-mode-status:empty{display:none}.app-mode-status{max-width:28em;color:#ffce96;flex-basis:100%;text-align:right}
.app-install-help{position:fixed;inset:0;margin:auto;width:min(440px,calc(100vw - 32px));max-height:calc(100dvh - 32px);overflow:auto;padding:28px;border:1px solid #ffffff40;border-radius:24px;background:#111914;color:#f7f7f4;font:400 16px/1.6 var(--jw-font-sans,system-ui,sans-serif);text-align:left;box-shadow:0 24px 100px #0009}
.app-install-help::backdrop{background:#000b;backdrop-filter:blur(6px)}.app-install-help h2{font-size:25px;line-height:1.2;letter-spacing:-.03em;margin:0 0 20px}.app-install-help p{color:#d5dfd8}.app-help-close{font-size:15px;min-width:100px}
body[data-pwa-app=pos]>.app-mode{display:none}
.pos-app-controls{position:sticky;top:calc(76px + env(safe-area-inset-top));z-index:10;padding:8px max(24px,env(safe-area-inset-right)) 8px max(24px,env(safe-area-inset-left));background:#080909}
body[data-pwa-app=pos] .tabs{top:calc(76px + var(--app-toolbar-height,56px) + env(safe-area-inset-top))}
body[data-pwa-app=pos]{padding-top:calc(76px + env(safe-area-inset-top));padding-bottom:env(safe-area-inset-bottom)}
body[data-pwa-app=pos] .topbar[data-topbar]{top:env(safe-area-inset-top)!important}
body[data-pwa-app=pos] .topbar{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto auto;gap:16px}
body[data-pwa-app=pos] .topbar .jw-login-open{margin:0;justify-self:end}
.screen-utilities{justify-self:end;display:flex;flex-direction:column;align-items:flex-end;gap:8px;min-width:0}.screen-utilities .app-mode{font-size:12px}.screen-utilities .app-mode>button{min-height:36px;padding:6px 11px}
.screen-utilities time{white-space:nowrap;flex-shrink:0}
body[data-pwa-app=screen] .stage{padding-top:max(3vh,env(safe-area-inset-top));padding-right:max(4vw,env(safe-area-inset-right));padding-bottom:max(2vh,env(safe-area-inset-bottom));padding-left:max(4vw,env(safe-area-inset-left))}
@media(max-width:700px){body[data-pwa-app=pos]{padding-top:calc(64px + env(safe-area-inset-top))}.pos-app-controls{top:calc(64px + env(safe-area-inset-top));padding:8px 12px}body[data-pwa-app=pos] .tabs{top:calc(64px + var(--app-toolbar-height,60px) + env(safe-area-inset-top))}.app-mode>button{min-height:44px}.screen-utilities{align-items:flex-start;justify-self:start;grid-column:1/-1;flex-direction:row;justify-content:space-between;width:100%}.screen-utilities .app-mode{justify-content:flex-end}.screen-utilities .app-mode>button{min-height:40px}.app-mode-status{font-size:11px}.app-install-help{padding:24px}}
@media print{.app-mode{display:none!important}}
@media(max-width:700px){body[data-pwa-app=pos] .topbar{grid-template-columns:minmax(0,1fr) auto auto;gap:10px;padding-inline:12px}body[data-pwa-app=pos] .topbar .merchant{display:none}body[data-pwa-app=pos] .brand small{display:none}body[data-pwa-app=pos] .topbar .jw-login-open{min-height:40px}}
@@ -0,0 +1,93 @@
// Shared display controls; credentials, live records and writes never enter storage.
const app = document.body.dataset.pwaApp;
const standalone = matchMedia('(display-mode: standalone)');
const fullscreen = matchMedia('(display-mode: fullscreen)');
let installed = standalone.matches || fullscreen.matches || navigator.standalone === true;
let installPrompt;
const controls = document.createElement('div');
controls.className = 'app-mode';
controls.setAttribute('aria-label', `${app === 'pos' ? 'POS' : 'Screen'} app controls`);
controls.innerHTML = `<button type="button" class="app-install">Install app</button><button type="button" class="app-fullscreen" aria-pressed="false">Fullscreen</button><span class="app-mode-status" role="status" aria-live="polite"></span><dialog class="app-install-help" aria-labelledby="app-install-title"><h2 id="app-install-title">Install Just Works ${app === 'pos' ? 'POS' : 'Screen'}</h2><p class="app-install-instructions"></p><p>Your merchant link stays with this app. A connection is required for live activity.</p><button type="button" class="app-help-close">Got it</button></dialog>`;
const install = controls.querySelector('.app-install');
const full = controls.querySelector('.app-fullscreen');
const status = controls.querySelector('.app-mode-status');
const help = controls.querySelector('dialog');
const manifest = document.querySelector('link[rel="manifest"]');
const npub = new URLSearchParams(location.search).get('npub');
// Manifest endpoint validates checksum. Never carry login tokens or fragments.
if (npub && /^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/.test(npub)) {
manifest.href = `/${app}.webmanifest?npub=${encodeURIComponent(npub)}`;
}
function syncInstall() {
install.hidden = installed;
install.textContent = installPrompt ? 'Install app' : 'Install guide';
}
function syncFullscreen() {
const active = Boolean(document.fullscreenElement || document.webkitFullscreenElement);
full.textContent = active ? 'Exit fullscreen' : 'Fullscreen';
full.setAttribute('aria-pressed', String(active));
full.title = active ? 'Exit fullscreen (Esc)' : 'Enter fullscreen';
}
function syncConnection() {
status.textContent = navigator.onLine ? '' : 'Offline · live updates unavailable';
controls.classList.toggle('is-offline', !navigator.onLine);
}
window.addEventListener('beforeinstallprompt', event => {
event.preventDefault();
installPrompt = event;
syncInstall();
});
window.addEventListener('appinstalled', () => {
installed = true;
installPrompt = undefined;
syncInstall();
status.textContent = 'App installed';
});
standalone.addEventListener('change', () => {installed = standalone.matches || navigator.standalone === true; syncInstall();});
install.addEventListener('click', async () => {
if (installPrompt) {
const prompt = installPrompt;
installPrompt = undefined;
install.disabled = true;
try {
await prompt.prompt();
const choice = await prompt.userChoice;
if (choice.outcome === 'accepted') status.textContent = 'Installation requested';
} catch {status.textContent = 'Use your browser menu to install this app.';}
finally {install.disabled = false; syncInstall();}
return;
}
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
help.querySelector('.app-install-instructions').textContent = ios
? 'In Safari, tap Share → Add to Home Screen, then enable Open as Web App if shown.'
: 'Open your browser menu and choose Install app or Add to Home Screen. If it is not offered, use a browser that supports web app installation.';
help.showModal();
});
help.querySelector('.app-help-close').addEventListener('click', () => help.close());
help.addEventListener('click', event => {if (event.target === help) {const r = help.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) help.close();}});
full.addEventListener('click', async () => {
const active = document.fullscreenElement || document.webkitFullscreenElement;
const action = active ? (document.exitFullscreen || document.webkitExitFullscreen) : (document.documentElement.requestFullscreen || document.documentElement.webkitRequestFullscreen);
if (!action) {status.textContent = 'Fullscreen is unavailable in this browser. Install the app for a standalone view.'; return;}
try {await action.call(active ? document : document.documentElement); syncFullscreen(); status.textContent = '';}
catch {status.textContent = 'Fullscreen could not open. Try again or use your browser fullscreen command.';}
});
document.addEventListener('fullscreenchange', syncFullscreen);
document.addEventListener('webkitfullscreenchange', syncFullscreen);
document.addEventListener('fullscreenerror', () => {status.textContent = 'Fullscreen is unavailable right now.'; syncFullscreen();});
window.addEventListener('online', syncConnection);
window.addEventListener('offline', syncConnection);
syncInstall(); syncFullscreen(); syncConnection();
// Dedicated production origins only: a localhost multi-surface server must not
// install competing root-scope workers. Tests use separate secure origins.
if (location.hostname === `${app}.justworks.cash` && 'serviceWorker' in navigator) {
navigator.serviceWorker.register(`/${app}-sw.js`, {scope: '/', updateViaCache: 'none'})
.catch(() => {install.title = 'Installation may be unavailable; the live website still works.';});
}
export function mountAppControls(host) {
host.append(controls);
if (app === 'pos' && 'ResizeObserver' in window) {
new ResizeObserver(() => document.body.style.setProperty('--app-toolbar-height', `${host.getBoundingClientRect().height}px`)).observe(host);
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
// Keep the unchanged Business UI's API requests in its isolated app namespace.
const businessFetch = window.fetch.bind(window);
window.fetch = (input, init) => {
const url = new URL(input instanceof Request ? input.url : input, location.href);
if (url.origin === location.origin && url.pathname.startsWith('/api/')) {
url.pathname = '/business-demo' + url.pathname;
input = input instanceof Request ? new Request(url, input) : url;
}
return businessFetch(input, init);
};
@@ -0,0 +1,3 @@
:root{--orange:#ff4f1f;--black:#080808;--ink:#0d0d0d;--muted:#747873;--line:#d3d6d2;--soft:#eff1ee;font-family:var(--jw-font-sans,"Geist Variable",Inter,system-ui,sans-serif)}*{box-sizing:border-box}html,body{min-height:100%;margin:0;background:var(--black)}button,input{font:inherit}.visual{position:relative;min-height:160px;padding:18px 20px 24px;display:flex;flex-direction:column;color:#fff;background:#111 var(--hero,linear-gradient(135deg,#252525,#090909)) center/cover}.wash{position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.88),rgba(0,0,0,.2)),linear-gradient(90deg,rgba(0,0,0,.75),transparent)}nav,.identity{position:relative;z-index:1}nav{display:flex;justify-content:space-between;align-items:center}nav a{color:#fff;text-decoration:none;font-size:14px;font-weight:900}nav small{font-size:8px;font-weight:900;letter-spacing:.15em;text-transform:uppercase}.identity{margin-top:auto}.identity p,.eyebrow{margin:0;color:var(--orange);font-size:10px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}.identity h1{margin:15px 0 0;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(34px,11vw,48px);line-height:.85;letter-spacing:-.06em}.identity>span{display:none}.booking{min-height:calc(100dvh - 160px);padding:26px 20px max(24px,env(safe-area-inset-bottom));background:#fff}form{max-width:560px;margin:auto}h2{max-width:500px;margin:9px 0 25px;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(38px,11vw,54px);font-weight:620;line-height:.92;letter-spacing:-.055em}.mode{display:grid;grid-template-columns:1fr 1fr;margin-bottom:18px;padding:4px;border-radius:12px;background:var(--soft)}.mode button,.soon button{min-height:47px;border:0;border-radius:9px;background:transparent;color:var(--muted);font-weight:850}.mode button.active,.soon button.active{background:var(--black);color:#fff}.booking label,#when>label{display:block;margin:17px 0 7px;color:var(--muted);font-size:10px;font-weight:900;letter-spacing:.1em;text-transform:uppercase}.booking label small{font-weight:600}.booking input{width:100%;min-height:51px;padding:14px;border:1px solid var(--line);border-radius:11px;background:var(--soft);color:var(--ink);outline:none}.booking input:focus{border-color:var(--orange);box-shadow:0 0 0 3px rgba(255,79,31,.14)}.soon{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.soon button{border:1px solid var(--line);font-size:13px}.guests{display:grid;grid-template-columns:55px 1fr 55px;align-items:center;min-height:57px;border:1px solid var(--line);border-radius:11px;background:var(--soft)}.guests button{height:100%;border:0;background:transparent;font-size:24px}.guests strong{text-align:center;font-size:22px}#submit{width:100%;min-height:58px;display:flex;justify-content:space-between;align-items:center;margin-top:23px;padding:0 18px;border:0;border-radius:11px;background:var(--orange);color:#fff;font-weight:900}.privacy{text-align:center;color:var(--muted);font-size:9px}.loading,.failure,.confirmed{min-height:100vh;display:grid;place-content:center;text-align:center;padding:28px;background:var(--black);color:#fff}.loading>span,.failure>span,.confirmed>span{font-size:70px}.failure h1,.confirmed h1{max-width:800px;margin:18px auto;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(45px,8vw,90px);line-height:.9}.failure small,.confirmed>small{color:#888}.confirmed>span{width:90px;height:90px;display:grid;place-items:center;margin:0 auto 24px;border-radius:50%;background:#16806a;font-size:44px}.confirmed>p:not(.eyebrow){max-width:620px;color:#bbb;font-size:17px;line-height:1.6}
@media(min-width:760px){body{padding:24px}.visual,.booking{min-height:min(780px,calc(100dvh - 48px))}#app:has(.visual){display:grid;grid-template-columns:minmax(330px,44%) minmax(430px,56%);width:min(1180px,100%);margin:auto}.visual{padding:28px 34px 40px;border-radius:28px 0 0 28px}.identity p{font-size:11px}.identity h1{font-size:clamp(58px,6vw,88px)}.identity>span{display:block;max-width:330px;margin-top:20px;color:#ccc}.booking{display:grid;align-items:center;padding:44px clamp(40px,5vw,72px);border-radius:0 28px 28px 0}h2{font-size:clamp(48px,4.6vw,66px)}}
@media(max-width:410px){.visual{min-height:145px}.booking{min-height:calc(100dvh - 145px);padding-top:22px}h2{font-size:37px}.soon button{font-size:12px}}
@@ -0,0 +1,13 @@
import {frame} from './ds/frame.js';
import {npubFrom} from './ds/env.js';
const app=document.querySelector('#app'),npub=npubFrom();let merchant,mode='now',delay=0,guests=2,busy=false;
const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
function timeValue(offsetMinutes){const date=new Date(Date.now()+offsetMinutes*60000);date.setMinutes(Math.ceil(date.getMinutes()/30)*30,0,0);return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}T${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`}
function error(message){app.innerHTML=`<section class="center"><span class="orbit">⌁</span><p class="eyebrow">Table booking</p><h1>${esc(message)}</h1><small>Use book.justworks.cash/?npub=npub1…</small></section>`}
function render(data){merchant=data.merchant;document.title=`Book ${merchant.name} · Just Works`;frame(app,{label:'Bookings',surface:'book',html:`<section class="pay-shell"><div class="pay-copy"><p class="eyebrow">Reserve a table</p><h1>When should we expect you?</h1><p>Your table at ${esc(merchant.name)}, without the phone call.</p></div><form id="form" class="pay-card form-card"><div class="merchant"><span>⌁</span><div><strong>${esc(merchant.name)}</strong><small>Taking bookings</small></div></div><div class="chips booking-mode">${[['now','Soon'],['later','Later']].map(([value,label])=>`<button type="button" class="${mode===value?'active':''}" data-mode="${value}">${label}</button>`).join('')}</div><div id="when"></div><label class="field"><span>Guests</span><div class="stepper"><button type="button" id="less" aria-label="Fewer guests">−</button><strong id="guestCount">2</strong><button type="button" id="more" aria-label="More guests">+</button></div></label><label class="field"><span>Your name</span><input id="name" autocomplete="name" maxlength="80" placeholder="Who should we expect?" required></label><label class="field"><span>Note <small>(optional)</small></span><input id="notes" maxlength="240" placeholder="High chair, outside table…"></label><button id="submit" type="submit" class="primary">Request table <span>→</span></button><p class="privacy">No account · no app · sent directly to ${esc(merchant.name)}</p></form></section>`});document.querySelectorAll('[data-mode]').forEach(button=>button.onclick=()=>setMode(button.dataset.mode));document.querySelector('#less').onclick=()=>setGuests(guests-1);document.querySelector('#more').onclick=()=>setGuests(guests+1);document.querySelector('#form').onsubmit=submit;renderWhen()}
function setMode(next){mode=next;document.querySelectorAll('[data-mode]').forEach(button=>button.classList.toggle('active',button.dataset.mode===mode));renderWhen()}
function setGuests(next){guests=Math.max(1,Math.min(20,next));document.querySelector('#guestCount').textContent=guests}
function renderWhen(){const host=document.querySelector('#when');if(mode==='now'){host.innerHTML=`<label class="field"><span>Arriving in</span><div class="chips">${[[0,'Now'],[5,'+5 min'],[10,'+10 min'],[15,'+15 min']].map(([value,label])=>`<button type="button" data-delay="${value}" class="${delay===value?'active':''}">${label}</button>`).join('')}</div></label>`;host.querySelectorAll('[data-delay]').forEach(button=>button.onclick=()=>{delay=Number(button.dataset.delay);renderWhen()})}else host.innerHTML=`<label class="field"><span>Date and time</span><input id="date" type="datetime-local" min="${timeValue(30)}" value="${timeValue(24*60)}" required></label>`}
async function submit(event){event.preventDefault();if(busy)return;busy=true;const button=document.querySelector('#submit');button.disabled=true;button.firstChild.textContent='Sending… ';try{const scheduled=mode==='now'?new Date(Date.now()+delay*60000):new Date(document.querySelector('#date').value);const response=await fetch('/api/bookings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({npub,name:document.querySelector('#name').value,notes:document.querySelector('#notes').value,guests,mode,scheduled_at:scheduled.toISOString()})});const result=await response.json();if(!response.ok)throw new Error(result.error||'Could not book');confirm(result)}catch(error){button.disabled=false;button.firstChild.textContent='Try again ';alert(error.message)}finally{busy=false}}
function confirm(result){const date=new Date(result.scheduled_at);frame(app,{label:'Bookings',surface:'book',html:`<section class="center"><span class="orbit">✓</span><p class="eyebrow">Request sent</p><h1>Table for ${result.guests} requested.</h1><p>${esc(merchant.name)} received your booking for <strong>${date.toLocaleDateString([],{weekday:'long',day:'numeric',month:'long'})} at ${date.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</strong>.</p><small>The merchant still needs to confirm it. Keep this screen open for your reference.</small></section>`})}
if(!npub)error('Add the merchant npub to book.');else fetch(`/api/order-merchant?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async response=>{const data=await response.json();if(!response.ok)throw new Error(data.error);render(data)}).catch(reason=>error(reason.message||'Merchant unavailable'));
@@ -0,0 +1,4 @@
.module.needs-setup{border-color:#d4d4cf;background:#e8e8e4;color:#696965}
.module.needs-setup .module-icon{background:#aaa}
.module.needs-setup .module-actions>a{max-width:76%;color:#111;pointer-events:auto}
.setup-lock{color:#999;font-size:9px;font-weight:850;letter-spacing:.1em;text-transform:uppercase}
@@ -0,0 +1,72 @@
import { link, base } from './ds/frame.js';
import { prod } from './ds/env.js';
import { moduleQrUrl } from './module-qr.js?v=2';
// The dashboard and the printable master use one destination catalog.
export const freeDefinitions = [
['justworks', 'Just Works page', 'Your public merchant page', 'JW'],
['tip', 'Tip', 'Bitcoin tips and community wall', '⚡'],
['nsite', 'nsite', 'Your site on Nostr', 'NS'],
['nostr', 'Nostr profile', 'Your signed identity', 'NP'],
['maps', 'Maps', 'Find your place on the map', '⌖'],
['gallery', 'Gallery', 'Photos and discovery', '□'],
];
export const businessDefinitions = [
['pos', 'POS', 'Run orders and payments', 'Counter view', '↗', false],
['order', 'Order', 'Order to table', 'Customer', '+', true],
['pay', 'Pay Bill', 'Settle an open order', 'Customer', '€', true],
['payandgo', 'Just Pay', 'Pay a paper receipt', 'Customer', '₿', true],
['booking', 'Bookings', 'Tables now or later', 'Customer', '⌁', true, 'book'],
['screen', 'Screen', 'Merchant media display', '4K display', '▣', true],
['stats', 'Stats', 'Audience, actions and revenue', 'Merchant view', '↗', true, 'business'],
['events', 'Events', 'Merchant Nostr events', 'Coming soon', '◌', true],
];
export function businessUrl(npub, sheet = false) {
const path = sheet ? '/qr-sheet' : prod ? '/' : '/dashboard';
return `${prod ? 'https://business.justworks.cash' : location.origin + base}${path}?npub=${encodeURIComponent(npub)}`;
}
export function moduleDestination(definition, data, npub) {
const [id, , , , , configurable, target = id] = definition;
const state = data.modules?.[id] || {};
const paymentSetup = ['pay', 'payandgo'].includes(id) && !data.payment_ready;
const disabled = id === 'events' || state.status === 'coming-soon' || paymentSetup;
const url = paymentSetup
? `${base}/profile?npub=${encodeURIComponent(npub)}#lightning`
: id === 'stats'
? `${prod ? 'https://business.justworks.cash' : base}/stats?npub=${encodeURIComponent(npub)}`
: prod ? `https://${target}.justworks.cash/?npub=${encodeURIComponent(npub)}` : link(target);
return {state, paymentSetup, disabled, url,
qrUrl: disabled ? '' : moduleQrUrl(url, npub),
available: !disabled && (!configurable || !!state.enabled)};
}
export function sheetDestinations(data, npub) {
return [
{id: 'business', title: 'Business dashboard', url: businessUrl(npub)},
...freeDefinitions.filter(([id]) => data.links?.[id] && data.links[id] !== '#')
.map(([id, title]) => ({id, title, url: data.links[id]})),
...businessDefinitions.flatMap(definition => {
const destination = moduleDestination(definition, data, npub);
return destination.available ? [{id: definition[0], title: definition[1], url: destination.qrUrl}] : [];
}),
];
}
// Verify the Bech32 checksum and the 32-byte public-key payload, without a CDN.
export function validNpub(value) {
if (!/^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/.test(value || '')) return false;
const alphabet = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const words = [...value.slice(5)].map(char => alphabet.indexOf(char));
let checksum = 1;
const expanded = [...'npub'].map(char => char.charCodeAt(0) >> 5)
.concat(0, [...'npub'].map(char => char.charCodeAt(0) & 31), words);
const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
for (const word of expanded) {
const top = checksum >>> 25;
checksum = ((checksum & 0x1ffffff) << 5) ^ word;
for (let bit = 0; bit < 5; bit++) if ((top >>> bit) & 1) checksum ^= generators[bit];
}
return checksum === 1 && (words[51] & 15) === 0;
}
@@ -0,0 +1,2 @@
.exchange {max-width:36rem; margin:2rem auto; padding:1.5rem; font:1rem/1.5 system-ui,sans-serif; overflow-wrap:anywhere}
.exchange h1 {font-size:1.6rem}.exchange button {padding:.8rem 1rem; margin:.3rem; min-height:44px; cursor:pointer}.exchange button:disabled {cursor:default; opacity:.6}.exchange :focus-visible {outline:3px solid #e99a30; outline-offset:3px}.jw-session-transfer {padding:.75rem; margin-bottom:1rem; width:100%; cursor:pointer}
@@ -0,0 +1,64 @@
// Business-only session transfer. Identity verification remains with Core/Keys/Login.
export async function post(path, body) {
const response = await fetch(path, {method:'POST', credentials:'same-origin', cache:'no-store', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Please log in and try again.');
return result;
}
export async function transferSession(npub, status) {
const popup = window.open('about:blank', 'justworks-session', 'popup,width=560,height=680');
if (!popup) throw new Error('Allow the login window, or use the login form below.');
let remove = () => {};
try {
status.textContent = 'Confirm access in the Business login window…';
const pending = await post('/api/business-sso/start', {npub, return_path:location.pathname + location.search});
await new Promise((resolve, reject) => {
let busy = false;
const timeout = setTimeout(() => { remove(); reject(new Error('Login window expired. Please try again.')); }, 180000);
const onMessage = async event => {
if (busy || event.origin !== pending.source_origin || event.source !== popup || event.data?.type !== 'justworks-session' || event.data.request_id !== pending.request_id) return;
busy = true;
try {
await post('/api/business-sso/complete', {request_id:pending.request_id, code:event.data.code});
remove(); resolve();
} catch (error) { remove(); reject(error); }
};
remove = () => { clearTimeout(timeout); window.removeEventListener('message', onMessage); };
window.addEventListener('message', onMessage);
popup.location = pending.authorize_url;
});
status.textContent = 'Business session connected.';
// Keep the existing URL and fragment; no server-provided redirect is followed.
location.reload();
} finally { remove(); if (!popup.closed) popup.close(); }
}
if (location.pathname.endsWith('/business-sso')) {
const requestId = location.hash.slice(1);
history.replaceState(null, '', location.pathname + location.search);
const status = document.querySelector('#exchange-status');
const destination = document.querySelector('#exchange-destination');
const approve = document.querySelector('#exchange-approve');
async function details() {
approve.disabled = true;
try {
const result = await post('/api/business-sso/details', {request_id:requestId});
destination.textContent = new URL(result.audience).hostname;
status.textContent = 'Your verified Business login will be used for this site only.';
approve.disabled = !window.opener;
if (!window.opener) status.textContent = 'Start session transfer from the site you want to open.';
} catch (error) { status.textContent = 'Use Login above, then confirm access. ' + error.message; }
}
approve.addEventListener('click', async () => {
approve.disabled = true;
try {
const result = await post('/api/business-sso/approve', {request_id:requestId});
window.opener.postMessage({type:'justworks-session', request_id:requestId, code:result.code}, result.audience);
status.textContent = 'Access confirmed. Return to your original window.';
} catch (error) { status.textContent = error.message; }
});
document.querySelector('#exchange-cancel').onclick = () => window.close();
window.addEventListener('jw-session-changed', details);
details();
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,89 @@
import { mountLogo } from "./ds/logo.js";
import { startNavScroll } from "./ds/header.js";
import { mountMobileChrome, base } from "./ds/frame.js";
import { npubFrom, prod } from "./ds/env.js";
import { qrButton, mountModuleQrs } from "./module-qr.js?v=3";
import { freeDefinitions, businessDefinitions, moduleDestination, businessUrl } from './business-destinations.js?v=1';
const app = document.querySelector("#app"),
toast = document.querySelector("#toast");
const npub = npubFrom();
const esc = (value) =>
String(value ?? "").replace(
/[&<>"']/g,
(char) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
char
],
);
function freeCard([id, name, copy, icon], data) {
const url = data.links?.[id] || "#";
const qr = url === '#' ? '' : qrButton(id, name, url, {free: true});
return `<article class="free-link"><div class="free-link-top"><span class="free-icon">${icon}</span>${qr}</div><div><h2>${esc(name)}</h2><p>${esc(copy)}</p></div><a class="free-open" href="${esc(url)}" target="_blank" rel="noopener">Open <b>↗</b></a></article>`;
}
function moduleCard(
[id, name, copy, audience, icon, configurable, target = id],
data,
) {
const {state, paymentSetup, disabled, url, qrUrl} = moduleDestination(
[id, name, copy, audience, icon, configurable, target], data, npub);
const qr = disabled ? '' : qrButton(id, name, qrUrl, {hidden: configurable && !state.enabled});
return `<article class="module ${disabled ? "soon" : ""} ${paymentSetup ? "needs-setup" : ""}"><div class="module-top"><span class="module-icon">${icon}</span><span class="module-audience">${paymentSetup ? "Setup required" : esc(audience)}</span></div><div class="module-summary"><div><h2>${esc(name)}</h2><p>${paymentSetup ? "Add the merchant’s Lightning address before accepting payments." : esc(copy)}</p></div>${qr}</div><div class="module-actions"><a href="${esc(url)}" ${paymentSetup ? "" : 'target="_blank" rel="noopener"'}>${paymentSetup ? "Change Lightning address" : "Open"} <span>↗</span></a>${configurable && !paymentSetup ? `<label class="switch" aria-label="Activate ${esc(name)}"><input type="checkbox" data-module="${id}" ${state.enabled ? "checked" : ""} ${disabled ? "disabled" : ""}><span></span></label>` : paymentSetup ? '<span class="setup-lock">Locked</span>' : '<span class="always">Operations</span>'}</div></article>`;
}
function render(data) {
const merchant = data.merchant,
image = data.images[0] || "";
document.title = `${merchant.name} · Just Works Business`;
app.innerHTML = `<header class="top" data-topbar><a class="brand" href="${esc(data.links.justworks)}"><span data-logo="icon"></span><b>Just Works<small>Business</small></b></a><a class="stats-shortcut" href="${prod ? `https://business.justworks.cash/stats?npub=${encodeURIComponent(npub)}` : `${base}/stats?npub=${encodeURIComponent(npub)}`}" aria-label="Open Stats" title="Open Stats"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 16.5h14M5 14V9m5 5V4m5 10V7"/></svg></a><a class="profile" href="${esc(data.links.nsite)}" target="_blank" rel="noopener">View nsite ↗</a></header><section class="merchant" ${image ? `style="--hero:url('${esc(image)}')"` : ""}><div class="merchant-wash"></div><div class="merchant-copy"><p>${esc([merchant.category, merchant.area].filter(Boolean).join(" · "))}</p><h1>${esc(merchant.name)}</h1><span>Free public presence. Business tools when you need them.</span></div><div class="merchant-stats"><div><strong>${data.counts.orders}</strong><span>Open orders</span></div><div><strong>${data.counts.payments}</strong><span>Payments</span></div><a data-qr-sheet href="${esc(businessUrl(npub, true))}">QR in DIN A4 <b>↗</b></a></div></section><section class="modules-head free-head"><div><p class="eyebrow">Free links</p><h2>Included with Just Works.</h2></div><p>Your merchant identity, website and discovery pages stay free.</p></section><section class="free-links">${freeDefinitions.map((item) => freeCard(item, data)).join("")}</section><section class="modules-head business-head"><div><p class="eyebrow">Business modules</p><h2>Tools that run the place.</h2></div><p>Every module carries this merchant’s npub. Switch it on, share its link, or put it behind a QR code.</p></section><section class="modules">${businessDefinitions.map((item) => moduleCard(item, data)).join("")}</section><footer><span>npub</span><code>${esc(npub)}</code></footer>`;
document
.querySelectorAll("[data-module]")
.forEach((input) => input.addEventListener("change", save));
document
.querySelectorAll("[data-logo]")
.forEach((host) => mountLogo(host, { icon: true }));
startNavScroll(document.querySelector("[data-topbar]"));
mountModuleQrs(app);
}
async function save() {
const input = this;
input.disabled = true;
const modules = Object.fromEntries(
[...document.querySelectorAll("[data-module]")].map((item) => [
item.dataset.module,
item.checked,
]),
);
try {
const response = await fetch("/api/business-config", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ npub, modules }),
});
if (!response.ok)
throw new Error((await response.json()).error || "Could not save");
const qr = input.closest('.module').querySelector('[data-module-qr]');
if (qr) qr.hidden = !input.checked;
toast.textContent = `${input.checked ? "Activated" : "Disabled"} ${input.closest(".module").querySelector("h2").textContent}`;
toast.classList.add("show");
setTimeout(() => toast.classList.remove("show"), 1500);
} catch (error) {
input.checked = !input.checked;
toast.textContent = error.message;
toast.classList.add("show", "error");
setTimeout(() => toast.classList.remove("show", "error"), 2200);
} finally {
input.disabled = false;
}
}
function fail(message) {
app.innerHTML = `<section class="failure"><span>∩</span><p class="eyebrow">Business setup</p><h1>${esc(message)}</h1><p>Open this page with the merchant’s npub.</p><form><input name="npub" placeholder="npub1…" required><button>Open business <span>→</span></button></form></section>`;
}
if (!npub) fail("Which merchant is this?");
else
fetch(`/api/business?npub=${encodeURIComponent(npub)}`, { cache: "no-store" })
.then(async (response) => {
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Merchant unavailable");
render(data);
mountMobileChrome(app);
})
.catch((error) => fail(error.message));
@@ -0,0 +1 @@
.module-console{margin-bottom:16px;padding:clamp(22px,3vw,32px);border:1px solid var(--line);border-radius:24px;background:var(--panel)}.module-console>header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start}.module-console h2{font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(30px,4vw,48px);letter-spacing:-.05em;margin:8px 0}.module-console header p:last-child{color:var(--body);margin:0}.module-config-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin-top:24px}.module-config{display:grid;grid-template-columns:42px 1fr auto;gap:10px;padding:14px;border:1px solid var(--line);border-radius:16px;background:var(--soft)}.module-config-icon{width:42px;height:42px;display:grid;place-items:center;border-radius:12px;background:var(--panel);font-size:18px;font-weight:900}.module-config h3{margin:2px 0;font-size:14px}.module-config p{margin:0;color:var(--muted);font-size:10px}.module-config a{display:block;margin-top:8px;color:var(--text);font-size:10px;font-weight:800;text-decoration:none}.module-config .switch{width:42px;height:24px;margin:0}.module-config .switch span:after{width:16px;height:16px}.module-config .switch input:checked+span:after{transform:translateX(18px)}.module-config>small{grid-column:1/-1;color:var(--muted);font-size:9px;font-weight:850;text-transform:uppercase;letter-spacing:.08em}@media(max-width:1050px){.module-config-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:800px){.module-console>header{flex-direction:column}.module-config-grid{grid-template-columns:1fr}}
@@ -0,0 +1,23 @@
import { mountThemeToggle } from './ds/theme.js';
import { mountLogo } from './ds/logo.js';
import { link } from './ds/frame.js';
import { prod } from './ds/env.js';
mountThemeToggle(document.querySelector('#themeToggle'));
for (const host of document.querySelectorAll('[data-logo]')) mountLogo(host, { icon: host.dataset.logo === 'icon' });
const base = location.pathname.split('/').slice(0,-1).join('/') || '';
const api = (path) => `${base}/api${path}`;
const els={enabled:document.querySelector('#enabled'),moduleState:document.querySelector('#moduleState'),moduleGrid:document.querySelector('#moduleGrid'),tableCount:document.querySelector('#tableCount'),products:document.querySelector('#products'),payments:document.querySelector('#payments'),orders:document.querySelector('#orders'),save:document.querySelector('#save'),toast:document.querySelector('#toast'),tableUrl:document.querySelector('#tableUrl'),pulse:document.querySelector('#pulse')};
const moduleMeta={order:['Order to Table','Customer ordering','↗'],pay:['Pay Bill','Settle an open order','€'],payandgo:['Just Pay','Pay a paper receipt','€'],booking:['Bookings','Now or later','⌁'],screen:['4K Screen','Pictures and activity','▣'],gallery:['Gallery','Merchant discovery','□'],events:['Events','Nostr events','◌']};
function moduleRows(modules){els.moduleGrid.innerHTML=Object.entries(moduleMeta).map(([id,[name,copy,icon]])=>{const item=modules[id]||{};const url=prod?(id==='booking'?'https://pos.justworks.cash/':`https://${id}.justworks.cash/`):link(id);return `<article class="module-config"><span class="module-config-icon">${icon}</span><div><h3>${name}</h3><p>${copy}</p><a href="${url}" target="_blank">${url.replace('https://','')} ↗</a></div><label class="switch"><input data-module="${id}" type="checkbox" ${item.enabled?'checked':''} ${item.status==='coming-soon'?'disabled':''}><span></span></label><small>${item.status==='coming-soon'?'Coming soon':item.status==='live'?'Live service':'Interactive preview'}</small></article>`}).join('');document.querySelectorAll('[data-module]').forEach(input=>input.onchange=()=>{if(input.dataset.module==='order')els.enabled.checked=input.checked;save(true)})}
let state=null,lastOrder='',configVersion=0,toggleSaving=false;
function money(c){return `€${(c/100).toFixed(2)}`}
function productRows(products){els.products.innerHTML=products.map(p=>`<div class="productedit"><input data-id="${p.id}" data-field="name" value="${p.name.replaceAll('"','&quot;')}"><input data-id="${p.id}" data-field="price" type="number" step="0.01" value="${(p.price_cents/100).toFixed(2)}"></div>`).join('')}
function orderRows(orders){if(!orders.length){els.orders.innerHTML='<div class="empty">Waiting for the first table order…</div>';return}const labels={accepted:'Confirm',delivered:'Delivered',closed:'Close'};els.orders.innerHTML=orders.map(o=>`<article class="order" data-status="${o.status}"><div class="orderhead"><strong>Table ${o.table_number}</strong><span class="badge">${o.status}</span></div><div class="items">${o.items.map(i=>`${i.qty}× ${i.name}`).join(' · ')}</div>${o.note?`<div class="muted">“${o.note}”</div>`:''}<div class="muted" style="margin:8px 0">${new Date(o.created_at).toLocaleTimeString()}</div><div class="actions">${['accepted','delivered','closed'].map(s=>`<button class="button small ${s==='accepted'?'':'secondary'}" data-order="${o.id}" data-status="${s}">${labels[s]}</button>`).join('')}</div></article>`).join('');document.querySelectorAll('[data-order]').forEach(b=>b.onclick=()=>updateOrder(b.dataset.order,b.dataset.status))}
function paymentRows(payments){els.payments.innerHTML=payments.slice(0,3).map(p=>`<div class="payment" data-payment-card="${p.id}"><div><span>Table ${p.table_number} paid</span><strong>€${(p.amount_cents/100).toFixed(2)} · ${p.amount_sats.toLocaleString()} sats</strong></div><button class="payment-ack" data-payment="${p.id}">Got it</button></div>`).join('');document.querySelectorAll('[data-payment]').forEach(b=>b.onclick=()=>acknowledgePayment(b.dataset.payment,b))}
function showModuleState(enabled,saving=false){els.moduleState.textContent=saving?'saving…':enabled?'active':'disabled';els.moduleState.style.color=enabled?'var(--green)':'var(--muted)'}
async function load(initial=false){const requestedVersion=configVersion;const res=await fetch(api('/dashboard'),{cache:'no-store'});const next=await res.json();state=next;if(requestedVersion===configVersion&&!toggleSaving){els.enabled.checked=state.module_enabled;showModuleState(state.module_enabled)}els.tableCount.value=state.table_count;if(initial){productRows(state.products);moduleRows(state.modules)}paymentRows(state.payments||[]);orderRows(state.orders);const table=state.tables.find(t=>t.table_number===21)||state.tables[0];els.tableUrl.textContent=prod?`https://order.justworks.cash/?table=${encodeURIComponent(table.token)}`:`${location.origin}${link('order')}${location.search?'&':'?'}table=${encodeURIComponent(table.token)}`;if(state.orders[0]?.id&&lastOrder&&lastOrder!==state.orders[0].id){els.pulse.textContent='new order';els.pulse.style.color='var(--green)';setTimeout(()=>{els.pulse.textContent='connected';els.pulse.style.color=''},1800)}lastOrder=state.orders[0]?.id||''}
async function save(showToast=true,enabled=els.enabled.checked){const products=state.products.map(p=>{const name=document.querySelector(`[data-id="${p.id}"][data-field="name"]`).value;const price=document.querySelector(`[data-id="${p.id}"][data-field="price"]`).value;return{id:p.id,name,price_cents:Math.round(Number(price)*100)}});const modules=Object.fromEntries([...document.querySelectorAll('[data-module]')].map(input=>[input.dataset.module,{enabled:input.checked,config:state.modules[input.dataset.module]?.config||{}}]));modules.order.enabled=enabled;showModuleState(enabled,true);await fetch(api('/config'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({module_enabled:enabled,table_count:Number(els.tableCount.value),products,modules})});els.enabled.checked=enabled;showModuleState(enabled);if(showToast){els.toast.textContent='Configuration saved';els.toast.classList.add('show');setTimeout(()=>els.toast.classList.remove('show'),1300)}await load(true)}
async function updateOrder(id,status){await fetch(api(`/orders/${id}/status`),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({status})});await load()}
async function acknowledgePayment(id,button){button.disabled=true;button.textContent='Clearing…';const res=await fetch(api(`/payments/${id}/acknowledge`),{method:'POST'});if(!res.ok){button.disabled=false;button.textContent='Try again';return}document.querySelector(`[data-payment-card="${id}"]`)?.classList.add('leaving');setTimeout(()=>load(),260)}
els.save.onclick=()=>save(true);els.enabled.onchange=async()=>{const enabled=els.enabled.checked;configVersion+=1;toggleSaving=true;els.enabled.disabled=true;try{await save(false,enabled);els.toast.textContent=enabled?'Napplet enabled':'Napplet disabled';els.toast.classList.add('show');setTimeout(()=>els.toast.classList.remove('show'),1300)}finally{els.enabled.checked=enabled;showModuleState(enabled);els.enabled.disabled=false;toggleSaving=false}};load(true);setInterval(()=>load().catch(()=>{els.pulse.textContent='reconnecting'}),1200);
@@ -0,0 +1,8 @@
/* Just Works design system: load this single file before page CSS. */
@import url("./tokens.css");
@import url("./ds/base.css");
@import url("./ds.css");
@import url("./ds/components.css");
@import url("./ds/topbar.css");
@import url("./ds/mobile.css");
@import url("./ds/presets.css");
@@ -0,0 +1,141 @@
/*
* Design-system chrome shared with cash-just-works: the three-way theme
* toggle and the brand mark. Ported verbatim from public/css/components.css
* in the cash-just-works repo — colors resolve through tokens.css, so this
* follows the same light/dark rules as the source site.
*/
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
border: 1px solid var(--jw-line);
border-radius: var(--jw-radius-pill);
background: var(--jw-surface-sunken);
}
.theme-toggle button {
width: 32px;
height: 28px;
display: inline-grid;
place-items: center;
border: 0;
border-radius: var(--jw-radius-pill);
background: transparent;
color: var(--jw-text-muted);
cursor: pointer;
transition:
background var(--jw-duration-base) var(--jw-ease-out),
color var(--jw-duration-base) var(--jw-ease-out);
}
.theme-toggle button:hover { color: var(--jw-text-primary); }
.theme-toggle button[aria-pressed="true"] {
background: var(--jw-surface);
color: var(--jw-text-primary);
box-shadow: var(--jw-shadow-xs);
}
.theme-toggle button:focus-visible {
outline: 2px solid var(--jw-field-border-focus);
outline-offset: 1px;
}
.theme-toggle svg {
width: 15px;
height: 15px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Brand mark: one continuous line, path swapped by assets/ds/logo.js. */
.jw-mark {
width: var(--jw-mark-width, 2.1em);
height: auto;
flex: none;
display: block;
overflow: visible;
}
.jw-mark-portal {
width: auto;
height: var(--jw-mark-height, 1.35em);
}
.jw-mark-portal { filter: var(--jw-portal-depth); }
.jw-portal-frame { fill: var(--jw-text-primary); }
.jw-portal-head { stop-color: var(--jw-text-subtle); }
.jw-portal-floor { stop-color: transparent; }
.jw-mark-path {
fill: none;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
.jw-mark-stop-a { stop-color: var(--jw-accent); }
.jw-mark-stop-b { stop-color: var(--jw-highlight); }
.brand .jw-mark { margin-right: -0.15em; }
/* App icon: the mark in a translucent rimmed container. Fixed dark ground in
both themes on purpose (see tokens.css). */
.jw-app-icon {
position: relative;
width: var(--jw-app-icon-size);
height: var(--jw-app-icon-size);
flex: none;
display: grid;
place-items: center;
border-radius: var(--jw-app-icon-radius);
background: var(--jw-app-icon-bg);
-webkit-backdrop-filter: blur(var(--jw-app-icon-blur));
backdrop-filter: blur(var(--jw-app-icon-blur));
}
.jw-app-icon::before {
content: "";
position: absolute;
inset: 0;
padding: var(--jw-app-icon-rim-width);
border-radius: inherit;
background: linear-gradient(
135deg,
var(--jw-app-icon-rim-bright) 0%,
var(--jw-app-icon-rim-fade) 32%,
var(--jw-app-icon-rim-fade) 68%,
var(--jw-app-icon-rim-bright) 100%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.jw-app-icon .jw-mark.jw-mark-portal,
.jw-app-icon .jw-mark.jw-mark-line {
width: auto;
height: calc(var(--jw-app-icon-size) * 0.52);
transform: translateY(calc(var(--jw-app-icon-size) * -0.025));
}
.brand .jw-app-icon { margin-right: var(--jw-space-1); }
.jw-app-icon.lg { --jw-app-icon-size: 76px; --jw-app-icon-rim-width: 1.5px; }
.jw-app-icon.sm { --jw-app-icon-size: 26px; }
.nav-actions { display: flex; align-items: center; gap: 14px; }
@@ -0,0 +1,2 @@
*,*::before,*::after{box-sizing:border-box}html{min-height:100%;background:var(--jw-bg);color-scheme:light}html[data-theme="dark"]{color-scheme:dark}body{min-height:100vh;margin:0;background:var(--jw-bg);color:var(--jw-text-primary);font-family:var(--jw-font-sans);font-size:1rem;line-height:1.5;text-rendering:optimizeLegibility}button,input,textarea,select{font:inherit}input,textarea,select{font-size:1rem}button,a{touch-action:manipulation}img,svg{max-width:100%}a{color:inherit}button{cursor:pointer}:is(a,button,input,textarea,select,[tabindex]):focus-visible{outline:2px solid var(--jw-field-border-focus);outline-offset:3px}body.creating{overflow:hidden}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto!important}*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}}
#themeToggle{display:none!important}
@@ -0,0 +1,14 @@
/* Brand */
.jw-brand,.jw-topbar .brand{display:inline-flex;align-items:center;gap:var(--jw-space-3);color:inherit;text-decoration:none;white-space:nowrap}.jw-brand>[data-logo],.jw-topbar .brand>[data-logo],.brand>[data-logo]{width:var(--jw-app-icon-size);height:var(--jw-app-icon-size);display:block;flex:none}.jw-brand b,.jw-topbar .brand b{font-size:1rem;font-weight:var(--jw-weight-black);line-height:1}.jw-brand small,.jw-topbar .brand small{display:inline!important;margin:0!important;color:inherit!important;font:inherit!important;letter-spacing:0!important;text-transform:none!important}.jw-brand small::before,.jw-topbar .brand small::before{content:"·";margin:0 var(--jw-space-2);color:var(--jw-text-muted)}
/* Buttons */
.jw-button{min-height:var(--jw-button-height);display:inline-flex;align-items:center;justify-content:center;gap:var(--jw-space-2);padding:var(--jw-button-padding);border:1px solid var(--jw-line-strong);border-radius:var(--jw-button-radius);background:var(--jw-surface);color:var(--jw-text-primary);font-weight:var(--jw-button-weight);text-decoration:none;transition:background var(--jw-duration-base) var(--jw-ease-out),color var(--jw-duration-base) var(--jw-ease-out),transform var(--jw-duration-fast) var(--jw-ease-out)}.jw-button:hover{background:var(--jw-surface-sunken)}.jw-button:active{transform:translateY(1px)}.jw-button--primary{border-color:var(--jw-button-primary-fill);background:var(--jw-button-primary-fill);color:var(--jw-button-primary-text)}.jw-button--primary:hover{background:var(--jw-button-primary-fill-hover)}.jw-button--pill{border-radius:var(--jw-radius-pill)}.jw-button--compact{min-height:42px;padding-inline:var(--jw-space-4);font-size:.875rem}.jw-icon-button{width:44px;height:44px;display:inline-grid;place-items:center;padding:0;border:1px solid var(--jw-line);border-radius:50%;background:var(--jw-surface);color:var(--jw-text-primary)}
/* Cards and panels */
.jw-card{padding:var(--jw-card-padding);border:1px solid var(--jw-card-border);border-radius:var(--jw-card-radius);background:var(--jw-card-bg);box-shadow:var(--jw-shadow-sm)}.jw-card--flat{box-shadow:none}.jw-card--sunken{background:var(--jw-surface-sunken)}.jw-card--glass{background:var(--jw-surface-glass);-webkit-backdrop-filter:blur(var(--jw-topbar-blur));backdrop-filter:blur(var(--jw-topbar-blur))}
/* Forms */
.jw-field{display:grid;gap:var(--jw-space-2);margin-top:var(--jw-space-5)}.jw-field>span,.jw-label{color:var(--jw-field-label);font-size:.8125rem;font-weight:var(--jw-weight-bold);letter-spacing:var(--jw-tracking-eyebrow);text-transform:uppercase}.jw-field :is(input,textarea,select),.jw-input{width:100%;min-height:var(--jw-field-height);padding:var(--jw-space-3) var(--jw-space-4);border:1px solid var(--jw-field-border);border-radius:var(--jw-field-radius);background:var(--jw-field-bg);color:var(--jw-field-text);font-size:1rem;outline:0}.jw-field textarea{min-height:112px;resize:vertical}.jw-field :is(input,textarea,select):focus,.jw-input:focus{border-color:var(--jw-field-border-focus);box-shadow:var(--jw-field-ring)}.jw-help{margin:var(--jw-space-2) 0 0;color:var(--jw-text-muted);font-size:.8125rem;line-height:1.5}
/* Chips and status */
.jw-chip{min-height:42px;display:inline-flex;align-items:center;justify-content:center;padding:0 var(--jw-space-4);border:1px solid var(--jw-chip-border);border-radius:var(--jw-radius-pill);background:var(--jw-chip-bg);color:var(--jw-chip-text);font-size:.8125rem;font-weight:var(--jw-weight-bold)}.jw-chip[aria-pressed="true"],.jw-chip.is-active{background:var(--jw-text-primary);color:var(--jw-bg)}.jw-badge{display:inline-flex;align-items:center;gap:var(--jw-space-2);padding:var(--jw-space-2) var(--jw-space-3);border:1px solid currentColor;border-radius:var(--jw-radius-pill);font-size:.8125rem;font-weight:var(--jw-weight-bold)}.jw-badge--success{color:var(--jw-success)}.jw-badge--warning{color:var(--jw-warning)}.jw-badge--danger{color:var(--jw-danger)}
/* Async states */
.jw-state{min-height:50vh;display:grid;place-content:center;justify-items:center;gap:var(--jw-space-3);padding:var(--jw-space-6);text-align:center}.jw-state__mark{font-size:clamp(3rem,8vw,5rem)}.jw-state h1{max-width:15ch;margin:0;font-family:var(--jw-font-display);font-size:clamp(2.5rem,7vw,5.5rem);line-height:.9;letter-spacing:-.055em}.jw-state p{max-width:42rem;margin:0;color:var(--jw-text-muted);font-size:1rem}.jw-state--page{min-height:100vh;background:var(--jw-black);color:var(--jw-white)}
/* Toast */
.jw-toast{position:fixed;right:var(--jw-space-5);bottom:var(--jw-space-5);z-index:var(--jw-z-toast);padding:var(--jw-space-3) var(--jw-space-4);border-radius:var(--jw-radius-md);background:var(--jw-text-primary);color:var(--jw-bg);box-shadow:var(--jw-shadow-lg);font-size:.875rem;font-weight:var(--jw-weight-bold)}
@@ -0,0 +1,5 @@
// Shared environment helpers. In production (justworks.cash) pages behave as deployed;
// anywhere else (local dev) pages default to the demo merchant and local routes.
export const prod=location.hostname.endsWith('justworks.cash');
export const DEMO_NPUB='npub15yfu23xhew6d3d2dqs6ntt5w8myj6p5dj9m0d2trz00au5kv0rnsdhw6za';
export const npubFrom=(search=location.search)=>new URLSearchParams(search).get('npub')?.trim()||'';
@@ -0,0 +1,47 @@
import {mountLogo} from './logo.js';
import {startNavScroll} from './header.js';
const prod=location.hostname.endsWith('justworks.cash');
export const base=location.pathname.startsWith('/business-demo')?'/business-demo':'';
const search=location.search;
export const link=id=>prod?`${id==='business'?'https://business.justworks.cash/':`https://${id}.justworks.cash/`}${search}`:({business:`${base}/dashboard${search}`,pos:`${base}/pos${search}`,order:`${base}/order${search}`,book:`${base}/book${search}`,booking:`${base}/book${search}`,screen:`${base}/screen${search}`,gallery:`${base}/gallery${search}`})[id]||`${base}/portal?surface=${id}`;
const links=[['order','Order'],['pay','Pay Bill'],['book','Book'],['gallery','Explore']];
const menuSvg='<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 5h11M2.5 11h11"/></svg>';
const closeSvg='<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
const upSvg='<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 9.5 8 5l4.5 4.5"/></svg>';
const topbar=(label,endHtml='')=>`<nav class="jw-topbar" data-topbar><a class="jw-brand brand" href="${link('business')}"><span data-logo="icon"></span><b>Just Works<small>${label}</small></b></a><div class="jw-topbar-links links">${links.map(([id,name])=>`<a href="${link(id)}">${name}</a>`).join('')}</div>${endHtml?`<div class="jw-topbar-end top">${endHtml}</div>`:''}</nav>`;
function mountChromeControls(app){
const existing=app.querySelector('.mobile-menu');
if(existing)return existing;
app.insertAdjacentHTML('beforeend',`<button class="button icon ring mobile-menu-button" type="button" aria-label="Menu" aria-expanded="false" aria-controls="mobileMenu">${menuSvg}</button><button class="button icon ring back-to-top" type="button" aria-label="Back to top">${upSvg}</button><div class="mobile-menu" id="mobileMenu" aria-hidden="true"><button class="button icon ring mobile-menu-close" type="button" aria-label="Close menu">${closeSvg}</button><nav class="mobile-menu-items" aria-label="Menu">${links.map(([id,name])=>`<a href="${link(id)}">${name}</a>`).join('')}</nav></div>`);
const trigger=app.querySelector('.mobile-menu-button');
const menu=app.querySelector('.mobile-menu');
const close=app.querySelector('.mobile-menu-close');
const back=app.querySelector('.back-to-top');
let previousFocus=null;
const focusable=()=>[...menu.querySelectorAll('a,button,[tabindex]:not([tabindex="-1"])')];
const setMenu=open=>{menu.classList.toggle('open',open);menu.setAttribute('aria-hidden',String(!open));trigger.setAttribute('aria-expanded',String(open));document.body.classList.toggle('creating',open);if(open){previousFocus=document.activeElement;close.focus()}else if(previousFocus instanceof HTMLElement){previousFocus.focus()}};
trigger.addEventListener('click',()=>setMenu(true));
close.addEventListener('click',()=>setMenu(false));
menu.addEventListener('click',event=>{if(event.target.closest('a'))setMenu(false)});
menu.addEventListener('keydown',event=>{if(event.key==='Escape'){setMenu(false);return}if(event.key!=='Tab')return;const items=focusable();const first=items[0],last=items.at(-1);if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus()}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus()}});
back.addEventListener('click',()=>scrollTo({top:0,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth'}));
let request=0;
const update=()=>{request=0;back.classList.toggle('show',scrollY>innerHeight)};
addEventListener('scroll',()=>{if(!request)request=requestAnimationFrame(update)},{passive:true});
addEventListener('resize',()=>{if(innerWidth>980&&menu.classList.contains('open'))setMenu(false)},{passive:true});
update();
return menu;
}
function initialize(app,grounded=false){
app.querySelectorAll('[data-logo]').forEach(host=>mountLogo(host,{icon:true}));
mountChromeControls(app);
const bar=app.querySelector('[data-topbar]');
if(grounded)bar?.classList.add('is-scrolled');else startNavScroll(bar);
}
export function mountMobileChrome(app){return mountChromeControls(app)}
export function mountAppChrome(app,{label='',endHtml='',grounded=false}={}){app.classList.toggle('chrome-grounded',grounded);app.insertAdjacentHTML('afterbegin',topbar(label,endHtml));initialize(app,grounded)}
export function frame(app,{label='',surface='',html='',nav=true}){app.innerHTML=nav?`${topbar(label)}<main class="surface ${surface}">${html}</main>`:`<main class="surface ${surface}">${html}</main>`;if(nav)initialize(app);else app.querySelectorAll('[data-logo]').forEach(host=>mountLogo(host,{icon:true}))}
@@ -0,0 +1,24 @@
/*
* Top bar scroll state — shared with the main site's behaviour.
* Transparent while parked; past the first 80px the bar earns the
* translucent ground, hairline and blur. Toggles `is-scrolled`.
*/
const SCROLL_START = 80;
export function startNavScroll(bar = document.querySelector("[data-topbar]")) {
if (!bar) return () => {};
let frame = 0;
const apply = () => {
frame = 0;
bar.classList.toggle("is-scrolled", window.scrollY > SCROLL_START);
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(apply);
};
apply();
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
if (frame) cancelAnimationFrame(frame);
};
}
@@ -0,0 +1,198 @@
/*
* The Just Works mark.
*
* One continuous line, drawn as a single closed path, with no beginning and no
* end. As it travels it settles through three states: a plain loop, a section
* squared into a card outline (payments), and a section drawn out into a
* speech bubble (social). The resting state carries a pointer notch on the
* left lobe (website), so the mark reads correctly even with motion disabled.
*
* The path is the ONLY thing that changes. Stroke, gradient, size and caps are
* styled in components.css from tokens, so the mark follows the theme.
*
* Rendered from here rather than written into each page, so the topbar, the
* footer and /design all draw the same mark from one source.
*/
/*
* All four states share the same anchor budget and the same left-lobe opening,
* so the morph travels rather than jumps. Authored on a 56x28 field with the
* crossing point at the centre.
*/
export const LOGO_STATES = {
/* The loop at rest, with the pointer notch on the left lobe. */
loop: "M13.5 14C13.5 7.5 20 7 27 14C34 21 41 20.5 41.5 14C42 7.5 34.5 7 27 14C20.5 20.2 15 21 13.5 14Z",
/* Right lobe squared into a card. */
card: "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 30 19.5 33 19.5L41 19.5C43 19.5 44 18.5 44 16.5L44 11.5C44 9.5 43 8.5 41 8.5L33 8.5C30 8.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14Z",
/* Right lobe drawn out into a speech bubble, tail dropping below. */
bubble: "M13.5 14C13.5 7.5 20 7 27 14C29 16 29 18.5 30.5 19.5C31.6 20.3 31 23.4 32.6 22.3C34 21.3 34.6 19.5 36.6 19.5L39 19.5C42 19.5 44 17 44 14C44 11 42 8.5 39 8.5L33 8.5C29.5 8.5 29 11.5 27 14C20.5 20.2 15 21 13.5 14Z",
/* Lab 07: the right lobe opens into a round doorway. The resting mark for
now, per the direction of 2026-08-18, until the lab work settles. */
portal: "M13.5 14C13.5 7.5 20 7 27 14C30.5 17.5 34.5 19.5 37.5 18C35.5 15.5 35 13.5 36.5 12C38 10.5 41 10.5 42 12.5C43 14.5 42 16 40.5 17.5C43 17.5 44 14.5 43 11.5C41.5 7.5 34 6.5 30 10.5C28.5 12 28 13 27 14C20.5 20.2 15 21 13.5 14Z",
/* Left lobe pulled into a cursor arrow. */
cursor: "M13.5 14C10.6 8.2 9.8 4.6 11.6 4.2C13.4 3.8 20.6 9.6 27 14C34 21 41 20.5 41.5 14C42 7.5 34.5 7 27 14C23 16.6 18.6 21.6 17.1 22.6C15.6 23.6 14.6 19.8 13.5 14Z"
};
/*
* The lab: further single-line ideas, same method as LOGO_STATES — one
* continuous closed path on the 56x28 field, each working a different motif
* into the loop's travel. Rendered in /design for comparison; not part of the
* live cycle. Keep adding here rather than replacing.
*/
export const LOGO_LAB = {
/* Payments and Bitcoin. */
"sat-symbol": "M13.5 14C13.5 7.5 20 7 27 14C30 17 30 19 34 19L40 19M34 9L40 9M31 14L43 14C43 14 43.5 10 41.5 8.5C38 6.5 31 9.5 27 14C20.5 20.2 15 21 13.5 14Z",
"coin-edge": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18.5 35 18.5C39 18.5 42.5 16.5 42.5 13.5C42.5 10.5 39 8.5 35 8.5C32 8.5 30 11 27 14C24 17 21 19.5 17.5 19.5C14.5 19.5 13.5 17 13.5 14Z",
"zap-bolt": "M13.5 14C13.5 7.5 20 7 27 14C30 17 33 18 35 15.5L32 15.5L38 8.5L36 13L39.5 13C41.5 13 42 10 40 8.5C36.5 6 30.5 10 27 14C20.5 20.2 15 21 13.5 14Z",
"lightning-path": "M13.5 14C13.5 7.5 20 7 27 14C34 21 41 20.5 41.5 14C42 7.5 34.5 7 27 14C25 16 23.5 14.5 22 16.5C20.5 18.5 19 16.5 17.5 18.5C15.5 21 13.5 17.5 13.5 14Z",
"banknote": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 30 18.5 33 18.5L42 18.5C43.5 18.5 44 18 44 16.5L44 11.5C44 10 43.5 9.5 42 9.5L33 9.5C30 9.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM38.5 11.7A2.3 2.3 0 1 0 38.5 16.3A2.3 2.3 0 1 0 38.5 11.7Z",
/* Identity and keys. */
"key-bit": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18 35 18L37 18L37 20L39 20L39 18L41 18L41 20.5L43.5 20.5L43.5 16C43.5 16 42 15.5 40 13C37.5 10 34 7.5 31 9.5C28.5 11 28.5 12.5 27 14C20.5 20.2 15 21 13.5 14Z",
"portal": "M13.5 14C13.5 7.5 20 7 27 14C30.5 17.5 34.5 19.5 37.5 18C35.5 15.5 35 13.5 36.5 12C38 10.5 41 10.5 42 12.5C43 14.5 42 16 40.5 17.5C43 17.5 44 14.5 43 11.5C41.5 7.5 34 6.5 30 10.5C28.5 12 28 13 27 14C20.5 20.2 15 21 13.5 14Z",
"signature": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30.5 17 32 15.5C33.5 14 32 12 33.5 10.5C35 9 36.5 11 38 9.5C39.5 8 41 9.5 42.5 8C43.5 7 44 8.5 43 10C41 13 37 19 33 19C30 19 29 16 27 14C20.5 20.2 15 21 13.5 14Z",
/* Web and social. */
"at-sign": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31.5 19 35.5 19C39.5 19 43 16.5 43 13C43 9.5 40.5 8 38 8C35.5 8 33.5 9.5 33.5 12C33.5 14.5 35.5 16 37.5 15.5C39.5 15 40 13 39.5 11.5C39 13.5 38 14.5 36.5 14C35 13.5 35.5 10.5 38 10.5C40 10.5 41 12 41 13.5C41 15.5 39 17 36.5 17C33.5 17 31 15 29.5 12.5C28.5 11 28 12.8 27 14C20.5 20.2 15 21 13.5 14Z",
"pin-drop": "M13.5 14C13.5 7.5 20 7 27 14C30 17 33.5 20.5 35.5 22C37.5 20.5 41 16.5 41 12.5C41 9 38.5 7 35.5 7C32.5 7 31 9 29.5 11.5C28.7 12.8 28 13 27 14C20.5 20.2 15 21 13.5 14ZM35.5 10.5A2 2 0 1 0 35.5 14.5A2 2 0 1 0 35.5 10.5Z",
"heart-zap": "M13.5 14C13.5 7.5 20 7 27 14C29 16 31 18.5 34.5 21C38 18.5 43 14.5 43 11C43 8.5 41 7.5 39.5 7.5C37.5 7.5 36.5 9 34.5 11C32.5 9 31.5 7.5 29.5 7.5C28.5 7.5 27.5 8 27 9.5C26 12 24 16 20 18.5C16.5 20.5 13.5 17.5 13.5 14Z",
"follow-plus": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18 34.5 18L34.5 21L37.5 21L37.5 18L40.5 18L40.5 15L37.5 15L37.5 12L34.5 12L34.5 15C32 15 30 12 27 14C20.5 20.2 15 21 13.5 14Z",
/* Continuity and craft. */
"mobius": "M13.5 14C13.5 7.5 21 6 27 14C33 22 40.5 20.5 41.5 14C42.5 7 35 6.5 29 12C24 16.5 18 22 14.5 18C12.5 15.8 13 15 13.5 14Z",
"knot": "M13.5 14C13.5 8 19 6.5 24 10.5C27 13 25 17.5 28.5 19C32 20.5 35 18 34.5 14.5C34 11 30 10 27 14C24.5 17.5 21 21.5 16.5 20C13.5 19 13.5 16 13.5 14Z",
"orbit-dot": "M13.5 14C13.5 7.5 20 7 27 14C34 21 41 20.5 41.5 14C42 7.5 34.5 7 27 14C20.5 20.2 15 21 13.5 14ZM45 8A1.8 1.8 0 1 0 45 11.6A1.8 1.8 0 1 0 45 8Z",
"spark-tail": "M13.5 14C13.5 7.5 20 7 27 14C33 20 39 21 41 17C42 15 41 13.5 43 12.5C44.5 11.8 45 10 44 9C42.5 7.5 40 8 38.5 9.5C34.5 6.5 30 10.5 27 14C20.5 20.2 15 21 13.5 14Z",
"wave-crest": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18.5 34 16.5C36 14.5 34.5 12 36.5 10C38.5 8 41.5 8.5 42.5 11C43.5 13.5 42 16 39.5 17.5C42.5 17.5 44.5 14.5 43.5 11C42 6.5 36 6.5 33 10C31 12.3 28.7 12.3 27 14C20.5 20.2 15 21 13.5 14Z",
"double-cross": "M13.5 14C13.5 8 19 6.5 24.5 11C27 13 29 15 31.5 17C34.5 19.5 38.5 20 41 17.5C43.5 15 42.5 11 39.5 9C36.5 7 33 8.5 30.5 11C28 13.5 25 16.5 21 18.5C16.5 20.5 13.5 17.5 13.5 14Z",
"qr-corner": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 30.5 18.5 33.5 18.5L43.5 18.5L43.5 8.5L33.5 8.5C30.5 8.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM36 11L41 11L41 16L36 16Z",
"storefront": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 18.5L42 18.5L42 12.5C43.5 12 44 10.5 43 9.5C42 8.3 40 8.5 36.5 8.5C33 8.5 31.5 8.5 30.5 9.5C29.5 10.5 30 12 31.5 12.5L31.5 15C31 13 29 12 27 14C20.5 20.2 15 21 13.5 14Z",
/* Batch two — 2026-08-18. */
"relay-waves": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32 18.5 34 17C32.5 15.5 32.5 12.5 34 11C35 13 37 13.5 38 12C36.5 10.5 37 8.5 39 8C41.5 7.4 43.5 9.5 43.5 12C43.5 16.5 38 20 33 17.5C30.5 16.2 29 15.8 27 14C20.5 20.2 15 21 13.5 14Z",
"antenna-mast": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 32 18 35 18L36.5 18L36.5 9.5C35 9 34.5 7 36 6C37.5 5 39.5 6 39.5 7.5C39.5 8.5 39 9.2 38 9.5L38 18L40 18C42.5 18 43.5 15.5 42.5 13C44.5 14 44.5 17.5 42 19C38.5 21 32 19 29 15.5C28.2 14.6 27.7 14.6 27 14C20.5 20.2 15 21 13.5 14Z",
"chain-link": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30 17.5 32.5 17.5C34.5 17.5 36 16 36 14C36 12 34.5 10.5 32.5 10.5C31 10.5 30 11.5 29 13C30 10 32.5 8 35.5 9C38.5 10 39.5 13.5 38 16C39.5 17 42 16.5 43 14.5C43.5 17.5 40 19.5 37 18.5C33.5 17.4 31 14 29.5 12.5C28.6 13.1 27.8 13.4 27 14C20.5 20.2 15 21 13.5 14Z",
"menu-book": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 18.5L36.5 18.5L36.5 9.5C34.5 8.5 31.5 9 30 10.5C29.2 11.5 28.2 12.6 27 14C20.5 20.2 15 21 13.5 14ZM38 18.5L42.5 18.5C43.5 18.5 44 18 44 17L44 10.5C44 9.5 43.5 9 42.5 9L38 9Z",
"cup-steam": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18 32 18.5C34.5 19 37.5 19 39.5 18C41.5 17 42 15 42 13L33 13C33 11.5 33.5 10 35 9C34 7.5 34.5 6 36 5.5C35.5 7 36.5 7.5 36.5 9L42 9C43.5 9 44 10 43.5 11.5L42.9 13.4C44.5 13.6 45 15 44 16C43.4 16.6 42.5 16.6 41.8 16.2C40.5 18.8 36.5 20.5 32.5 19.5C29.5 18.7 28.5 15.6 27 14C20.5 20.2 15 21 13.5 14Z",
"receipt": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 19L32 20.5L33.8 19.3L35.6 20.5L37.4 19.3L39.2 20.5L41 19.3L42.5 20.3L42.5 9.5C42.5 8.5 42 8 41 8L34 8C31 8 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM35 12L40 12M35 15L40 15",
"price-tag": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 32 19.5 34.5 20.5C35.5 21 36.5 20.8 37.5 19.8L42.8 14.5C43.8 13.5 43.8 12.2 42.8 11.2L38.5 7.5C36 7 32 7.5 30 10C28.8 11.5 28 13 27 14C20.5 20.2 15 21 13.5 14ZM39 11A1.5 1.5 0 1 0 39 14A1.5 1.5 0 1 0 39 11Z",
"calendar-day": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 18.5L42 18.5C43.5 18.5 44 18 44 16.5L44 10.5C44 9 43.5 8.5 42 8.5L40.5 8.5L40.5 7L38.5 7L38.5 8.5L35 8.5L35 7L33 7L33 8.7C30.8 9.6 29.2 11.6 27 14C20.5 20.2 15 21 13.5 14ZM32 12L44 12",
"clock-open": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31.5 18.5 35 18.5C39 18.5 42 16.5 42 13.5C42 10.5 39.5 8.5 36.5 8.5C33.5 8.5 31.5 10.5 29.5 12.5C28.6 13.3 27.9 13.2 27 14C20.5 20.2 15 21 13.5 14ZM36.5 10.5L36.5 13.5L39 15",
"compass-needle": "M13.5 14C13.5 7.5 20 7 27 14C34 21 41 20.5 41.5 14C41.9 9 37.5 7.2 33 10L36.5 12.5L31 17L29.5 12.2C28.6 12.9 27.8 13.4 27 14C20.5 20.2 15 21 13.5 14Z",
"north-arrow": "M13.5 14C13.5 7.5 20 7 27 14C30 17 32.5 19.5 35.5 21.5L37.5 12.5L42 14.5C41 10 36.5 6.5 32 8.5C29.8 9.6 28.5 12 27 14C20.5 20.2 15 21 13.5 14Z",
"camera-eye": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32.5 18.5L41 18.5C42.5 18.5 43.5 17.5 43.5 16L43.5 11C43.5 9.5 42.5 8.5 41 8.5L39.5 8.5L38.5 7L35.5 7L34.5 8.5C31.5 8.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM37 10.5A3 3 0 1 0 37 16.5A3 3 0 1 0 37 10.5Z",
"mic-live": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30 18 32.5 18.5L34.5 18.5L34.5 16.8C32.5 16.3 31.5 14.8 31.5 13L33 13C33 14.5 34.5 15.5 36 15.5C37.5 15.5 39 14.5 39 13L40.5 13C40.5 14.8 39.5 16.3 37.5 16.8L37.5 18.5C40 18.5 42 17 42.5 14.5C44 15.5 43.5 18 41 19.3C37.5 21 31 19.5 29 15.8C28.3 14.9 27.7 14.6 27 14C20.5 20.2 15 21 13.5 14ZM34 9.5C34 8 35 7 36 7C37 7 38 8 38 9.5L38 12C38 13.5 37 14 36 14C35 14 34 13.5 34 12Z",
"note-beam": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31 18 33 17.5L33 10L41 8L41 15.5C42.5 15.8 43.5 17 43 18.3C42.3 19.8 40 20 39.3 18.5C38.9 17.6 39.3 16.6 40.3 16.2L40.3 10.5L33.7 12.2L33.7 18C34.2 19.8 32 21 30.3 20C28.8 19 28.8 17 30.3 16.2C29 15.5 28 14.8 27 14C20.5 20.2 15 21 13.5 14Z",
"ticket-stub": "M13.5 14C13.5 7.5 20 7 27 14C29 16 29.5 18.5 32 18.5L42 18.5C43 18.5 43.5 18 43.5 17C42.5 17 41.8 16.2 41.8 15.2C41.8 14.2 42.5 13.5 43.5 13.5L43.5 10C43.5 9 43 8.5 42 8.5L33.5 8.5C30.5 8.5 29.5 11.5 27 14C20.5 20.2 15 21 13.5 14ZM37.5 8.5L37.5 18.5",
"bell-ring": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30 17.5 32 18L32 19C32 20 32.8 20.8 33.8 20.8C34.8 20.8 35.6 20 35.6 19L41.5 19C41.5 17.5 40 17 40 14.5C40 11 38 8.5 35 8.5C32 8.5 30.8 11 29.3 12.7C28.5 13.4 27.7 13.4 27 14C20.5 20.2 15 21 13.5 14ZM30 19L41.5 19",
"star-review": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31.5 18.5 33.5 20L35.5 15.5L31.5 12.5L36.5 12.5L38 8L39.5 12.5L44.5 12.5L40.5 15.5L42 20.2L38 17.4C36 19 33 20.7 30 18C28.9 17 28 15 27 14C20.5 20.2 15 21 13.5 14Z",
"check-badge": "M13.5 14C13.5 7.5 20 7 27 14C29 16 30 17.8 32.3 18.4C32.1 19.4 32.8 20.3 33.9 20.3C34.9 20.3 35.6 19.5 35.5 18.5C37 18.2 38.4 17.3 39.2 16C40.2 16.4 41.4 16 41.9 15C42.4 14 42 12.9 41.1 12.4C41.2 10.8 40.5 9.3 39.2 8.4C36.4 6.5 32.5 7.8 30.5 10.7C29.4 12.2 28.2 13 27 14C20.5 20.2 15 21 13.5 14ZM33.5 13.5L35.5 15.5L39.5 11.5",
"handshake-loop": "M13.5 14C13.5 8 18.5 6.5 23.5 10C26 11.7 28.5 13.8 31 15.8C33 17.5 36 18 38.5 16.5C41.5 14.7 42 11 39.5 9C37 7 33.5 8 31.5 10.5C30 12.4 28 15.5 24.5 17.8C20 20.8 13.5 19 13.5 14Z",
"ribbon-award": "M13.5 14C13.5 7.5 20 7 27 14C29.5 16.5 31 18 33.5 18.5L32.5 21.5L35 20L37.5 21.5L36.5 18.5C39.5 18.5 42 16.5 42 13.5C42 10 39.5 8 36.5 8C33 8 31 10.5 29.2 12.7C28.4 13.4 27.7 13.4 27 14C20.5 20.2 15 21 13.5 14ZM35 11A2.6 2.6 0 1 0 35 16.2A2.6 2.6 0 1 0 35 11Z"
};
/* Parked while the mark rests on `portal`; restore the startLogoMotion
calls in application.js and design.js to revive the cycle. */
/*
* Portal at small sizes: the full portal's inner spiral cannot resolve under
* ~40px, so icon-scale marks use this variant — the same continuous line,
* with the doorway drawn as a literal opening in the right lobe. One line,
* one gap: the portal.
*/
export const LOGO_PORTAL_SMALL = "M41.5 16.5C40 20.8 33.5 20.5 27 14C20 7 13.5 7.5 13.5 14C13.5 21 20.5 20.2 27 14C34.5 7 42 7.5 41.7 11.5";
const ORDER = ["portal", "loop", "card", "bubble", "cursor"];
/*
* THE MARK — "07 Portal", from the logo exploration deck: a doorway whose far
* side is lit. The frame is a heavy silhouette at maximum contrast so it
* survives any ground; all of the depth lives in the interior gradient,
* running from the ramp's mid tone at the head to its faintest at the floor.
* The frame's legs run to the floor with no bottom bar: the door does not
* close behind you.
*
* Colours come from CSS (.jw-portal-*), so the mark follows the theme.
*/
export const LOGO_MARKUP = `
<svg class="jw-mark jw-mark-portal" viewBox="0 0 32 32" role="img" aria-hidden="true" focusable="false">
<defs>
<linearGradient class="jw-portal-glow" x1="0" y1="0" x2="0" y2="1">
<stop class="jw-portal-head" offset="0%"></stop>
<stop class="jw-portal-floor" offset="100%"></stop>
</linearGradient>
</defs>
<path class="jw-portal-light" d="M9 32V16a7 7 0 0 1 14 0v16Z"></path>
<path class="jw-portal-frame" fill-rule="evenodd"
d="M2 32V16C2 8.3 8.3 2 16 2s14 6.3 14 14v16h-7V16a7 7 0 0 0-14 0v16Z"></path>
</svg>`.trim();
/* The one-line loop markup, kept for the /design specimens and the lab. */
export const LOGO_LINE_MARKUP = `
<svg class="jw-mark jw-mark-line" viewBox="0 0 56 28" role="img" aria-hidden="true" focusable="false">
<defs>
<linearGradient class="jw-mark-gradient" x1="0" y1="0" x2="1" y2="1">
<stop class="jw-mark-stop-a" offset="0%"></stop>
<stop class="jw-mark-stop-b" offset="100%"></stop>
</linearGradient>
</defs>
<path class="jw-mark-path" d="${LOGO_STATES.portal}"></path>
</svg>`.trim();
/* Each mounted mark needs its own gradient id, or the second one on the page
would reference the first one's definition. */
let gradientSeed = 0;
/**
* Renders the mark into `host` and returns the created <svg>.
* Pass `{ icon: true }` to wrap it in the app-icon container.
* Motion is opt-in via startLogoMotion so a static mark costs nothing.
*/
export function mountLogo(host, { icon = false, variant = "portal" } = {}) {
if (!host) return null;
/* "portal" is the brand mark (the lit doorway). "line" is the one-line loop
used by the /design specimens and the lab. `icon` wraps either in the
app-icon container from components.css. */
const markup = variant === "line" ? LOGO_LINE_MARKUP : LOGO_MARKUP;
host.innerHTML = icon ? `<span class="jw-app-icon">${markup}</span>` : markup;
const svg = host.querySelector(".jw-mark");
gradientSeed += 1;
if (variant === "line") {
const gradient = svg.querySelector(".jw-mark-gradient");
const path = svg.querySelector(".jw-mark-path");
const id = `jw-mark-gradient-${gradientSeed}`;
gradient.id = id;
path.setAttribute("stroke", `url(#${id})`);
} else {
const glow = svg.querySelector(".jw-portal-glow");
const id = `jw-portal-glow-${gradientSeed}`;
glow.id = id;
svg.querySelector(".jw-portal-light").setAttribute("fill", `url(#${id})`);
}
return svg;
}
/**
* Cycles the mark through its states. Returns a teardown.
*
* `gsap` and `MorphSVGPlugin` are passed in rather than imported so this stays
* usable without motion: if either is missing the mark simply rests on `loop`.
*/
export function startLogoMotion(svg, { gsap, MorphSVGPlugin, hold = 2.4, morph = 1.1 } = {}) {
if (!svg || !gsap || !MorphSVGPlugin) return () => {};
const path = svg.querySelector(".jw-mark-path");
if (!path) return () => {};
const timeline = gsap.timeline({ repeat: -1, defaults: { ease: "power2.inOut" } });
for (let index = 1; index <= ORDER.length; index += 1) {
/* Wraps back to `loop` on the last step, so the cycle closes seamlessly. */
const next = ORDER[index % ORDER.length];
timeline.to(path, {
duration: morph,
morphSVG: { shape: LOGO_STATES[next], shapeIndex: 0 }
}, `+=${hold}`);
}
return () => timeline.kill();
}
@@ -0,0 +1 @@
.button{position:relative;isolation:isolate;min-height:var(--jw-button-height);display:inline-flex;align-items:center;justify-content:center;gap:var(--jw-space-2);padding:var(--jw-button-padding);border:0;border-radius:var(--jw-button-radius);background:transparent;color:var(--jw-button-secondary-text);font-weight:var(--jw-button-weight);cursor:pointer}.button::before{content:"";position:absolute;inset:0;z-index:-1;padding:var(--jw-button-rim-width);border-radius:inherit;background:linear-gradient(135deg,var(--jw-button-rim-bright) 0%,transparent var(--jw-button-rim-gap-start),transparent var(--jw-button-rim-gap-end),var(--jw-button-rim-bright) 100%);-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask-composite:exclude;pointer-events:none}.button::after{content:"";position:absolute;inset:var(--jw-button-rim-width);z-index:-2;border-radius:calc(var(--jw-button-radius) - var(--jw-button-rim-width));background:var(--jw-button-secondary-fill)}.button:hover:not(:disabled)::after{background:var(--jw-button-secondary-fill-hover)}.button.icon.ring{width:var(--jw-ring-button-size);min-width:var(--jw-ring-button-size);height:var(--jw-ring-button-size);min-height:var(--jw-ring-button-size);padding:0;border-radius:var(--jw-radius-pill);color:var(--jw-text-primary)}.button.icon.ring::before{padding:var(--jw-app-icon-rim-width);background:linear-gradient(135deg,var(--jw-app-icon-rim-bright) 0%,var(--jw-app-icon-rim-fade) 32%,var(--jw-app-icon-rim-fade) 68%,var(--jw-app-icon-rim-bright) 100%)}.button.icon.ring::after{inset:var(--jw-app-icon-rim-width);border-radius:var(--jw-radius-pill);background:var(--jw-bg)}.mobile-menu-button{display:none;position:fixed;right:max(var(--jw-space-5),env(safe-area-inset-right));bottom:max(var(--jw-space-5),env(safe-area-inset-bottom));z-index:var(--jw-z-overlay);--jw-ring-button-size:54px}.mobile-menu{position:fixed;inset:0;z-index:var(--jw-z-modal);display:none;flex-direction:column;overflow:auto;padding:max(var(--jw-space-6),env(safe-area-inset-top)) max(var(--jw-space-6),env(safe-area-inset-right)) max(var(--jw-space-6),env(safe-area-inset-bottom)) max(var(--jw-space-6),env(safe-area-inset-left));background:var(--jw-modal-bg);-webkit-backdrop-filter:blur(26px);backdrop-filter:blur(26px)}.mobile-menu.open{display:flex}.mobile-menu-close{position:fixed;right:max(var(--jw-space-5),env(safe-area-inset-right));bottom:max(var(--jw-space-5),env(safe-area-inset-bottom));--jw-ring-button-size:54px}.mobile-menu-items{position:static!important;inset:auto!important;z-index:auto!important;width:auto!important;height:auto!important;flex:1;display:grid!important;align-content:center;justify-items:start;gap:var(--jw-space-6);padding:0!important;border:0!important;background:transparent!important;-webkit-backdrop-filter:none!important;backdrop-filter:none!important;pointer-events:auto!important}.mobile-menu-items a{color:var(--jw-text-primary);font-family:var(--jw-font-display);font-size:clamp(2.2rem,10vw,3.4rem);font-weight:700;line-height:1.3;text-decoration:none}.mobile-menu-foot{display:flex;align-items:center;justify-content:space-between;gap:var(--jw-space-4);padding-top:var(--jw-space-5);margin-bottom:calc(54px + var(--jw-space-6));border-top:1px solid var(--jw-line)}.mobile-menu-foot>span{color:var(--jw-text-muted);font-size:.8125rem;font-weight:var(--jw-weight-bold);letter-spacing:var(--jw-tracking-eyebrow);text-transform:uppercase}.mobile-menu-button svg,.mobile-menu-close svg,.back-to-top svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.back-to-top{display:none;position:fixed;right:max(var(--jw-space-5),env(safe-area-inset-right));bottom:calc(max(var(--jw-space-5),env(safe-area-inset-bottom)) + 66px);z-index:var(--jw-z-overlay);--jw-ring-button-size:44px;opacity:0;visibility:hidden;transform:translateY(var(--jw-space-2));transition:opacity var(--jw-duration-base) var(--jw-ease-out),transform var(--jw-duration-base) var(--jw-ease-out),visibility var(--jw-duration-base)}.back-to-top.show{opacity:1;visibility:visible;transform:none}@media(max-width:980px){.mobile-menu-button,.back-to-top{display:inline-flex}}@media(max-height:520px) and (orientation:landscape){.mobile-menu-items{align-content:start;padding-top:var(--jw-space-10)!important}.mobile-menu-items a{font-size:2rem}.mobile-menu-foot{margin-top:var(--jw-space-6)}}
@@ -0,0 +1,2 @@
/* Layout presets for page composition. */
.jw-container{width:min(var(--jw-container),calc(100% - 2.5rem));margin-inline:auto}.jw-container--narrow{width:min(var(--jw-container-narrow),calc(100% - 2.5rem));margin-inline:auto}.jw-page{padding-block:clamp(3rem,7vw,7rem)}.jw-stack{display:grid;gap:var(--jw-stack-gap,var(--jw-space-5))}.jw-cluster{display:flex;align-items:center;flex-wrap:wrap;gap:var(--jw-cluster-gap,var(--jw-space-3))}.jw-split{display:grid;grid-template-columns:minmax(0,.9fr) minmax(22rem,1.1fr);align-items:start;gap:clamp(2rem,8vw,7rem)}.jw-split__copy{position:sticky;top:calc(var(--jw-topbar-height) + var(--jw-space-10))}.jw-split__copy h1{max-width:10ch;margin:var(--jw-space-3) 0 var(--jw-space-5);font-family:var(--jw-font-display);font-size:clamp(3rem,7vw,6rem);font-weight:650;line-height:.9;letter-spacing:-.06em}.jw-split__copy p{max-width:38rem;color:var(--jw-text-body);font-size:1.125rem;line-height:1.6}.jw-grid{display:grid;grid-template-columns:repeat(var(--jw-grid-columns,3),minmax(0,1fr));gap:var(--jw-grid-gap,var(--jw-space-4))}.jw-hero{position:relative;min-height:clamp(22rem,52vw,42rem);display:flex;align-items:flex-end;padding:clamp(2rem,7vw,6rem);overflow:hidden}.jw-hero__content{position:relative;z-index:1;max-width:60rem}.jw-hero h1{margin:var(--jw-space-4) 0;font-family:var(--jw-font-display);font-size:clamp(3.5rem,10vw,9rem);font-weight:650;line-height:.8;letter-spacing:-.07em}.jw-eyebrow{margin:0;color:var(--jw-accent);font-size:.8125rem;font-weight:var(--jw-weight-black);letter-spacing:var(--jw-tracking-eyebrow);text-transform:uppercase}@media(max-width:980px){.jw-split{grid-template-columns:1fr}.jw-split__copy{position:static}.jw-grid{--jw-grid-columns:2}}@media(max-width:640px){.jw-container,.jw-container--narrow{width:min(100% - 2rem,var(--jw-container))}.jw-grid{--jw-grid-columns:1}.jw-hero{padding-inline:var(--jw-space-5)}}
@@ -0,0 +1,119 @@
/*
* Theme controller.
*
* Three preferences: "system" (default), "light", "dark". The choice is
* stored in localStorage and applied as `data-theme` on <html>; "system"
* removes the attribute so the `prefers-color-scheme` block in tokens.css
* takes over. A small inline snippet in each page's <head> applies the stored
* value before first paint, so this module only has to own the control.
*/
export const THEME_STORAGE_KEY = "jw-theme";
const THEMES = ["system", "light", "dark"];
/* Must match --jw-bg in tokens.css for each theme. */
const BROWSER_CHROME = { light: "#fafafa", dark: "#0b100f" };
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
const listeners = new Set();
function readStored() {
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
return THEMES.includes(stored) ? stored : "system";
} catch {
/* Private mode or blocked storage: fall back to the system preference. */
return "system";
}
}
/** The preference the user chose ("system" | "light" | "dark"). */
export function getPreference() {
return readStored();
}
/** The theme actually on screen ("light" | "dark"), resolving "system". */
export function getResolvedTheme(preference = readStored()) {
if (preference !== "system") return preference;
return darkQuery.matches ? "dark" : "light";
}
export function applyTheme(preference) {
const next = THEMES.includes(preference) ? preference : "system";
const root = document.documentElement;
if (next === "system") root.removeAttribute("data-theme");
else root.setAttribute("data-theme", next);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", BROWSER_CHROME[getResolvedTheme(next)]);
try {
localStorage.setItem(THEME_STORAGE_KEY, next);
} catch {
/* Not fatal: the theme still applies for this page view. */
}
for (const listener of listeners) listener(next, getResolvedTheme(next));
return next;
}
/** Subscribe to preference changes. Returns an unsubscribe function. */
export function onThemeChange(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
/* Keep the browser chrome correct when the OS flips while set to "system". */
darkQuery.addEventListener("change", () => {
if (readStored() === "system") applyTheme("system");
});
const ICONS = {
system: '<path d="M3 4.5h12v8H3z"/><path d="M6.5 15.5h5"/>',
light: '<circle cx="9" cy="9" r="3.4"/><path d="M9 1.6v1.8M9 14.6v1.8M1.6 9h1.8M14.6 9h1.8M3.8 3.8l1.3 1.3M12.9 12.9l1.3 1.3M14.2 3.8l-1.3 1.3M5.1 12.9l-1.3 1.3"/>',
dark: '<path d="M14.5 10.4A6 6 0 0 1 7.6 3.5a6 6 0 1 0 6.9 6.9Z"/>'
};
const LABELS = { system: "Match system theme", light: "Light theme", dark: "Dark theme" };
/**
* Renders the segmented theme control into `container` and keeps its pressed
* state in sync with the current preference.
*/
export function mountThemeToggle(container) {
if (!container) return null;
container.classList.add("theme-toggle");
container.setAttribute("role", "group");
container.setAttribute("aria-label", "Color theme");
container.innerHTML = THEMES.map((theme) => `
<button type="button" data-theme-option="${theme}" title="${LABELS[theme]}" aria-label="${LABELS[theme]}">
<svg viewBox="0 0 18 18" aria-hidden="true">${ICONS[theme]}</svg>
</button>
`).join("");
const buttons = [...container.querySelectorAll("[data-theme-option]")];
function sync(preference = readStored()) {
for (const button of buttons) {
button.setAttribute("aria-pressed", String(button.dataset.themeOption === preference));
}
}
container.addEventListener("click", (event) => {
const button = event.target.closest("[data-theme-option]");
if (button) applyTheme(button.dataset.themeOption);
});
onThemeChange(sync);
sync();
return { sync };
}
/**
* The snippet that must run before first paint. Kept here so the inline copy
* in each page's <head> has a single documented source; if you change it,
* update index.html and design/index.html to match.
*/
export const PREPAINT_SNIPPET = `(function(){try{var t=localStorage.getItem("${THEME_STORAGE_KEY}");if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}})()`;
@@ -0,0 +1 @@
[data-topbar]{position:fixed;inset:0 0 auto;z-index:var(--jw-z-overlay);height:76px;display:flex;align-items:center;gap:var(--jw-space-6);padding:0 clamp(1.125rem,5vw,4.375rem);border-bottom:1px solid transparent;background:transparent;color:var(--jw-text-primary);pointer-events:none;transition:background var(--jw-duration-base) var(--jw-ease-out),border-color var(--jw-duration-base) var(--jw-ease-out),box-shadow var(--jw-duration-base) var(--jw-ease-out)}[data-topbar]>*{pointer-events:auto}[data-topbar].is-scrolled{border-bottom-color:var(--jw-topbar-scrolled-border);background:var(--jw-topbar-scrolled-bg);box-shadow:var(--jw-topbar-scrolled-shadow);-webkit-backdrop-filter:blur(var(--jw-topbar-blur));backdrop-filter:blur(var(--jw-topbar-blur))}[data-theme="dark"] [data-topbar].is-scrolled{border-bottom-color:rgba(255,255,255,.06);background:linear-gradient(180deg,rgba(10,10,10,.72),rgba(10,10,10,.42))}.jw-topbar-links,.jw-topbar .links{display:flex;align-items:center;gap:var(--jw-space-2);margin-left:auto}.jw-topbar-links a,.jw-topbar .links a{min-height:42px;display:inline-flex;align-items:center;padding:0 var(--jw-space-3);border-radius:var(--jw-radius-sm);color:var(--jw-text-body);text-decoration:none;font-size:.875rem;font-weight:var(--jw-weight-bold)}.jw-topbar-links a:hover,.jw-topbar .links a:hover{background:var(--jw-surface-sunken);color:var(--jw-text-primary)}.jw-topbar-end{display:flex;align-items:center;gap:var(--jw-space-3)}.chrome-grounded{padding-top:76px}.jw-chrome--inverse{color:var(--jw-white)}.jw-chrome--inverse .jw-brand small::before{color:rgba(255,255,255,.62)}@media(max-width:980px){[data-topbar]{height:66px;padding-inline:var(--jw-space-5)}.chrome-grounded{padding-top:66px}.jw-topbar-links,.jw-topbar .links,.jw-topbar-end{display:none}}@media(max-width:420px){.jw-brand b,.jw-topbar .brand b{font-size:.875rem}.jw-brand small,.jw-topbar .brand small{font-size:.875rem!important}.jw-brand small::before,.jw-topbar .brand small::before{margin-inline:var(--jw-space-1)}}
@@ -0,0 +1,5 @@
.experience-showcase{margin-top:clamp(70px,10vw,130px);padding-top:clamp(30px,5vw,56px);border-top:1px solid var(--line)}
.experience-head{display:flex;align-items:flex-start;justify-content:space-between;gap:30px;margin-bottom:28px}.experience-head>div{max-width:850px}.experience-head h2{margin:10px 0 14px;font-size:clamp(38px,6vw,68px);font-weight:650;letter-spacing:-.055em;line-height:.94}.experience-head p:last-child{max-width:700px;margin:0;color:var(--body);font-size:17px;line-height:1.55}.experience-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.journey-card,.nostr-card{min-width:0;padding:clamp(18px,3vw,30px);border:1px solid var(--line);border-radius:var(--jw-radius-xl,30px);background:var(--panel);box-shadow:var(--jw-shadow-md,0 15px 42px rgba(0,0,0,.07))}.experience-label{display:flex;align-items:center;gap:12px;margin-bottom:22px}.experience-label>span{width:38px;height:38px;display:grid;place-items:center;border:1px solid var(--line);border-radius:12px;background:var(--soft);color:var(--muted);font-size:10px;font-weight:900}.experience-label strong,.experience-label small{display:block}.experience-label strong{font-size:14px}.experience-label small{margin-top:2px;color:var(--muted);font-size:11px}
.status-device{position:relative;overflow:hidden;min-height:610px;padding:20px clamp(18px,4vw,42px) 28px;border-radius:24px;background:#0d0f0e;color:#fafafa;box-shadow:inset 0 0 0 1px rgba(255,255,255,.09),0 24px 54px rgba(0,0,0,.15)}.status-device:before{content:"";position:absolute;width:330px;height:330px;left:50%;top:70px;transform:translateX(-50%);border-radius:50%;background:radial-gradient(circle,rgba(244,81,30,.18),transparent 66%);pointer-events:none}.device-bar{position:relative;display:flex;align-items:center;justify-content:center;gap:9px;color:rgba(250,250,250,.55);font-size:10px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}.device-bar i{width:4px;height:4px;border-radius:50%;background:var(--green)}.status-orbit{position:relative;width:112px;height:112px;margin:64px auto 30px;display:grid;place-items:center;border:1px solid rgba(255,255,255,.14);border-radius:50%}.status-orbit:before,.status-orbit:after{content:"";position:absolute;inset:10px;border:1px solid rgba(255,255,255,.08);border-radius:50%}.status-orbit:after{inset:-9px;border-color:rgba(244,81,30,.25);border-top-color:var(--accent);animation:status-spin 4s linear infinite}.status-orbit span{position:absolute;inset:25px;border-radius:50%;background:var(--green);box-shadow:0 0 40px rgba(8,128,107,.38)}.status-orbit svg{position:relative;width:30px;z-index:1;fill:none;stroke:#fff;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}.status-kicker{margin:0;text-align:center;color:var(--green);font-size:10px;font-weight:900;letter-spacing:.13em;text-transform:uppercase}.status-device h3{max-width:470px;margin:10px auto 14px;text-align:center;font-family:var(--jw-font-display,"Syne Variable",sans-serif);font-size:clamp(32px,4vw,48px);font-weight:650;line-height:.96;letter-spacing:-.045em}.status-device>p:not(.status-kicker){max-width:430px;margin:0 auto;text-align:center;color:rgba(250,250,250,.6);font-size:13px;line-height:1.55}.order-summary{display:flex;justify-content:space-between;gap:20px;margin:30px 0 24px;padding:15px 0;border-top:1px solid rgba(255,255,255,.1);border-bottom:1px solid rgba(255,255,255,.1);font-size:13px}.journey-steps{display:grid;grid-template-columns:auto 1fr auto 1fr auto;align-items:start;margin:10px 0 28px}.journey-steps>div{display:grid;justify-items:center;gap:7px;text-align:center}.journey-steps i{width:26px;height:26px;display:grid;place-items:center;border-radius:50%;background:rgba(250,250,250,.1);font-style:normal;font-size:10px}.journey-steps .done i{background:var(--green)}.journey-steps .current i{background:var(--accent);box-shadow:0 0 0 7px rgba(244,81,30,.13);animation:status-pulse 1.8s ease-in-out infinite}.journey-steps b{height:1px;margin-top:13px;background:var(--green)}.journey-steps b.active{background:linear-gradient(90deg,var(--green),var(--accent))}.journey-steps span{font-size:9px;font-weight:850}.journey-steps small{display:block;margin-top:3px;color:rgba(250,250,250,.4);font-size:8px}.status-action{width:100%;height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 16px;border:1px solid rgba(255,255,255,.18);border-radius:13px;background:rgba(255,255,255,.06);color:#fafafa;font-weight:850}.status-action span{font-size:18px}
.nostr-feed{overflow:hidden;border:1px solid var(--line);border-radius:24px;background:var(--soft)}.nostr-feed>header{display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:11px;padding:18px}.nostr-avatar{width:44px;height:44px;display:grid;place-items:center;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--gold));color:#fff;font-weight:900}.nostr-feed header strong,.nostr-feed header small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nostr-feed header strong{font-size:13px}.nostr-feed header strong em{color:var(--green);font-style:normal}.nostr-feed header small{margin-top:3px;color:var(--muted);font-size:9px}.nostr-feed header button{min-height:34px;padding:0 12px;border:1px solid var(--line-strong);border-radius:10px;background:var(--panel);color:var(--text);font-size:10px;font-weight:900}.feed-copy{padding:0 18px 16px}.feed-copy p{margin:0 0 7px;font-size:14px;line-height:1.5}.feed-copy span{color:var(--muted);font-size:9px}.feed-mosaic{display:grid;grid-template-columns:repeat(2,1fr);grid-template-rows:245px 165px;gap:3px;background:var(--line)}.feed-mosaic figure{min-width:0;margin:0;overflow:hidden;background:#222}.feed-mosaic .wide{grid-column:1/-1}.feed-mosaic img{width:100%;height:100%;display:block;object-fit:cover;transition:transform .5s ease}.feed-mosaic figure:hover img{transform:scale(1.035)}.nostr-feed>footer{display:flex;align-items:center;gap:6px;padding:13px 15px;border-bottom:1px solid var(--line)}.nostr-feed>footer button{border:0;background:transparent;color:var(--text);font-size:12px;font-weight:800}.nostr-feed>footer button span{color:var(--muted);font-size:9px}.signed-mark{margin-left:auto;color:var(--green);font-size:8px;font-weight:900;letter-spacing:.08em;text-transform:uppercase}.feed-review{display:grid;grid-template-columns:34px 1fr;gap:10px;padding:16px 18px;background:var(--panel)}.nostr-avatar.small{width:34px;height:34px;font-size:11px}.feed-review strong{font-size:11px}.feed-review p{margin:5px 0;color:var(--body);font-size:11px;line-height:1.45}.feed-review small{color:var(--muted);font-size:8px}
@keyframes status-spin{to{transform:rotate(360deg)}}@keyframes status-pulse{50%{box-shadow:0 0 0 12px rgba(244,81,30,0)}}@media(prefers-reduced-motion:reduce){.status-orbit:after,.journey-steps .current i{animation:none}}@media(max-width:800px){.experience-head{flex-direction:column}.experience-grid{grid-template-columns:1fr}.status-device{min-height:580px}}@media(max-width:460px){.journey-card,.nostr-card{padding:14px}.status-device{padding-inline:15px}.feed-mosaic{grid-template-rows:190px 120px}.nostr-feed>header{grid-template-columns:40px minmax(0,1fr) auto;padding:14px}.nostr-avatar{width:40px;height:40px}}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,250 @@
import { mountLogo } from "./ds/logo.js";
import { trackGallery } from "./general-analytics.js";
document
.querySelectorAll("[data-logo]")
.forEach((el) => mountLogo(el, { icon: true }));
document.querySelector("#themeToggle")?.remove();
const $ = (id) => document.querySelector(id);
const gallery = $("#gallery"),
search = $("#search"),
empty = $("#empty");
const categoryNames = {
restaurant: "Restaurant",
cafe: "Café",
bar: "Bar",
bakery: "Bakery",
stay: "Stay",
hotel: "Stay",
shop: "Shop",
market: "Market",
beauty: "Beauty",
barber: "Barber",
activity: "Experience",
other: "Local place",
};
const mealTerms = {
breakfast: ["breakfast", "brunch", "bakery", "cafe", "coffee"],
lunch: ["lunch", "restaurant", "cafe", "takeaway"],
dinner: ["dinner", "restaurant", "bar", "evening"],
coffee: ["coffee", "cafe", "bakery"],
drinks: ["drinks", "bar", "wine", "cocktail", "beer", "poncha"],
stay: ["stay", "hotel", "hostel", "guesthouse", "accommodation"],
shop: ["shop", "market", "store", "retail"],
};
let places = [],
filtered = [],
payment = "all",
quick = "",
shown = 0,
seed = 0;
const PAGE = 36;
const esc = (v) =>
String(v ?? "").replace(
/[&<>"']/g,
(c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
c
],
);
const words = (p) =>
[p.name, p.category, p.area, p.address, ...(p.tags || [])]
.join(" ")
.toLowerCase();
const photo = (p) => (p.images || []).find(Boolean) || p.image || "";
function pseudo(value) {
let n = 0;
for (const c of value) n = (n * 31 + c.charCodeAt(0)) >>> 0;
return n;
}
function isOpen(p) {
if (!p.hours) return false;
const text = p.hours.toLowerCase();
if (/open 24|24\/7/.test(text)) return true;
if (/closed/.test(text) && !/[–-]/.test(text)) return false;
return true;
}
function matchQuick(p) {
if (!quick) return true;
if (quick === "open") return isOpen(p);
const hay = words(p);
return (mealTerms[quick] || [quick]).some((term) => hay.includes(term));
}
function card(p, index) {
const image = photo(p),
height = ["portrait", "landscape", "tall"][(pseudo(p.slug) + seed) % 3],
bitcoin = p.payment === "bitcoin",
url = p.justworks || p.nsite || p.website || "#";
return `<article class="place-card ${height} ${image ? "has-image" : "no-image"}" data-category="${esc(p.category || "other")}" style="--delay:${Math.min(index, 15) * 25}ms"><a href="${esc(url)}" ${url.startsWith("http") ? 'target="_blank" rel="noopener"' : ""}>${image ? `<figure><img src="${esc(image)}" alt="${esc(p.name)}" loading="lazy" decoding="async"><div class="image-wash"></div><span class="pay-mark ${bitcoin ? "bitcoin" : "fiat"}">${bitcoin ? "₿ Bitcoin" : "Fiat"}</span></figure>` : `<figure class="fallback"><span>${esc((p.name || "?").slice(0, 2).toUpperCase())}</span><small>${esc(categoryNames[p.category] || p.category || "Local")}</small></figure>`}<div class="card-copy"><div><p>${esc(categoryNames[p.category] || p.category || "Local place")} · ${esc(p.area || "Madeira")}</p><h2>${esc(p.name)}</h2></div><span class="arrow">↗</span>${p.rating ? `<small class="rating">★ ${Number(p.rating).toFixed(1)}</small>` : ""}<div class="card-tags">${(
p.tags || []
)
.slice(0, 3)
.map((tag) => `<span>${esc(tag)}</span>`)
.join("")}</div></div></a></article>`;
}
function apply(reset = true) {
const term = search.value.trim().toLowerCase();
filtered = places.filter(
(p) =>
(payment === "all" || p.payment === payment) &&
matchQuick(p) &&
(!term || words(p).includes(term)),
);
filtered.sort(
(a, b) =>
(photo(b) ? 1 : 0) - (photo(a) ? 1 : 0) ||
(b.rating || 0) - (a.rating || 0),
);
if (seed)
filtered.sort(
(a, b) => (pseudo(a.slug + seed) % 997) - (pseudo(b.slug + seed) % 997),
);
if (reset) {
shown = 0;
gallery.innerHTML = "";
}
renderMore();
$("#resultCount").textContent =
`${filtered.length} ${filtered.length === 1 ? "place" : "places"}`;
$("#resultContext").textContent =
[
payment !== "all"
? payment === "bitcoin"
? "Bitcoin accepted"
: "Fiat payment"
: "",
quick && quick !== "open"
? quick[0].toUpperCase() + quick.slice(1)
: quick === "open"
? "Published hours available"
: "",
term ? `matching “${term}”` : "",
]
.filter(Boolean)
.join(" · ") || "Real merchants from Just Works Maps";
empty.hidden = filtered.length > 0;
gallery.hidden = !filtered.length;
history.replaceState(
null,
"",
`?${new URLSearchParams({ ...(term ? { q: term } : {}), ...(payment !== "all" ? { pay: payment } : {}), ...(quick ? { filter: quick } : {}) })}`,
);
}
function renderMore() {
const batch = filtered.slice(shown, shown + PAGE);
gallery.insertAdjacentHTML(
"beforeend",
batch.map((p, i) => card(p, shown + i)).join(""),
);
shown += batch.length;
document
.querySelectorAll("img")
.forEach(
(img) =>
(img.onerror = () =>
img.closest(".place-card")?.classList.add("broken-image")),
);
}
function selectPayment(value) {
payment = value;
document
.querySelectorAll("[data-payment]")
.forEach((b) => b.classList.toggle("active", b.dataset.payment === value));
apply();
trackGallery("surface_interaction", `payment_filter:${value}`);
}
function selectQuick(value) {
quick = value;
document
.querySelectorAll("[data-query]")
.forEach((b) => b.classList.toggle("active", b.dataset.query === value));
apply();
if (value) trackGallery("surface_interaction", `filter:${value}`);
}
function clear() {
search.value = "";
payment = "all";
quick = "";
seed = 0;
document
.querySelectorAll("[data-payment]")
.forEach((b) => b.classList.toggle("active", b.dataset.payment === "all"));
document
.querySelectorAll("[data-query]")
.forEach((b) => b.classList.toggle("active", !b.dataset.query));
apply();
}
document
.querySelectorAll("[data-payment]")
.forEach((b) => (b.onclick = () => selectPayment(b.dataset.payment)));
document
.querySelectorAll("[data-query]")
.forEach((b) => (b.onclick = () => selectQuick(b.dataset.query)));
let searchTimer;
search.oninput = () => {
apply();
clearTimeout(searchTimer);
if (search.value.trim())
searchTimer = setTimeout(
() => trackGallery("surface_interaction", "search"),
700,
);
};
gallery.addEventListener("click", (event) => {
const card = event.target.closest(".place-card");
if (card)
trackGallery(
"surface_interaction",
`place_open:${card.dataset.category || "other"}`,
);
});
$("#clearSearch").onclick = () => {
search.value = "";
apply();
search.focus();
};
$("#clearAll").onclick = clear;
$("#shuffle").onclick = () => {
seed = Date.now();
apply();
trackGallery("surface_interaction", "shuffle");
};
new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && shown < filtered.length) renderMore();
},
{ rootMargin: "800px" },
).observe($("#sentinel"));
async function boot() {
const data = await fetch("/api/gallery", { cache: "no-store" }).then((r) => {
if (!r.ok) throw new Error("data");
return r.json();
});
places = data.places || [];
$("#totalCount").textContent = `${places.length} independent places`;
$("#allCount").textContent = places.length;
$("#bitcoinCount").textContent = places.filter(
(p) => p.payment === "bitcoin",
).length;
$("#fiatCount").textContent = places.filter(
(p) => p.payment === "fiat",
).length;
const params = new URLSearchParams(location.search);
search.value = params.get("q") || "";
payment = params.get("pay") || "all";
quick = params.get("filter") || "";
document
.querySelectorAll("[data-payment]")
.forEach((b) =>
b.classList.toggle("active", b.dataset.payment === payment),
);
document
.querySelectorAll("[data-query]")
.forEach((b) => b.classList.toggle("active", b.dataset.query === quick));
apply();
trackGallery("surface_view", "open");
}
boot().catch(() => {
$("#resultCount").textContent = "Gallery temporarily unavailable";
empty.hidden = false;
gallery.hidden = true;
});
@@ -0,0 +1,12 @@
const endpoint = "https://justworks.cash/api/analytics/event";
export function trackGallery(type = "surface_interaction", action = "open") {
try {
const body = JSON.stringify({ surface: "gallery", type, action });
if (
navigator.sendBeacon?.(endpoint, new Blob([body], { type: "text/plain" }))
)
return;
fetch(endpoint, { method: "POST", body, keepalive: true }).catch(() => {});
} catch {}
}
@@ -0,0 +1 @@
.jw-login-open{margin-left:10px;padding:10px 14px;border:1px solid #444;border-radius:99px;background:#fff;color:#080808;font:inherit;font-size:10px;font-weight:900;cursor:pointer}.jw-login-open span{color:#777;font-weight:700}.jw-login{width:min(590px,calc(100% - 24px));max-height:calc(100dvh - 24px);padding:clamp(26px,5vw,48px);border:0;border-radius:0;background:#f5f5f1;color:#0a0a0a;box-shadow:0 35px 100px rgba(0,0,0,.45)}.jw-login::backdrop{background:rgba(0,0,0,.75);backdrop-filter:blur(5px)}.jw-login-close{position:absolute;right:18px;top:16px;width:38px;height:38px;border:1px solid #d4d4cf;background:transparent;font-size:25px}.jw-kicker{margin:0;color:#ff4f1f;font-size:9px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}.jw-login h2{max-width:470px;margin:13px 0 15px;font-family:var(--jw-font-display,"Syne Variable",system-ui);font-size:clamp(42px,7vw,64px);font-weight:620;line-height:.86;letter-spacing:-.055em}.jw-intro{max-width:460px;margin:0 0 28px;color:#73736f;line-height:1.5}.jw-login-methods{display:grid;gap:7px}.jw-login-methods>a,.jw-classic>a,.jw-classic>button{display:flex;align-items:center;justify-content:space-between;gap:15px;padding:17px;border:1px solid #d7d7d2;background:#fff;color:#111;text-decoration:none}.jw-login-methods .jw-primary{background:#080808;color:#fff}.jw-login b,.jw-login small{display:block}.jw-login b{font-size:14px}.jw-login small{margin-top:4px;color:#888;font-size:10px}.jw-login i{color:#ff4f1f;font-style:normal;font-weight:900}.jw-more{width:100%;display:flex;justify-content:space-between;margin-top:15px;padding:14px 0;border:0;border-bottom:1px solid #d7d7d2;background:transparent;text-align:left;font-weight:850}.jw-classic{display:grid;gap:7px;padding-top:10px}.jw-classic>button{width:100%;font:inherit;text-align:left}.jw-classic>label{display:grid;grid-template-columns:1fr auto;gap:10px;padding:17px;border:1px solid #d7d7d2;background:#fff}.jw-classic>label>span{grid-column:1/-1}.jw-classic input{min-width:0;padding:11px;border:1px solid #ccc;background:#f4f4f0;font:inherit;font-size:12px}.jw-classic label button{border:0;background:#080808;color:#fff;font-weight:850}.jw-login-status{min-height:18px;margin:8px 0 0;color:#087c67;font-size:11px}.jw-login-status.error{color:#b32}.jw-login footer{display:flex;justify-content:space-between;gap:15px;margin-top:24px;padding-top:18px;border-top:1px solid #d7d7d2;color:#888;font-size:9px;text-transform:uppercase}.jw-login footer a{text-decoration:none}.jw-dev-note{display:inline-block;margin-left:6px;padding:2px 7px;border:1px solid var(--accent,#e8dd2f);border-radius:99px;color:var(--fg,#0a0a0a);font-size:10px;letter-spacing:.04em;text-transform:uppercase}@media(max-width:620px){.jw-login-open span{display:none}.jw-login{max-height:100dvh;margin:0 0 0 auto;padding:28px 20px}.jw-login h2{font-size:44px}.jw-classic>label{grid-template-columns:1fr}.jw-classic label button{min-height:44px}.jw-login footer{display:grid}}
@@ -0,0 +1,32 @@
import {npubFrom,prod} from './ds/env.js';
import {transferSession} from './business-sso.js';
let npub=npubFrom();
let mounted=false;
const sessionKey=`jw.business.login.${npub}`;
function mount(){if(mounted)return;const header=document.querySelector('.top,body>main header,header');if(!header)return;mounted=true;const trigger=document.createElement('button');trigger.className='jw-login-open';trigger.type='button';trigger.innerHTML='Login <span>with login.justworks.cash</span>';header.append(trigger);sessionStorage.removeItem(sessionKey);sessionStorage.removeItem('jw.business.signer');refreshSession(trigger);document.body.insertAdjacentHTML('beforeend',`<dialog class="jw-login" aria-labelledby="jwLoginTitle"><button class="jw-login-close" aria-label="Close">×</button><p class="jw-kicker">Merchant access</p><h2 id="jwLoginTitle">Just Works Login</h2><p class="jw-intro">Login with the identity you use for your business.</p><form class="jw-simple-login"><label>Login<input name="ownerIdentifier" autocomplete="username" placeholder="Email or identity" required></label><label>Password<input name="ownerPassword" type="password" autocomplete="current-password" required></label><button type="submit">Login <span>→</span></button><p class="jw-login-status" role="status"></p></form><button class="jw-more" type="button" aria-expanded="false">… more options <span>+</span></button><section class="jw-classic" hidden><form class="jw-nsec-login"><label><span><b>Raw nsec</b><small>Expert owner-key login</small></span><input name="ownerKey" type="password" placeholder="nsec1… or ncryptsec1…" autocomplete="off" required></label><button type="submit">Login with owner key</button></form><button type="button" data-nip07><span><b>Browser extension</b><small>NIP-07 · Alby, nos2x and compatible signers</small></span><i>Connect</i></button><label><span><b>Nostr bunker</b><small>NIP-46 remote signer</small></span><input data-bunker placeholder="bunker://… or nostrconnect://…"><button type="button" data-bunker-connect>Connect</button></label><a href="intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end"><span><b>Amber</b><small>Android Nostr signer</small></span><i>Open</i></a><p class="jw-expert-status" role="status"></p></section><footer><span>Non-custodial</span><a href="https://login.justworks.cash/docs" target="_blank" rel="noopener">How Just Works Login works ↗</a></footer></dialog>`);const dialog=document.querySelector('.jw-login'),status=dialog.querySelector('.jw-login-status'),expertStatus=dialog.querySelector('.jw-expert-status');if(!prod){dialog.querySelector('[name="ownerIdentifier"]').value='demo';dialog.querySelector('[name="ownerPassword"]').value='demo';dialog.querySelector('.jw-intro').insertAdjacentHTML('beforeend',' <b class="jw-dev-note">Dev · demo login prefilled</b>')}trigger.onclick=()=>dialog.showModal();dialog.querySelector('.jw-login-close').onclick=()=>dialog.close();dialog.onclick=e=>{if(e.target===dialog)dialog.close()};dialog.querySelector('.jw-more').onclick=function(){const classic=dialog.querySelector('.jw-classic'),open=classic.hidden;classic.hidden=!open;this.setAttribute('aria-expanded',String(open));this.querySelector('span').textContent=open?'−':'+'};dialog.querySelector('.jw-simple-login').onsubmit=event=>login(event,status);dialog.querySelector('.jw-nsec-login').onsubmit=event=>login(event,expertStatus);dialog.querySelector('[data-nip07]').onclick=async()=>{try{if(!window.nostr)throw new Error('No Nostr browser extension found.');const pubkey=await window.nostr.getPublicKey();sessionStorage.setItem('jw.business.signer',JSON.stringify({type:'nip07',pubkey}));expertStatus.textContent=`Extension connected · ${pubkey.slice(0,12)}…`;expertStatus.classList.remove('error')}catch(error){expertStatus.textContent=error.message;expertStatus.classList.add('error')}};dialog.querySelector('[data-bunker-connect]').onclick=()=>{const value=dialog.querySelector('[data-bunker]').value.trim();if(!/^(bunker|nostrconnect):\/\//i.test(value)){expertStatus.textContent='Enter a valid bunker:// or nostrconnect:// URI.';expertStatus.classList.add('error');return}window.jwBusinessSigner={type:'nip46',uri:value};expertStatus.textContent='Bunker connected for this page only.';expertStatus.classList.remove('error')}}
async function login(event,status){event.preventDefault();const form=event.currentTarget,button=form.querySelector('button[type="submit"]');button.disabled=true;status.textContent='Checking your Just Works Login…';status.classList.remove('error');const values=Object.fromEntries(new FormData(form));try{const response=await fetch('/api/business-owner-login',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({npub,...values})}),data=await response.json();if(!response.ok)throw new Error(data.error||'Login failed');form.querySelectorAll('input[type="password"]').forEach(input=>input.value='');status.textContent=`Logged in to ${data.merchant}.`;button.innerHTML='Logged in ✓';await refreshSession(document.querySelector('.jw-login-open'));setTimeout(()=>document.querySelector('.jw-login').close(),900)}catch(error){status.textContent=error.message;status.classList.add('error');button.disabled=false;button.innerHTML='Try again <span>↻</span>'}finally{form.querySelectorAll('input[type="password"]').forEach(input=>input.value='')}}
async function refreshSession(trigger){
try {
const response=await fetch('/api/business-session',{cache:'no-store',credentials:'same-origin'});
const data=await response.json();
if(!npub&&data.legacy_npub)npub=data.legacy_npub;
const authenticated=data.authenticated&&data.session?.npub===npub;
trigger.innerHTML=authenticated?'Logged in <span>Just Works</span>':'Login <span>with login.justworks.cash</span>';
let logout=document.querySelector('.jw-logout');
if(authenticated&&!logout){
logout=document.createElement('button');logout.type='button';logout.className='jw-logout';logout.textContent='Logout';
trigger.after(logout);
logout.onclick=async()=>{await fetch('/api/business-logout',{method:'POST',headers:{'content-type':'application/json'},body:'{}'});logout.remove();await refreshSession(trigger)};
}
if(!authenticated&&logout)logout.remove();
const form=document.querySelector('.jw-simple-login');
let transfer=document.querySelector('.jw-session-transfer');
if(data.sso_enabled&&!authenticated&&npub&&!location.pathname.endsWith('/business-sso')&&form&&!transfer){
transfer=document.createElement('button');transfer.type='button';transfer.className='jw-session-transfer';transfer.textContent='Use existing Business session';form.before(transfer);
transfer.onclick=async()=>{transfer.disabled=true;const status=document.querySelector('.jw-login-status');try{await transferSession(npub,status)}catch(error){status.textContent=error.message;transfer.disabled=false}};
}
if((authenticated||!data.sso_enabled)&&transfer)transfer.remove();
window.dispatchEvent(new CustomEvent('jw-session-changed',{detail:{authenticated,npub}}));
} catch { trigger.innerHTML='Login <span>with login.justworks.cash</span>'; }
}
const observer=new MutationObserver(()=>{mount();if(mounted)observer.disconnect()});observer.observe(document.documentElement,{childList:true,subtree:true});mount();
@@ -0,0 +1 @@
.jw-classic[hidden]{display:none}.jw-simple-login{display:grid;gap:12px}.jw-simple-login label{color:#777;font-size:9px;font-weight:900;letter-spacing:.11em;text-transform:uppercase}.jw-simple-login input{width:100%;display:block;margin-top:6px;padding:14px;border:1px solid #d3d3ce;background:#fff;font:inherit;font-size:15px}.jw-simple-login>button,.jw-nsec-login>button{display:flex;justify-content:space-between;padding:15px 17px;border:0;background:#080808;color:#fff;font-weight:900}.jw-simple-login button span{color:#ff4f1f}.jw-simple-login button:disabled,.jw-nsec-login button:disabled{opacity:.6}.jw-nsec-login{display:grid;gap:7px}.jw-nsec-login>label{display:block!important}.jw-nsec-login>button{width:100%}.jw-expert-status{min-height:18px;margin:8px 0 0;color:#087c67;font-size:11px}.jw-expert-status.error{color:#b32}
@@ -0,0 +1,37 @@
.module-summary{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0}
.module-summary>div{min-width:0}
.module-summary h2{font-size:clamp(26px,2.5vw,36px)}
.module-qr{flex:0 0 58px;display:grid;justify-items:center;gap:2px;padding:4px;border:1px solid var(--line);border-radius:6px;background:#fff;color:var(--ink);cursor:pointer;font-family:inherit;transition:border-color .18s,box-shadow .18s}
.module-qr img{display:block;width:48px;height:48px}
.module-qr span{font-size:9px;font-weight:800;letter-spacing:.1em}
.free-link{min-width:0;min-height:240px}
.free-link-top{display:flex;align-items:center;justify-content:space-between;gap:8px}
.free-link-top .free-icon{flex:0 0 42px}
.free-link .module-qr{border-color:#555}
.free-link h2{overflow-wrap:anywhere;line-height:1.15}
.free-open{display:flex;align-items:center;justify-content:space-between;color:#fff;text-decoration:none;font-size:12px;font-weight:900}
.free-open b{color:var(--orange);font-size:20px}
.free-open:focus-visible{outline:3px solid var(--orange);outline-offset:3px}
@media(max-width:680px){.free-link{min-height:230px;padding:14px;gap:16px}.free-link h2{font-size:22px}.free-link:first-child{grid-column:auto}}
.module-qr:hover{border-color:var(--orange);box-shadow:0 0 0 2px #ff4f1f1a}
.module-qr:focus-visible,.module-qr-dialog :focus-visible{outline:3px solid var(--orange);outline-offset:3px}
.module-qr[hidden],.module-qr-dialog [hidden]{display:none!important}
.module-qr-dialog{width:min(520px,calc(100% - 28px));max-height:calc(100dvh - 28px);margin:auto;padding:30px;border:1px solid var(--line);border-radius:16px;background:#fff;color:var(--ink);box-shadow:0 24px 100px #0005;overflow:auto;text-align:center}
.module-qr-dialog::backdrop{background:#080808b3;backdrop-filter:blur(4px)}
.module-qr-dialog .eyebrow{margin:6px 32px 12px}
.module-qr-dialog h2{margin:0;font-family:var(--jw-font-display,"Syne Variable",system-ui);font-size:34px;letter-spacing:-.04em}
.module-qr-dialog #module-qr-description{margin:10px 0 16px;color:#666;font-size:14px;line-height:1.5}
.module-qr-close{position:absolute;top:8px;right:8px;width:44px;height:44px;border:0;border-radius:50%;background:#f5f5f1;font-size:28px;cursor:pointer}
.module-qr-image{min-height:220px;display:grid;place-items:center}
.module-qr-image img{width:100%;max-width:360px;height:auto;aspect-ratio:1;display:block}
.module-qr-image p:empty{display:none}
.module-qr-image p{color:#777;font-size:14px}
.module-qr-url{display:block;margin:14px 0 20px;overflow-wrap:anywhere;font-size:12px;line-height:1.6;text-underline-offset:3px}
.module-qr-tools{display:flex;flex-wrap:wrap;justify-content:center;gap:8px}
.module-qr-tools button,.module-qr-tools a{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:10px 14px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);font-family:inherit;font-size:12px;font-weight:700;text-decoration:none;cursor:pointer}
.module-qr-tools [data-qr-copy]{background:var(--black);border-color:var(--black);color:#fff}
.module-qr-tools button:disabled{opacity:.4;cursor:default}
.module-qr-feedback{min-height:20px;margin:12px 0 0;color:#666;font-size:12px}
body:has(.module-qr-dialog[open]){overflow:hidden}
@media(max-width:680px){.module-summary h2{font-size:32px}.module-qr-dialog{padding:24px 18px}.module-qr-dialog h2{font-size:30px}}
@media print{body:has(.module-qr-dialog[open])>*:not(.module-qr-dialog){display:none!important}.module-qr-dialog[open]{position:static;width:100%;max-height:none;overflow:visible;border:0;box-shadow:none}.module-qr-dialog::backdrop{background:none}.module-qr-close,.module-qr-tools,.module-qr-feedback{display:none!important}.module-qr-image img{width:360px;max-width:100%}}
@@ -0,0 +1,104 @@
export function qrImageUrl(text) {
const prefix = location.pathname.startsWith('/business-demo/') ? '/business-demo' : '';
return `${prefix}/api/qr?text=${encodeURIComponent(text)}`;
}
// QR payloads contain only the public destination and merchant context.
export function moduleQrUrl(destination, npub) {
const url = new URL(destination, location.origin);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Invalid module URL');
const surface = url.searchParams.get('surface');
url.username = '';
url.password = '';
url.search = '';
url.hash = '';
if (surface) url.searchParams.set('surface', surface);
url.searchParams.set('npub', npub);
return url.href;
}
const escapeHtml = value => String(value).replace(/[&<>"']/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
// Both public links and business modules share the exact same QR control.
// Free-link URLs are already resolved by the API: preserve them verbatim.
export function qrButton(id, name, url, {free = false, hidden = false} = {}) {
return `<button type="button" class="module-qr" data-qr-trigger="${escapeHtml(id)}" ${free ? 'data-free-qr' : 'data-module-qr'}="${escapeHtml(id)}" data-qr-name="${escapeHtml(name)}" data-qr-url="${escapeHtml(url)}" aria-label="Show ${escapeHtml(name)} QR code" aria-haspopup="dialog" title="Show ${escapeHtml(name)} QR code" ${hidden ? 'hidden' : ''}><img src="${qrImageUrl(url)}" alt="" loading="lazy"><span>QR</span></button>`;
}
export function mountModuleQrs(root) {
const dialog = document.createElement('dialog');
dialog.className = 'module-qr-dialog';
dialog.setAttribute('aria-labelledby', 'module-qr-title');
dialog.setAttribute('aria-describedby', 'module-qr-description');
dialog.innerHTML = `<button type="button" class="module-qr-close" aria-label="Close QR code" autofocus>×</button><p class="eyebrow">Quick access</p><h2 id="module-qr-title"></h2><p id="module-qr-description">Scan to open this merchant’s module on your phone.</p><div class="module-qr-image"><img alt=""><p role="status" aria-live="polite"></p></div><a class="module-qr-url" target="_blank" rel="noopener"></a><div class="module-qr-tools"><button type="button" data-qr-copy>Copy link</button><a data-qr-download>Download QR</a><button type="button" data-qr-print>Print QR</button></div><p class="module-qr-feedback" role="status" aria-live="polite"></p>`;
document.body.append(dialog);
const title = dialog.querySelector('h2');
const img = dialog.querySelector('img');
const status = dialog.querySelector('.module-qr-image [role=status]');
const urlLink = dialog.querySelector('.module-qr-url');
const feedback = dialog.querySelector('.module-qr-feedback');
const download = dialog.querySelector('[data-qr-download]');
const print = dialog.querySelector('[data-qr-print]');
img.onload = () => {
img.hidden = false;
status.textContent = '';
download.hidden = false;
print.disabled = false;
};
img.onerror = () => {
img.hidden = true;
status.textContent = 'QR code could not load. You can still open or copy the link.';
download.hidden = true;
print.disabled = true;
};
root.addEventListener('click', (event) => {
const button = event.target.closest('[data-qr-trigger]');
if (!button || button.hidden) return;
const { qrUrl, qrName, qrTrigger } = button.dataset;
dialog.querySelector('#module-qr-description').textContent = button.hasAttribute('data-free-qr')
? 'Scan to open this merchant’s public page on your phone.'
: 'Scan to open this merchant’s module on your phone.';
title.textContent = qrName;
urlLink.href = qrUrl;
urlLink.textContent = qrUrl;
feedback.textContent = '';
img.hidden = true;
status.textContent = 'Loading QR code…';
print.disabled = true;
download.hidden = true;
img.alt = `QR code for ${qrName}`;
img.src = qrImageUrl(qrUrl);
download.href = img.src;
download.download = `justworks-${qrTrigger}-qr.svg`;
dialog.showModal();
});
dialog.querySelector('.module-qr-close').onclick = () => dialog.close();
dialog.addEventListener('click', (event) => {
if (event.target !== dialog) return;
const bounds = dialog.getBoundingClientRect();
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) dialog.close();
});
// Native modal dialogs restore focus on close. Do not also restore it from
// the queued `close` event: that can steal focus from the next QR trigger.
dialog.addEventListener('keydown', (event) => {
if (event.key !== 'Tab') return;
const controls = [...dialog.querySelectorAll('button:not(:disabled), a[href]')].filter(node => !node.hidden);
const first = controls[0], last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
dialog.querySelector('[data-qr-copy]').onclick = async () => {
try {
await navigator.clipboard.writeText(urlLink.href);
feedback.textContent = 'Link copied.';
} catch {
feedback.textContent = 'Could not copy automatically. Select and copy the link above.';
}
};
print.onclick = () => window.print();
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
import {frame as shellFrame} from './ds/frame.js';
const ids=['business','pos','order','pay','payandgo','screen','gallery','events'];
const query=new URLSearchParams(location.search);
const npub=query.get('npub')?.trim()||'';
const host=location.hostname.split('.')[0];
const surface=ids.includes(host)?host:(query.get('surface')||'pos');
const prod=location.hostname.endsWith('justworks.cash');
const link=id=>prod?`https://${id}.justworks.cash/`:`/portal?surface=${id}`;
const imgs=['https://justworks.cash/assets/examples/tiles/localpub-venue-1.webp','https://justworks.cash/assets/examples/tiles/localpub-venue-3.webp','https://justworks.cash/assets/examples/tiles/localpub-venue-5.webp'];
const app=document.querySelector('#app');let state;
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const frame=(html,label=surface,nav=true)=>shellFrame(app,{html,label,surface,nav});
function off(id){const m=state.modules[id];return m&&!m.enabled?`<section class="center"><p class="eyebrow">Module disabled</p><h1>${id} is currently off.</h1><p>The merchant can activate it in Business.</p><a href="${link('business')}">Open Business</a></section>`:''}
function sample(){return `<article class="queue-item"><i>08</i><div><strong>Table 8</strong><small>2× Coral · 1× Nikita</small></div><time>now</time><button>Accept</button></article><article class="queue-item"><i>⌁</i><div><strong>Anna · 4 guests</strong><small>Arriving in 10 minutes</small></div><time>18:40</time><button>Seat</button></article>`}
function orderCard(o){return `<article class="queue-item"><i>${o.table_number}</i><div><strong>Table ${o.table_number}</strong><small>${o.items.map(x=>`${x.qty}× ${esc(x.name)}`).join(' · ')}</small></div><time>${new Date(o.created_at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</time><button data-order="${o.id}">Accept</button></article>`}
function pos(){const orders=state.orders||[];frame(`<header class="head"><div><p class="eyebrow">LocalPub Madeira · Staff tablet</p><h1>Service, without the shouting.</h1></div><span class="online">● Live</span></header><section class="metrics"><article><span>Open orders</span><strong>${orders.length}</strong><small>Order to table</small></article><article><span>Bookings</span><strong>4</strong><small>2 arriving soon</small></article><article><span>Paid today</span><strong>286k</strong><small>sats settled</small></article></section><div class="pos-grid"><section class="panel"><p class="eyebrow">Live queue</p><h2>Orders & bookings</h2><div class="queue">${orders.length?orders.map(orderCard).join(''):sample()}</div></section><aside class="panel quick"><p class="eyebrow">New walk-in</p><h2>Start a service</h2><a href="${link('order')}"><b>↗</b><span><strong>New order</strong><small>Table order and tab</small></span></a><a href="${link('pay')}"><b>€</b><span><strong>Pay an order</strong><small>Find table or receipt</small></span></a><a href="${link('payandgo')}"><b>€</b><span><strong>Just Pay</strong><small>Enter receipt total</small></span></a><button><b>□</b><span><strong>Book a table</strong><small>Now or later</small></span></button></aside></div>`,'POS');document.querySelectorAll('[data-order]').forEach(b=>b.onclick=async()=>{await fetch(`/api/orders/${b.dataset.order}/status`,{method:'POST',headers:{'content-type':'application/json'},body:'{"status":"accepted"}'});boot()})}
function euro(cents){return `€${(cents/100).toFixed(2)}`}
function orderTotal(order){return (order?.items||[]).reduce((sum,item)=>sum+Number(item.price_cents||0)*Number(item.qty||0),0)}
function invoiceView(payment){return `<section class="invoice-view" aria-live="polite"><p class="eyebrow">Invoice ready</p><h2>${payment.amount_sats.toLocaleString()} sats</h2><div class="invoice-qr">${payment.qr_svg}</div><a class="open-wallet" href="lightning:${esc(payment.bolt11)}">Open Lightning wallet <span>↗</span></a><button class="copy-invoice" type="button">Copy invoice</button><div class="settlement-wait"><i></i><strong>Waiting for payment</strong><small>${payment.verifiable?'This page checks Lightning automatically.':'Keep this page open while the merchant confirms.'}</small></div><button class="check-payment" type="button">Check payment now</button></section>`}
function paidView(payment){const proof=payment.receipt_id?`<a class="payment-proof" href="https://njump.me/${esc(payment.receipt_id)}" target="_blank" rel="noopener"><span>Verified Lightning proof</span><code>${esc(payment.receipt_id.slice(0,12))}…${esc(payment.receipt_id.slice(-8))}</code><b>↗</b></a>`:'<p class="payment-proof unavailable">Payment confirmed by the provider</p>';return `<section class="paid-view" role="status" aria-live="assertive"><div class="paid-mark">✓</div><p class="eyebrow">Lightning settled</p><h2>PAID</h2><div class="paid-amount"><strong>${euro(payment.amount_cents)}</strong><span>${payment.amount_sats.toLocaleString()} sats</span></div><p class="paid-merchant">Paid to <strong>${esc(payment.merchant_name)}</strong></p>${proof}<div class="paid-meta"><span>Confirmed</span><time>${new Date(payment.settled_at||Date.now()).toLocaleString([],{dateStyle:'medium',timeStyle:'short'})}</time></div><p class="show-staff">Show this screen to the merchant</p></section>`}
async function createQuickInvoice(amountCents,comment){const response=await fetch('/api/quick-pay',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({npub,amount_cents:amountCents,comment})});const value=await response.json();if(!response.ok)throw new Error(value.error||'Invoice unavailable');return value}
async function paymentStatus(id){const response=await fetch(`/api/quick-pay-status?npub=${encodeURIComponent(npub)}&id=${encodeURIComponent(id)}`,{cache:'no-store'});const value=await response.json();if(!response.ok)throw new Error(value.error||'Payment status unavailable');return value}
function pay(go=false){const id=go?'payandgo':'pay';if(off(id)){frame(off(id),go?'Just Pay':'Pay Bill');return}const merchant=state.merchant||{},profile=state.profile||{},orders=state.orders||[];let pollTimer;const initialTable=query.get('table')||'';frame(`<section class="pay-shell quick-pay"><div class="pay-copy"><p class="eyebrow">${go?'Pay & go':'Pay your table'}</p><h1>${go?'Receipt in. Wallet out.':'Find it. Scan it. Paid.'}</h1><p>${go?'Enter the exact receipt total and pay. No account.':'Enter your table number. We find the open order and create one Lightning invoice.'}</p></div><div class="pay-card"><div class="merchant"><span>${esc((merchant.name||'JW').split(/\s+/).slice(0,2).map(x=>x[0]).join('').toUpperCase())}</span><div><strong>${esc(merchant.name||'Merchant')}</strong><small>${esc(profile.lud16||state.lightning_address||'Lightning')}</small></div></div><label for="payAmount">${go?'Receipt total':'Table number'}</label><div class="amount"><span>${go?'€':'#'}</span><input id="payAmount" inputmode="${go?'decimal':'numeric'}" autocomplete="off" placeholder="${go?'0.00':'Table'}" value="${esc(initialTable)}"><small id="amountHint">${go?'Add the amount printed on the receipt.':'Enter your table to find its open order.'}</small></div>${go?'':`<div id="receipt" class="receipt hidden"></div>`}<button id="payButton" class="primary" type="button">Generate invoice <span>→</span></button><p id="payStatus" class="secure">QR code + one-tap wallet link</p><div id="invoiceResult"></div></div></section>`,go?'Just Pay':'Pay Bill');const input=document.querySelector('#payAmount'),button=document.querySelector('#payButton'),status=document.querySelector('#payStatus'),receipt=document.querySelector('#receipt'),result=document.querySelector('#invoiceResult');let selectedOrder=null;const findOrder=()=>{if(go)return;const table=Number.parseInt(input.value,10);selectedOrder=orders.find(order=>Number(order.table_number)===table&&order.status!=='closed')||null;const cents=orderTotal(selectedOrder);receipt.classList.toggle('hidden',!selectedOrder);receipt.innerHTML=selectedOrder?`<span>${selectedOrder.items.map(item=>`${item.qty}× ${esc(item.name)}`).join(' · ')}</span><strong>${euro(cents)}</strong>`:'';document.querySelector('#amountHint').textContent=selectedOrder?(cents?`Open order · ${euro(cents)}`:'This order has no priced items.'):'No open order found for this table.'};if(!go){input.addEventListener('input',findOrder);if(initialTable)findOrder()}button.onclick=async()=>{let cents;if(go){const raw=Number.parseFloat(input.value.replace(',','.'));if(!Number.isFinite(raw)||raw<=0){status.textContent='Enter the receipt total.';status.classList.add('error');input.focus();return}cents=Math.round(raw*100)}else{findOrder();cents=orderTotal(selectedOrder);if(!selectedOrder||!cents){status.textContent=selectedOrder?'This order has no payable amount.':'Enter a table with an open order.';status.classList.add('error');input.focus();return}}button.disabled=true;button.innerHTML='Creating invoice…';status.textContent='Connecting to the merchant’s Lightning address…';status.classList.remove('error');try{const payment=await createQuickInvoice(cents,go?'Pay & Go':`Table ${selectedOrder.table_number}`);result.innerHTML=invoiceView(payment);document.querySelector('.copy-invoice').onclick=async event=>{await navigator.clipboard.writeText(payment.bolt11);event.currentTarget.textContent='Copied ✓'};button.classList.add('hidden');status.textContent=`${euro(payment.amount_cents)} · ready to pay`;const check=async()=>{try{const current=await paymentStatus(payment.id);if(current.status!=='paid')return;clearInterval(pollTimer);status.classList.add('hidden');result.innerHTML=paidView(current);document.querySelector('.paid-view').scrollIntoView({behavior:'smooth',block:'center'})}catch(error){status.textContent='Still checking Lightning…'}};document.querySelector('.check-payment').onclick=check;if(payment.verifiable)pollTimer=setInterval(check,2500);document.querySelector('.invoice-view').scrollIntoView({behavior:'smooth',block:'nearest'})}catch(error){status.textContent=error.message;status.classList.add('error');button.disabled=false;button.innerHTML='Try again <span>→</span>'}}}
function screen(){if(off('screen')){frame(off('screen'),'Screen');return}frame(`<section class="signage"><img src="${imgs[0]}"><div class="shade"></div><header><span data-logo="icon"></span><b>LocalPub Madeira</b><time>${new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</time></header><div class="sign-copy"><p>Tonight in Madeira</p><h1>Fresh poncha.<br>Cold Coral.<br>Good people.</h1><div class="ticker"><span>21,000 sats tipped</span><span>Table 8 · order confirmed</span><span>Next · Sunset Sessions</span></div></div><aside><span>Scan to order</span><div>▦</div><strong>order.justworks.cash</strong></aside></section>`,'Screen',false)}
const card=(name,type,img,rating)=>`<article class="merchant-card"><figure><img src="${img}"><span>Open now</span></figure><div><p>${type}</p><h2>${name}</h2><small>★ ${rating} · Bitcoin accepted</small><footer><span>Order</span><span>Book</span><span>Pay</span></footer></div></article>`;
function gallery(){if(off('gallery')){frame(off('gallery'),'Gallery');return}frame(`<header class="gallery-head"><p class="eyebrow">Discover Madeira</p><h1>Places that just work.</h1><p>Bitcoin-friendly merchants, without the map.</p><div class="filters"><button class="active">All places</button><button>Open now</button><button>Food & drink</button><button>Stay</button><button>Shop</button><button>Lightning</button></div></header><section class="gallery-grid">${card('LocalPub Madeira','Bar · Funchal',imgs[0],'4.8')}${card('Barreirinha Bar Café','Café · Old Town',imgs[1],'4.7')}${card('Madeira Sunset Lodge','Stay · Ponta do Sol',imgs[2],'4.9')}${card('Mercado Local','Market · Funchal',imgs[0],'4.6')}</section>`,'Gallery');document.querySelectorAll('.filters button').forEach(b=>b.onclick=()=>{document.querySelectorAll('.filters button').forEach(x=>x.classList.remove('active'));b.classList.add('active')})}
function events(){frame(`<section class="center"><span class="orbit">⌁</span><p class="eyebrow">Nostr-native discovery</p><h1>Events are coming soon.</h1><p>Signed events from Just Works merchants—shown like the gallery, owned by the people publishing them.</p><div class="tags"><span>Merchant events</span><span>Nostr signed</span><span>Local discovery</span></div><a href="${link('gallery')}">Explore merchants instead →</a></section>`,'Events')}
async function boot(){if(surface==='order')return location.replace('/nsite'+location.search);if(surface==='business')return location.replace(link('business'));if(['pay','payandgo'].includes(surface)){if(!npub)throw new Error('Merchant missing');const [business,posState]=await Promise.all([fetch(`/api/business?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async r=>{const v=await r.json();if(!r.ok)throw new Error(v.error);return v}),fetch(`/api/pay-orders?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async r=>{const v=await r.json();if(!r.ok)throw new Error(v.error);return v})]);state={...business,...posState};return pay(surface==='payandgo')}state=await fetch('/api/dashboard',{cache:'no-store'}).then(r=>r.json());({pos,screen,gallery,events}[surface]||pos)()}boot().catch(error=>frame(`<section class="center"><h1>Payment temporarily unavailable.</h1><p>${esc(error.message)}</p></section>`));
@@ -0,0 +1,34 @@
const prefix = location.pathname.split('/').slice(0, -1).join('/');
const mount = document.querySelector('#mount');
let syncing = false;
addEventListener('message',event=>{const frame=mount.querySelector('iframe');if(!frame||event.source!==frame.contentWindow||event.data?.type!=='napplet.resize')return;const height=Math.max(320,Math.min(4000,Number(event.data.height)||0));frame.style.height=`${height}px`});
async function tableToken() {
const existing = new URLSearchParams(location.search).get('table');
if (existing) return existing;
const dashboard = await fetch(`${prefix}/api/dashboard`, { cache: 'no-store' }).then(response => response.json());
const table = dashboard.tables.find(item => item.table_number === 21) || dashboard.tables[0];
history.replaceState(null, '', `${location.pathname}?table=${encodeURIComponent(table.token)}`);
return table.token;
}
async function syncActivation() {
if (syncing) return;
syncing = true;
try {
const config = await fetch(`${prefix}/api/config`, { cache: 'no-store' }).then(response => response.json());
const frame = mount.querySelector('iframe');
if (!config.module_enabled && frame) {
mount.className = 'napplet off';
mount.textContent = 'Order to Table is currently disabled by the merchant.';
} else if (config.module_enabled && !frame) {
const token = await tableToken();
mount.className = 'napplet';
mount.innerHTML = `<iframe title="Order to Table napplet shell" src="${prefix}/embed?table=${encodeURIComponent(token)}"></iframe>`;
}
} finally {
syncing = false;
}
}
window.setInterval(() => syncActivation().catch(() => {}), 700);
@@ -0,0 +1,4 @@
html{background:#0a0a0a}body{min-height:100vh;min-height:100dvh;background:#0a0a0a}.menu.intro{display:none}.merchant-hero{min-height:156px;padding:18px 20px 22px;background-position:center 44%;overflow:hidden}.merchant-hero .wash{background:linear-gradient(90deg,rgba(0,0,0,.9),rgba(0,0,0,.35)),linear-gradient(0deg,rgba(0,0,0,.72),transparent 70%)}.merchant-hero nav{min-height:30px}.merchant-hero nav a{font-size:14px}.merchant-hero nav small{font-size:8px}.hero-copy{margin-top:auto}.hero-copy p,.hero-copy>span{display:none}.hero-copy h1{max-width:92%;margin:18px 0 0;font-size:clamp(32px,10vw,46px);line-height:.9;letter-spacing:-.055em}.order-shell{width:100%;display:block;margin:0;padding:0;background:var(--panel)}.menu:not(.intro){padding:28px 20px 0}.menu:not(.intro) h2{font-size:34px}.order-form{position:static;width:100%;padding:28px 20px max(24px,env(safe-area-inset-bottom));border:0;border-radius:0;background:var(--panel);box-shadow:none}.order-form h2{max-width:390px;margin:10px 0 25px;font-size:clamp(38px,11vw,48px);line-height:.92}.order-form label{margin-top:17px}.order-form input,.order-form textarea{padding:15px;border-radius:11px;font-size:16px}.order-form textarea{min-height:116px}.order-form #submit{min-height:58px;align-items:center;margin-top:22px;border-radius:11px;font-size:15px}.privacy{margin:13px 0 0}.confirmed{background:var(--bg)}
@media(min-width:760px){body{padding:24px;background:#0a0a0a}#app:not(:has(>.loading)):not(:has(>.error)):not(:has(>.confirmed)){width:min(1180px,100%);min-height:min(760px,calc(100dvh - 48px));margin:auto;display:grid;grid-template-columns:minmax(320px,43%) minmax(420px,57%);overflow:hidden;border:1px solid rgba(255,255,255,.12);border-radius:28px;background:#fff;box-shadow:0 35px 100px rgba(0,0,0,.38)}.merchant-hero{min-height:100%;padding:28px 32px 38px;background-position:center}.merchant-hero .wash{background:linear-gradient(0deg,rgba(0,0,0,.85),rgba(0,0,0,.08) 68%),linear-gradient(90deg,rgba(0,0,0,.4),transparent)}.merchant-hero nav a{font-size:17px}.merchant-hero nav small{font-size:9px}.hero-copy p,.hero-copy>span{display:block}.hero-copy p{color:#ff6b35}.hero-copy h1{max-width:100%;margin:18px 0 20px;font-size:clamp(50px,5.4vw,82px);line-height:.84}.hero-copy>span{max-width:330px;color:rgba(255,255,255,.76);font-size:16px}.order-shell{min-height:100%;display:flex;flex-direction:column;justify-content:center;overflow:auto}.menu:not(.intro){padding:42px 50px 0}.products{max-height:32vh;overflow:auto}.order-form{max-width:640px;margin:auto;padding:48px clamp(42px,5vw,76px)}.order-form h2{max-width:520px;font-size:clamp(48px,4.5vw,68px)}.order-form input,.order-form textarea{padding:16px}.order-form textarea{min-height:126px}.order-form #submit{min-height:62px}.menu:not(.intro)+.order-form{margin-top:0;padding-top:30px}}
@media(min-width:1400px){body{display:grid;place-items:center}#app:not(:has(>.loading)):not(:has(>.error)):not(:has(>.confirmed)){min-height:min(800px,calc(100dvh - 64px))}}
@media(max-width:420px){.merchant-hero{min-height:138px;padding-top:max(16px,env(safe-area-inset-top))}.order-form h2{font-size:38px}.order-form{padding-top:24px}.order-form textarea{min-height:104px}}
@@ -0,0 +1,24 @@
.status-view{min-height:100dvh;display:grid;grid-template-columns:minmax(330px,42%) 1fr;background:var(--bg)}
.status-merchant{position:relative;min-height:100%;display:flex;flex-direction:column;justify-content:space-between;padding:30px clamp(25px,4vw,58px) 55px;color:#fff;background:#111 var(--hero,linear-gradient(135deg,#252525,#080808)) center/cover}
.status-merchant>.wash{background:linear-gradient(0deg,rgba(0,0,0,.92),rgba(0,0,0,.08) 70%)}
.status-merchant>a,.status-merchant>div:last-child{position:relative;z-index:1}
.status-merchant>a{color:#fff;text-decoration:none;font-weight:900}
.status-merchant>div:last-child p{color:var(--accent);font-size:10px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}
.status-merchant h2{margin:12px 0;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(52px,7vw,100px);line-height:.82;letter-spacing:-.065em}
.status-merchant>div:last-child>span{color:#ccc}
.status-view .confirmed{min-height:100%;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;text-align:left;padding:clamp(35px,8vw,120px)}
.status-view .confirmed h1{max-width:760px;margin:18px 0 24px;font-size:clamp(58px,7vw,110px);line-height:.84}
.status-view .confirmed>p:not(.eyebrow){margin:0;color:var(--muted);font-size:17px;line-height:1.5}
.status-view .confirmed small{font-size:11px}
.live-pill{display:flex;align-items:center;gap:8px;margin-bottom:38px;padding:9px 12px;border:1px solid var(--line);border-radius:99px;font-size:10px;font-weight:900;letter-spacing:.09em;text-transform:uppercase}
.live-pill i{width:8px;height:8px;border:0;border-radius:50%;background:var(--green);box-shadow:0 0 0 5px rgba(8,128,107,.12);animation:pulse 1.8s infinite}
.status-view .journey{width:100%;max-width:none;display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:48px 0 42px;text-align:left}
.journey-step{position:relative;padding-top:32px;border-top:2px solid #d7d8d4;color:#9a9d99;font-size:11px;font-weight:850;text-transform:uppercase;letter-spacing:.06em}
.status-view .journey-step i{position:absolute;top:-10px;left:0;width:19px;height:19px;display:grid;place-items:center;border:2px solid #c5c8c4;border-radius:50%;background:var(--bg);font-size:10px;font-style:normal}
.journey-step.done{border-color:var(--green);color:var(--text)}
.status-view .journey-step.done i{border-color:var(--green);background:var(--green);color:#fff}
.status-view .journey-step.current i{box-shadow:0 0 0 7px rgba(8,128,107,.12)}
.again{width:min(320px,100%);display:flex;justify-content:space-between;padding:17px 19px;background:var(--text);color:#fff!important;text-decoration:none;font-weight:900}
.again span{color:var(--accent)}
@keyframes pulse{50%{opacity:.45}}
@media(max-width:760px){.status-view{display:block;background:#0a0a0a}.status-merchant{min-height:210px;padding:22px 20px;background-position:center 45%}.status-merchant h2{font-size:42px}.status-view .confirmed{min-height:calc(100dvh - 210px);padding:38px 20px max(28px,env(safe-area-inset-bottom));background:var(--bg)}.status-view .confirmed h1{margin-left:0;font-size:14vw}.live-pill{margin-bottom:30px}.status-view .journey{grid-template-columns:repeat(3,1fr);margin:38px 0 32px}.journey-step{padding-top:27px;font-size:9px}.again{width:100%}}
@@ -0,0 +1 @@
:root{--bg:#f7f7f4;--panel:#fff;--soft:#eef0ed;--line:rgba(10,10,10,.11);--text:#0a0a0a;--body:#454545;--muted:#737773;--accent:#f4511e;--green:#08806b;font-family:var(--jw-font-sans,"Geist Variable",Inter,system-ui,sans-serif)}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text)}button,input,textarea{font:inherit}.merchant-hero{position:relative;min-height:62svh;display:flex;flex-direction:column;padding:26px clamp(18px,5vw,70px) clamp(45px,7vw,90px);color:#fff;background:#151817 var(--hero,linear-gradient(135deg,#26332e,#080909)) center/cover}.wash{position:absolute;inset:0;background:linear-gradient(90deg,rgba(0,0,0,.9),rgba(0,0,0,.35) 62%,rgba(0,0,0,.1)),linear-gradient(0deg,rgba(0,0,0,.7),transparent 55%)}nav,.hero-copy{position:relative;z-index:1}nav{display:flex;justify-content:space-between;align-items:center}nav a{color:#fff;text-decoration:none;font-weight:900;font-size:18px}nav a:first-letter{display:inline-block;border:1px solid #fff}nav small{text-transform:uppercase;letter-spacing:.14em;font-size:9px;font-weight:850}.hero-copy{margin-top:auto;max-width:1000px}.hero-copy p,.eyebrow{margin:0;color:var(--accent);font-size:10px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}.hero-copy h1,.order-shell h2,.confirmed h1{font-family:var(--jw-font-display,"Syne Variable",system-ui);font-weight:630;letter-spacing:-.06em}.hero-copy h1{font-size:clamp(65px,10vw,150px);line-height:.78;margin:20px 0 28px}.hero-copy>span{color:rgba(255,255,255,.72);font-size:18px}.order-shell{width:min(1200px,calc(100% - 32px));margin:auto;padding:clamp(55px,8vw,110px) 0;display:grid;grid-template-columns:1.1fr .8fr;gap:clamp(35px,8vw,100px);align-items:start}.order-shell h2{font-size:clamp(38px,5vw,66px);line-height:.95;margin:12px 0 24px}.intro>p:last-child{max-width:600px;color:var(--body);font-size:18px;line-height:1.65}.products{display:grid;gap:9px}.product{display:flex;justify-content:space-between;gap:20px;padding:18px;border:1px solid var(--line);border-radius:17px;background:var(--panel)}.product h3{margin:0 0 5px}.product p{margin:0;color:var(--muted);font-size:12px}.product strong{display:block;margin-top:8px;color:var(--accent)}.stepper{display:flex;align-items:center;gap:12px}.stepper button{width:34px;height:34px;border:1px solid var(--line);border-radius:50%;background:var(--soft);font-weight:900}.stepper span{min-width:18px;text-align:center;font-weight:900}.order-form{padding:clamp(22px,4vw,38px);border:1px solid var(--line);border-radius:26px;background:var(--panel);box-shadow:0 24px 70px rgba(0,0,0,.08);position:sticky;top:20px}.order-form h2{font-size:42px}.order-form label{display:block;margin-top:20px;color:var(--muted);font-size:10px;font-weight:900;text-transform:uppercase;letter-spacing:.1em}.order-form label small{font-weight:600}.order-form input,.order-form textarea{display:block;width:100%;margin-top:7px;padding:13px;border:1px solid var(--line);border-radius:13px;background:var(--soft);color:var(--text);outline:none;text-transform:none;letter-spacing:0;font-size:15px;resize:vertical}.order-form input:focus,.order-form textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(244,81,30,.14)}#submit{width:100%;display:flex;justify-content:space-between;margin-top:25px;padding:16px 18px;border:0;border-radius:14px;background:var(--accent);color:#fff;font-weight:900}#submit:disabled{opacity:.65}.privacy{text-align:center;color:var(--muted);font-size:9px}.loading,.error,.confirmed{min-height:100vh;display:grid;place-content:center;text-align:center;padding:30px}.loading>span,.error>span{font-size:70px}.error h1,.confirmed h1{max-width:900px;font-size:clamp(48px,7vw,100px);line-height:.88;margin:20px}.error small,.loading p,.confirmed>p{color:var(--muted)}.check{width:90px;height:90px;display:grid;place-items:center;margin:0 auto 25px;border-radius:50%;background:var(--green);color:#fff;font-size:45px}.journey{display:grid;grid-template-columns:18px auto 1fr 18px auto 1fr 18px auto;align-items:center;gap:8px;margin:45px auto;max-width:800px}.journey i{width:16px;height:16px;border:2px solid #bbc0bd;border-radius:50%}.journey i.done{border-color:var(--green);background:var(--green)}.journey b{height:2px;background:#d4d7d5}.confirmed a{color:var(--text);font-weight:850}@media(max-width:760px){.merchant-hero{min-height:52svh}.hero-copy h1{font-size:17vw}.order-shell{grid-template-columns:1fr}.order-form{position:static}.journey{grid-template-columns:14px 1fr;max-width:260px;text-align:left}.journey b{display:none}.confirmed h1{font-size:14vw}}
@@ -0,0 +1,14 @@
import {frame} from './ds/frame.js';
import {npubFrom} from './ds/env.js';
const app=document.querySelector('#app');const params=new URLSearchParams(location.search);const npub=npubFrom();const prefilledTable=params.get('table')||'';let merchant,quantities={},statusTimer;
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function error(message){app.innerHTML=`<section class="center"><span class="orbit">⌁</span><p class="eyebrow">Just Works · Order</p><h1>${esc(message)}</h1><small>Use order.justworks.cash/?npub=npub1…</small></section>`}
function productCard(item){return `<article class="menu-item" data-id="${esc(item.id)}"><div><strong>${esc(item.name)}</strong>${item.description?`<small>${esc(item.description)}</small>`:''}${item.price?`<small>${esc(item.price)}</small>`:''}</div><div class="step"><button data-minus="${esc(item.id)}" aria-label="Remove ${esc(item.name)}">−</button><b data-qty="${esc(item.id)}">0</b><button data-plus="${esc(item.id)}" aria-label="Add ${esc(item.name)}">+</button></div></article>`}
function render(data){merchant=data;document.title=`Order ${data.merchant.name} · Just Works`;frame(app,{label:'Order',surface:'order',html:`<section class="pay-shell"><div class="pay-copy"><p class="eyebrow">Order to table</p><h1>What can we bring you?</h1><p>Order from ${esc(data.merchant.name)} without waiting for the waiter.</p></div><form id="orderForm" class="pay-card form-card"><div class="merchant"><span>↗</span><div><strong>${esc(data.merchant.name)}</strong><small>Taking orders</small></div></div>${data.products.length?`<div class="order-menu"><p class="eyebrow">Merchant menu</p>${data.products.map(productCard).join('')}</div>`:''}<label class="field"><span>Table number</span><input id="table" type="number" inputmode="numeric" min="1" max="999" value="${esc(prefilledTable)}" placeholder="e.g. 12" required></label><label class="field"><span>Order or request</span><textarea id="request" rows="5" placeholder="${data.products.length?'Two coffees, one without sugar…':'This merchant has not published a structured menu yet. Tell the staff exactly what you want… two coffees, one without sugar'}" ${data.products.length?'':'required'}></textarea></label><label class="field"><span>Your name <small>(optional)</small></span><input id="guest" maxlength="80" autocomplete="name" placeholder="So staff knows who asked"></label><button id="submit" type="submit" class="primary">Send to ${esc(data.merchant.name)} <span>→</span></button><p class="privacy">No account · no app · sent directly to this merchant</p></form></section>`});bind(data.products)}
function bind(products){for(const item of products){quantities[item.id]=0;document.querySelector(`[data-plus="${CSS.escape(item.id)}"]`).onclick=()=>setQty(item.id,quantities[item.id]+1);document.querySelector(`[data-minus="${CSS.escape(item.id)}"]`).onclick=()=>setQty(item.id,quantities[item.id]-1)}document.querySelector('#orderForm').onsubmit=submit}
function setQty(id,value){quantities[id]=Math.max(0,Math.min(20,value));document.querySelector(`[data-qty="${CSS.escape(id)}"]`).textContent=quantities[id]}
const statusCopy={'new':{eyebrow:'Sent to the staff',title:'Your order is on its way.',body:'Waiting for the team to confirm it.',step:0},accepted:{eyebrow:'Confirmed by the staff',title:'They are preparing it.',body:'Your order has been accepted.',step:1},delivered:{eyebrow:'Coming to your table',title:'It is ready for you.',body:'The team is bringing it now.',step:2},closed:{eyebrow:'Order complete',title:'Just Works.',body:'Enjoy—and order again whenever you like.',step:3}};
function renderStatus(order){const state=statusCopy[order.status]||statusCopy['new'];const card=document.querySelector('#orderForm,.order-status-card');if(!card)return;card.outerHTML=`<section class="pay-card form-card order-status-card" aria-live="polite"><div class="merchant"><span>✓</span><div><strong>${esc(merchant.merchant.name)}</strong><small>Table ${order.table||order.table_number}</small></div></div><div class="live-pill"><i></i> Live order status</div><p class="eyebrow">${esc(state.eyebrow)}</p><h2>${esc(state.title)}</h2><p class="status-copy">${esc(state.body)}</p><small class="order-reference">Table ${order.table||order.table_number} · ${esc(order.id.toUpperCase())}</small><div class="journey">${['Sent','Staff confirmed','Coming to table'].map((label,index)=>`<div class="journey-step ${index<=state.step?'done':''} ${index===state.step?'current':''}"><i>${index<state.step?'✓':index+1}</i><span>${label}</span></div>`).join('')}</div><a class="link-button" href="${location.href}">Add another order <span>+</span></a></section>`}
async function pollStatus(order){try{const response=await fetch(`/api/merchant-order-status?npub=${encodeURIComponent(npub)}&id=${encodeURIComponent(order.id)}`,{cache:'no-store'});if(response.ok){const latest=await response.json();if(latest.status!==order.status){order.status=latest.status;order.table=latest.table_number;renderStatus(order)}}}catch{}if(order.status!=='closed')statusTimer=setTimeout(()=>pollStatus(order),1500)}
async function submit(event){event.preventDefault();const button=document.querySelector('#submit');button.disabled=true;button.innerHTML='Sending to staff…';const items=merchant.products.filter(item=>quantities[item.id]>0).map(item=>({id:item.id,name:item.name,qty:quantities[item.id]}));const response=await fetch('/api/merchant-orders',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({npub,table:Number(document.querySelector('#table').value),request:document.querySelector('#request').value,guest:document.querySelector('#guest').value,items})});const data=await response.json();if(!response.ok){button.disabled=false;button.innerHTML=`${esc(data.error||'Try again')} <span>↻</span>`;return}renderStatus(data);pollStatus(data)}
if(!npub)error('Add the merchant npub to order.');else fetch(`/api/order-merchant?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async r=>{const data=await r.json();if(!r.ok)throw new Error(data.error||'Merchant unavailable');return data}).then(render).catch(e=>error(e.message));
@@ -0,0 +1,83 @@
/*
* Just Works page template — local twin of the platform-hosted merchant page
* (justworks.cash/{slug}): the stable address printed on QR codes. Carries the
* module CTAs (Order, Pay Bill, Just Pay, Bookings) and links out to the
* sovereign nsite copy. Same tokens as the dashboard.
*/
#app{min-height:100vh;display:flex;flex-direction:column;background:var(--jw-bg)}
/* Platform chrome */
.page-nav{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:18px clamp(20px,5vw,56px);border-bottom:1px solid var(--jw-line);background:var(--jw-surface)}
.page-nav .brand{display:flex;align-items:center;gap:10px;color:var(--jw-text-primary);text-decoration:none;font-weight:900}
.page-nav .brand small{display:block;font-size:10px;font-weight:850;letter-spacing:.14em;text-transform:uppercase;color:var(--jw-text-muted)}
.page-nav .chip{border:1px solid var(--jw-line-strong);border-radius:999px;padding:7px 12px;font-size:10px;font-weight:900;letter-spacing:.12em;text-transform:uppercase;color:var(--jw-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:44vw}
/* Hero */
.page-hero{position:relative;display:flex;align-items:flex-end;min-height:74svh;padding:clamp(48px,8vw,104px) clamp(20px,6vw,72px);background:var(--jw-surface-sunken);overflow:hidden}
.page-hero[data-hero] .backdrop{position:absolute;inset:0;background:var(--hero) center/cover}
.page-hero .shade{position:absolute;inset:0;background:linear-gradient(0deg,rgba(10,10,10,.82),rgba(10,10,10,.3) 46%,rgba(10,10,10,.14))}
.page-hero .copy{position:relative;z-index:1;max-width:860px;color:#fafafa}
.page-hero .tag{display:inline-flex;border:1px solid rgba(255,255,255,.3);border-radius:999px;padding:8px 12px;font-size:10px;font-weight:900;letter-spacing:.14em;text-transform:uppercase;color:rgba(255,255,255,.85)}
.page-hero h1{font-family:var(--jw-font-display);font-size:clamp(48px,9vw,120px);font-weight:750;line-height:.84;letter-spacing:-.06em;margin:24px 0}
.page-hero .lead{max-width:600px;font-size:clamp(16px,1.8vw,20px);line-height:1.55;color:rgba(250,250,250,.8);margin:0}
.page-hero .facts{display:flex;flex-wrap:wrap;gap:8px;margin-top:24px}
.page-hero .facts span{border:1px solid rgba(255,255,255,.32);border-radius:999px;padding:7px 12px;font-size:10px;font-weight:900;letter-spacing:.1em;text-transform:uppercase;color:rgba(255,255,255,.85)}
/* Sections */
main{flex:1}
.eyebrow{color:var(--jw-accent);font-size:10px;font-weight:900;letter-spacing:.17em;text-transform:uppercase;margin:0}
.page-section{border-top:1px solid var(--jw-line);padding:clamp(52px,8vw,104px) clamp(20px,6vw,72px)}
.page-section>div{max-width:1080px;margin:0 auto}
.page-section h2{font-family:var(--jw-font-display);font-size:clamp(34px,5.4vw,64px);font-weight:700;line-height:.94;letter-spacing:-.04em;margin:12px 0 30px;color:var(--jw-text-primary)}
/* Module cards with QR */
.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px}
.module-card{display:grid;grid-template-columns:1fr auto;gap:18px;align-items:center;border:1px solid var(--jw-line);border-radius:18px;padding:22px;background:var(--jw-surface)}
.module-card .glyph{width:42px;height:42px;display:grid;place-items:center;border-radius:12px;background:var(--jw-black,#0a0a0a);color:#fff;font-weight:900;font-size:17px}
.module-card h3{display:flex;align-items:center;gap:10px;font-size:17px;font-weight:850;margin:0;color:var(--jw-text-primary)}
.module-card p{margin:8px 0 0;font-size:13px;line-height:1.5;color:var(--jw-text-muted)}
.module-card .status{font-size:10px;font-weight:900;letter-spacing:.1em;text-transform:uppercase;color:var(--jw-text-muted)}
.module-card .status.live{color:var(--jw-accent)}
.module-card .qr{width:96px;border:6px solid #fafafa;border-radius:10px;background:#fafafa}
.module-card .qr img{display:block;width:100%}
.module-card .cta{grid-column:1/-1;display:inline-flex;justify-content:space-between;align-items:center;border:1px solid var(--jw-line-strong);border-radius:999px;padding:11px 16px;color:var(--jw-text-primary);text-decoration:none;font-size:13px;font-weight:850}
.module-card .cta:hover{border-color:var(--jw-text-primary)}
/* Gallery + facts */
.photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
.photo-grid figure{margin:0;border:1px solid var(--jw-line);border-radius:14px;overflow:hidden;background:var(--jw-surface-sunken);aspect-ratio:4/3}
.photo-grid img{display:block;width:100%;height:100%;object-fit:cover}
.facts-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px}
.facts-list div{border:1px solid var(--jw-line);border-radius:14px;padding:16px 18px;background:var(--jw-surface)}
.facts-list dt{font-size:10px;font-weight:900;letter-spacing:.14em;text-transform:uppercase;color:var(--jw-text-muted)}
.facts-list dd{margin:6px 0 0;font-size:14px;line-height:1.5;color:var(--jw-text-primary)}
.facts-list a{color:var(--jw-text-primary)}
/* Sovereign copy strip */
.page-section.dark{border-top:0;background:var(--jw-black,#0a0a0a);color:var(--jw-white,#fafafa);--jw-line:rgba(255,255,255,.12);--jw-text-muted:rgba(250,250,250,.6);--jw-text-primary:var(--jw-white,#fafafa)}
.sovereign{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:20px;max-width:1080px;margin:0 auto}
.sovereign h2{margin:12px 0 8px;color:var(--jw-white,#fafafa)}
.sovereign p{margin:0;max-width:560px;font-size:14px;line-height:1.6;color:rgba(250,250,250,.7)}
.sovereign a.go{display:inline-flex;align-items:center;gap:8px;border:1px solid rgba(255,255,255,.3);border-radius:999px;padding:12px 18px;color:#fafafa;text-decoration:none;font-size:13px;font-weight:850;white-space:nowrap}
.sovereign a.go:hover{border-color:rgba(255,255,255,.6)}
/* Footer */
.page-footer{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;padding:24px clamp(20px,6vw,72px);border-top:1px solid var(--jw-line);color:var(--jw-text-muted);font-size:12px}
.page-footer code{font-family:var(--jw-font-mono);font-size:11px}
.page-footer .tagline{display:flex;align-items:center;gap:8px}
/* Failure */
.page-fail{min-height:100svh;display:grid;place-content:center;text-align:center;gap:10px;background:var(--jw-bg);color:var(--jw-text-primary)}
.page-fail span{display:inline-grid;place-items:center;width:52px;height:52px;margin:0 auto 6px;border:1px solid var(--jw-line-strong);border-radius:50%;font-size:22px}
.page-fail h1{font-family:var(--jw-font-display);font-size:clamp(30px,5vw,52px);letter-spacing:-.03em;margin:0}
.page-fail small{color:var(--jw-text-muted)}
@media(max-width:720px){
.page-nav{padding:14px 16px}
.page-hero{min-height:60svh;padding:36px 16px}
.page-section{padding:48px 16px}
.module-card{grid-template-columns:1fr}
.module-card .qr{width:110px;justify-self:center}
.sovereign{justify-content:center;text-align:center}
}
@@ -0,0 +1,86 @@
// Just Works page template — renders the platform-hosted merchant page
// locally from /api/business. This is the design surface for the stable
// justworks.cash/{slug} page: module CTAs, the QR that never moves, and the
// pointer to the sovereign nsite copy rendered at /site.
import {mountLogo} from './ds/logo.js';
import {link} from './ds/frame.js';
const app=document.querySelector('#app');
const base=location.pathname.split('/').slice(0,-1).join('/');
const npub=new URLSearchParams(location.search).get('npub')?.trim()||'';
const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
const qr=text=>`${base}/api/qr?text=${encodeURIComponent(text)}`;
const moduleMeta={
order:{name:'Order to table',copy:'Scan, order, and pay from the table. No app for customers, no terminal for staff.',glyph:'+',target:'order'},
pay:{name:'Pay Bill',copy:'Close the table from the customer side. A confirmed Lightning payment settles instantly.',glyph:'€',target:'pay'},
payandgo:{name:'Just Pay',copy:'Pay a paper receipt in sats. Type the total, get an invoice, done.',glyph:'₿',target:'payandgo'},
booking:{name:'Bookings',copy:'Book a table now or later, straight from the page.',glyph:'⌁',target:'book'},
};
function fail(message){
app.innerHTML=`<section class="page-fail"><span>⌁</span><h1>${esc(message)}</h1><small>Use /page?npub=npub1…</small></section>`;
}
function fact(term,value,href){
return `<div><dt>${esc(term)}</dt><dd>${href?`<a href="${esc(href)}" target="_blank" rel="noopener">${esc(value)} ↗</a>`:esc(value)}</dd></div>`;
}
function moduleCard(id,state){
const meta=moduleMeta[id];
if(!meta||!state.enabled||state.status==='coming-soon')return '';
const url=link(meta.target);
return `<article class="module-card">
<div>
<h3><span class="glyph">${meta.glyph}</span>${esc(meta.name)} <span class="status ${state.status==='live'?'live':''}">${state.status==='live'?'Live service':'Preview'}</span></h3>
<p>${esc(meta.copy)}</p>
</div>
${id==='order'?`<a class="qr" href="${esc(url)}" aria-label="Order to table QR"><img src="${qr(url)}" alt=""></a>`:''}
<a class="cta" href="${esc(url)}">Open ${esc(meta.name)} <span>→</span></a>
</article>`;
}
function render(data){
const merchant=data.merchant,about=(data.profile?.about||'').trim(),hero=data.images[0]||'';
const facts=[merchant.hours,merchant.address,merchant.phone].filter(Boolean);
document.title=`${merchant.name} — Just Works`;
const cards=Object.entries(moduleMeta).map(([id,meta])=>moduleCard(id,data.modules[id]||{})).filter(Boolean).join('');
app.innerHTML=`
<nav class="page-nav">
<a class="brand" href="${esc(link('business'))}"><span data-logo="icon"></span><span>Just Works<small>Merchant page</small></span></a>
<span class="chip">${esc(merchant.name)}</span>
</nav>
<section class="page-hero" ${hero?`data-hero style="--hero:url('${esc(hero)}')"`:''}>
${hero?'<div class="backdrop"></div>':''}
<div class="shade"></div>
<div class="copy">
<span class="tag">${esc([merchant.category,merchant.area].filter(Boolean).join(' · ')||'Merchant')}</span>
<h1>${esc(merchant.name)}</h1>
${about?`<p class="lead">${esc(about)}</p>`:''}
${facts.length?`<div class="facts">${facts.map(item=>`<span>${esc(item)}</span>`).join('')}</div>`:''}
</div>
</section>
<main>
${cards?`<section class="page-section" id="modules"><div><p class="eyebrow">Just Works modules</p><h2>Run the place from the page.</h2><div class="module-grid">${cards}</div></div></section>`:''}
${data.images.length>1?`<section class="page-section" id="gallery"><div><p class="eyebrow">Gallery</p><h2>The place in photos.</h2><div class="photo-grid">${data.images.slice(1).map(src=>`<figure><img src="${esc(src)}" alt="${esc(merchant.name)}" loading="lazy"></figure>`).join('')}</div></div></section>`:''}
${(merchant.address||merchant.hours||merchant.phone||merchant.website)?`<section class="page-section" id="visit"><div><p class="eyebrow">Visit</p><h2>Find us.</h2><dl class="facts-list">${merchant.address?fact('Address',merchant.address):''}${merchant.hours?fact('Hours',merchant.hours):''}${merchant.phone?fact('Phone',merchant.phone,`tel:${esc(merchant.phone)}`):''}${merchant.website?fact('Website',merchant.website.replace(/^https?:\/\//,''),merchant.website):''}${merchant.rating?fact('Rating',`★ ${merchant.rating} / 5`):''}</dl></div></section>`:''}
<section class="page-section dark" id="sovereign"><div class="sovereign">
<div>
<p class="eyebrow">Ownership</p>
<h2>This page has a sovereign copy.</h2>
<p>The same page is signed by the merchant and published to Nostr relays and Blossom storage. It resolves even if Just Works disappears.</p>
</div>
<a class="go" href="${esc(data.links.nsite)}">View the nsite copy <span>↗</span></a>
</div></section>
</main>
<footer class="page-footer">
<span class="tagline"><span data-logo="icon"></span> Built with Just Works</span>
<code>${esc(npub)}</code>
</footer>`;
document.querySelectorAll('[data-logo]').forEach(host=>mountLogo(host,{icon:true}));
}
if(!npub)fail('Add the merchant npub.');
else fetch(`${base}/api/business?npub=${encodeURIComponent(npub)}`,{cache:'no-store'})
.then(async response=>{const data=await response.json();if(!response.ok)throw new Error(data.error||'Page unavailable');render(data)})
.catch(error=>fail(error.message));
@@ -0,0 +1 @@
.payments{display:grid;gap:8px;margin-top:18px}.payment{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:14px 16px;border:1px solid rgba(247,147,26,.28);border-radius:14px;background:rgba(247,147,26,.08);color:var(--body);animation:payment-in .45s ease}.payment>div{display:grid;gap:4px}.payment span{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.04em}.payment strong{color:var(--text)}.payment-ack{flex:0 0 auto;border:1px solid rgba(255,255,255,.18);border-radius:999px;background:rgba(255,255,255,.08);color:var(--text);padding:8px 12px;font:inherit;font-size:12px;font-weight:800;cursor:pointer;transition:transform .18s ease,background .18s ease,opacity .18s ease}.payment-ack:hover{background:rgba(255,255,255,.15);transform:translateY(-1px)}.payment-ack:disabled{cursor:wait;opacity:.55}.payment.leaving{pointer-events:none;animation:payment-out .28s ease forwards}@keyframes payment-in{from{opacity:0;transform:translateY(-8px)}}@keyframes payment-out{to{opacity:0;transform:translateX(18px) scale(.98)}}@media(max-width:560px){.payment{gap:10px}.payment-ack{padding:7px 11px}}
@@ -0,0 +1,2 @@
:root{color-scheme:dark;--bg:#080909;--panel:#111312;--soft:#191c1a;--line:rgba(255,255,255,.12);--text:#f7f7f4;--muted:#929894;--accent:#ff5630;--green:#16a085;--blue:#4c7dff}html,body{background:var(--bg);color:var(--text)}body{background:radial-gradient(circle at 50% -20%,#202522 0,#080909 46%)}#app{width:100%;max-width:1680px;margin:auto}.topbar{position:relative;z-index:60;width:100%;background:transparent;border:0;backdrop-filter:none}.brand>span{border-color:rgba(255,255,255,.35)}.stats article,.tabs button,.ticket,.payment-ticket{background:var(--panel);border-color:var(--line);box-shadow:none}.stats strong,.ticket header strong,.ticket-body p,.payment-ticket strong,.empty h2{color:var(--text)}.stats span,.tabs button,.ticket header small,.ticket header time,.ticket-body small,.status,.payment-ticket p,.empty{color:var(--muted)}.tabs{position:sticky;top:76px;z-index:9;padding-top:8px;background:linear-gradient(180deg,var(--bg) 78%,transparent)}.tabs button.active{background:var(--text);color:#080909}.tabs span{background:#dfe3df;color:#080909}.ticket>header,.ticket>footer{border-color:var(--line)}.ticket>footer{background:var(--soft)}.table{background:#292d2a}.ticket footer button,.payment-ticket button{box-shadow:0 8px 22px rgba(255,86,48,.18)}.status-new{border-color:rgba(255,86,48,.42)}.status-new .table{background:var(--accent)}.status-accepted{border-color:rgba(76,125,255,.48)}.status-delivered{border-color:rgba(22,160,133,.52)}.payment-ticket header>span{background:#2b271b}.pending{background:var(--soft);color:var(--muted)}.empty{border-color:rgba(255,255,255,.16)}.toast{background:#f7f7f4;color:#080909}.pos-loading,.pos-error{background:radial-gradient(circle at 50% 40%,#222724,#080909 65%)}@media(max-width:700px){#app{min-height:100svh}.topbar{height:64px}.topbar .brand b{font-size:14px}.connection time{display:none}.stats{gap:7px;padding:9px}.stats article{min-height:58px;padding:9px 12px}.stats strong{font-size:26px}.tabs{top:64px;padding:5px 9px 9px}.tabs button{min-height:43px;min-width:102px;padding-inline:12px}.board{padding:0 9px 12px;gap:9px}.ticket,.payment-ticket{min-height:230px;border-radius:17px}.ticket>header{padding:11px}.ticket-body{padding:14px}.ticket-body p{font-size:18px}.ticket>footer{padding:9px}.ticket footer button,.payment-ticket button{min-height:58px;flex:1;font-size:16px}.empty{min-height:300px;border-radius:18px}}@media(min-width:1101px){.stats,.tabs,.board{padding-left:24px;padding-right:24px}.board{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:1500px){.board{grid-template-columns:repeat(4,minmax(0,1fr))}}
.topbar[data-topbar]{position:fixed!important;inset:0 0 auto!important}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
import {mountAppControls} from './app-mode.js?v=1';
import {mountLogo} from './ds/logo.js';
import {mountMobileChrome} from './ds/frame.js';
import {npubFrom} from './ds/env.js';
const app=document.querySelector('#app');const params=new URLSearchParams(location.search);const npub=npubFrom();let merchant,state,timer,busy=false,lastIds='',lastBookingIds='';
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const ago=stamp=>{const s=Math.max(0,Math.floor((Date.now()-new Date(stamp).getTime())/1000));return s<60?`${s}s`:s<3600?`${Math.floor(s/60)}m`:`${Math.floor(s/3600)}h`};
function error(message){app.innerHTML=`<section class="pos-error"><span>⌁</span><p>Just Works POS</p><h1>${esc(message)}</h1><small>Use pos.justworks.cash/?npub=npub1…</small></section>`}
function shell(){app.innerHTML=`<header class="topbar" data-topbar><div class="brand"><span data-logo="icon"></span><b>Just Works<small>Counter POS</small></b></div><div class="merchant"><strong>${esc(merchant.name)}</strong><small>${esc([merchant.category,merchant.area].filter(Boolean).join(' · '))}</small></div><div class="connection" id="connection"><i></i><span>Live</span><time id="clock"></time></div></header><div class="pos-app-controls"></div><section class="stats"><article><span>Waiting</span><strong id="waitingCount">0</strong></article><article><span>Preparing</span><strong id="preparingCount">0</strong></article><article><span>Ready</span><strong id="readyCount">0</strong></article><article><span>Payments</span><strong id="paymentCount">0</strong></article></section><nav class="tabs"><button class="active" data-tab="orders">Orders <span id="orderBadge">0</span></button><button data-tab="payments">Payments <span id="payBadge">0</span></button><button data-tab="bookings">Bookings <span id="bookingBadge">0</span></button></nav><section id="orders" class="board active"></section><section id="payments" class="board"></section><section id="bookings" class="board"></section><div id="toast" class="toast"></div>`;document.querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>showTab(b.dataset.tab));document.querySelectorAll('[data-logo]').forEach(host=>mountLogo(host,{icon:true}));mountMobileChrome(app);mountAppControls(document.querySelector(".pos-app-controls"));if(location.hash==='#bookings')showTab('bookings');tick();setInterval(tick,10000)}
function tick(){const c=document.querySelector('#clock');if(c)c.textContent=new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}
function showTab(id){document.querySelectorAll('[data-tab]').forEach(b=>b.classList.toggle('active',b.dataset.tab===id));document.querySelectorAll('.board').forEach(b=>b.classList.toggle('active',b.id===id))}
function orderCard(order){const next={new:['Accept','accepted'],accepted:['Mark delivered','delivered'],delivered:['Close table','closed']}[order.status];const request=order.items.map(item=>`${item.qty>1?`${item.qty}× `:''}${esc(item.name)}`).join(' · ');return `<article class="ticket status-${order.status}" data-ticket="${order.id}"><header><span class="table">${order.table_number}</span><div><small>Table</small><strong>${order.table_number}</strong></div><time data-age="${order.created_at}">${ago(order.created_at)}</time></header><div class="ticket-body"><p>${request}</p>${order.note?`<small>${esc(order.note)}</small>`:''}</div><footer><span class="status">${order.status==='new'?'Waiting':order.status==='accepted'?'Preparing':'Delivered'}</span>${next?`<button data-order="${order.id}" data-status="${next[1]}">${next[0]} <b>→</b></button>`:''}</footer></article>`}
function paymentCard(payment){return `<article class="payment-ticket"><header><span>€</span><div><small>Table ${payment.table_number}</small><strong>${payment.amount_sats.toLocaleString()} sats</strong></div></header><p>€${(payment.amount_cents/100).toFixed(2)} · ${payment.status}</p>${payment.status==='paid'?`<button data-payment="${payment.id}">Acknowledge payment ✓</button>`:'<span class="pending">Waiting for Lightning settlement…</span>'}</article>`}
function bookingCard(booking){const next={new:['Confirm booking','confirmed'],confirmed:['Mark seated','seated'],seated:['Close','closed']}[booking.status];const time=new Date(booking.scheduled_at);return `<article class="ticket booking-ticket status-${booking.status}"><header><span class="table">${booking.guests}</span><div><small>${booking.mode==='now'?'Arriving soon':'Reserved'}</small><strong>${time.toLocaleDateString([],{weekday:'short',day:'numeric',month:'short'})} · ${time.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</strong></div><time>${ago(booking.created_at)}</time></header><div class="ticket-body"><p>${esc(booking.guest_name)} · ${booking.guests} ${booking.guests===1?'guest':'guests'}</p>${booking.notes?`<small>${esc(booking.notes)}</small>`:''}</div><footer><span class="status">${booking.status}</span>${next?`<button data-booking="${booking.id}" data-status="${next[1]}">${next[0]} <b>→</b></button>`:''}</footer></article>`}
function render(){const orders=state.orders||[],payments=state.payments||[],bookings=state.bookings||[];document.querySelector('#waitingCount').textContent=orders.filter(o=>o.status==='new').length;document.querySelector('#preparingCount').textContent=orders.filter(o=>o.status==='accepted').length;document.querySelector('#readyCount').textContent=orders.filter(o=>o.status==='delivered').length;document.querySelector('#paymentCount').textContent=payments.length;document.querySelector('#orderBadge').textContent=orders.length;document.querySelector('#payBadge').textContent=payments.length;document.querySelector('#bookingBadge').textContent=bookings.length;document.querySelector('#orders').innerHTML=orders.length?orders.map(orderCard).join(''):`<div class="empty"><span>✓</span><h2>No open orders.</h2><p>New table orders appear here automatically.</p></div>`;document.querySelector('#payments').innerHTML=payments.length?payments.map(paymentCard).join(''):`<div class="empty"><span>€</span><h2>No pending payments.</h2><p>Pay Bill and Just Pay confirmations appear here.</p></div>`;document.querySelector('#bookings').innerHTML=bookings.length?bookings.map(bookingCard).join(''):`<div class="empty"><span>□</span><h2>No upcoming bookings.</h2><p>Table reservations appear here automatically.</p></div>`;document.querySelectorAll('[data-order]').forEach(b=>b.onclick=()=>updateOrder(b.dataset.order,b.dataset.status));document.querySelectorAll('[data-payment]').forEach(b=>b.onclick=()=>ack(b.dataset.payment));document.querySelectorAll('[data-booking]').forEach(b=>b.onclick=()=>updateBooking(b.dataset.booking,b.dataset.status));const ids=orders.map(o=>o.id).join(',');if(lastIds&&ids!==lastIds&&orders.some(o=>!lastIds.includes(o.id)))notify('New order received');lastIds=ids;const bookingIds=bookings.map(item=>item.id).join(',');if(lastBookingIds&&bookingIds!==lastBookingIds&&bookings.some(item=>!lastBookingIds.includes(item.id)))notify('New booking received');lastBookingIds=bookingIds}
function notify(text){const t=document.querySelector('#toast');t.textContent=text;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),1800)}
async function updateOrder(id,status){if(busy)return;busy=true;try{const r=await fetch(`/api/orders/${id}/status`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({status})});if(!r.ok)throw new Error();notify(status==='accepted'?'Order accepted':status==='delivered'?'Marked delivered':'Table closed');await load()}catch{notify('Could not update order')}finally{busy=false}}
async function ack(id){if(busy)return;busy=true;try{const response=await fetch(`/api/payments/${id}/acknowledge`,{method:'POST'});if(!response.ok)throw new Error();notify('Payment cleared');await load()}catch{notify('Could not acknowledge payment. Check your login.')}finally{busy=false}}
async function updateBooking(id,status){if(busy)return;busy=true;try{const response=await fetch(`/api/bookings/${id}/status`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({status})});if(!response.ok)throw new Error();notify(status==='confirmed'?'Booking confirmed':status==='seated'?'Guests seated':'Booking closed');await load()}catch{notify('Could not update booking')}finally{busy=false}}
function unavailable(message){const connection=document.querySelector('#connection');connection?.classList.add('offline');connection?.querySelector('span').replaceChildren(message);if(!document.querySelector('#orders'))return;state={orders:[],payments:[],bookings:[]};render();document.querySelector('#orders').innerHTML=message==='Login required'?'<div class="empty"><h2>Merchant login required</h2><p>Use Login above to connect your Business session.</p></div>':'<div class="empty"><h2>Waiting for connection</h2><p>Live orders and payments are unavailable. Nothing is saved for later.</p></div>'}
window.addEventListener('offline',()=>unavailable('Offline'));
async function load(){const connection=document.querySelector('#connection');try{if(!navigator.onLine)throw new Error('Offline');const response=await fetch(`/api/pos?npub=${encodeURIComponent(npub)}`,{cache:'no-store'});if(!response.ok)throw new Error(response.status===403?'Login required':'Reconnecting');const data=await response.json();if(!navigator.onLine)throw new Error('Offline');state=data;render();connection?.classList.remove('offline');connection?.querySelector('span').replaceChildren('Live')}catch(error){unavailable(error.message==='Login required'?'Login required':navigator.onLine?'Reconnecting':'Offline')}}
async function boot(){if(!npub)return error('Add the merchant npub to start.');const data=await fetch(`/api/order-merchant?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async r=>{const d=await r.json();if(!r.ok)throw new Error(d.error);return d});merchant=data.merchant;shell();tick();await load();timer=setInterval(load,1500)}boot().catch(e=>error(e.message||'Merchant unavailable'));
@@ -0,0 +1 @@
@media(max-width:720px){.submit-row{position:static;display:block;margin:10px 0 0;padding:18px}.submit-row button{width:100%;margin-top:12px}}
@@ -0,0 +1,8 @@
:root{--orange:#ff4f1f;--black:#080808;--ink:#101010;--muted:#72726e;--line:#dcdcd7;font-family:var(--jw-font-sans,"Geist Variable",Inter,system-ui,sans-serif)}*{box-sizing:border-box}html{background:var(--black)}body{margin:0;background:#f5f5f1;color:var(--ink)}a{color:inherit}header{height:76px;display:flex;align-items:center;justify-content:space-between;padding:0 clamp(18px,4vw,56px);background:var(--black);color:#fff}header>a:last-child{margin-left:auto;padding:9px 13px;border:1px solid #393939;border-radius:99px;text-decoration:none;font-size:11px;font-weight:800}.brand{display:flex;align-items:center;gap:10px;text-decoration:none;font-weight:900}.brand [data-logo]{width:26px}.brand b{font-size:16px;font-weight:900}.brand small{display:inline;font-size:16px;font-weight:900;color:inherit;letter-spacing:0;text-transform:none}.brand small:before{content:"·";color:#90908d;margin:0 9px}.profile-hero{position:relative;min-height:clamp(280px,38vw,500px);display:flex;align-items:flex-end;padding:clamp(28px,5vw,68px);overflow:hidden;color:#fff;background:#111 var(--hero,linear-gradient(135deg,#252525,#090909)) center/cover}.wash{position:absolute;inset:0;background:linear-gradient(0deg,rgba(0,0,0,.93),rgba(0,0,0,.08) 75%)}.profile-hero>div:last-child{position:relative;z-index:1}.profile-hero p,.form-head p{margin:0;color:var(--orange);font-size:10px;font-weight:900;letter-spacing:.17em;text-transform:uppercase}.profile-hero h1{margin:12px 0;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(52px,9vw,120px);line-height:.8;letter-spacing:-.06em}.profile-hero span{display:block;max-width:80vw;overflow:hidden;text-overflow:ellipsis;color:#aaa;font-size:10px}form{padding:clamp(46px,7vw,90px) clamp(18px,4vw,56px)}.form-head{display:flex;justify-content:space-between;align-items:end;gap:30px;margin-bottom:36px}.form-head h2{max-width:780px;margin:10px 0 0;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(42px,6vw,78px);line-height:.88;letter-spacing:-.055em}.form-head>span{max-width:350px;color:var(--muted);line-height:1.5}.fields{display:grid;grid-template-columns:1fr 1fr;gap:10px}.fields>section{padding:clamp(22px,3vw,36px);border:1px solid var(--line);background:#fff}.fields h3{margin:0 0 26px;font-family:var(--jw-font-display,"Syne Variable");font-size:30px;letter-spacing:-.04em}.fields label{display:block;margin-top:17px;color:#777;font-size:9px;font-weight:900;letter-spacing:.11em;text-transform:uppercase}.fields input,.fields textarea{width:100%;margin-top:7px;padding:14px;border:1px solid var(--line);border-radius:0;background:#f1f1ed;color:#111;font:inherit;font-size:15px;outline:none;text-transform:none;letter-spacing:0;resize:vertical}.fields input:focus,.fields textarea:focus{border-color:var(--orange);box-shadow:0 0 0 3px rgba(255,79,31,.12)}.fields input[readonly]{color:#888}.lightning{border-color:#f2a38d!important}.hint{margin:16px 0 0;color:#858580;font-size:11px;line-height:1.5}.or{height:22px;display:flex;align-items:center;margin:12px 0 -5px;color:#aaa;font-size:9px;text-transform:uppercase}.or:before,.or:after{content:"";height:1px;flex:1;background:var(--line)}.or span{padding:0 10px}.submit-row{display:flex;justify-content:space-between;align-items:center;gap:25px;margin-top:18px;padding:22px;background:var(--black);color:#fff}.submit-row p{margin:0;color:#aaa;font-size:12px}.submit-row p.error{color:#ff8b6b}.submit-row button{min-width:220px;display:flex;justify-content:space-between;padding:16px 18px;border:0;background:var(--orange);color:#fff;font-weight:900}.submit-row button:disabled{opacity:.6}.loading,.failure{min-height:100vh;display:grid;place-content:center;text-align:center;padding:25px;background:var(--black);color:#fff}.loading span,.failure span{font-size:70px}.failure h1{max-width:760px;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(44px,8vw,90px)}@media(max-width:720px){header{height:66px}.profile-hero{min-height:260px}.fields{grid-template-columns:1fr}.form-head{display:block}.form-head>span{display:block;margin-top:18px}.submit-row{position:sticky;bottom:0;display:block;margin-left:-18px;margin-right:-18px;padding:16px 18px}.submit-row button{width:100%;margin-top:12px}.profile-hero h1{font-size:16vw}}
.profile-back{padding:9px 13px;border:1px solid rgba(255,255,255,.18);border-radius:99px;color:inherit;text-decoration:none;font-size:11px;font-weight:800}
.jw-topbar{color:#fff}
.jw-topbar .brand small,.jw-topbar .brand small:before{color:#90908d!important}
.jw-topbar .links a{color:rgba(255,255,255,.76)}
.jw-topbar .links a:hover{background:rgba(255,255,255,.08)}
[data-topbar].is-scrolled{border-bottom-color:rgba(255,255,255,.09);background:linear-gradient(180deg,rgba(8,8,8,.72),rgba(8,8,8,.45));-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
.jw-topbar .brand>[data-logo]{width:var(--jw-app-icon-size)!important;height:var(--jw-app-icon-size)!important;margin:0!important}
@@ -0,0 +1,12 @@
import {mountLogo} from './ds/logo.js';
import {startNavScroll} from './ds/header.js';
import {link,mountAppChrome} from './ds/frame.js';
import {npubFrom,prod} from './ds/env.js';
const app=document.querySelector('#app');
const npub=npubFrom();
const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
function fail(message){app.innerHTML=`<section class="failure"><span>∩</span><p>Business profile</p><h1>${esc(message)}</h1><a href="${link('business')}">Back to Business</a></section>`}
function field(label,id,value,type='text',extra=''){return `<label>${label}<input id="${id}" type="${type}" value="${esc(value)}" ${extra}></label>`}
function render(data){const profile=data.profile||{},image=profile.banner||data.images[0]||'';document.title=`${data.merchant.name} · Business profile`;app.innerHTML=`<header><a class="brand" href="${link('business')}"><span data-logo="icon"></span><b>Just Works<small>Business profile</small></b></a><a href="${link('business')}">Back to modules ↙</a></header><section class="profile-hero" ${image?`style="--hero:url('${esc(image)}')"`:''}><div class="wash"></div><div><p>Nostr merchant identity</p><h1>${esc(data.merchant.name)}</h1><span>${esc(npub)}</span></div></section><form id="profileForm"><section class="form-head"><div><p>Public profile</p><h2>What customers see.</h2></div><span>Saving republishes the signed Nostr profile and nsite.</span></section><div class="fields"><section><h3>Identity</h3>${field('Display name','displayName',profile.display_name||data.merchant.name)}<label>About<textarea id="about" rows="5">${esc(profile.about||'')}</textarea></label>${field('Nostr name','nostrName',profile.name||'','text','readonly')}${field('Public website','website',profile.website||data.links.justworks,'url','readonly')}</section><section><h3>Images</h3>${field('Profile picture URL','picture',profile.picture||'','url')}${field('Banner image URL','banner',profile.banner||'','url')}<p class="hint">Use public HTTPS image URLs. These images appear in the signed Nostr profile.</p></section><section id="lightning" class="lightning"><h3>Payments</h3>${field('Lightning address','lud16',profile.lud16||'','email','placeholder="merchant@example.com" required')}<p class="hint">Pay Bill and Just Pay remain locked while this uses the default <code>@npub.cash</code> address.</p></section><section class="owner"><h3>Owner authorization</h3>${field('Owner login','ownerIdentifier',prod?'':'demo','text','autocomplete="username"')}${field('Owner password','ownerPassword',prod?'':'demo','password','autocomplete="current-password"')}<div class="or"><span>or</span></div>${field('Owner key','ownerKey','','password','placeholder="nsec1… or ncryptsec1…" autocomplete="off"')}<p class="hint">Credentials are used only for this save and publish request. Just Works Business does not store them.</p></section></div><div class="submit-row"><p id="status">Ready to update the signed merchant profile.</p><button id="save" type="submit">Save & publish <span>→</span></button></div></form>`;document.querySelector('#profileForm').onsubmit=save;document.querySelectorAll('[data-logo]').forEach(host=>mountLogo(host,{icon:true}))}
async function save(event){event.preventDefault();const button=document.querySelector('#save'),status=document.querySelector('#status');button.disabled=true;status.textContent='Signing and publishing the Nostr profile…';const profile={display_name:document.querySelector('#displayName').value,about:document.querySelector('#about').value,picture:document.querySelector('#picture').value,banner:document.querySelector('#banner').value,lud16:document.querySelector('#lud16').value};const body={npub,profile,ownerIdentifier:document.querySelector('#ownerIdentifier').value,ownerPassword:document.querySelector('#ownerPassword').value,ownerKey:document.querySelector('#ownerKey').value};try{const response=await fetch('/api/business-profile',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}),data=await response.json();if(!response.ok)throw new Error(data.error||'Could not publish');document.querySelector('#ownerPassword').value='';document.querySelector('#ownerKey').value='';status.textContent='Published. Pay Bill and Just Pay are now available.';button.innerHTML='Published ✓';setTimeout(()=>location.href=link('business'),1200)}catch(error){status.textContent=error.message;status.classList.add('error');button.disabled=false;button.innerHTML='Try again <span>↻</span>'}finally{document.querySelector('#ownerPassword').value='';document.querySelector('#ownerKey').value=''}}
if(!npub)fail('Add the merchant npub.');else fetch(`/api/business?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async response=>{const data=await response.json();if(!response.ok)throw new Error(data.error||'Profile unavailable');render(data);app.querySelector(':scope > header')?.remove();mountAppChrome(app,{label:'Business profile',endHtml:`<a class="profile-back" href="${link('business')}">Back to modules ↙</a>`})}).catch(error=>fail(error.message));
@@ -0,0 +1 @@
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#080909"><title>Just Works · Offline</title><style>html{color-scheme:dark;background:#080909;color:#f7f7f4;font:18px/1.6 system-ui}body{max-width:480px;margin:15vh auto;padding:24px}small{color:#ff996f}h1{font-size:48px;line-height:1.1;letter-spacing:-.04em}a{color:#fff;border:1px solid #ffffff50;border-radius:30px;padding:12px 24px;display:inline-block;text-decoration:none}</style><small>JUST WORKS</small><h1>Let’s reconnect.</h1><p>You’re offline. Orders, payments and live activity are unavailable. Nothing is being submitted or saved for later.</p><p>Reconnect, then reload to return to this merchant.</p><a href="">Try again</a></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><linearGradient id="glow" x2="0" y2="1"><stop stop-color="#ff996f"/><stop offset="1" stop-color="#ff5630" stop-opacity=".1"/></linearGradient></defs><path fill="#080909" d="M0 0h512v512H0z"/><g transform="translate(120 82) scale(8.5)"><path fill="url(#glow)" d="M9 32V16a7 7 0 0 1 14 0v16Z"/><path fill="#f7f7f4" fill-rule="evenodd" d="M2 32V16C2 8.3 8.3 2 16 2s14 6.3 14 14v16h-7V16a7 7 0 0 0-14 0v16Z"/></g><text x="256" y="416" fill="#ff996f" font-family="Arial,sans-serif" font-size="42" font-weight="700" letter-spacing="8" text-anchor="middle">POS</text></svg>

After

Width:  |  Height:  |  Size: 635 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><linearGradient id="glow" x2="0" y2="1"><stop stop-color="#90e8b9"/><stop offset="1" stop-color="#90e8b9" stop-opacity=".1"/></linearGradient></defs><path fill="#050606" d="M0 0h512v512H0z"/><g transform="translate(120 82) scale(8.5)"><path fill="url(#glow)" d="M9 32V16a7 7 0 0 1 14 0v16Z"/><path fill="#f7f7f4" fill-rule="evenodd" d="M2 32V16C2 8.3 8.3 2 16 2s14 6.3 14 14v16h-7V16a7 7 0 0 0-14 0v16Z"/></g><text x="256" y="416" fill="#90e8b9" font-family="Arial,sans-serif" font-size="36" font-weight="700" letter-spacing="5" text-anchor="middle">SCREEN</text></svg>

After

Width:  |  Height:  |  Size: 638 B

@@ -0,0 +1,24 @@
// APP is supplied by the dedicated root worker. Never intercept non-GET writes,
// APIs, identities, QR endpoints, merchant photos or third-party requests.
const VERSION = 'v1';
const PREFIX = `jw-${self.APP}-shell-`;
const CACHE = `${PREFIX}${VERSION}`;
const OFFLINE = '/assets/pwa/offline.html';
const SHELL = [OFFLINE, `/assets/pwa/${self.APP}-192.png`, `/assets/pwa/${self.APP}-512.png`];
self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)));
// No skipWaiting: an update never reloads an in-progress POS interaction.
});
self.addEventListener('activate', event => {
event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key.startsWith(PREFIX) && key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim()));
});
self.addEventListener('fetch', event => {
const request = event.request, url = new URL(request.url);
if (request.method !== 'GET' || url.origin !== self.location.origin) return;
if (request.mode === 'navigate') {
// Network-only HTML; the only fallback contains no merchant or live data.
event.respondWith(fetch(request, {cache: 'no-store'}).catch(async () => (await caches.match(OFFLINE, {cacheName: CACHE})) || Response.error()));
} else if (!url.search && SHELL.includes(url.pathname)) {
event.respondWith(caches.open(CACHE).then(async cache => (await cache.match(request)) || fetch(request)));
}
});
@@ -0,0 +1,46 @@
@font-face{font-family:Geist;src:url('/fonts/geist-latin-wght-normal.woff2') format('woff2');font-weight:100 900;font-display:swap}
@font-face{font-family:Syne;src:url('/fonts/syne-latin-wght-normal.woff2') format('woff2');font-weight:400 800;font-display:swap}
*{box-sizing:border-box}
:root{font-family:Geist,Arial,sans-serif;color:#151515;background:#eeeFEB;font-synthesis:none}
body{margin:0}
[hidden]{display:none!important}
.sheet-tools{background:#090909;color:white;display:flex;align-items:center;gap:20px;padding:18px max(24px,calc((100vw - 980px)/2));font-size:13px}
.sheet-tools>a{color:white;text-decoration:none;white-space:nowrap}
.sheet-tools>span{font-weight:650;margin-right:auto}
.sheet-tools button{border:0;background:#ff5a31;color:#111;border-radius:5px;padding:13px 17px;font:inherit;font-weight:700;cursor:pointer;white-space:nowrap}
.sheet-tools button:disabled{opacity:.5;cursor:wait}
a:focus-visible,button:focus-visible{outline:3px solid #ff5a31;outline-offset:4px}
.print-help,.status{max-width:794px;margin:22px auto 0;padding:0 16px;font-size:12px;line-height:1.6;color:#555}
.print-help strong{color:#111;display:block;font-size:17px;margin-bottom:5px}
.status{margin-top:12px;font-size:11px}
.status.error{color:#9d2615;font-size:15px}
.sheet{width:210mm;min-height:297mm;margin:22px auto 44px;background:white;padding:10mm;box-shadow:0 12px 50px #00000012;color:#111}
.sheet-heading{display:flex;align-items:center;justify-content:space-between;gap:5mm;height:20mm;margin-bottom:4mm}
.eyebrow{font-size:8pt;font-weight:750;text-transform:uppercase;letter-spacing:.15em;margin:0 0 2mm;color:#bd3b19}
h1{font-family:Syne,Geist,sans-serif;font-size:24pt;line-height:1.05;margin:0;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.sheet-heading>div{min-width:0;max-width:125mm}
.sheet-caption{font-size:8pt;line-height:1.6;text-align:right;flex-shrink:0;margin:0}
.sheet-caption span{color:#666;font-size:7pt}
.labels{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));grid-auto-rows:48mm;gap:0}
.qr-label{border:1px dashed #b5b5b5;text-align:center;display:flex;flex-direction:column;align-items:center;padding:2mm 2mm 1.5mm;break-inside:avoid;overflow:hidden;min-width:0}
.label-merchant{font-size:6.5pt;line-height:1.15;margin:0 0 .8mm;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#555}
.qr-label h2{font-family:Syne,Geist,sans-serif;font-size:11pt;line-height:1.1;font-weight:750;margin:0 0 .6mm;max-width:100%}
.qr-label a{display:block;width:33mm;height:33mm;padding:2mm;background:#fff;flex-shrink:0}
.qr-label img{display:block;width:100%;height:100%;object-fit:contain}
.label-caption{font-size:6pt;margin:.6mm 0 0;line-height:1.1;color:#555}
.sheet-footer{display:flex;justify-content:space-between;gap:4mm;padding-top:4mm;font-size:6pt;color:#666;line-height:1.3}
.sheet-footer>span:first-child{font-weight:800;letter-spacing:.13em;color:#111}
@media screen and (max-width:830px){.sheet{width:calc(100% - 28px);min-height:0;padding:20px}.sheet-heading{height:auto;min-height:70px;margin-bottom:15px}.sheet-heading>div{max-width:60%}h1{font-size:28px}.sheet-caption{font-size:10px}.sheet-caption span{font-size:9px}.labels{grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:auto}.qr-label{min-height:185px;padding:10px 4px}.sheet-footer{font-size:8px;flex-wrap:wrap}.sheet-tools{padding:14px;gap:12px;flex-wrap:wrap}.sheet-tools>span{font-size:11px}.sheet-tools button{font-size:12px}.eyebrow{font-size:9px}.sheet-heading{gap:10px}}
@media screen and (max-width:380px){.labels{grid-template-columns:1fr}.sheet-heading{display:block}.sheet-heading>div{max-width:100%}.sheet-caption{text-align:left;margin-top:10px}}
@page{size:A4 portrait;margin:10mm}
@media print{
:root,body{background:#fff}
.sheet-tools,.print-help,.status{display:none!important}
.sheet{width:190mm;min-height:0;margin:0;padding:0;box-shadow:none}
.sheet-heading{height:20mm}
.labels{grid-template-columns:repeat(3,minmax(0,1fr));grid-auto-rows:48mm}
.sheet-footer{padding-top:4mm}
.sheet:not([data-ready="true"]){display:none!important}
body:has(.sheet:not([data-ready="true"])) .status{display:block!important;color:#111}
a{color:inherit;text-decoration:none}
}
@@ -0,0 +1,66 @@
import { qrImageUrl } from './module-qr.js?v=3';
import { businessUrl, sheetDestinations, validNpub } from './business-destinations.js?v=1';
const status = document.querySelector('#status');
const sheet = document.querySelector('#sheet');
const print = document.querySelector('#print');
const labels = document.querySelector('#labels');
const npub = new URLSearchParams(location.search).get('npub')?.trim();
const fail = message => {
status.textContent = message;
status.classList.add('error');
print.disabled = true;
};
async function openSheet() {
if (!validNpub(npub)) throw new Error('Open this QR sheet from your Business dashboard with a valid merchant npub.');
// Do not propagate unrelated URL parameters or fragments into links or prints.
history.replaceState(null, '', `${location.pathname}?npub=${encodeURIComponent(npub)}`);
document.querySelector('#back').href = businessUrl(npub);
const response = await fetch(`/api/business?npub=${encodeURIComponent(npub)}`, {cache: 'no-store'});
if (!response.ok) throw new Error('The merchant could not be loaded. Return to Business or reload to try again.');
const data = await response.json();
if (data.merchant?.npub !== npub) throw new Error('Merchant identity could not be verified. Open the sheet again from Business.');
const destinations = sheetDestinations(data, npub);
document.title = `${data.merchant.name} · QR in DIN A4 · Just Works`;
document.querySelector('#merchant-name').textContent = data.merchant.name;
document.querySelector('#label-count').textContent = `${destinations.length} merchant links · Print & cut`;
const loading = destinations.map(({id, title, url}) => {
const target = new URL(url, location.origin);
if (!['https:', 'http:'].includes(target.protocol) || target.username || target.password) throw new Error('A merchant link is unavailable. Return to Business to check the links.');
const card = document.createElement('article');
card.className = 'qr-label';
card.dataset.destination = id;
const name = document.createElement('p');
name.className = 'label-merchant';
name.textContent = data.merchant.name;
const heading = document.createElement('h2');
heading.textContent = title;
const anchor = document.createElement('a');
anchor.href = target.href;
anchor.setAttribute('aria-label', `Open ${title}`);
const image = document.createElement('img');
image.alt = `${title} QR code for ${data.merchant.name}`;
image.width = image.height = 320;
const ready = new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = () => reject(new Error('Some QR codes could not load. Reload this page before printing.'));
});
image.src = qrImageUrl(target.href);
anchor.append(image);
const caption = document.createElement('p');
caption.className = 'label-caption';
caption.textContent = ['business', 'pos', 'stats'].includes(id) ? 'Team access · Just Works' : 'Scan to open · Just Works';
card.append(name, heading, anchor, caption);
labels.append(card);
return ready;
});
sheet.hidden = false;
await Promise.all(loading);
await document.fonts.ready;
status.textContent = 'Ready to print. All QR codes loaded.';
print.disabled = false;
sheet.dataset.ready = 'true';
}
print.addEventListener('click', () => { if (!print.disabled) window.print(); });
openSheet().catch(fail);
@@ -0,0 +1,5 @@
/* The payment action is the product, especially on a phone. */
.hidden{display:none!important}.secure.error{color:#d7462d}.primary:disabled{opacity:.62;cursor:wait}.quick-pay .pay-card{max-width:540px;width:100%;justify-self:end}.invoice-view{display:grid;gap:12px;margin-top:24px;text-align:center}.invoice-view h2{margin:0;font-family:var(--jw-font-display,"Syne Variable");font-size:34px}.invoice-qr{width:min(100%,340px);margin:auto;padding:14px;border-radius:18px;background:#fff}.invoice-qr svg{display:block;width:100%;height:auto}.open-wallet,.copy-invoice{min-height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;border:0;border-radius:14px;font-weight:900;text-decoration:none}.open-wallet{background:var(--accent);color:#fff}.copy-invoice{justify-content:center;background:var(--soft);color:var(--text)}.invoice-view>small{color:var(--muted)}
.settlement-wait{display:grid;grid-template-columns:14px 1fr;gap:2px 10px;align-items:center;margin-top:4px;padding:15px;border:1px solid var(--line);border-radius:14px;text-align:left;background:var(--soft)}.settlement-wait i{grid-row:1/3;width:11px;height:11px;border-radius:50%;background:#f7931a;box-shadow:0 0 0 0 rgba(247,147,26,.5);animation:settlement-pulse 1.5s infinite}.settlement-wait strong{font-size:13px}.settlement-wait small{color:var(--muted);font-size:11px}.check-payment{min-height:46px;border:1px solid var(--line);border-radius:13px;background:transparent;color:var(--text);font-weight:850}.paid-view{display:grid;justify-items:center;gap:10px;margin-top:24px;padding:28px 20px;border:1px solid rgba(22,160,133,.38);border-radius:24px;background:radial-gradient(circle at 50% 4%,rgba(22,160,133,.2),transparent 40%),#0d1411;color:#fff;text-align:center;box-shadow:0 28px 70px rgba(0,0,0,.28)}.paid-mark{width:84px;height:84px;display:grid;place-items:center;border:2px solid #5ee495;border-radius:50%;background:rgba(94,228,149,.12);color:#5ee495;font-size:44px;font-weight:950}.paid-view .eyebrow{margin:8px 0 0;color:#5ee495}.paid-view h2{margin:0;font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(54px,15vw,84px);line-height:.85;letter-spacing:-.06em}.paid-amount{display:grid;margin:7px 0 2px}.paid-amount strong{font-size:30px}.paid-amount span{color:#5ee495;font-size:17px;font-weight:850}.paid-merchant{margin:4px 0 8px;color:#b7c2bc}.paid-merchant strong{display:block;margin-top:2px;color:#fff;font-size:18px}.payment-proof{width:100%;display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:8px;padding:14px;border:1px solid rgba(94,228,149,.28);border-radius:14px;background:rgba(94,228,149,.08);color:#fff;text-align:left;text-decoration:none}.payment-proof span{font-size:12px;font-weight:850}.payment-proof code{color:#8dddb4;font-size:10px}.payment-proof b{color:#5ee495}.payment-proof.unavailable{display:block;color:#b7c2bc;text-align:center}.paid-meta{width:100%;display:flex;justify-content:space-between;padding-top:14px;border-top:1px solid rgba(255,255,255,.12);color:#8d9992;font-size:11px}.paid-meta time{color:#fff;font-weight:750}.show-staff{margin:8px 0 0!important;padding:10px 15px;border-radius:99px;background:#fff;color:#0a0a0a!important;font-size:12px;font-weight:900}.secure.hidden{display:none}@keyframes settlement-pulse{70%{box-shadow:0 0 0 9px rgba(247,147,26,0)}}
@media(max-width:850px){body{padding-top:64px}.surface.pay,.surface.payandgo{width:100%;padding:0}.quick-pay{display:block;min-height:auto}.quick-pay .pay-copy{padding:30px 18px 22px}.quick-pay .pay-copy h1{font-size:42px;margin:10px 0}.quick-pay .pay-copy>p:last-child{font-size:15px;margin-bottom:0}.quick-pay .pay-card{max-width:none;padding:24px 18px 34px;border-left:0;border-right:0;border-radius:0;box-shadow:none}.quick-pay .merchant{padding-bottom:18px}.quick-pay .amount input{font-size:34px}.quick-pay .tips button{min-height:48px}.quick-pay .primary{min-height:58px}.invoice-qr{width:min(100%,320px)}.paid-view{min-height:70svh;margin-inline:-5px;padding:28px 16px}.payment-proof{grid-template-columns:1fr auto}.payment-proof b{display:none}}
.quick-pay:has(.paid-view) .pay-copy{display:none}.pay-card:has(.paid-view)>:not(#invoiceResult){display:none}.pay-card:has(.paid-view){padding-top:8px}
@@ -0,0 +1,16 @@
:root{font-family:var(--jw-font-sans,"Geist Variable",Inter,system-ui,sans-serif);color:#fff;background:#050606}*{box-sizing:border-box}html,body,#screen{width:100%;height:100%;margin:0}body{color:#fff;background:#050606}button{font:inherit}a,button{-webkit-tap-highlight-color:transparent}a:focus-visible,button:focus-visible{outline:4px solid #ff996f;outline-offset:6px}
.stage{position:relative;isolation:isolate;width:100%;height:100%;min-height:650px;overflow:hidden;background:#101915;display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,23vw);grid-template-rows:auto minmax(0,1fr) auto auto;gap:2.5vh 3vw;padding:3vh 4vw 2vh}.slides,.shade{position:absolute;inset:0;z-index:-1}.slide{position:absolute;inset:-2%;width:104%;height:104%;object-fit:cover;opacity:0;transform:scale(1.03);transition:opacity 1.8s ease,transform 10s linear;filter:saturate(1.04)}.slide.active{opacity:1;transform:scale(1.1)}.shade{background:linear-gradient(90deg,#000d,#0007 65%,#0004),linear-gradient(0deg,#000c,transparent 70%)}
.stage>header{grid-column:1/-1;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:3vw;min-width:0}.brand{display:flex;align-items:center;gap:12px}.brand b,.brand small,.merchant strong,.merchant small{display:block}.brand [data-logo]{width:clamp(32px,2.3vw,88px);height:clamp(32px,2.3vw,88px)}.brand b{font-size:clamp(14px,1.05vw,40px);font-weight:850}.brand small,.merchant small{color:#ffffffbf;font-size:clamp(12px,.85vw,33px);margin-top:4px}.merchant{text-align:center;max-width:36vw;min-width:0}.merchant strong{font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(19px,1.3vw,50px);overflow-wrap:anywhere}header time{justify-self:end;font-size:clamp(18px,1.4vw,54px);font-weight:750;font-variant-numeric:tabular-nums}
.screen-actions{grid-column:1;grid-row:2;align-self:center;min-width:0;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:2vh 2vw;align-items:center}.qr-intro{min-width:0}.eyebrow,.identity>p{text-transform:uppercase;letter-spacing:.2em;font-size:clamp(11px,.8vw,31px);font-weight:800;color:#ff996f;margin:0 0 2vh}.qr-intro h2{font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(40px,4.6vw,177px);font-weight:650;letter-spacing:-.055em;line-height:1.02;margin:0 0 2.5vh}.qr-invitation{max-width:21em;color:#d5dfd8;font-size:clamp(16px,1.25vw,48px);line-height:1.5;margin:0}.qr-cards{grid-column:2;grid-row:1/3;min-width:0}.screen-qr{display:flex;flex-direction:column;align-items:center;padding:clamp(14px,1.3vw,50px);border-radius:clamp(20px,1.4vw,54px);background:#fff;color:#101713;text-align:center;text-decoration:none;box-shadow:0 16px 70px #0003;min-width:0}.screen-qr>span{font-weight:850;font-size:clamp(25px,2.3vw,88px);letter-spacing:-.04em;margin-bottom:1vh}.screen-qr img{display:block;width:100%;height:auto;aspect-ratio:1}.screen-qr img[hidden]{display:none}.screen-qr small{font-size:clamp(14px,1.1vw,42px);margin-top:1vh;color:#465149}.stage[data-view=idle] .screen-qr:not(.is-current){display:none}.stage[data-view=idle][data-rotation=running] .screen-qr.is-current{animation:qr-arrive .6s ease-out}.qr-controls{grid-column:1;display:flex;flex-direction:column;align-items:flex-start;gap:16px}.qr-choices{display:flex;gap:clamp(8px,1vw,38px);flex-wrap:wrap}.qr-choices button{border:0;border-top:2px solid #ffffff4d;padding:12px 0 0;color:#c4cec7;background:none;cursor:pointer;font-weight:750;font-size:clamp(14px,1.05vw,40px)}.qr-choices button span{display:block;font-size:.65em;letter-spacing:.1em;margin-bottom:7px;color:#b9c4bd}.qr-choices button[aria-pressed=true]{border-color:#ff996f;color:#fff}.qr-pause{background:none;border:1px solid #ffffff45;border-radius:30px;color:#d5dfd8;padding:8px 14px;cursor:pointer;font-size:clamp(12px,.7vw,27px)}
.live-feed{grid-column:1;grid-row:2;min-height:0;min-width:0;display:flex;flex-direction:column;gap:1.6vh}.stage[data-view=idle] .live-feed{display:none}.feed-heading,.feed-heading>div{display:flex;align-items:center;gap:12px}.feed-heading{justify-content:space-between;gap:16px}.feed-heading h2{font-size:clamp(24px,2vw,77px);letter-spacing:-.035em;margin:0}.live-dot{width:clamp(8px,.6vw,23px);height:clamp(8px,.6vw,23px);flex-shrink:0;border-radius:50%;background:#90e8b9}.feed-intro{color:#c6d2cb;font-size:clamp(12px,.85vw,33px)}.feed-footnote{font-size:clamp(11px,.75vw,29px);color:#b7c4bc;margin:0}.feed-connection{font-size:inherit;color:#a3f2c6;letter-spacing:.04em}.feed-connection.offline{color:#ffce96}#activity-items{flex:1;min-height:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:minmax(0,1fr);gap:1.5vh 1vw}.activity-card{display:flex;flex-direction:column;justify-content:center;gap:1.3vh;position:relative;overflow:hidden;padding:clamp(18px,1.7vw,65px);border:1px solid #ffffff30;background:#0c1813eb;border-radius:clamp(20px,1.3vw,50px);line-height:1.2;animation:arrive .35s ease-out}.activity-icon{width:clamp(35px,2.4vw,92px);height:clamp(35px,2.4vw,92px);display:grid;place-items:center;border-radius:25%;background:#ff694125;color:#ff996f;font-size:clamp(23px,1.8vw,69px)}.justpay .activity-icon{background:#83e5b321;color:#9ef3c7}.tip .activity-icon{background:#b8a0ff24;color:#d8caff}.activity-label{display:flex;justify-content:space-between;gap:10px;font-size:clamp(13px,1vw,38px);color:#bac8bf}.activity-label>span{color:#e5efe8}.activity-card h3{font-size:clamp(30px,2.7vw,104px);letter-spacing:-.035em;margin:.25em 0;overflow-wrap:anywhere}.activity-card p{margin:0;font-size:clamp(19px,1.5vw,58px);color:#e1e9e4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;line-height:1.3}.activity-card>div{min-width:0}.justpay h3,.tip h3{font-size:clamp(28px,2.25vw,86px)}.justpay p,.tip p{font-size:clamp(30px,2.8vw,108px);font-weight:800;color:#fff;letter-spacing:-.03em}.event-lifetime{position:absolute;bottom:0;left:0;width:100%;height:3px;background:#a9eac354;transform-origin:left;animation:expire var(--remaining) linear forwards}.stage[data-activity-count="1"] #activity-items{grid-template-columns:1fr}.stage[data-activity-count="1"] .activity-card h3{font-size:clamp(44px,5vw,192px)}.stage[data-activity-count="1"] .activity-card p{font-size:clamp(24px,2.5vw,96px)}
.stage[data-view=active] .screen-actions{grid-column:2;align-self:center;display:block}.stage[data-view=active] .qr-intro,.stage[data-view=active] .qr-controls{display:none}.stage[data-view=active] .qr-cards{display:grid;gap:1.5vh}.stage[data-view=active] .screen-qr{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:0 1vw;padding:clamp(12px,.8vw,31px);border-radius:clamp(16px,1vw,38px);animation:none}.stage[data-view=active] .screen-qr img{grid-column:1;grid-row:1/3}.stage[data-view=active] .screen-qr>span{font-size:clamp(22px,1.6vw,61px);align-self:end;grid-column:2}.stage[data-view=active] .screen-qr small{font-size:clamp(12px,.85vw,33px);align-self:start;grid-column:2;margin:0}
.nostr-note{grid-column:2;grid-row:2;align-self:center;min-width:0;padding:clamp(18px,1.3vw,50px);border:1px solid #ffffff29;border-radius:clamp(20px,1vw,38px);background:#080d0cbb;backdrop-filter:blur(18px)}.note-head{display:flex;align-items:center;gap:10px}.note-head>span{width:clamp(30px,2vw,77px);height:clamp(30px,2vw,77px);display:grid;place-items:center;border-radius:50%;background:#7a40ff}.note-head strong,.note-head small{display:block}.note-head strong{font-size:clamp(14px,1vw,38px)}.note-head small,.npub{color:#c4cbc7;font-size:clamp(11px,.7vw,27px)}.nostr-note>p{margin:1.4vh 0;font-size:clamp(16px,1.25vw,48px);line-height:1.45;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}.stage[data-view=active] .nostr-note{grid-row:3;padding:0;border:0;background:none;backdrop-filter:none}.stage[data-view=active] .nostr-note .note-head>span,.stage[data-view=active] .nostr-note .note-head small,.stage[data-view=active] .nostr-note .npub{display:none}.stage[data-view=active] .nostr-note>p{font-size:clamp(12px,.8vw,31px);-webkit-line-clamp:1;margin:7px 0 0}
.identity{grid-column:1;grid-row:3;min-width:0}.identity>p{margin:0}.identity h1{font-family:var(--jw-font-display,"Syne Variable");font-size:clamp(40px,5vw,192px);font-weight:620;line-height:1.05;letter-spacing:-.055em;margin:1vh 0 1.5vh;text-shadow:0 8px 40px #0006;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.stage[data-view=active] .identity h1{font-size:clamp(30px,2.4vw,92px);margin:.6vh 0 1vh}.stage[data-view=active] .identity>p{display:none}.progress{display:flex;gap:7px;width:min(480px,38vw)}.progress i{height:3px;flex:1;background:#ffffff4d}.progress i.active{background:#fff}.stage>footer{grid-column:1/-1;display:flex;justify-content:space-between;align-items:center;gap:15px;color:#c5cbc7;font-size:clamp(10px,.65vw,25px);text-transform:uppercase;letter-spacing:.1em}.screen-loading,.screen-error{height:100%;display:grid;place-content:center;text-align:center;background:radial-gradient(circle at 50% 40%,#232826,#050606 60%)}.loading-mark,.screen-error>span{font-size:70px}.screen-error h1{max-width:800px;font-size:clamp(36px,6vw,90px);margin:18px}.screen-error p,.screen-error small,.screen-loading p{color:#b7c4bc}
@keyframes arrive{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}@keyframes qr-arrive{from{opacity:.65;transform:translateY(5px)}to{opacity:1;transform:none}}@keyframes expire{to{transform:scaleX(0)}}
@media(max-width:1100px),(max-height:760px){.stage{height:auto;min-height:100%;display:flex;flex-direction:column;gap:28px;padding:25px 24px;overflow:hidden}.stage>header{grid-template-columns:1fr auto;gap:16px}.merchant{grid-column:1/-1;grid-row:2;justify-self:start;text-align:left;max-width:100%}.brand small{display:none}.screen-actions{width:100%;gap:24px}.qr-intro h2{font-size:clamp(40px,5.5vw,64px)}.qr-cards{width:100%;max-width:390px;justify-self:end}.screen-qr>span{font-size:30px}.qr-controls{gap:16px}.qr-choices button{font-size:16px}.nostr-note{width:100%;max-width:600px;align-self:flex-end}.identity h1{font-size:clamp(40px,8vw,75px)}.stage>footer{font-size:9px;line-height:1.5;flex-wrap:wrap}.stage[data-view=active] .live-feed{order:1;min-height:0}.stage[data-view=active] .screen-actions{order:2;width:100%}.stage[data-view=active] .nostr-note{order:3;width:100%;align-self:flex-start}.stage[data-view=active] .identity{order:4}.stage>footer{order:5}.stage[data-view=active] .qr-cards{max-width:none;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.stage[data-view=active] .screen-qr{display:flex;padding:12px;gap:6px}.stage[data-view=active] .screen-qr img{order:1;width:min(100%,160px)}.stage[data-view=active] .screen-qr>span{align-self:center;order:0;margin:0}.stage[data-view=active] .screen-qr small{align-self:center;order:2}#activity-items{gap:14px;grid-auto-rows:auto}.activity-card{padding:22px;min-height:220px}.activity-card h3{font-size:36px}.activity-card p{font-size:22px}.justpay p,.tip p{font-size:36px}.feed-footnote{margin-top:10px}}
@media(max-width:600px){.stage{padding:24px 20px;gap:24px}.screen-actions{display:flex;flex-direction:column;align-items:stretch;gap:20px}.qr-intro h2{font-size:42px;margin:0 0 14px}.qr-intro h2 br{display:none}.eyebrow{margin-bottom:12px}.qr-invitation{font-size:16px}.qr-cards{max-width:340px;align-self:center}.screen-qr{padding:20px}.qr-controls{align-items:stretch}.qr-choices{justify-content:space-between}.qr-pause{align-self:flex-start}.nostr-note{padding:20px}.identity h1{font-size:42px}.stage>footer{flex-direction:column;align-items:flex-start;gap:8px}#activity-items{grid-template-columns:1fr}.activity-card{min-height:190px;padding:24px}.feed-heading{align-items:flex-start}.feed-heading h2{font-size:26px}.feed-intro{display:none}.stage[data-view=active] .qr-cards{gap:8px}.stage[data-view=active] .screen-qr{padding:8px;border-radius:12px}.stage[data-view=active] .screen-qr>span{font-size:18px}.stage[data-view=active] .screen-qr small{font-size:10px}.stage[data-activity-count="1"] .activity-card h3{font-size:44px}.stage[data-activity-count="1"] .activity-card p{font-size:24px}}
@media(prefers-reduced-motion:reduce){.slide,.slide.active{transition:none;transform:none}.activity-card,.event-lifetime,.stage[data-view=idle] .screen-qr.is-current{animation:none}.stage[data-view=idle] .screen-actions{display:flex;flex-direction:column;align-items:stretch;gap:3vh}.stage[data-view=idle] .qr-intro h2{font-size:clamp(36px,3vw,115px);margin-bottom:1vh}.stage[data-view=idle] .qr-intro h2 br{display:none}.stage[data-view=idle] .qr-cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1vw;max-width:none}.stage[data-view=idle] .screen-qr{display:flex;padding:clamp(12px,1vw,38px)}.stage[data-view=idle] .screen-qr>span{font-size:clamp(22px,1.8vw,69px)}.stage[data-view=idle] .qr-controls{display:none}}
.brand .jw-app-icon{width:100%;height:100%}
.activity-announcement{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
@media(prefers-reduced-motion:reduce){.stage[data-view=idle] .screen-qr:not(.is-current){display:flex}}
@media(prefers-reduced-motion:reduce) and (max-width:600px){.stage[data-view=idle] .qr-cards{grid-template-columns:1fr;gap:16px;max-width:300px}.stage[data-view=idle] .screen-qr{padding:22px}.stage[data-view=idle] .screen-qr>span{font-size:30px}}
@@ -0,0 +1,168 @@
import {mountAppControls} from './app-mode.js?v=1';
import {mountLogo} from './ds/logo.js';
import {npubFrom} from './ds/env.js';
const app = document.querySelector('#screen');
const npub = npubFrom();
const esc = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
const apiBase = location.pathname.startsWith('/business-demo') ? '/business-demo' : '';
const ago = stamp => {
const seconds = Math.max(0, Math.floor((Date.now() - stamp * 1000) / 1000));
return seconds < 60 ? 'just now' : seconds < 3600 ? `${Math.floor(seconds / 60)}m ago` : `${Math.floor(seconds / 3600)}h ago`;
};
function error(message) {
app.innerHTML = `<section class="screen-error"><span>⌁</span><p>Just Works Screen</p><h1>${esc(message)}</h1><small>Use screen.justworks.cash/?npub=npub1…</small></section>`;
}
function qrCard(id, label, url) {
// Only server-resolved public destinations; never forward the screen query or hash.
const target = new URL(url);
if (!['https:', 'http:'].includes(target.protocol)) throw new Error('Invalid screen destination');
return `<a class="screen-qr${id === 'order' ? ' is-current' : ''}" id="qr-${id}" data-screen-qr="${id}" href="${esc(url)}" aria-label="${label} for this merchant"><span>${label}</span><img src="${apiBase}/api/qr?text=${encodeURIComponent(url)}" alt="QR code: ${label}" width="480" height="480"><small>Scan to ${id === 'order' ? 'order' : id === 'justpay' ? 'pay' : 'tip'} ↗</small></a>`;
}
function render(data) {
const {merchant, images, latest, screen_links: links} = data;
const safeImages = images || [];
app.innerHTML = `<section class="stage" data-view="idle">
<div class="slides" aria-hidden="true">${safeImages.map((src, i) => `<img class="slide ${i === 0 ? 'active' : ''}" src="${esc(src)}" alt="">`).join('')}</div><div class="shade"></div>
<header><div class="brand"><span data-logo="icon"></span><b>Just Works<small>Live merchant screen</small></b></div><div class="merchant"><strong>${esc(merchant.name)}</strong><small>${esc([merchant.category, merchant.area].filter(Boolean).join(' · '))}</small></div><div class="screen-utilities"><time id="clock" aria-label="Local time"></time><div class="screen-app-controls"></div></div></header>
<aside class="screen-actions" aria-label="Quick access for this merchant"><div class="qr-intro"><p class="eyebrow">One scan away</p><h2 id="qr-title">Good things<br> start here.</h2><p class="qr-invitation">Order to your table. Pay simply. Leave a little love.</p></div><div class="qr-cards">${qrCard('order', 'Order', links.order)}${qrCard('justpay', 'JustPay', links.justpay)}${qrCard('tip', 'Tip', links.tip)}</div><div class="qr-controls" aria-label="Choose a QR code"><div class="qr-choices">${['Order', 'JustPay', 'Tip'].map((label, i) => `<button type="button" data-qr-choice="${i}" aria-controls="qr-${['order','justpay','tip'][i]}" aria-pressed="${i === 0}" aria-label="Show ${label} QR code"><span>0${i + 1}</span>${label}</button>`).join('')}</div><button class="qr-pause" type="button" aria-pressed="false">Pause rotation</button></div></aside>
<section class="live-feed" aria-labelledby="live-title"><div class="feed-heading"><div><span class="live-dot" aria-hidden="true"></span><h2 id="live-title">Happening now</h2></div><span class="feed-intro">Orders · Payments · Tips</span></div><div id="activity-items"></div><p class="feed-footnote">Only the last 2 minutes · completed orders leave automatically</p></section>
<section class="nostr-note" aria-label="Merchant’s Nostr feed"><div class="note-head"><span aria-hidden="true">◉</span><div><strong>Latest on Nostr</strong><small>${latest ? `${ago(latest.created_at)} · signed event` : 'Merchant feed'}</small></div></div><p>${latest ? esc(latest.content.slice(0, 220)) : 'No signed text post from this merchant yet.'}</p><small class="npub">${esc(merchant.npub.slice(0, 18))}…${esc(merchant.npub.slice(-8))}</small></section>
<section class="identity"><p>Now at</p><h1>${esc(merchant.name)}</h1><div class="progress" aria-hidden="true">${safeImages.map((_, i) => `<i class="${i === 0 ? 'active' : ''}"></i>`).join('')}</div></section>
<footer><span>Pictures from the merchant’s nsite</span><span class="feed-connection" role="status">Connecting…</span><b>₿ Payments · Orders · Nostr-owned</b></footer><p class="activity-announcement" role="status" aria-live="polite" aria-atomic="true"></p></section>`;
document.querySelectorAll('[data-logo]').forEach(host => mountLogo(host, {icon: true}));
document.querySelectorAll('.screen-qr img').forEach(img => img.addEventListener('error', () => {
img.hidden = true;
img.closest('a').querySelector('small').textContent = 'QR unavailable · open link ↗';
}));
mountAppControls(document.querySelector(".screen-app-controls"));
startSlideshow(safeImages.length);
startActivity(startQRTheatre());
}
function startQRTheatre() {
const stage = document.querySelector('.stage'), actions = document.querySelector('.screen-actions');
const cards = [...actions.querySelectorAll('.screen-qr')], choices = [...actions.querySelectorAll('[data-qr-choice]')];
const pause = actions.querySelector('.qr-pause'), motion = matchMedia('(prefers-reduced-motion: reduce)');
let current = 0, timer, paused = false, active = false;
const choose = index => {
current = index;
cards.forEach((card, i) => card.classList.toggle('is-current', i === current));
choices.forEach((button, i) => button.setAttribute('aria-pressed', String(i === current)));
};
const sync = () => {
clearTimeout(timer);
const running = !active && !motion.matches && !paused && !actions.contains(document.activeElement);
stage.dataset.rotation = running ? 'running' : 'paused';
if (running) timer = setTimeout(() => {choose((current + 1) % cards.length); sync();}, 12000);
};
choices.forEach((button, i) => button.addEventListener('click', () => {choose(i); sync();}));
pause.addEventListener('click', () => {
paused = !paused;
pause.setAttribute('aria-pressed', String(paused));
pause.textContent = paused ? 'Resume rotation' : 'Pause rotation';
sync();
});
actions.addEventListener('focusin', sync);
actions.addEventListener('focusout', () => queueMicrotask(sync));
motion.addEventListener('change', () => {
if (motion.matches && actions.querySelector('.qr-controls').contains(document.activeElement)) cards[current].focus();
// When leaving reduced motion, retain whichever of the three links has focus.
const focused = cards.indexOf(document.activeElement);
if (focused !== -1) choose(focused);
sync();
});
return count => {
const nextActive = count > 0;
// Keep keyboard focus on a usable link when a control leaves the layout.
if (nextActive && actions.querySelector('.qr-controls').contains(document.activeElement)) cards[current].focus();
if (!nextActive && active) {
const focused = cards.indexOf(document.activeElement);
if (focused !== -1) choose(focused);
}
stage.dataset.view = nextActive ? 'active' : 'idle';
stage.dataset.activityCount = String(count);
if (nextActive !== active || !stage.dataset.rotation) {active = nextActive; sync();}
};
}
function startSlideshow(count) {
let current = 0;
const slides = [...document.querySelectorAll('.slide')], dots = [...document.querySelectorAll('.progress i')];
const clock = document.querySelector('#clock');
const tick = () => {const date = new Date(); clock.textContent = date.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); clock.dateTime = date.toISOString();};
tick();
setInterval(tick, 10000);
if (count > 1) setInterval(() => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
slides[current].classList.remove('active'); dots[current].classList.remove('active');
current = (current + 1) % count;
slides[current].classList.add('active'); dots[current].classList.add('active');
}, 9000);
}
function startActivity(setView) {
const list = document.querySelector('#activity-items'), connection = document.querySelector('.feed-connection');
const announcement = document.querySelector('.activity-announcement');
let events = [], lastHTML = '', connected = false, tipSource = false;
let expiryTimer;
let serverTime = Date.now(), receivedAt = performance.now();
const draw = () => {
const current = serverTime + performance.now() - receivedAt;
events = events.filter(item => Date.parse(item.expires_at) > current);
setView(Math.min(events.length, 4));
clearTimeout(expiryTimer);
if (events.length) expiryTimer = setTimeout(draw, Math.max(1, Math.ceil(Math.min(...events.map(item => Date.parse(item.expires_at))) - current)));
const html = events.slice(0, 4).map(item => {
const order = item.type === 'order', tip = item.type === 'tip';
const title = order ? `Table ${item.table}` : tip ? 'Thank you for the tip!' : 'JustPay received';
const details = order ? (item.items?.length ? item.items.map(product => `${product.qty} × ${product.name}`).join(' · ') : 'New order · details with the team') : tip ? `${Number(item.amount_sats).toLocaleString()} sats` : new Intl.NumberFormat('en', {style:'currency',currency:'EUR'}).format(item.amount_cents / 100);
const remaining = Math.max(0, Date.parse(item.expires_at) - current);
return `<article class="activity-card ${esc(item.type)}"><span class="activity-icon" aria-hidden="true">${order ? '↗' : tip ? '♥' : '₿'}</span><div><div class="activity-label">${order ? 'Order to table' : tip ? 'Nostr tip' : 'Payment'}<span>${order ? item.status === 'accepted' ? 'Preparing' : 'New' : 'Received'}</span></div><h3>${esc(title)}</h3><p>${esc(details)}</p></div><div class="event-lifetime" aria-hidden="true" style="--remaining:${remaining}ms"></div></article>`;
}).join('');
// Avoid repeat live-region announcements on every timer tick.
const signature = events.slice(0, 4).map(event => JSON.stringify(event)).join('|') + connected;
if (signature !== lastHTML) {
list.innerHTML = html; lastHTML = signature;
// This live region stays in the accessibility tree even while the empty
// visual feed is hidden, so the first incoming activity is announced too.
announcement.textContent = events.length ? [...list.children].map(card => card.innerText).join('. ') : 'No recent activity. Scan to order, pay or tip.';
}
connection.textContent = connected ? tipSource ? 'Live' : 'Live · tips reconnecting' : 'Reconnecting';
connection.classList.toggle('offline', !connected);
};
const poll = async () => {
const requestedAt = performance.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 6500);
try {
const response = await fetch(`${apiBase}/api/screen-activity?npub=${encodeURIComponent(npub)}`, {cache: 'no-store', signal: controller.signal});
if (!response.ok) throw new Error('Activity unavailable');
const data = await response.json();
const reference = Date.parse(data.server_time);
if (!Number.isFinite(reference) || !Array.isArray(data.events)) throw new Error('Invalid activity response');
receivedAt = performance.now();
// Conservatively include request travel time; latency must not extend the TTL.
serverTime = reference + receivedAt - requestedAt;
events = data.events.filter(item => ['order', 'justpay', 'tip'].includes(item.type) &&
Date.parse(item.occurred_at) <= reference && Date.parse(item.expires_at) > reference &&
Date.parse(item.expires_at) - Date.parse(item.occurred_at) <= 120000 &&
(item.type !== 'order' || ['new', 'accepted'].includes(item.status)));
connected = true; tipSource = data.tips_available === true;
} catch {
// A completed order may have disappeared while offline. Do not keep stale tickets.
events = []; connected = false;
} finally {
clearTimeout(timeout); draw(); setTimeout(poll, 4000);
}
};
poll();
}
if (!npub) error('Add the merchant npub to start.');
else fetch(`${apiBase}/api/screen?npub=${encodeURIComponent(npub)}`, {cache: 'no-store'})
.then(async response => {const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Merchant unavailable'); return data;})
.then(render).catch(failure => error(failure.message));
@@ -0,0 +1,9 @@
const prefix=location.pathname.split('/').slice(0,-1).join('/');
const token=new URLSearchParams(location.search).get('table')||'';
const stage=document.querySelector('#stage');
const pending=new Map();let frame;
function injectedShim(config){return `<script>(()=>{let seq=0;const pending=new Map();addEventListener('message',e=>{const m=e.data||{};if(m.id&&pending.has(m.id)){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(new Error(m.error)):p.resolve(m.value)}});const call=(type,payload={})=>new Promise((resolve,reject)=>{const id='nap-'+(++seq);pending.set(id,{resolve,reject});parent.postMessage({type,id,...payload},'*')});window.napplet={config:{get:()=>call('config.get'),subscribe:()=>({close(){}}),openSettings:()=>{}},storage:{getItem:key=>call('storage.getItem',{key}),setItem:(key,value)=>call('storage.setItem',{key,value}),removeItem:key=>call('storage.removeItem',{key})},notify:{send:input=>call('notify.send',{input})},shell:{supports:domain=>['config','storage','notify','shell'].includes(domain)}};const report=()=>parent.postMessage({type:'shell.resize',id:'resize-'+(++seq),height:Math.ceil(document.documentElement.scrollHeight)},'*');new ResizeObserver(report).observe(document.documentElement);addEventListener('load',report);parent.postMessage({type:'shell.ready'},'*')})()<\/script>`}
async function boot(){const config=await fetch(`${prefix}/api/table?token=${encodeURIComponent(token)}`,{cache:'no-store'}).then(async r=>{const j=await r.json();if(!r.ok)throw new Error(j.error||'configuration unavailable');return j});const artifact=await fetch(`${prefix}/napplet/order-to-table/index.html`,{cache:'no-store'}).then(r=>r.text());const srcdoc=artifact.replace(/<head([^>]*)>/i,`<head$1>${injectedShim(config)}`);frame=document.createElement('iframe');frame.sandbox='allow-scripts';frame.title='Order to Table napplet';frame.srcdoc=srcdoc;stage.replaceChildren(frame)}
addEventListener('message',async event=>{if(!frame||event.source!==frame.contentWindow)return;const m=event.data||{};if(!m.id)return;const answer=(value,error)=>frame.contentWindow.postMessage({type:`${m.type}.result`,id:m.id,value,error},'*');try{if(m.type==='config.get'){const value=await fetch(`${prefix}/api/table?token=${encodeURIComponent(token)}`,{cache:'no-store'}).then(r=>r.json());return answer({...value,tableToken:token})}if(m.type==='storage.setItem'){if(m.key.startsWith('order:')){const payload=typeof m.value==='string'?JSON.parse(m.value):m.value;const res=await fetch(`${prefix}/api/orders`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({...payload,token})});const value=await res.json();if(!res.ok)throw new Error(value.error||'order failed');return answer(value)}if(m.key==='payment:create'){const payload=typeof m.value==='string'?JSON.parse(m.value):m.value;const res=await fetch(`${prefix}/api/payments`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({...payload,token})});const value=await res.json();if(!res.ok)throw new Error(value.error||'invoice unavailable');sessionStorage.setItem(`napplet:payment-created:${payload.order_id}`,JSON.stringify(value));return answer()}sessionStorage.setItem(`napplet:${m.key}`,JSON.stringify(m.value));return answer()}if(m.type==='storage.getItem'){if(m.key.startsWith('order-status:')){const id=m.key.slice('order-status:'.length);const res=await fetch(`${prefix}/api/order?token=${encodeURIComponent(token)}&id=${encodeURIComponent(id)}`,{cache:'no-store'});const value=await res.json();if(!res.ok)throw new Error(value.error||'order status unavailable');return answer(value)}if(m.key.startsWith('payment-status:')){const id=m.key.slice('payment-status:'.length);const res=await fetch(`${prefix}/api/payment?token=${encodeURIComponent(token)}&id=${encodeURIComponent(id)}`,{cache:'no-store'});const value=await res.json();if(!res.ok)throw new Error(value.error||'payment status unavailable');return answer(value)}const raw=sessionStorage.getItem(`napplet:${m.key}`);return answer(raw?JSON.parse(raw):null)}if(m.type==='storage.removeItem'){sessionStorage.removeItem(`napplet:${m.key}`);return answer()}if(m.type==='notify.send'){return answer({shown:true})}answer(null,`unsupported capability: ${m.type}`)}catch(error){answer(null,String(error.message||error))}});
addEventListener('message',event=>{if(!frame||event.source!==frame.contentWindow||event.data?.type!=='shell.resize')return;const height=Math.max(320,Math.min(4000,Number(event.data.height)||0));frame.style.height=`${height}px`;stage.style.height=`${height}px`;if(window.frameElement instanceof HTMLIFrameElement)window.frameElement.style.height=`${height}px`;parent.postMessage({type:'napplet.resize',height},'*')});
boot().catch(error=>stage.innerHTML=`<div class="error">Napplet unavailable: ${String(error.message||error)}</div>`);
@@ -0,0 +1,100 @@
/*
* nsite template — local twin of the sovereign page the product generates
* (static-renderer.mjs → nsite.run). Structure mirrors the generated page:
* photo hero with transparent nav, What's on (sources), The place, gallery,
* visit, then dark stay-connected + ownership sections. Typography comes from
* the shared tokens so this always matches the dashboard.
*/
#app{min-height:100vh;display:flex;flex-direction:column;background:var(--jw-bg)}
/* Nav overlaid on the hero photo */
.site-nav{position:absolute;inset:0 0 auto;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:20px clamp(20px,6vw,72px)}
.site-nav .brand{display:flex;align-items:center;gap:10px;color:#fafafa;text-decoration:none;font-weight:900}
.site-nav .brand small{display:block;font-size:10px;font-weight:850;letter-spacing:.14em;text-transform:uppercase;color:rgba(250,250,250,.7)}
.site-nav .anchors{display:flex;gap:18px}
.site-nav .anchors a{color:rgba(250,250,250,.85);text-decoration:none;font-size:12px;font-weight:850;letter-spacing:.04em}
.site-nav .anchors a:hover{color:#fafafa}
/* Hero */
.site-hero{position:relative;display:flex;align-items:flex-end;min-height:80svh;padding:clamp(56px,9vw,120px) clamp(20px,6vw,72px) clamp(48px,7vw,88px);background:var(--jw-surface-sunken);overflow:hidden}
.site-hero[data-hero] .backdrop{position:absolute;inset:0;background:var(--hero) center/cover;transform:scale(1.02)}
.site-hero .shade{position:absolute;inset:0;background:linear-gradient(0deg,rgba(10,10,10,.82),rgba(10,10,10,.3) 46%,rgba(10,10,10,.14))}
.site-hero .copy{position:relative;z-index:1;max-width:860px;color:#fafafa}
.site-hero .tag{display:inline-flex;border:1px solid rgba(255,255,255,.3);border-radius:999px;padding:8px 12px;font-size:10px;font-weight:900;letter-spacing:.14em;text-transform:uppercase;color:rgba(255,255,255,.85)}
.site-hero h1{font-family:var(--jw-font-display);font-size:clamp(52px,10vw,138px);font-weight:750;line-height:.82;letter-spacing:-.06em;margin:26px 0}
.site-hero .lead{max-width:600px;font-size:clamp(16px,1.8vw,20px);line-height:1.55;color:rgba(250,250,250,.8);margin:0}
/* Sections */
main{flex:1}
.eyebrow{color:var(--jw-accent);font-size:10px;font-weight:900;letter-spacing:.17em;text-transform:uppercase;margin:0}
.site-section{border-top:1px solid var(--jw-line);padding:clamp(56px,9vw,120px) clamp(20px,6vw,72px)}
.site-section>div{max-width:1080px;margin:0 auto}
.site-section h2{font-family:var(--jw-font-display);font-size:clamp(34px,5.4vw,64px);font-weight:700;line-height:.94;letter-spacing:-.04em;margin:12px 0 30px;color:var(--jw-text-primary)}
/* What's on — source cards */
.feature-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
.feature-card{display:flex;gap:14px;border:1px solid var(--jw-line);border-radius:16px;padding:20px;background:var(--jw-surface)}
.feature-card>span{font-family:var(--jw-font-mono);font-size:11px;color:var(--jw-text-muted);letter-spacing:.08em}
.feature-card strong{display:block;font-size:15px;font-weight:850;color:var(--jw-text-primary)}
.feature-card p{margin:6px 0 0;font-size:13px;line-height:1.5;color:var(--jw-text-muted)}
/* The place */
.story-grid{display:grid;grid-template-columns:1.2fr .8fr;gap:clamp(24px,6vw,72px);align-items:center}
.statement{font-family:var(--jw-font-display);font-size:clamp(24px,3.4vw,40px);font-weight:650;line-height:1.08;letter-spacing:-.02em;color:var(--jw-text-primary);margin:0}
.story-media{aspect-ratio:4/3;border:1px solid var(--jw-line);border-radius:18px;background:var(--story) center/cover;background-color:var(--jw-surface-sunken)}
/* Menu */
.menu-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
.menu-item{display:flex;justify-content:space-between;gap:16px;align-items:baseline;border:1px solid var(--jw-line);border-radius:14px;padding:18px 20px;background:var(--jw-surface)}
.menu-item h3{font-size:16px;font-weight:800;margin:0;color:var(--jw-text-primary)}
.menu-item p{margin:6px 0 0;font-size:13px;line-height:1.5;color:var(--jw-text-muted)}
.menu-item strong{font-size:14px;font-weight:850;color:var(--jw-text-strong);white-space:nowrap}
/* Gallery */
.photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
.photo-grid figure{margin:0;border:1px solid var(--jw-line);border-radius:14px;overflow:hidden;background:var(--jw-surface-sunken);aspect-ratio:4/3}
.photo-grid img{display:block;width:100%;height:100%;object-fit:cover}
/* Visit */
.facts-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px}
.facts-list div{border:1px solid var(--jw-line);border-radius:14px;padding:16px 18px;background:var(--jw-surface)}
.facts-list dt{font-size:10px;font-weight:900;letter-spacing:.14em;text-transform:uppercase;color:var(--jw-text-muted)}
.facts-list dd{margin:6px 0 0;font-size:14px;line-height:1.5;color:var(--jw-text-primary)}
.facts-list a{color:var(--jw-text-primary)}
/* Dark sections */
.site-section.dark{border-top:0;background:var(--jw-black,#0a0a0a);color:var(--jw-white,#fafafa);--jw-line:rgba(255,255,255,.12);--jw-text-muted:rgba(250,250,250,.6);--jw-text-primary:var(--jw-white,#fafafa)}
.site-section.dark .eyebrow{color:var(--jw-accent)}
.site-section.dark h2{color:var(--jw-white,#fafafa)}
.connect-grid{display:grid;grid-template-columns:auto 1fr;gap:clamp(24px,6vw,64px);align-items:center}
.connect-grid .qr{width:min(210px,58vw);border:10px solid #fafafa;border-radius:16px;background:#fafafa}
.connect-grid .qr img{display:block;width:100%}
.connect-copy p{max-width:520px;margin:0 0 18px;font-size:15px;line-height:1.6;color:rgba(250,250,250,.72)}
.connect-copy .pair{display:flex;flex-wrap:wrap;gap:10px}
.connect-copy .pair a{border:1px solid rgba(255,255,255,.3);border-radius:999px;padding:10px 16px;color:#fafafa;text-decoration:none;font-size:12px;font-weight:850}
.connect-copy .pair a:hover{border-color:rgba(255,255,255,.6)}
/* Ownership */
.own-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px}
.own-grid div{border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:16px 18px;background:rgba(255,255,255,.04)}
.own-grid dt{font-size:10px;font-weight:900;letter-spacing:.14em;text-transform:uppercase;color:rgba(250,250,250,.55)}
.own-grid dd{margin:6px 0 0;font-size:13px;line-height:1.5;color:rgba(250,250,250,.85);word-break:break-all}
.own-grid a{color:var(--jw-white,#fafafa)}
/* Failure */
.site-fail{min-height:100svh;display:grid;place-content:center;text-align:center;gap:10px;background:var(--jw-bg);color:var(--jw-text-primary)}
.site-fail span{display:inline-grid;place-items:center;width:52px;height:52px;margin:0 auto 6px;border:1px solid var(--jw-line-strong);border-radius:50%;font-size:22px}
.site-fail h1{font-family:var(--jw-font-display);font-size:clamp(30px,5vw,52px);letter-spacing:-.03em;margin:0}
.site-fail small{color:var(--jw-text-muted)}
@media(max-width:720px){
.site-nav{padding:16px}
.site-nav .anchors{display:none}
.site-hero{min-height:64svh;padding:96px 16px 40px}
.site-hero h1{margin:18px 0}
.site-section{padding:52px 16px}
.story-grid{grid-template-columns:1fr}
.connect-grid{grid-template-columns:1fr;justify-items:center;text-align:center}
.connect-copy .pair{justify-content:center}
}
@@ -0,0 +1,64 @@
// nsite template — renders the sovereign published page locally from the
// merchant's real nsite data. This is the design surface for the generated
// customer pages: changes here are what the product's generator adopts.
import {mountLogo} from './ds/logo.js';
const app=document.querySelector('#app');
const base=location.pathname.split('/').slice(0,-1).join('/');
const npub=new URLSearchParams(location.search).get('npub')?.trim()||'';
const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
const qr=text=>`${base}/api/qr?text=${encodeURIComponent(text)}`;
function fail(message){
app.innerHTML=`<section class="site-fail"><span>⌁</span><h1>${esc(message)}</h1><small>Use /site?npub=npub1…</small></section>`;
}
function fact(term,value,href){
return `<div><dt>${esc(term)}</dt><dd>${href?`<a href="${esc(href)}" target="_blank" rel="noopener">${esc(value)} ↗</a>`:esc(value)}</dd></div>`;
}
function render(data){
const merchant=data.merchant,about=data.about,hero=data.images[0]||'';
document.title=`${merchant.name} — Just Works`;
app.innerHTML=`
<nav class="site-nav">
<a class="brand" href="${esc(data.links.page)}"><span data-logo="icon"></span><span>Just Works<small>The sovereign copy</small></span></a>
<div class="anchors">
${data.sources.length?'<a href="#whats-on">What’s on</a>':''}
<a href="#story">The place</a>
${(merchant.address||merchant.hours||merchant.phone)?'<a href="#visit">Visit</a>':''}
</div>
</nav>
<section class="site-hero" ${hero?`data-hero style="--hero:url('${esc(hero)}')"`:''}>
${hero?'<div class="backdrop"></div>':''}
<div class="shade"></div>
<div class="copy">
<span class="tag">${esc([merchant.category,merchant.area].filter(Boolean).join(' · ')||'Merchant')}</span>
<h1>${esc(merchant.name)}</h1>
${about?`<p class="lead">${esc(about)}</p>`:''}
</div>
</section>
<main>
${data.sources.length?`<section class="site-section" id="whats-on"><div><p class="eyebrow">${data.products.length?'This week':'What’s on'}</p><h2>What’s on.</h2><div class="feature-grid">${data.sources.map((source,index)=>`<article class="feature-card"><span>${String(index+1).padStart(2,'0')}</span><div><strong>${esc(source.host||'Source')}${source.photos?` · ${source.photos} photos`:''}</strong><p>${esc(source.description||source.name)}${source.url?`<br><a href="${esc(source.url)}" target="_blank" rel="noopener">${esc(source.url.replace(/^https?:\//,''))} ↗</a>`:''}</p></div></article>`).join('')}</div></div></section>`:''}
<section class="site-section" id="story"><div><p class="eyebrow">The place</p><h2>A local place called ${esc(merchant.name)}.</h2><div class="story-grid"><p class="statement">${esc(about||`${merchant.name} keeps a page on the open web — photos, links and the latest info, signed by the owner.`)}</p>${data.images[1]?`<div class="story-media" style="--story:url('${esc(data.images[1])}')"></div>`:''}</div></div></section>
${data.products.length?`<section class="site-section" id="menu"><div><p class="eyebrow">Menu</p><h2>What we serve.</h2><div class="menu-grid">${data.products.map(product=>`<article class="menu-item"><div><h3>${esc(product.name)}</h3>${product.description?`<p>${esc(product.description)}</p>`:''}</div>${product.price?`<strong>${esc(product.price)}</strong>`:''}</article>`).join('')}</div></div></section>`:''}
${data.images.length>1?`<section class="site-section" id="gallery"><div><p class="eyebrow">Gallery</p><h2>The place in photos.</h2><div class="photo-grid">${data.images.slice(1).map(src=>`<figure><img src="${esc(src)}" alt="${esc(merchant.name)}" loading="lazy"></figure>`).join('')}</div></div></section>`:''}
${(merchant.address||merchant.hours||merchant.phone||merchant.website)?`<section class="site-section" id="visit"><div><p class="eyebrow">Visit</p><h2>Find us.</h2><dl class="facts-list">${merchant.address?fact('Address',merchant.address):''}${merchant.hours?fact('Hours',merchant.hours):''}${merchant.phone?fact('Phone',merchant.phone,`tel:${esc(merchant.phone)}`):''}${merchant.website?fact('Website',merchant.website.replace(/^https?:\/\//,''),merchant.website):''}${merchant.rating?fact('Rating',`★ ${merchant.rating} / 5`):''}</dl></div></section>`:''}
<section class="site-section dark" id="connect"><div class="connect-grid">
<a class="qr" href="${esc(data.links.page)}" aria-label="QR code for the merchant page"><img src="${qr(data.links.page)}" alt=""></a>
<div class="connect-copy">
<p class="eyebrow">Stay connected</p>
<h2>Keep the direct connection.</h2>
<p>This page is signed by the merchant and published to Nostr relays and Blossom storage. If every platform disappears, this page still resolves.</p>
<div class="pair"><a href="${esc(data.links.page)}">Just Works page ↗</a><a href="${esc(data.links.business)}">Business tools ↗</a></div>
</div>
</div></section>
<section class="site-section dark" id="ownership"><div><p class="eyebrow">Technical ownership details</p><h2>Who owns this page.</h2><dl class="own-grid">${fact('Signed by',merchant.npub)}${data.gateway?fact('Nsite gateway',data.gateway.replace(/^https?:\/\//,''),data.gateway):''}${fact('Published via','Nostr relays · Blossom storage')}${fact('Stable page',data.links.page)}</dl></div></section>
</main>`;
document.querySelectorAll('[data-logo]').forEach(host=>mountLogo(host,{icon:true}));
}
if(!npub)fail('Add the merchant npub.');
else fetch(`${base}/api/nsite-page?npub=${encodeURIComponent(npub)}`,{cache:'no-store'})
.then(async response=>{const data=await response.json();if(!response.ok)throw new Error(data.error||'Site unavailable');render(data)})
.catch(error=>fail(error.message));
@@ -0,0 +1,287 @@
:root {
--ink: #0a0a0a;
--paper: #f3f3ef;
--panel: #fff;
--line: #d8dad5;
--muted: #747a75;
--orange: #ff4f25;
--green: #0c8d73;
font-family: var(
--jw-font-sans,
"Geist Variable",
Inter,
system-ui,
sans-serif
);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--paper);
color: var(--ink);
}
header {
height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4vw;
border-bottom: 1px solid var(--line);
background: #fff;
}
header > span {
color: var(--muted);
font-size: 10px;
}
.brand {
display: flex;
align-items: center;
gap: 11px;
color: inherit;
text-decoration: none;
}
.brand > [data-logo] {
width: 38px;
height: 38px;
}
.brand small:before {
content: " · ";
}
.brand small {
color: var(--muted);
}
.hero {
min-height: 380px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 30px;
padding: 60px 4vw;
background: #090909;
color: #fff;
}
.eyebrow {
margin: 0 0 10px;
color: var(--orange);
font-size: 10px;
font-weight: 900;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.hero h1 {
max-width: 1000px;
margin: 0;
font: 650 clamp(56px, 9vw, 118px) / 0.88
var(--jw-font-display, "Syne Variable");
letter-spacing: -0.07em;
}
.hero div > p:last-child {
max-width: 700px;
overflow: hidden;
text-overflow: ellipsis;
color: #888;
font-size: 10px;
}
.hero > a {
color: #fff;
font-size: 11px;
font-weight: 800;
text-decoration: none;
}
.kpis {
display: grid;
grid-template-columns: repeat(6, 1fr);
background: #fff;
border-bottom: 1px solid var(--line);
}
.kpis article {
min-height: 130px;
padding: 24px;
border-right: 1px solid var(--line);
}
.kpis span,
.actions span {
color: var(--muted);
font-size: 9px;
text-transform: uppercase;
}
.kpis strong {
display: block;
margin-top: 18px;
font: 750 26px var(--jw-font-display, "Syne Variable");
}
.report {
display: grid;
grid-template-columns: 1.7fr 0.8fr;
gap: 16px;
padding: 52px 4vw 0;
}
.panel {
min-width: 0;
padding: 28px;
border: 1px solid var(--line);
border-radius: 22px;
background: #fff;
}
.panel > header {
height: auto;
padding: 0;
border: 0;
}
.panel h2 {
margin: 0;
font: 700 38px/1 var(--jw-font-display, "Syne Variable");
letter-spacing: -0.05em;
}
.legend {
display: flex;
gap: 14px;
color: var(--muted);
font-size: 9px;
}
.legend span:before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
margin-right: 5px;
border-radius: 50%;
background: var(--orange);
}
.legend span + span:before {
background: var(--green);
}
.trend svg {
width: 100%;
margin-top: 28px;
}
.trend .grid {
fill: none;
stroke: #e7e8e4;
}
.trend polyline {
fill: none;
stroke-width: 3;
stroke-linecap: round;
}
.trend .views {
stroke: var(--orange);
}
.trend .interactions {
stroke: var(--green);
}
.axis {
display: flex;
justify-content: space-between;
color: var(--muted);
font-size: 9px;
}
.outcomes > div,
.actions > div {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 14px 0;
border-top: 1px solid var(--line);
}
.outcomes > div:first-of-type,
.actions > div:first-child {
margin-top: 22px;
}
.outcomes span {
color: var(--muted);
font-size: 11px;
}
.outcomes strong,
.actions strong {
font-size: 12px;
}
.lower {
grid-template-columns: 1fr 1fr;
padding-top: 16px;
padding-bottom: 52px;
}
.actions span {
text-transform: capitalize;
}
.note {
padding: 18px;
background: #eff0ec;
color: var(--muted);
font-size: 11px;
}
footer {
padding: 18px 4vw;
background: #090909;
color: #888;
font-size: 10px;
}
.loading,
.failure {
min-height: 100vh;
display: grid;
place-content: center;
text-align: center;
background: #090909;
color: #fff;
}
.loading > span,
.failure > span {
font-size: 70px;
}
.failure h1 {
max-width: 700px;
font: 650 clamp(42px, 8vw, 84px) / 0.9 var(--jw-font-display, "Syne Variable");
}
.failure a {
color: var(--orange);
}
@media (max-width: 900px) {
header {
height: 68px;
padding: 0 16px;
}
header > span {
display: none;
}
.hero {
min-height: 330px;
display: block;
padding: 48px 18px;
}
.hero > a {
display: inline-block;
margin-top: 24px;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
}
.kpis article {
min-height: 105px;
padding: 18px;
}
.report,
.lower {
grid-template-columns: 1fr;
padding: 30px 14px 0;
}
.lower {
padding-top: 14px;
padding-bottom: 30px;
}
.panel {
padding: 20px;
}
.panel > header {
align-items: flex-start;
flex-direction: column;
}
.legend {
margin-top: 12px;
}
.hero h1 {
font-size: 56px;
}
}
@@ -0,0 +1,91 @@
import { mountLogo } from "./ds/logo.js";
const app = document.querySelector("#app"),
npub = new URLSearchParams(location.search).get("npub")?.trim() || "";
const esc = (value) =>
String(value ?? "").replace(
/[&<>"']/g,
(char) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
char
],
);
const compact = (value) =>
new Intl.NumberFormat(undefined, {
notation: "compact",
maximumFractionDigits: 1,
}).format(Number(value || 0));
const sats = (value) => `${Number(value || 0).toLocaleString()} sats`,
euro = (value) =>
new Intl.NumberFormat(undefined, {
style: "currency",
currency: "EUR",
}).format(Number(value || 0) / 100),
pct = (value) => (value == null ? "—" : `${(value * 100).toFixed(1)}%`);
function chart(rows) {
const w = 900,
h = 250,
p = 18,
max = Math.max(
1,
...rows.flatMap((row) => [row.pageViews, row.interactions]),
);
const points = (key) =>
rows
.map(
(row, i) =>
`${p + (i / Math.max(1, rows.length - 1)) * (w - p * 2)},${h - p - (Number(row[key] || 0) / max) * (h - p * 2)}`,
)
.join(" ");
return `<svg viewBox="0 0 ${w} ${h}" role="img" aria-label="Views and interactions over 90 days"><path class="grid" d="M18 62H882M18 125H882M18 188H882"/><polyline class="views" points="${points("pageViews")}"/><polyline class="interactions" points="${points("interactions")}"/></svg><div class="axis"><span>${esc(rows[0]?.date || "")}</span><span>${esc(rows.at(-1)?.date || "")}</span></div>`;
}
function render(data) {
const m = data.merchant,
a = data.analytics || {};
document.title = `${m.name} · Stats`;
app.innerHTML = `<header><a href="/?npub=${encodeURIComponent(npub)}" class="brand"><span data-logo></span><b>Just Works<small>Stats</small></b></a><span>Public preview · Nostr login follows</span></header><section class="hero"><div><p class="eyebrow">Merchant intelligence · ${data.periodDays} days</p><h1>${esc(m.name)}</h1><p>${esc(m.npub)}</p></div><a href="/?npub=${encodeURIComponent(npub)}">Back to Business ↙</a></section><section class="kpis">${[
["Routed views", compact(a.pageViews)],
["Interactions", compact(a.interactions)],
["Engagement", pct(a.engagementRate)],
["Revenue", euro(a.revenueCents)],
["Settled sats", sats(a.paymentSats)],
["Paid", a.paidPayments || 0],
]
.map(
([label, value]) =>
`<article><span>${label}</span><strong>${value}</strong></article>`,
)
.join(
"",
)}</section><section class="report"><article class="panel trend"><header><div><p class="eyebrow">Audience & intent</p><h2>What moves people.</h2></div><div class="legend"><span>Views</span><span>Interactions</span></div></header>${chart(a.daily || [])}</article><article class="panel outcomes"><p class="eyebrow">Business outcomes</p><h2>From attention to action.</h2>${[
["Orders", a.orders || 0],
["Bookings", a.bookings || 0],
["Payment attempts", a.paymentAttempts || 0],
["Payment conversion", pct(a.paymentConversionRate)],
["Public tips", sats(m.publicTipSats)],
]
.map(
([label, value]) =>
`<div><span>${label}</span><strong>${value}</strong></div>`,
)
.join(
"",
)}</article></section><section class="report lower"><article class="panel"><p class="eyebrow">Top actions</p><h2>What customers do.</h2><div class="actions">${a.actions?.length ? a.actions.map((item) => `<div><span>${esc(item.action.replaceAll("_", " "))}</span><strong>${item.count}</strong></div>`).join("") : '<p class="note">No tracked interactions yet. Collection starts with the analytics release.</p>'}</div></article><article class="panel"><p class="eyebrow">Modules</p><h2>Product footprint.</h2><div class="actions">${m.modules?.map((item) => `<div><span>${esc(item.id)}</span><strong>${item.enabled ? "On" : "Off"} · ${esc(item.status)}</strong></div>`).join("") || '<p class="note">No Business modules configured.</p>'}</div></article></section><footer>${esc(data.notes?.traffic || "")}</footer>`;
document
.querySelectorAll("[data-logo]")
.forEach((node) => mountLogo(node, { icon: true }));
}
function fail(message) {
app.innerHTML = `<section class="failure"><span>∩</span><p class="eyebrow">Stats unavailable</p><h1>${esc(message)}</h1><a href="/">Back to Business</a></section>`;
}
if (!npub) fail("Add a merchant npub to open its stats.");
else
fetch(
`https://justworks.cash/api/merchant-stats?npub=${encodeURIComponent(npub)}`,
{ cache: "no-store" },
)
.then(async (response) => {
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Stats unavailable");
render(data);
})
.catch((error) => fail(error.message));
@@ -0,0 +1,553 @@
/*
* Just Works design tokens — the single source of truth for color, type,
* space, radius, shadow, and motion. Everything else in the site (base.css,
* components.css, home.css, cms.css, public-page.css) and the /design viewer
* read from here. Change a value in this file and both the live site and the
* design system move together.
*
* Three tiers, in order:
* 1. Primitives --jw-<hue>-<step> raw palette, identical in every theme
* 2. Semantic --jw-bg, --jw-text-*, --jw-accent... theme-dependent roles
* 3. Component --jw-button-*, --jw-field-*... what components actually use
*
* Components must consume tier 3 (or tier 2), never tier 1 directly, so a
* theme swap reaches every surface.
*
* THEMING: light is the default; dark is declared twice on purpose, once for
* `prefers-color-scheme: dark` (system setting, works with JS disabled) and
* once for `[data-theme="dark"]` (explicit toggle). The two blocks must stay
* in sync — `npm run check` fails the build if they drift apart.
*/
/* ---------------------------------------------------------------------------
* 1. PRIMITIVES — the raw brand palette. Theme-independent.
* ------------------------------------------------------------------------ */
:root {
/* Mint — the primary brand hue. */
--jw-mint-50: #e8faf6;
--jw-mint-300: #55d6bd;
--jw-mint-400: #20c5a8;
--jw-mint-500: #12a98e;
--jw-mint-600: #08806b;
/* Sun — warmth, highlights, the "create" energy. */
--jw-sun-50: #fff8e3;
--jw-sun-300: #ffd463;
--jw-sun-400: #ffcc4d;
--jw-sun-600: #cd8f08;
/* Coral — alerts, destructive states, accent contrast. */
--jw-coral-50: #ffecec;
--jw-coral-300: #ff8585;
--jw-coral-500: #ed3c3c;
/* Blue — links, information, the Nostr/identity surfaces. */
--jw-blue-50: #eaf0ff;
--jw-blue-100: #cfdcff;
--jw-blue-300: #6f95ff;
--jw-blue-400: #2764ff;
/* Canonical white and black. Every white or black surface, fill, and piece
of text in the system resolves to one of these two — never to #fff or
#000 — so the whole site moves if they change. */
--jw-white: #fafafa;
--jw-black: #0a0a0a;
/* Neutral ramp between them. */
--jw-neutral-0: var(--jw-white);
--jw-neutral-100: #f2f4f3;
--jw-neutral-1000: var(--jw-black);
/* Ember — the accent. A vermilion red one step from Bitcoin orange:
provenance without impersonating the coin itself. */
--jw-ember-100: #ffe1d6;
--jw-ember-200: #ffb49a;
--jw-ember-300: #ff7a52;
--jw-ember-400: #f4511e;
--jw-ember-600: #b0300a;
/* Bitcoin orange. Payment rails only — never decorative. */
--jw-bitcoin: #f7931a;
}
/* ---------------------------------------------------------------------------
* 2. SEMANTIC — light theme (the default).
* ------------------------------------------------------------------------ */
:root {
color-scheme: light;
/* Ground and surfaces, back to front. */
--jw-bg: var(--jw-white);
--jw-bg-gradient: linear-gradient(180deg, var(--jw-white) 0%, #f4f5f4 46%, #eef0ef 78%, #e9ebea 100%);
--jw-surface: var(--jw-white);
--jw-surface-raised: var(--jw-white);
--jw-surface-sunken: var(--jw-neutral-100);
--jw-surface-veil: rgba(250, 250, 250, 0.78);
--jw-surface-glass: rgba(250, 250, 250, 0.86);
--jw-scrim: rgba(10, 10, 10, 0.42);
/* Text, strongest to faintest. */
--jw-text-primary: var(--jw-black);
--jw-text-strong: #1f2422;
--jw-text-body: #454545;
--jw-text-muted: #737373;
--jw-text-subtle: #8f9995;
--jw-text-inverse: var(--jw-white);
--jw-text-on-accent: var(--jw-black);
/* Lines and dividers. */
--jw-line: rgba(10, 10, 10, 0.1);
--jw-line-strong: rgba(10, 10, 10, 0.2);
--jw-line-faint: rgba(10, 10, 10, 0.06);
/* Accents. `accent` is the one the eye should follow. */
--jw-accent: var(--jw-ember-400);
--jw-accent-strong: var(--jw-ember-600);
--jw-accent-soft: var(--jw-ember-100);
--jw-accent-contrast: var(--jw-white);
--jw-accent-ring: rgba(244, 81, 30, 0.22);
--jw-highlight: var(--jw-sun-400);
--jw-highlight-soft: var(--jw-sun-50);
--jw-info: var(--jw-blue-400);
--jw-info-soft: var(--jw-blue-50);
/* Status. */
--jw-success: var(--jw-mint-600);
--jw-success-soft: var(--jw-mint-50);
--jw-warning: var(--jw-sun-600);
--jw-warning-soft: var(--jw-sun-50);
--jw-danger: var(--jw-coral-500);
--jw-danger-soft: var(--jw-coral-50);
/* Shadows carry the light theme's depth. */
--jw-shadow-color: rgba(0, 0, 0, 0.12);
--jw-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.06);
--jw-shadow-sm: 0 8px 22px rgba(0, 0, 0, 0.045);
--jw-shadow-md: 0 14px 34px rgba(0, 0, 0, 0.055);
--jw-shadow-lg: 0 18px 54px rgba(0, 0, 0, 0.08);
--jw-shadow-xl: 0 28px 80px rgba(0, 0, 0, 0.16);
--jw-shadow-accent: 0 14px 34px rgba(244, 81, 30, 0.16);
/* Ambient shadow behind the hero device: enough depth to lift a dark
object off either ground without reading as a glow. */
--jw-device-shadow:
0 4px 10px rgba(0, 0, 0, 0.18),
0 30px 60px rgba(0, 0, 0, 0.26);
/* The brand mark's conic sweep, so the logo follows the theme. */
--jw-mark-sweep: conic-gradient(from 200deg, #ffcc4d, #ff5d5d, #2764ff, #20c5a8, #ffcc4d);
--jw-mark-inset: var(--jw-white);
}
/* ---------------------------------------------------------------------------
* 2b. SEMANTIC — dark theme, applied by system preference.
*
* Keep every declaration below identical to the [data-theme="dark"] block.
* ------------------------------------------------------------------------ */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--jw-bg: var(--jw-black);
--jw-bg-gradient: linear-gradient(180deg, var(--jw-black), var(--jw-black));
--jw-surface: rgba(255, 255, 255, 0.04);
--jw-surface-raised: rgba(255, 255, 255, 0.07);
--jw-surface-sunken: rgba(0, 0, 0, 0.28);
--jw-surface-veil: rgba(18, 18, 18, 0.72);
--jw-surface-glass: rgba(10, 10, 10, 0.82);
--jw-scrim: rgba(0, 0, 0, 0.66);
--jw-text-primary: var(--jw-white);
--jw-text-strong: rgba(250, 250, 250, 0.92);
--jw-text-body: rgba(250, 250, 250, 0.72);
--jw-text-muted: rgba(250, 250, 250, 0.56);
--jw-text-subtle: rgba(250, 250, 250, 0.44);
--jw-text-inverse: var(--jw-black);
--jw-text-on-accent: #052e27;
--jw-line: rgba(255, 255, 255, 0.12);
--jw-line-strong: rgba(255, 255, 255, 0.24);
--jw-line-faint: rgba(255, 255, 255, 0.07);
--jw-accent: var(--jw-ember-300);
--jw-accent-strong: var(--jw-ember-200);
--jw-accent-soft: rgba(244, 81, 30, 0.16);
--jw-accent-contrast: var(--jw-black);
--jw-accent-ring: rgba(255, 122, 82, 0.28);
--jw-highlight: var(--jw-sun-300);
--jw-highlight-soft: rgba(255, 204, 77, 0.14);
--jw-info: var(--jw-blue-300);
--jw-info-soft: rgba(39, 100, 255, 0.16);
--jw-success: var(--jw-mint-300);
--jw-success-soft: rgba(32, 197, 168, 0.14);
--jw-warning: var(--jw-sun-300);
--jw-warning-soft: rgba(255, 204, 77, 0.14);
--jw-danger: var(--jw-coral-300);
--jw-danger-soft: rgba(255, 93, 93, 0.16);
--jw-shadow-color: rgba(0, 0, 0, 0.6);
--jw-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
--jw-shadow-sm: 0 8px 22px rgba(0, 0, 0, 0.34);
--jw-shadow-md: 0 14px 34px rgba(0, 0, 0, 0.42);
--jw-shadow-lg: 0 18px 54px rgba(0, 0, 0, 0.5);
--jw-shadow-xl: 0 28px 80px rgba(0, 0, 0, 0.62);
--jw-shadow-accent: 0 14px 34px rgba(244, 81, 30, 0.22);
--jw-topbar-scrolled-bg: linear-gradient(180deg, rgba(10, 10, 10, 0.6), rgba(10, 10, 10, 0.3));
--jw-topbar-scrolled-border: rgba(255, 255, 255, 0.06);
--jw-topbar-scrolled-shadow: none;
--jw-button-primary-fill: var(--jw-white);
--jw-button-primary-text: var(--jw-black);
--jw-button-primary-fill-hover: #ececec;
--jw-button-secondary-fill: var(--jw-black);
--jw-button-secondary-fill-hover: #171717;
--jw-app-icon-bg: rgba(10, 10, 10, 0.72);
--jw-app-icon-rim-bright: rgba(255, 255, 255, 0.85);
--jw-app-icon-rim-fade: rgba(10, 10, 10, 0.04);
--jw-modal-bg: rgba(17, 17, 17, 0.78);
--jw-portal-depth: drop-shadow(0 0 5px rgba(250, 250, 250, 0.22));
--jw-device-shadow:
0 0 0 1px rgba(255, 255, 255, 0.07),
0 6px 14px rgba(0, 0, 0, 0.5),
0 34px 70px rgba(0, 0, 0, 0.6);
--jw-mark-sweep: conic-gradient(from 200deg, #ffd463, #ff8585, #6f95ff, #55d6bd, #ffd463);
--jw-mark-inset: var(--jw-black);
}
}
/* ---------------------------------------------------------------------------
* 2c. SEMANTIC — dark theme, applied by explicit toggle.
*
* Keep every declaration below identical to the prefers-color-scheme block.
* ------------------------------------------------------------------------ */
:root[data-theme="dark"] {
color-scheme: dark;
--jw-bg: var(--jw-black);
--jw-bg-gradient: linear-gradient(180deg, var(--jw-black), var(--jw-black));
--jw-surface: rgba(255, 255, 255, 0.04);
--jw-surface-raised: rgba(255, 255, 255, 0.07);
--jw-surface-sunken: rgba(0, 0, 0, 0.28);
--jw-surface-veil: rgba(18, 18, 18, 0.72);
--jw-surface-glass: rgba(10, 10, 10, 0.82);
--jw-scrim: rgba(0, 0, 0, 0.66);
--jw-text-primary: var(--jw-white);
--jw-text-strong: rgba(250, 250, 250, 0.92);
--jw-text-body: rgba(250, 250, 250, 0.72);
--jw-text-muted: rgba(250, 250, 250, 0.56);
--jw-text-subtle: rgba(250, 250, 250, 0.44);
--jw-text-inverse: var(--jw-black);
--jw-text-on-accent: #052e27;
--jw-line: rgba(255, 255, 255, 0.12);
--jw-line-strong: rgba(255, 255, 255, 0.24);
--jw-line-faint: rgba(255, 255, 255, 0.07);
--jw-accent: var(--jw-ember-300);
--jw-accent-strong: var(--jw-ember-200);
--jw-accent-soft: rgba(244, 81, 30, 0.16);
--jw-accent-contrast: var(--jw-black);
--jw-accent-ring: rgba(255, 122, 82, 0.28);
--jw-highlight: var(--jw-sun-300);
--jw-highlight-soft: rgba(255, 204, 77, 0.14);
--jw-info: var(--jw-blue-300);
--jw-info-soft: rgba(39, 100, 255, 0.16);
--jw-success: var(--jw-mint-300);
--jw-success-soft: rgba(32, 197, 168, 0.14);
--jw-warning: var(--jw-sun-300);
--jw-warning-soft: rgba(255, 204, 77, 0.14);
--jw-danger: var(--jw-coral-300);
--jw-danger-soft: rgba(255, 93, 93, 0.16);
--jw-shadow-color: rgba(0, 0, 0, 0.6);
--jw-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
--jw-shadow-sm: 0 8px 22px rgba(0, 0, 0, 0.34);
--jw-shadow-md: 0 14px 34px rgba(0, 0, 0, 0.42);
--jw-shadow-lg: 0 18px 54px rgba(0, 0, 0, 0.5);
--jw-shadow-xl: 0 28px 80px rgba(0, 0, 0, 0.62);
--jw-shadow-accent: 0 14px 34px rgba(244, 81, 30, 0.22);
--jw-topbar-scrolled-bg: linear-gradient(180deg, rgba(10, 10, 10, 0.6), rgba(10, 10, 10, 0.3));
--jw-topbar-scrolled-border: rgba(255, 255, 255, 0.06);
--jw-topbar-scrolled-shadow: none;
--jw-button-primary-fill: var(--jw-white);
--jw-button-primary-text: var(--jw-black);
--jw-button-primary-fill-hover: #ececec;
--jw-button-secondary-fill: var(--jw-black);
--jw-button-secondary-fill-hover: #171717;
--jw-app-icon-bg: rgba(10, 10, 10, 0.72);
--jw-app-icon-rim-bright: rgba(255, 255, 255, 0.85);
--jw-app-icon-rim-fade: rgba(10, 10, 10, 0.04);
--jw-modal-bg: rgba(17, 17, 17, 0.78);
--jw-portal-depth: drop-shadow(0 0 5px rgba(250, 250, 250, 0.22));
--jw-device-shadow:
0 0 0 1px rgba(255, 255, 255, 0.07),
0 6px 14px rgba(0, 0, 0, 0.5),
0 34px 70px rgba(0, 0, 0, 0.6);
--jw-mark-sweep: conic-gradient(from 200deg, #ffd463, #ff8585, #6f95ff, #55d6bd, #ffd463);
--jw-mark-inset: var(--jw-black);
}
/* ---------------------------------------------------------------------------
* 3. TYPE, SPACE, SHAPE, MOTION — theme-independent.
* ------------------------------------------------------------------------ */
:root {
/* Geist carries the headings; Inter carries everything else. Swap this one
token to change every heading on the site and in /design. */
--jw-font-display: "Syne Variable", "Geist Variable", Inter, ui-sans-serif, system-ui, sans-serif;
--jw-font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--jw-font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
/* Static steps for UI. */
--jw-text-2xs: 0.72rem;
--jw-text-xs: 0.78rem;
--jw-text-sm: 0.88rem;
--jw-text-base: 1rem;
--jw-text-lg: 1.125rem;
--jw-text-xl: 1.25rem;
--jw-text-2xl: 1.5rem;
--jw-text-3xl: 1.875rem;
/* Fluid steps for editorial headlines. The vw term dominates from phone to
desktop so poster type scales with the viewport rather than stepping. */
--jw-text-title: clamp(1.7rem, 3vw, 2.6rem);
--jw-text-headline: clamp(2.1rem, 4.6vw, 3.6rem);
--jw-text-display: clamp(2.45rem, 6.5vw, 5rem);
/* Syne hangs deep descenders; 0.96 clipped them. */
--jw-leading-tight: 1.06;
--jw-leading-snug: 1.12;
--jw-leading-normal: 1.45;
--jw-leading-relaxed: 1.6;
--jw-weight-normal: 450;
--jw-weight-medium: 560;
--jw-weight-semibold: 700;
--jw-weight-bold: 820;
--jw-weight-black: 900;
--jw-tracking-eyebrow: 0.09em;
--jw-tracking-tight: -0.01em;
/* Space scale — 4px base. */
--jw-space-1: 4px;
--jw-space-2: 8px;
--jw-space-3: 12px;
--jw-space-4: 16px;
--jw-space-5: 20px;
--jw-space-6: 24px;
--jw-space-8: 32px;
--jw-space-10: 40px;
--jw-space-12: 48px;
--jw-space-16: 64px;
--jw-space-20: 80px;
--jw-space-24: 96px;
--jw-section-gap: clamp(64px, 9vw, 128px);
--jw-radius-xs: 4px;
--jw-radius-sm: 6px;
--jw-radius-md: 8px;
--jw-radius-lg: 14px;
--jw-radius-xl: 22px;
--jw-radius-pill: 999px;
/* Fixed at 1220px through laptop widths, then grows with the window to a
1560px ceiling so large displays do not strand the content in gutters.
The outer min() keeps the side gutter on small screens. */
--jw-container: min(calc(100% - 32px), clamp(1220px, 82vw, 1560px));
--jw-container-narrow: min(calc(100% - 32px), 880px);
--jw-hitbox-min: 42px;
/* Motion. GSAP reads the durations/eases through motion.js so CSS
transitions and scripted tweens stay on the same curve. */
--jw-duration-fast: 120ms;
--jw-duration-base: 180ms;
--jw-duration-slow: 340ms;
--jw-duration-slower: 620ms;
--jw-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--jw-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--jw-ease-spring: cubic-bezier(0.2, 0.8, 0.2, 1);
--jw-z-base: 1;
--jw-z-sticky: 10;
--jw-z-overlay: 60;
--jw-z-modal: 70;
--jw-z-toast: 80;
}
/* ---------------------------------------------------------------------------
* 4. COMPONENT TOKENS
*
* What components.css actually reads. Every value resolves to a semantic
* token, so these follow the theme automatically and can be retargeted
* (e.g. point --jw-button-primary-bg at --jw-info) without touching CSS
* rules. This is the layer to tweak when a component needs to change.
* ------------------------------------------------------------------------ */
:root {
/* Buttons.
There are two: primary and secondary. Both are rimmed rather than
filled — a two-colour gradient painted onto the border only, with the
middle left transparent so whatever is behind shows through. Hierarchy
comes from which two colours the rim uses, not from a fill. */
--jw-button-height: var(--jw-hitbox-min);
--jw-button-radius: var(--jw-radius-md);
--jw-button-padding: 0 var(--jw-space-6);
--jw-button-weight: var(--jw-weight-bold);
/* Matched to the ring/app-icon rim the menu button wears. */
--jw-button-rim-width: 1px;
/* The rim is bright at the two diagonal corners and drops out entirely
across the middle, so the page shows through the gap. Same very light
grey in both themes. */
--jw-button-rim-bright: #e6e6e6;
--jw-button-rim-gap-start: 27%;
--jw-button-rim-gap-end: 73%;
/* Primary inverts the page: black on light, white on dark. */
--jw-button-primary-fill: var(--jw-black);
--jw-button-primary-text: var(--jw-white);
--jw-button-primary-fill-hover: #1c1c1c;
--jw-button-secondary-fill: var(--jw-white);
--jw-button-secondary-text: var(--jw-text-body);
--jw-button-secondary-fill-hover: var(--jw-neutral-100);
/* Ghost — a quiet inline action: no rim, no fill until hovered. */
--jw-button-ghost-text: var(--jw-text-muted);
--jw-button-ghost-text-hover: var(--jw-text-primary);
--jw-button-ghost-fill-hover: var(--jw-surface-veil);
/* Fields. */
--jw-field-height: 50px;
--jw-field-radius: var(--jw-radius-md);
--jw-field-bg: var(--jw-surface);
--jw-field-text: var(--jw-text-primary);
--jw-field-placeholder: var(--jw-text-subtle);
--jw-field-border: var(--jw-line);
--jw-field-border-focus: var(--jw-info);
--jw-field-ring: 0 0 0 4px var(--jw-accent-ring);
--jw-field-shadow: var(--jw-shadow-sm);
--jw-field-label: var(--jw-text-body);
--jw-field-sheen: var(--jw-line);
/* Cards and panels. */
--jw-card-bg: var(--jw-surface);
--jw-card-border: var(--jw-line);
--jw-card-radius: var(--jw-radius-md);
--jw-card-shadow: var(--jw-shadow-lg);
--jw-card-padding: var(--jw-space-5);
--jw-modal-padding: clamp(20px, 3vw, 30px);
/* Dialog glass: translucent over the scrim's blur, opaque enough to read. */
--jw-modal-bg: rgba(252, 252, 252, 0.94);
/* Depth under the brand mark: a soft contact shadow on light. */
--jw-portal-depth: drop-shadow(0 1px 1.5px rgba(10, 10, 10, 0.28));
/* Chips. */
--jw-chip-bg: var(--jw-surface-veil);
--jw-chip-text: var(--jw-text-strong);
--jw-chip-border: var(--jw-line);
--jw-chip-dot: var(--jw-accent);
/* Navigation chrome. */
--jw-topbar-height: 64px;
--jw-topbar-bg: var(--jw-surface-glass);
--jw-topbar-border: var(--jw-line);
/* Applied only once the page has scrolled past --jw-topbar-scroll-start. */
--jw-topbar-scrolled-bg: linear-gradient(180deg, rgba(250, 250, 250, 0.6), rgba(250, 250, 250, 0.3));
--jw-topbar-scrolled-border: rgba(10, 10, 10, 0.06);
--jw-topbar-scrolled-shadow: none;
--jw-topbar-blur: 8px;
/* Brand mark. */
--jw-mark-size: 30px;
--jw-mark-radius: var(--jw-radius-md);
--jw-mark-ring: 6px;
}
/* ---------------------------------------------------------------------------
* 5. LEGACY BRIDGE
*
* base.css, cms.css and public-page.css were written against these short
* names before the token layer existed. Mapping them here means the existing
* site inherits theming for free, and new work should reach for --jw-* names.
* ------------------------------------------------------------------------ */
:root {
--ink: var(--jw-text-primary);
--paper: var(--jw-bg);
--card: var(--jw-surface);
--muted: var(--jw-text-muted);
--line: var(--jw-line);
--mint: var(--jw-accent);
--green: var(--jw-accent-strong);
--sun: var(--jw-highlight);
--coral: var(--jw-danger);
--blue: var(--jw-info);
--shadow: var(--jw-shadow-color);
--soft-shadow: var(--jw-shadow-lg);
--radius: var(--jw-radius-md);
}
/* ---------------------------------------------------------------------------
* 6. APP ICON
*
* The mark in its container. Light theme: light ground, dark rim. Dark theme
* overrides these to the inverse in the theme blocks above.
* ------------------------------------------------------------------------ */
:root {
--jw-app-icon-size: 38px;
/* Roughly the iOS squircle ratio. */
--jw-app-icon-radius: 24%;
--jw-app-icon-bg: rgba(250, 250, 250, 0.72);
--jw-app-icon-blur: 10px;
--jw-app-icon-rim-width: 1px;
--jw-app-icon-rim-bright: rgba(10, 10, 10, 0.6);
--jw-app-icon-rim-fade: rgba(10, 10, 10, 0.04);
/* Circular icon actions (dialog close, help). */
--jw-ring-button-size: 44px;
}
/* ---------------------------------------------------------------------------
* 7. SELF-HOSTED FONTS
*
* The same files justworks.cash serves, so every surface here — dashboard,
* module pages, and the locally generated merchant site — renders the real
* Syne headings and Geist body instead of falling back to system fonts.
* Paths are relative to this stylesheet, so they work under any base path.
* ------------------------------------------------------------------------ */
@font-face {
font-family: "Syne Variable";
src: url("../fonts/syne-latin-wght-normal.woff2") format("woff2-variations");
font-weight: 400 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Geist Variable";
src: url("../fonts/geist-latin-wght-normal.woff2") format("woff2-variations");
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Geist Variable";
src: url("../fonts/geist-latin-ext-wght-normal.woff2") format("woff2-variations");
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7, U+02CC, U+02CE-02D7, U+02DD, U+02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@@ -0,0 +1,9 @@
.upsell-showcase{margin-top:clamp(70px,10vw,130px);padding-top:clamp(30px,5vw,56px);border-top:1px solid var(--line)}
.upsell-head{display:flex;align-items:flex-start;justify-content:space-between;gap:30px;margin-bottom:28px}.upsell-head>div{max-width:800px}.upsell-head h2{max-width:750px;margin:10px 0 14px;font-size:clamp(38px,6vw,68px);font-weight:650;letter-spacing:-.055em;line-height:.94}.upsell-head p:last-child{max-width:680px;margin:0;color:var(--body);font-size:17px;line-height:1.55}.preview-badge,.preview-state{flex:none;display:inline-flex;align-items:center;min-height:28px;padding:0 10px;border:1px solid var(--line-strong);border-radius:99px;color:var(--muted);font-size:10px;font-weight:850;letter-spacing:.08em;text-transform:uppercase}
.module-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:16px}.module-chip{display:grid;grid-template-columns:40px minmax(0,1fr) auto;align-items:center;gap:10px;min-width:0;padding:13px;border:1px solid var(--line);border-radius:16px;background:color-mix(in srgb,var(--panel) 88%,transparent);box-shadow:var(--jw-shadow-xs,0 3px 10px rgba(0,0,0,.025))}.module-chip.live{border-color:color-mix(in srgb,var(--green) 34%,var(--line));background:color-mix(in srgb,var(--green) 5%,var(--panel))}.module-icon{width:40px;height:40px;display:grid;place-items:center;border-radius:12px;background:var(--soft);font-size:18px;font-weight:900}.module-chip.live .module-icon{color:var(--green);background:color-mix(in srgb,var(--green) 10%,var(--soft))}.module-chip div{min-width:0}.module-chip strong,.module-chip small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.module-chip strong{font-size:13px}.module-chip small{margin-top:2px;color:var(--muted);font-size:11px}.module-chip>span:last-child{color:var(--muted);font-size:9px;font-weight:900;letter-spacing:.09em;text-transform:uppercase}.module-chip.live>span:last-child{color:var(--green)}
.preview-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.service-preview{position:relative;overflow:hidden;min-height:650px;padding:clamp(22px,3.5vw,38px);border:1px solid var(--line);border-radius:var(--jw-radius-xl,30px);background:var(--panel);box-shadow:var(--jw-shadow-md,0 15px 42px rgba(0,0,0,.07))}.service-preview:before{content:"";position:absolute;inset:0 0 auto;height:4px;background:linear-gradient(90deg,var(--accent),var(--gold));opacity:.9}.booking-preview:before{background:linear-gradient(90deg,var(--green),var(--jw-info,#2764ff))}.preview-top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:38px}.preview-symbol{width:52px;height:52px;display:grid;place-items:center;border:1px solid var(--line);border-radius:16px;background:var(--soft);font-size:24px;font-weight:900}.service-preview h3{margin:8px 0 10px;font-family:var(--jw-font-display,"Syne Variable",system-ui,sans-serif);font-size:clamp(36px,4.5vw,58px);font-weight:650;letter-spacing:-.055em;line-height:.94}.preview-copy{max-width:520px;min-height:54px;margin:0 0 28px;color:var(--body);line-height:1.55}.preview-field{margin-top:24px}.preview-field>span{display:block;margin-bottom:9px;color:var(--muted);font-size:10px;font-weight:900;letter-spacing:.11em;text-transform:uppercase}
.option-row{display:flex;gap:8px;flex-wrap:wrap}.option-row button,.arrival-grid button,.calendar button,.calendar-head button{border:1px solid var(--line);background:var(--soft);color:var(--text);cursor:pointer;transition:transform .16s ease,border-color .16s ease,background .16s ease,color .16s ease}.option-row button{min-height:42px;padding:0 15px;border-radius:12px;font-weight:800}.option-row.compact button{min-width:54px}.option-row.times button{flex:1;min-width:70px}.option-row button:hover,.arrival-grid button:hover,.calendar button:hover{transform:translateY(-1px);border-color:var(--line-strong)}.option-row button.selected,.arrival-grid button.selected,.calendar button.selected{border-color:var(--text);background:var(--text);color:var(--bg);box-shadow:0 8px 22px color-mix(in srgb,var(--text) 14%,transparent)}
.arrival-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.arrival-grid button{min-height:76px;padding:12px 6px;border-radius:14px}.arrival-grid strong,.arrival-grid small{display:block}.arrival-grid strong{font-size:18px}.arrival-grid small{margin-top:4px;color:var(--muted);font-size:10px}.arrival-grid button.selected small{color:color-mix(in srgb,var(--bg) 70%,transparent)}
.preview-cta{width:100%;min-height:54px;margin-top:28px;display:flex;align-items:center;justify-content:space-between;padding:0 20px;border:0;border-radius:14px;background:var(--accent);color:#fff;font-weight:900;cursor:pointer;box-shadow:0 12px 28px color-mix(in srgb,var(--accent) 24%,transparent);transition:transform .16s ease,box-shadow .16s ease}.booking-preview .preview-cta{background:var(--text);color:var(--bg);box-shadow:0 12px 28px color-mix(in srgb,var(--text) 16%,transparent)}.preview-cta:hover{transform:translateY(-2px);box-shadow:0 16px 34px color-mix(in srgb,var(--accent) 30%,transparent)}.preview-cta span{font-size:20px}.preview-cta.preview-confirmed{background:var(--green);color:#fff}.preview-note{display:flex;align-items:center;justify-content:center;gap:8px;margin:15px 0 0;color:var(--muted);font-size:11px}.preview-note i{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 0 5px color-mix(in srgb,var(--green) 12%,transparent)}
.calendar-head{display:flex;align-items:center;justify-content:space-between;margin:2px 0 12px}.calendar-head strong{font-size:14px}.calendar-head button{width:34px;height:34px;border-radius:10px;font-weight:900}.calendar{display:grid;grid-template-columns:repeat(7,1fr);gap:6px}.calendar>span{text-align:center;color:var(--muted);font-size:9px;font-weight:900;text-transform:uppercase}.calendar button{position:relative;min-width:0;height:58px;padding:8px 2px;border-radius:12px;font-weight:850}.calendar button.quiet{opacity:.55}.calendar button.today{border-color:var(--accent);color:var(--accent)}.calendar button small{display:block;margin-top:2px;font-size:7px;font-weight:800;letter-spacing:-.02em}.calendar button.selected small{color:color-mix(in srgb,var(--bg) 72%,transparent)}
@media(max-width:1000px){.module-strip{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:800px){.preview-grid{grid-template-columns:1fr}.upsell-head{flex-direction:column}.service-preview{min-height:0}.preview-copy{min-height:0}}@media(max-width:520px){.module-strip{grid-template-columns:1fr}.arrival-grid{grid-template-columns:repeat(2,1fr)}.calendar{gap:4px}.calendar button{height:52px}.option-row.times button{flex:1 1 calc(50% - 8px)}}
@@ -0,0 +1,20 @@
document.querySelectorAll('[data-preview-group]').forEach((group) => {
group.querySelectorAll('button').forEach((button) => {
button.addEventListener('click', () => {
group.querySelectorAll('button').forEach((option) => option.classList.remove('selected'));
button.classList.add('selected');
});
});
});
document.querySelectorAll('[data-preview-cta]').forEach((button) => {
button.addEventListener('click', () => {
const original = button.innerHTML;
button.innerHTML = 'Preview only · ready to activate <span>✓</span>';
button.classList.add('preview-confirmed');
window.setTimeout(() => {
button.innerHTML = original;
button.classList.remove('preview-confirmed');
}, 1800);
});
});
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#080808"><title>Book with Just Works</title><link rel="stylesheet" href="/assets/design-system.css?v=1"><link rel="stylesheet" href="/assets/modules.css?v=5"><script>(function(){try{var t=localStorage.getItem('jw-theme');if(t)document.documentElement.dataset.theme=t}catch(e){}})()</script></head><body><main id="app"><section class="loading"><span>⌁</span><p>Opening table booking…</p></section></main><script type="module" src="/assets/book.js?v=6"></script></body></html>
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Connect Business session · Just Works</title><link rel="stylesheet" href="/assets/design-system.css"><link rel="stylesheet" href="/assets/login-panel.css"><link rel="stylesheet" href="/assets/login-simple.css"><link rel="stylesheet" href="/assets/business-sso.css"></head>
<body><main class="exchange"><header><h1>Connect your Business session</h1></header><p>Continue with your existing Just Works identity. Your private key and Core credentials are not shared with the destination.</p><h2 id="exchange-destination">Business access</h2><p id="exchange-status" role="status">Checking your session…</p><button id="exchange-approve" disabled>Confirm access</button><button id="exchange-cancel">Cancel</button></main><script type="module" src="/assets/login-panel.js?v=5"></script><script type="module" src="/assets/business-sso.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#080808"><title>Just Works Business</title><link rel="stylesheet" href="/assets/design-system.css?v=1"><link rel="stylesheet" href="/assets/business.css?v=10"><link rel="stylesheet" href="/assets/business-deps.css?v=1"><link rel="stylesheet" href="/assets/module-qr.css?v=2"><link rel="stylesheet" href="/assets/login-panel.css?v=3"><link rel="stylesheet" href="/assets/login-simple.css?v=1"></head><body><main id="app"><section class="loading"><span>⌁</span><p>Opening merchant business…</p></section></main><div id="toast" class="toast">Saved</div><script type="module" src="/assets/business.js?v=17"></script><script type="module" src="/assets/login-panel.js?v=5"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0a0a0a"><meta name="description" content="Discover Bitcoin-friendly places and independent merchants across Madeira."><title>Just Works Gallery — Madeira</title><link rel="stylesheet" href="/assets/tokens.css"><link rel="stylesheet" href="/assets/ds.css"><link rel="stylesheet" href="/assets/gallery.css?v=2"><script>(function(){try{var t=localStorage.getItem('jw-theme');if(t)document.documentElement.dataset.theme=t}catch(e){}})()</script></head><body><header class="gallery-nav"><a class="gallery-brand" href="https://justworks.cash/"><span data-logo="icon"></span><b>Just Works<small>Gallery · Madeira</small></b></a><a class="map-link" href="https://maps.justworks.cash/">Switch to map <span>↗</span></a><div id="themeToggle"></div></header><main style="padding-top:14px"><span id="totalCount" hidden></span><section class="filter-dock" aria-label="Gallery filters"><div class="search-wrap"><span>⌕</span><input id="search" type="search" placeholder="Breakfast, haircut, dinner…" autocomplete="off"><button id="clearSearch" aria-label="Clear search">×</button></div><div class="payment-tabs" id="payments"><button class="active" data-payment="all">All <span id="allCount">—</span></button><button data-payment="bitcoin">Bitcoin <span id="bitcoinCount">—</span></button><button data-payment="fiat">Fiat <span id="fiatCount">—</span></button></div><div class="quick-filters" id="quickFilters"><button class="active" data-query="">All Madeira</button><button data-query="breakfast">Breakfast</button><button data-query="lunch">Lunch</button><button data-query="dinner">Dinner</button><button data-query="coffee">Coffee</button><button data-query="drinks">Drinks</button><button data-query="stay">Stay</button><button data-query="shop">Shop</button><button data-query="open">Open now</button></div></section><section class="result-bar"><div><strong id="resultCount">Loading places…</strong><span id="resultContext">Real merchants from Just Works Maps</span></div><button id="shuffle">Shuffle the mood ↻</button></section><section id="gallery" class="masonry" aria-live="polite"></section><section id="empty" class="empty" hidden><span>⌁</span><h2>No place fits all of that.</h2><p>Clear a filter and let Madeira surprise you.</p><button id="clearAll">Show all places</button></section><div id="sentinel" aria-hidden="true"></div></main><script type="module" src="/assets/gallery.js?v=1"></script></body></html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#0a0a0a"><title>Order with Just Works</title><link rel="stylesheet" href="/assets/design-system.css?v=1"><link rel="stylesheet" href="/assets/modules.css?v=6"><script>(function(){try{var t=localStorage.getItem('jw-theme');if(t)document.documentElement.dataset.theme=t}catch(e){}})()</script></head><body><main id="app"><section class="loading"><span>⌁</span><p>Opening merchant order…</p></section></main><script type="module" src="/assets/order.js?v=8"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0a0a0a"><title>Merchant page — Just Works</title><link rel="icon" href="/assets/portal.svg"><link rel="stylesheet" href="/assets/design-system.css?v=1"><link rel="stylesheet" href="/assets/page.css?v=1"><script>(function(){try{var t=localStorage.getItem('jw-theme');if(t)document.documentElement.dataset.theme=t}catch(e){}})()</script></head><body><div id="app"></div><script type="module" src="/assets/page.js?v=1"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#0a0a0a"><title>Just Works</title><link rel="stylesheet" href="/assets/design-system.css?v=1"><link rel="stylesheet" href="/assets/modules.css?v=5"><link rel="stylesheet" href="/assets/quick-pay.css?v=2"><script>(function(){try{var t=localStorage.getItem('jw-theme');if(t)document.documentElement.dataset.theme=t}catch(e){}})()</script></head><body><div id="app"></div><script type="module" src="/assets/modules.js?v=9"></script></body></html>
@@ -0,0 +1,2 @@
self.APP = 'pos';
importScripts('/assets/pwa/worker.js?v=1');
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#080909"><link rel="manifest" href="/pos.webmanifest"><link rel="apple-touch-icon" href="/assets/pwa/pos-192.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><title>Just Works POS</title><link rel="stylesheet" href="/assets/design-system.css?v=1"><link rel="stylesheet" href="/assets/pos.css?v=6"><link rel="stylesheet" href="/assets/pos-dark.css?v=6"><link rel="stylesheet" href="/assets/login-panel.css?v=3"><link rel="stylesheet" href="/assets/login-simple.css?v=1"><link rel="stylesheet" href="/assets/business-sso.css"><link rel="stylesheet" href="/assets/app-mode.css?v=1"></head><body data-pwa-app="pos"><main id="app"><section class="pos-loading"><span>⌁</span><p>Connecting merchant POS…</p></section></main><script type="module" src="/assets/pos.js?v=10"></script><script type="module" src="/assets/login-panel.js?v=5"></script></body></html>
@@ -0,0 +1 @@
{"id":"/?app=pos","name":"Just Works POS","short_name":"JW POS","description":"Orders, payments and bookings for your merchant.","start_url":"/","scope":"/","display":"standalone","display_override":["fullscreen","standalone"],"background_color":"#080909","theme_color":"#080909","icons":[{"src":"/assets/pwa/pos-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/assets/pwa/pos-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#080808"><title>Business profile · Just Works</title><link rel="stylesheet" href="/assets/design-system.css?v=1"><link rel="stylesheet" href="/assets/profile.css?v=4"><link rel="stylesheet" href="/assets/profile-mobile.css?v=1"><link rel="stylesheet" href="/assets/login-panel.css?v=3"><link rel="stylesheet" href="/assets/login-simple.css?v=1"></head><body><main id="app"><section class="loading"><span>⌁</span><p>Opening business profile…</p></section></main><script type="module" src="/assets/profile.js?v=5"></script><script type="module" src="/assets/login-panel.js?v=5"></script></body></html>
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#080808">
<meta name="referrer" content="no-referrer">
<title>QR in DIN A4 · Just Works</title>
<link rel="stylesheet" href="/assets/qr-sheet.css?v=1">
</head>
<body>
<nav class="sheet-tools" aria-label="QR sheet actions">
<a id="back" href="/">← Business</a>
<span>QR in DIN A4</span>
<button id="print" type="button" disabled>Print / Save as PDF <span aria-hidden="true">↗</span></button>
</nav>
<aside class="print-help"><strong>One sheet. Every way in.</strong> Print on A4 at 100% scale, with browser headers and footers off. Choose “Save as PDF” in the print dialog to download. Cut along the guides for individual labels. Use the customer-facing labels on windows and tables; keep the Business, POS and Stats labels for your team.</aside>
<p id="status" class="status" role="status" aria-live="polite">Opening merchant QR sheet…</p>
<main id="sheet" class="sheet" hidden>
<header class="sheet-heading"><div><p class="eyebrow">Just Works · Quick access</p><h1 id="merchant-name"></h1></div><p class="sheet-caption">Scan. Open. Just works.<br><span id="label-count"></span></p></header>
<section id="labels" class="labels" aria-label="Merchant QR labels"></section>
<footer class="sheet-footer"><span>JUST WORKS</span><span>Public links · No login credentials in these codes</span><span>A4 · 210 × 297 mm</span></footer>
</main>
<script type="module" src="/assets/qr-sheet.js?v=3"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More