diff --git a/front/support.css b/front/support.css new file mode 100644 index 0000000..bfaedbc --- /dev/null +++ b/front/support.css @@ -0,0 +1,307 @@ +@import url("https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;600;700&family=Golos+Text:wght@400;500;600&display=swap"); + +:root { + color-scheme: light; + --support-ink: #1f1b16; + --support-muted: #5a4f44; + --support-paper: #f6efe7; + --support-soft: #efe5d7; + --support-accent: #e39b43; + --support-coral: #c96245; + --support-teal: #2e6e74; + --support-cream: #fffaf2; + --support-border: rgba(44, 34, 26, 0.2); + --support-shadow: 0 24px 60px rgba(30, 20, 12, 0.18); +} + +* { + box-sizing: border-box; +} + +body.page-support { + margin: 0; + font-family: "Golos Text", "Segoe UI", sans-serif; + background: radial-gradient(circle at 90% -10%, #ffe7c7, transparent 60%), + radial-gradient(circle at 10% 15%, #dfeef2, transparent 55%), + radial-gradient(circle at 70% 80%, #fbdac6, transparent 55%), + var(--support-paper); + color: var(--support-ink); + min-height: 100vh; +} + +body.page-support::before { + content: ""; + position: fixed; + inset: 0; + background-image: linear-gradient( + 120deg, + rgba(255, 255, 255, 0.3) 0%, + rgba(255, 255, 255, 0.05) 40%, + transparent 100% + ), + repeating-linear-gradient( + 90deg, + rgba(31, 27, 22, 0.04) 0px, + rgba(31, 27, 22, 0.04) 1px, + transparent 1px, + transparent 28px + ); + pointer-events: none; + z-index: 0; +} + +#root { + position: relative; + z-index: 1; +} + +.support { + width: min(1100px, 92vw); + margin: 0 auto; + padding: clamp(2.5rem, 6vw, 4.5rem) 0 5rem; + display: grid; + gap: clamp(2rem, 4vw, 3rem); +} + +.support__hero { + display: grid; + gap: 1.25rem; + align-items: start; + padding: clamp(2rem, 5vw, 3rem); + border-radius: 28px; + background: linear-gradient(140deg, #fff4e5, #f7e4cf 60%, #fbe8d6 100%); + border: 1px solid rgba(31, 27, 22, 0.12); + box-shadow: var(--support-shadow); +} + +.support__badge { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.2em; + color: var(--support-muted); + background: rgba(31, 27, 22, 0.08); + padding: 0.4rem 0.9rem; + border-radius: 999px; + width: fit-content; +} + +.support__back { + text-decoration: none; + color: var(--support-teal); + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.5rem; +} + +.support__back::before { + content: "<"; + font-size: 1rem; +} + +.support__hero h1 { + font-family: "Bricolage Grotesque", "Golos Text", sans-serif; + font-size: clamp(2rem, 4vw, 3rem); + margin: 0.4rem 0 0.6rem; +} + +.support__hero p { + margin: 0; + color: var(--support-muted); + max-width: 55ch; + line-height: 1.6; +} + +.support__grid { + display: grid; + gap: clamp(2rem, 4vw, 3rem); + grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); + align-items: start; +} + +.support__info { + display: grid; + gap: 1.4rem; +} + +.info-card { + background: var(--support-cream); + border: 1px solid var(--support-border); + border-radius: 22px; + padding: 1.4rem 1.6rem; + box-shadow: 0 18px 40px rgba(35, 24, 15, 0.08); +} + +.info-card h2 { + font-family: "Bricolage Grotesque", "Golos Text", sans-serif; + margin: 0 0 0.8rem; + font-size: 1.1rem; +} + +.info-card ul { + margin: 0; + padding-left: 1.2rem; + color: var(--support-muted); + line-height: 1.7; +} + +.info-card.accent { + border-color: rgba(227, 155, 67, 0.5); + background: linear-gradient(140deg, #fff6e9, #f9e6ce); +} + +.info-card.link-card { + background: #1d2128; + color: #fef4e5; + border-color: rgba(255, 255, 255, 0.12); +} + +.info-card.link-card p { + color: rgba(255, 255, 255, 0.72); + margin-top: 0; +} + +.info-link { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: #fff1d5; + text-decoration: none; + font-weight: 600; +} + +.info-link::after { + content: ">"; + font-size: 1rem; +} + +.support__form { + background: rgba(255, 255, 255, 0.85); + border-radius: 26px; + border: 1px solid rgba(31, 27, 22, 0.12); + padding: clamp(1.8rem, 4vw, 2.6rem); + box-shadow: var(--support-shadow); + backdrop-filter: blur(10px); + display: grid; + gap: 1.2rem; +} + +.form-head { + display: grid; + gap: 0.4rem; +} + +.form-head h2 { + font-family: "Bricolage Grotesque", "Golos Text", sans-serif; + margin: 0; +} + +.status { + font-size: 0.9rem; + color: var(--support-muted); +} + +.status.success { + color: var(--support-teal); +} + +.status.error { + color: var(--support-coral); +} + +.status.sending { + color: var(--support-accent); +} + +.field { + display: grid; + gap: 0.45rem; + font-weight: 600; + color: var(--support-ink); +} + +.field input, +.field textarea { + font: inherit; + padding: 0.8rem 1rem; + border-radius: 14px; + border: 1px solid rgba(31, 27, 22, 0.2); + background: #fffaf4; + color: var(--support-ink); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.field textarea { + resize: vertical; + min-height: 140px; +} + +.field input:focus-visible, +.field textarea:focus-visible { + outline: none; + border-color: rgba(46, 110, 116, 0.9); + box-shadow: 0 0 0 3px rgba(46, 110, 116, 0.2); +} + +.form-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.send { + border: none; + border-radius: 999px; + padding: 0.9rem 1.8rem; + font-weight: 700; + font-size: 1rem; + cursor: pointer; + color: #1d1a14; + background: linear-gradient(130deg, #f5c66c, #f0a85f 50%, #dd8d4f); + box-shadow: 0 12px 26px rgba(221, 141, 79, 0.4); + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; +} + +.send:hover { + transform: translateY(-1px); + box-shadow: 0 16px 30px rgba(221, 141, 79, 0.5); +} + +.send:disabled { + cursor: not-allowed; + opacity: 0.55; + box-shadow: none; +} + +.char-count { + font-size: 0.85rem; + color: var(--support-muted); +} + +@media (max-width: 900px) { + .support__grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 600px) { + .support { + width: min(92vw, 520px); + padding-top: 2rem; + } + + .support__hero { + padding: 1.8rem; + } + + .support__form { + padding: 1.6rem; + } + + .form-actions { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/front/support.html b/front/support.html new file mode 100644 index 0000000..c6b12b9 --- /dev/null +++ b/front/support.html @@ -0,0 +1,16 @@ + + + + + + Negopoly | Support + + + +
+ + + diff --git a/front/support.tsx b/front/support.tsx new file mode 100644 index 0000000..4015e04 --- /dev/null +++ b/front/support.tsx @@ -0,0 +1,192 @@ +import React, { useMemo, useState } from "react"; +import { createRoot } from "react-dom/client"; +import "./support.css"; + +type FormState = { + name: string; + email: string; + message: string; +}; + +type Status = "idle" | "sending" | "success" | "error"; + +const tips = [ + "Session code or lobby name", + "What you were trying to do", + "Anything that looked broken or confusing", +]; + +const contactNotes = [ + "Messages are routed to the Negopoly support desk.", + "Add an email if you want a reply.", + "No account needed. Just send and go.", +]; + +const MAX_NAME_LENGTH = 80; +const MAX_EMAIL_LENGTH = 120; +const MAX_MESSAGE_LENGTH = 2000; + +function Support() { + const [form, setForm] = useState({ + name: "", + email: "", + message: "", + }); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + + const canSubmit = useMemo(() => { + return ( + status !== "sending" && + form.name.trim().length > 0 && + form.email.trim().length > 0 && + form.message.trim().length > 0 + ); + }, [form, status]); + + const statusLabel = useMemo(() => { + if (status === "success") return "Message sent. We'll follow up if you left contact info."; + if (status === "error") return error ?? "Something went wrong. Try again in a moment."; + if (status === "sending") return "Sending your message..."; + return "We only use your contact info to reply to this request."; + }, [status, error]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!canSubmit) return; + setStatus("sending"); + setError(null); + + try { + const res = await fetch("/api/support", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: form.name, + email: form.email, + message: form.message, + }), + }); + + const payload = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(payload?.message ?? "Unable to send message."); + } + + setStatus("success"); + setForm({ name: "", email: "", message: "" }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unable to send message."; + setStatus("error"); + setError(message); + } + } + + return ( +
+
+
NegoCity support desk
+
+ + Back to NegoCity + +

Support, reports, and lost deals.

+

+ Tell us what went wrong, what you need, or where the Banker needs a hand. We will read + every message that lands in the support vault. +

+
+
+ +
+
+
+

What to include

+
    + {tips.map((tip) => ( +
  • {tip}
  • + ))} +
+
+
+

How this works

+
    + {contactNotes.map((note) => ( +
  • {note}
  • + ))} +
+
+
+

Need the rules instead?

+

Head to the full guide to get back into the game quickly.

+ + View the rulebook + +
+
+ +
+
+

Send a message

+ + {statusLabel} + +
+ + + + + +