Compare commits
3 commits
c0190b59ad
...
23941e200d
| Author | SHA1 | Date | |
|---|---|---|---|
| 23941e200d | |||
| 0bbeb129a6 | |||
| 053d663096 |
22 changed files with 2318 additions and 205 deletions
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: oven/bun:latest
|
||||||
|
working_dir: /app
|
||||||
|
command: bun install && bun run index.ts
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3000}:${PORT:-3000}"
|
||||||
|
environment:
|
||||||
|
- PORT=${PORT:-3000}
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./:/app
|
||||||
|
- bun_node_modules:/app/node_modules
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bun_node_modules:
|
||||||
116
front/home.css
116
front/home.css
|
|
@ -1,15 +1,63 @@
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Recursive:wght@300;400;600;700&family=Unbounded:wght@400;600;800&display=swap");
|
@import url("https://fonts.googleapis.com/css2?family=Recursive:wght@300;400;600;700&family=Unbounded:wght@400;600;800&display=swap");
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light dark;
|
||||||
--cream: #f7f2e8;
|
--ink: #0b1a2b;
|
||||||
--ink: #1b1b1f;
|
--ink-soft: #6b7280;
|
||||||
--tangerine: #ff8a3d;
|
--surface: #f7f7f9;
|
||||||
--teal: #1d9aa3;
|
--surface-strong: #ffffff;
|
||||||
--berry: #f25287;
|
--surface-alt: #f6f8fa;
|
||||||
--sun: #ffd166;
|
--accent: #1b8b75;
|
||||||
--sky: #c7f0ff;
|
--accent-bright: #14b8a6;
|
||||||
--shadow: rgba(12, 24, 38, 0.18);
|
--accent-weak: rgba(27, 139, 117, 0.12);
|
||||||
|
--accent-shadow: rgba(27, 139, 117, 0.3);
|
||||||
|
--neutral-weak: rgba(11, 26, 43, 0.08);
|
||||||
|
--warning-weak: rgba(245, 158, 11, 0.25);
|
||||||
|
--border: #d8dee5;
|
||||||
|
--shadow: rgba(11, 26, 43, 0.12);
|
||||||
|
--surface-glass: rgba(255, 255, 255, 0.8);
|
||||||
|
--brand-surface: #0b1a2b;
|
||||||
|
--brand-text: #f8fafc;
|
||||||
|
--brand-muted: #9fb3c8;
|
||||||
|
--tangerine: #1b8b75;
|
||||||
|
--teal: #14b8a6;
|
||||||
|
--berry: #1f334d;
|
||||||
|
--sun: #f59e0b;
|
||||||
|
--sky: #ecfdf9;
|
||||||
|
--map-glow-a: rgba(27, 139, 117, 0.35);
|
||||||
|
--map-glow-b: rgba(245, 158, 11, 0.35);
|
||||||
|
--pattern-opacity: 0.22;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--ink: #f8fafc;
|
||||||
|
--ink-soft: #a7b4c5;
|
||||||
|
--surface: #0b0f14;
|
||||||
|
--surface-strong: #111922;
|
||||||
|
--surface-alt: #0f1620;
|
||||||
|
--accent: #1fbf98;
|
||||||
|
--accent-bright: #2dd4bf;
|
||||||
|
--accent-weak: rgba(45, 212, 191, 0.18);
|
||||||
|
--accent-shadow: rgba(45, 212, 191, 0.2);
|
||||||
|
--neutral-weak: rgba(226, 232, 240, 0.08);
|
||||||
|
--warning-weak: rgba(245, 158, 11, 0.3);
|
||||||
|
--border: #1f2a37;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.35);
|
||||||
|
--surface-glass: rgba(17, 25, 34, 0.8);
|
||||||
|
--brand-surface: #101a27;
|
||||||
|
--brand-text: #f8fafc;
|
||||||
|
--brand-muted: #9fb3c8;
|
||||||
|
--tangerine: #1fbf98;
|
||||||
|
--teal: #2dd4bf;
|
||||||
|
--berry: #1b2b3f;
|
||||||
|
--sun: #f59e0b;
|
||||||
|
--sky: #0f2a24;
|
||||||
|
--map-glow-a: rgba(45, 212, 191, 0.35);
|
||||||
|
--map-glow-b: rgba(245, 158, 11, 0.35);
|
||||||
|
--pattern-opacity: 0.08;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
@ -19,7 +67,12 @@
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: radial-gradient(circle at top left, #fff5d6 0%, #f7f2e8 45%, #e8f6f8 100%);
|
background: radial-gradient(
|
||||||
|
circle at top left,
|
||||||
|
var(--surface-strong) 0%,
|
||||||
|
var(--surface) 45%,
|
||||||
|
var(--surface-alt) 100%
|
||||||
|
);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: "Recursive", "Segoe UI", sans-serif;
|
font-family: "Recursive", "Segoe UI", sans-serif;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -31,7 +84,7 @@ body::before {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140' viewBox='0 0 140 140'%3E%3Crect width='140' height='140' fill='none'/%3E%3Ccircle cx='4' cy='4' r='1.2' fill='%23000000' opacity='0.08'/%3E%3Ccircle cx='74' cy='60' r='1.3' fill='%23000000' opacity='0.08'/%3E%3Ccircle cx='120' cy='96' r='1' fill='%23000000' opacity='0.06'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140' viewBox='0 0 140 140'%3E%3Crect width='140' height='140' fill='none'/%3E%3Ccircle cx='4' cy='4' r='1.2' fill='%23000000' opacity='0.08'/%3E%3Ccircle cx='74' cy='60' r='1.3' fill='%23000000' opacity='0.08'/%3E%3Ccircle cx='120' cy='96' r='1' fill='%23000000' opacity='0.06'/%3E%3C/svg%3E");
|
||||||
opacity: 0.25;
|
opacity: var(--pattern-opacity);
|
||||||
mix-blend-mode: multiply;
|
mix-blend-mode: multiply;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
|
|
@ -56,7 +109,12 @@ body::before {
|
||||||
gap: 1.8rem;
|
gap: 1.8rem;
|
||||||
padding: 2.5rem;
|
padding: 2.5rem;
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
background: linear-gradient(130deg, #fff4dd 10%, #fff 45%, #e0fbff 100%);
|
background: linear-gradient(
|
||||||
|
130deg,
|
||||||
|
var(--surface-strong) 10%,
|
||||||
|
var(--surface) 45%,
|
||||||
|
var(--surface-alt) 100%
|
||||||
|
);
|
||||||
box-shadow: 0 20px 50px var(--shadow);
|
box-shadow: 0 20px 50px var(--shadow);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -84,8 +142,8 @@ body::before {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: #fff;
|
background: var(--surface-strong);
|
||||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 10px 20px var(--shadow);
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,13 +179,13 @@ body::before {
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.primary {
|
.btn.primary {
|
||||||
background: var(--tangerine);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 16px 30px rgba(255, 138, 61, 0.35);
|
box-shadow: 0 16px 30px var(--accent-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.ghost {
|
.btn.ghost {
|
||||||
background: rgba(0, 0, 0, 0.08);
|
background: var(--neutral-weak);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,10 +200,10 @@ body::before {
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: #fff;
|
background: var(--surface-strong);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 1.6rem;
|
padding: 1.6rem;
|
||||||
box-shadow: 0 16px 40px rgba(14, 30, 40, 0.08);
|
box-shadow: 0 16px 40px var(--shadow);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
@ -158,7 +216,7 @@ body::before {
|
||||||
|
|
||||||
.card p {
|
.card p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: rgba(20, 20, 20, 0.8);
|
color: var(--ink-soft);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,8 +234,8 @@ body::before {
|
||||||
}
|
}
|
||||||
|
|
||||||
.map__panel {
|
.map__panel {
|
||||||
background: #111827;
|
background: var(--brand-surface);
|
||||||
color: #fff;
|
color: var(--brand-text);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -189,7 +247,7 @@ body::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(120deg, rgba(29, 154, 163, 0.35), rgba(255, 138, 61, 0.35));
|
background: linear-gradient(120deg, var(--map-glow-a), var(--map-glow-b));
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,7 +264,7 @@ body::before {
|
||||||
|
|
||||||
.map__panel span {
|
.map__panel span {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
opacity: 0.8;
|
color: var(--brand-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.map__legend {
|
.map__legend {
|
||||||
|
|
@ -226,17 +284,17 @@ body::before {
|
||||||
height: 12px;
|
height: 12px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--teal);
|
background: var(--teal);
|
||||||
box-shadow: 0 0 0 4px rgba(29, 154, 163, 0.2);
|
box-shadow: 0 0 0 4px var(--accent-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-dot.orange {
|
.legend-dot.orange {
|
||||||
background: var(--tangerine);
|
background: var(--tangerine);
|
||||||
box-shadow: 0 0 0 4px rgba(255, 138, 61, 0.2);
|
box-shadow: 0 0 0 4px var(--accent-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-dot.sun {
|
.legend-dot.sun {
|
||||||
background: var(--sun);
|
background: var(--sun);
|
||||||
box-shadow: 0 0 0 4px rgba(255, 209, 102, 0.25);
|
box-shadow: 0 0 0 4px var(--warning-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
|
|
@ -247,8 +305,8 @@ body::before {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
background: rgba(255, 255, 255, 0.8);
|
background: var(--surface-glass);
|
||||||
box-shadow: 0 10px 25px rgba(12, 24, 38, 0.1);
|
box-shadow: 0 10px 25px var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer strong {
|
.footer strong {
|
||||||
|
|
|
||||||
204
front/play.css
204
front/play.css
|
|
@ -1,17 +1,81 @@
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Fraunces:wght@500;600;700&family=Sora:wght@300;400;600;700&display=swap");
|
@import url("https://fonts.googleapis.com/css2?family=Fraunces:wght@500;600;700&family=Sora:wght@300;400;600;700&display=swap");
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light dark;
|
||||||
--ink: #0c1824;
|
--ink: #0b1a2b;
|
||||||
--ink-soft: rgba(12, 24, 36, 0.72);
|
--ink-soft: #6b7280;
|
||||||
--surface: #f7f5f0;
|
--surface: #f7f7f9;
|
||||||
--surface-strong: #fff;
|
--surface-strong: #ffffff;
|
||||||
--accent: #2bb79a;
|
--surface-alt: #f6f8fa;
|
||||||
|
--surface-soft: #f4faf8;
|
||||||
|
--accent: #1b8b75;
|
||||||
--accent-dark: #1b8b75;
|
--accent-dark: #1b8b75;
|
||||||
--gold: #f4b648;
|
--accent-bright: #14b8a6;
|
||||||
--danger: #e84d4d;
|
--accent-text: #042f2e;
|
||||||
--shadow: rgba(12, 24, 36, 0.15);
|
--accent-weak: rgba(27, 139, 117, 0.12);
|
||||||
--border: rgba(12, 24, 36, 0.08);
|
--accent-weak-2: rgba(27, 139, 117, 0.16);
|
||||||
|
--accent-weak-3: rgba(27, 139, 117, 0.08);
|
||||||
|
--accent-weak-4: rgba(27, 139, 117, 0.18);
|
||||||
|
--accent-border: rgba(27, 139, 117, 0.4);
|
||||||
|
--accent-shadow: rgba(27, 139, 117, 0.3);
|
||||||
|
--accent-shadow-soft: rgba(27, 139, 117, 0.2);
|
||||||
|
--gold: #f59e0b;
|
||||||
|
--warning-text: #b45309;
|
||||||
|
--warning-text-strong: #7c2d12;
|
||||||
|
--warning-weak: rgba(245, 158, 11, 0.2);
|
||||||
|
--warning-weak-2: rgba(245, 158, 11, 0.15);
|
||||||
|
--warning-weak-3: rgba(245, 158, 11, 0.25);
|
||||||
|
--danger: #b91c1c;
|
||||||
|
--shadow: rgba(11, 26, 43, 0.12);
|
||||||
|
--shadow-strong: rgba(11, 26, 43, 0.2);
|
||||||
|
--surface-glass: rgba(255, 255, 255, 0.7);
|
||||||
|
--border: #d8dee5;
|
||||||
|
--border-muted: #e2e8f0;
|
||||||
|
--neutral-weak: rgba(11, 26, 43, 0.08);
|
||||||
|
--neutral-weak-2: rgba(11, 26, 43, 0.04);
|
||||||
|
--neutral-weak-3: rgba(11, 26, 43, 0.12);
|
||||||
|
--neutral-weak-4: rgba(11, 26, 43, 0.1);
|
||||||
|
--blackout: rgba(11, 15, 20, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--ink: #f8fafc;
|
||||||
|
--ink-soft: #a7b4c5;
|
||||||
|
--surface: #0b0f14;
|
||||||
|
--surface-strong: #111922;
|
||||||
|
--surface-alt: #0f1620;
|
||||||
|
--surface-soft: #0f1620;
|
||||||
|
--accent: #1fbf98;
|
||||||
|
--accent-dark: #2dd4bf;
|
||||||
|
--accent-bright: #2dd4bf;
|
||||||
|
--accent-text: #04221b;
|
||||||
|
--accent-weak: rgba(45, 212, 191, 0.18);
|
||||||
|
--accent-weak-2: rgba(45, 212, 191, 0.22);
|
||||||
|
--accent-weak-3: rgba(45, 212, 191, 0.12);
|
||||||
|
--accent-weak-4: rgba(45, 212, 191, 0.28);
|
||||||
|
--accent-border: rgba(45, 212, 191, 0.35);
|
||||||
|
--accent-shadow: rgba(45, 212, 191, 0.2);
|
||||||
|
--accent-shadow-soft: rgba(45, 212, 191, 0.14);
|
||||||
|
--gold: #f59e0b;
|
||||||
|
--warning-text: #f59e0b;
|
||||||
|
--warning-text-strong: #fbbf24;
|
||||||
|
--warning-weak: rgba(245, 158, 11, 0.22);
|
||||||
|
--warning-weak-2: rgba(245, 158, 11, 0.18);
|
||||||
|
--warning-weak-3: rgba(245, 158, 11, 0.28);
|
||||||
|
--danger: #f87171;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-strong: rgba(0, 0, 0, 0.45);
|
||||||
|
--surface-glass: rgba(17, 25, 34, 0.7);
|
||||||
|
--border: #1f2a37;
|
||||||
|
--border-muted: #243244;
|
||||||
|
--neutral-weak: rgba(226, 232, 240, 0.08);
|
||||||
|
--neutral-weak-2: rgba(226, 232, 240, 0.04);
|
||||||
|
--neutral-weak-3: rgba(226, 232, 240, 0.12);
|
||||||
|
--neutral-weak-4: rgba(226, 232, 240, 0.1);
|
||||||
|
--blackout: rgba(2, 6, 12, 0.94);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
@ -23,18 +87,23 @@ body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
font-family: "Sora", "Segoe UI", sans-serif;
|
font-family: "Sora", "Segoe UI", sans-serif;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: radial-gradient(circle at top, #f8f7f2 0%, #eef4f3 50%, #e6edf1 100%);
|
background: radial-gradient(
|
||||||
|
circle at top,
|
||||||
|
var(--surface-strong) 0%,
|
||||||
|
var(--surface) 55%,
|
||||||
|
var(--surface-alt) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
body::before {
|
body::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image: linear-gradient(120deg, rgba(43, 183, 154, 0.08), transparent),
|
background-image: linear-gradient(120deg, var(--accent-weak-3), transparent),
|
||||||
repeating-linear-gradient(
|
repeating-linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
rgba(12, 24, 36, 0.05) 0,
|
var(--neutral-weak-2) 0,
|
||||||
rgba(12, 24, 36, 0.05) 1px,
|
var(--neutral-weak-2) 1px,
|
||||||
transparent 1px,
|
transparent 1px,
|
||||||
transparent 80px
|
transparent 80px
|
||||||
);
|
);
|
||||||
|
|
@ -106,15 +175,15 @@ body::before {
|
||||||
.pill {
|
.pill {
|
||||||
padding: 0.4rem 0.8rem;
|
padding: 0.4rem 0.8rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(43, 183, 154, 0.12);
|
background: var(--accent-weak);
|
||||||
color: var(--accent-dark);
|
color: var(--accent-dark);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill.gold {
|
.pill.gold {
|
||||||
background: rgba(244, 182, 72, 0.2);
|
background: var(--warning-weak);
|
||||||
color: #9a6212;
|
color: var(--warning-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
|
|
@ -155,7 +224,7 @@ body::before {
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs-nav a.active {
|
.tabs-nav a.active {
|
||||||
background: rgba(43, 183, 154, 0.12);
|
background: var(--accent-weak);
|
||||||
color: var(--accent-dark);
|
color: var(--accent-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,7 +248,7 @@ body::before {
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: var(--surface-glass);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
box-shadow: 0 8px 20px var(--shadow);
|
box-shadow: 0 8px 20px var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
@ -197,7 +266,7 @@ body::before {
|
||||||
}
|
}
|
||||||
|
|
||||||
.tools-tab.active {
|
.tools-tab.active {
|
||||||
background: rgba(43, 183, 154, 0.16);
|
background: var(--accent-weak-2);
|
||||||
color: var(--accent-dark);
|
color: var(--accent-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,15 +289,15 @@ body::before {
|
||||||
padding: 0.7rem 0.9rem;
|
padding: 0.7rem 0.9rem;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
background: rgba(12, 24, 36, 0.04);
|
background: var(--neutral-weak-2);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border 0.2s ease, background 0.2s ease;
|
transition: border 0.2s ease, background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-roster-item.active {
|
.player-roster-item.active {
|
||||||
border-color: rgba(43, 183, 154, 0.4);
|
border-color: var(--accent-border);
|
||||||
background: rgba(43, 183, 154, 0.12);
|
background: var(--accent-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-roster-main {
|
.player-roster-main {
|
||||||
|
|
@ -264,7 +333,7 @@ body::before {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.8rem 1rem;
|
padding: 0.8rem 1rem;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: rgba(43, 183, 154, 0.08);
|
background: var(--accent-weak-3);
|
||||||
margin-bottom: 0.8rem;
|
margin-bottom: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -341,7 +410,8 @@ body::before {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
background: #fff;
|
background: var(--surface-strong);
|
||||||
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form select[multiple] {
|
.form select[multiple] {
|
||||||
|
|
@ -380,7 +450,7 @@ body::before {
|
||||||
padding: 0.5rem 0.7rem;
|
padding: 0.5rem 0.7rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: var(--surface-glass);
|
||||||
}
|
}
|
||||||
|
|
||||||
.autosave-meta {
|
.autosave-meta {
|
||||||
|
|
@ -397,12 +467,12 @@ body::before {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 12px 20px rgba(43, 183, 154, 0.3);
|
box-shadow: 0 12px 20px var(--accent-shadow);
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.secondary {
|
.button.secondary {
|
||||||
background: rgba(12, 24, 36, 0.08);
|
background: var(--neutral-weak);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
@ -444,7 +514,7 @@ body::before {
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.7rem 0.8rem;
|
padding: 0.7rem 0.8rem;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgba(12, 24, 36, 0.04);
|
background: var(--neutral-weak-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item strong {
|
.list-item strong {
|
||||||
|
|
@ -470,18 +540,18 @@ body::before {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: rgba(43, 183, 154, 0.12);
|
background: var(--accent-weak);
|
||||||
color: var(--accent-dark);
|
color: var(--accent-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge.dummy {
|
.badge.dummy {
|
||||||
background: rgba(244, 182, 72, 0.15);
|
background: var(--warning-weak-2);
|
||||||
color: #9a6212;
|
color: var(--warning-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge.offline {
|
.badge.offline {
|
||||||
background: rgba(12, 24, 36, 0.1);
|
background: var(--neutral-weak-4);
|
||||||
color: #5b6470;
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
|
|
@ -497,7 +567,7 @@ body::before {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #fff;
|
background: var(--surface-strong);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
@ -535,7 +605,7 @@ body::before {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.2rem;
|
gap: 1.2rem;
|
||||||
padding: 1.2rem 1.6rem;
|
padding: 1.2rem 1.6rem;
|
||||||
background: linear-gradient(135deg, #1d9d86, #2bb79a);
|
background: linear-gradient(135deg, var(--accent), var(--accent-bright));
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -559,7 +629,7 @@ body::before {
|
||||||
|
|
||||||
.chat-search {
|
.chat-search {
|
||||||
padding: 0.9rem 1.6rem;
|
padding: 0.9rem 1.6rem;
|
||||||
background: #f4faf8;
|
background: var(--surface-soft);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -569,7 +639,8 @@ body::before {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background: #fff;
|
background: var(--surface-strong);
|
||||||
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-list {
|
.chat-list {
|
||||||
|
|
@ -584,12 +655,12 @@ body::before {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: #fff;
|
background: var(--surface-strong);
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-list-item:hover {
|
.chat-list-item:hover {
|
||||||
background: rgba(43, 183, 154, 0.08);
|
background: var(--accent-weak-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-list-item.unread strong {
|
.chat-list-item.unread strong {
|
||||||
|
|
@ -607,17 +678,17 @@ body::before {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0b3c32;
|
color: var(--accent-text);
|
||||||
background: rgba(43, 183, 154, 0.18);
|
background: var(--accent-weak-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-avatar--direct {
|
.chat-avatar--direct {
|
||||||
background: rgba(244, 182, 72, 0.2);
|
background: var(--warning-weak);
|
||||||
color: #9a6212;
|
color: var(--warning-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-avatar--group {
|
.chat-avatar--group {
|
||||||
background: rgba(12, 24, 36, 0.1);
|
background: var(--neutral-weak-4);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -643,7 +714,7 @@ body::before {
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
box-shadow: 0 0 0 2px #fff;
|
box-shadow: 0 0 0 2px var(--surface-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-list-top strong {
|
.chat-list-top strong {
|
||||||
|
|
@ -677,22 +748,22 @@ body::before {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(12, 24, 36, 0.08);
|
background: var(--neutral-weak);
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-pill--direct {
|
.chat-pill--direct {
|
||||||
background: rgba(244, 182, 72, 0.25);
|
background: var(--warning-weak-3);
|
||||||
color: #9a6212;
|
color: var(--warning-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-pill--global {
|
.chat-pill--global {
|
||||||
background: rgba(43, 183, 154, 0.18);
|
background: var(--accent-weak-4);
|
||||||
color: var(--accent-dark);
|
color: var(--accent-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-pill--group {
|
.chat-pill--group {
|
||||||
background: rgba(12, 24, 36, 0.12);
|
background: var(--neutral-weak-3);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -709,7 +780,7 @@ body::before {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
box-shadow: 0 16px 24px rgba(27, 139, 117, 0.3);
|
box-shadow: 0 16px 24px var(--accent-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread-header {
|
.chat-thread-header {
|
||||||
|
|
@ -718,7 +789,7 @@ body::before {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1.2rem;
|
gap: 1.2rem;
|
||||||
padding: 1.1rem 1.6rem;
|
padding: 1.1rem 1.6rem;
|
||||||
background: linear-gradient(135deg, #1d9d86, #2bb79a);
|
background: linear-gradient(135deg, var(--accent), var(--accent-bright));
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -751,7 +822,7 @@ body::before {
|
||||||
.chat-thread {
|
.chat-thread {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 1fr auto;
|
grid-template-rows: 1fr auto;
|
||||||
background: #f4faf8;
|
background: var(--surface-soft);
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -762,7 +833,7 @@ body::before {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: linear-gradient(180deg, rgba(43, 183, 154, 0.08), transparent 45%);
|
background: linear-gradient(180deg, var(--accent-weak-3), transparent 45%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-empty {
|
.chat-empty {
|
||||||
|
|
@ -788,14 +859,14 @@ body::before {
|
||||||
max-width: 74%;
|
max-width: 74%;
|
||||||
padding: 0.6rem 0.85rem;
|
padding: 0.6rem 0.85rem;
|
||||||
border-radius: 16px 16px 16px 6px;
|
border-radius: 16px 16px 16px 6px;
|
||||||
background: #fff;
|
background: var(--surface-strong);
|
||||||
box-shadow: 0 8px 18px rgba(12, 24, 36, 0.12);
|
box-shadow: 0 8px 18px var(--shadow);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-bubble.me {
|
.chat-bubble.me {
|
||||||
background: rgba(43, 183, 154, 0.18);
|
background: var(--accent-weak-4);
|
||||||
border-radius: 16px 16px 6px 16px;
|
border-radius: 16px 16px 6px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -821,7 +892,7 @@ body::before {
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.9rem 1.1rem 1.1rem;
|
padding: 0.9rem 1.1rem 1.1rem;
|
||||||
background: #fff;
|
background: var(--surface-strong);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -830,6 +901,8 @@ body::before {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.7rem 1rem;
|
padding: 0.7rem 1rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
background: var(--surface-strong);
|
||||||
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-send {
|
.chat-send {
|
||||||
|
|
@ -846,7 +919,7 @@ body::before {
|
||||||
padding: 1.5rem 1.6rem 2rem;
|
padding: 1.5rem 1.6rem 2rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.4rem;
|
gap: 1.4rem;
|
||||||
background: #f7fbfa;
|
background: var(--surface-alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-toggle {
|
.chat-toggle {
|
||||||
|
|
@ -855,7 +928,7 @@ body::before {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #fff;
|
background: var(--surface-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-toggle button {
|
.chat-toggle button {
|
||||||
|
|
@ -883,7 +956,8 @@ body::before {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.7rem 0.9rem;
|
padding: 0.7rem 0.9rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background: #fff;
|
background: var(--surface-strong);
|
||||||
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-members h2 {
|
.chat-members h2 {
|
||||||
|
|
@ -904,13 +978,13 @@ body::before {
|
||||||
padding: 0.7rem 0.8rem;
|
padding: 0.7rem 0.8rem;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #fff;
|
background: var(--surface-strong);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-member.selected {
|
.chat-member.selected {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 8px 18px rgba(27, 139, 117, 0.2);
|
box-shadow: 0 8px 18px var(--accent-shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-member input {
|
.chat-member input {
|
||||||
|
|
@ -935,7 +1009,7 @@ body::before {
|
||||||
.blackout {
|
.blackout {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(12, 24, 36, 0.88);
|
background: var(--blackout);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|
|
||||||
|
|
@ -625,9 +625,14 @@ function EntryPage({ manager }: { manager: ReturnType<typeof useSessionManager>
|
||||||
manager.setError(t("entry.alert.selectDummy"));
|
manager.setError(t("entry.alert.selectDummy"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const selectedDummy = dummyOptions.find(
|
||||||
|
(dummy) => dummy.id === takeoverDummyId,
|
||||||
|
);
|
||||||
|
const fallbackName =
|
||||||
|
takeoverName.trim() || selectedDummy?.name || t("common.guest");
|
||||||
const data = await manager.joinSession(
|
const data = await manager.joinSession(
|
||||||
joinPreview.code,
|
joinPreview.code,
|
||||||
takeoverName || t("common.guest"),
|
fallbackName,
|
||||||
);
|
);
|
||||||
if (data) {
|
if (data) {
|
||||||
manager.setPendingTakeoverId(takeoverDummyId);
|
manager.setPendingTakeoverId(takeoverDummyId);
|
||||||
|
|
@ -781,9 +786,14 @@ function LobbyPage({ manager }: { manager: ReturnType<typeof useSessionManager>
|
||||||
manager.setError(t("entry.alert.selectDummy"));
|
manager.setError(t("entry.alert.selectDummy"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const selectedDummy = dummyOptions.find(
|
||||||
|
(dummy) => dummy.id === takeoverDummyId,
|
||||||
|
);
|
||||||
|
const fallbackName =
|
||||||
|
takeoverName.trim() || selectedDummy?.name || t("common.guest");
|
||||||
const data = await manager.joinSession(
|
const data = await manager.joinSession(
|
||||||
joinPreview.code,
|
joinPreview.code,
|
||||||
takeoverName || t("common.guest"),
|
fallbackName,
|
||||||
);
|
);
|
||||||
if (data) {
|
if (data) {
|
||||||
manager.setPendingTakeoverId(takeoverDummyId);
|
manager.setPendingTakeoverId(takeoverDummyId);
|
||||||
|
|
@ -841,6 +851,9 @@ function LobbyPage({ manager }: { manager: ReturnType<typeof useSessionManager>
|
||||||
}
|
}
|
||||||
|
|
||||||
const players = manager.players;
|
const players = manager.players;
|
||||||
|
const pendingRequests = manager.session?.takeoverRequests.filter(
|
||||||
|
(request) => request.status === "pending",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="play-shell">
|
<div className="play-shell">
|
||||||
|
|
@ -904,6 +917,49 @@ function LobbyPage({ manager }: { manager: ReturnType<typeof useSessionManager>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{manager.isBanker && (pendingRequests ?? []).length > 0 && (
|
||||||
|
<section className="card reveal" style={{ "--delay": "0.08s" } as React.CSSProperties}>
|
||||||
|
<h2>{t("banker.takeoverApprovals")}</h2>
|
||||||
|
<div className="list">
|
||||||
|
{(pendingRequests ?? []).map((request) => {
|
||||||
|
const requester = manager.session?.players.find(
|
||||||
|
(player) => player.id === request.requesterId,
|
||||||
|
);
|
||||||
|
const dummy = manager.session?.players.find(
|
||||||
|
(player) => player.id === request.dummyId,
|
||||||
|
);
|
||||||
|
const requesterName =
|
||||||
|
requester?.name ?? request.requesterName ?? t("common.player");
|
||||||
|
return (
|
||||||
|
<div key={request.id} className="list-item">
|
||||||
|
<div>
|
||||||
|
<strong>{requesterName}</strong>
|
||||||
|
<span>
|
||||||
|
{t("banker.wants", { name: dummy?.name ?? t("common.dummy") })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="button small"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
manager.sendMessage({
|
||||||
|
type: "banker_takeover_approve",
|
||||||
|
sessionId: manager.sessionId,
|
||||||
|
bankerId: manager.me.id,
|
||||||
|
dummyId: request.dummyId,
|
||||||
|
requesterId: request.requesterId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("banker.approve")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{manager.isBanker && (
|
{manager.isBanker && (
|
||||||
<>
|
<>
|
||||||
<section className="card reveal" style={{ "--delay": "0.1s" } as React.CSSProperties}>
|
<section className="card reveal" style={{ "--delay": "0.1s" } as React.CSSProperties}>
|
||||||
|
|
@ -1660,10 +1716,12 @@ function BankerPage({ manager }: { manager: ReturnType<typeof useSessionManager>
|
||||||
const dummy = manager.session?.players.find(
|
const dummy = manager.session?.players.find(
|
||||||
(player) => player.id === request.dummyId,
|
(player) => player.id === request.dummyId,
|
||||||
);
|
);
|
||||||
|
const requesterName =
|
||||||
|
requester?.name ?? request.requesterName ?? t("common.player");
|
||||||
return (
|
return (
|
||||||
<div key={request.id} className="list-item">
|
<div key={request.id} className="list-item">
|
||||||
<div>
|
<div>
|
||||||
<strong>{requester?.name ?? t("common.player")}</strong>
|
<strong>{requesterName}</strong>
|
||||||
<span>
|
<span>
|
||||||
{t("banker.wants", { name: dummy?.name ?? t("common.dummy") })}
|
{t("banker.wants", { name: dummy?.name ?? t("common.dummy") })}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"package": "fr.negopoly.app",
|
"package": "fr.negopoly.app",
|
||||||
|
"googleServicesFile": "./google-services.json",
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
|
|
@ -41,6 +42,7 @@
|
||||||
"category": ["BROWSABLE", "DEFAULT"]
|
"category": ["BROWSABLE", "DEFAULT"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"plugins": ["expo-notifications"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
mobile/google-services.json
Normal file
29
mobile/google-services.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "1011689140478",
|
||||||
|
"project_id": "negopoly-app",
|
||||||
|
"storage_bucket": "negopoly-app.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:1011689140478:android:46ff0db61b4516fbeb35af",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "fr.negopoly.app"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyDq0EcIFEnj3P2fLhS9mEXWF7eDYFUF5Fg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
534
mobile/package-lock.json
generated
534
mobile/package-lock.json
generated
|
|
@ -15,6 +15,7 @@
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-dev-client": "^6.0.20",
|
"expo-dev-client": "^6.0.20",
|
||||||
|
"expo-notifications": "~0.32.16",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
|
|
@ -2066,6 +2067,12 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ide/backoff": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@isaacs/balanced-match": {
|
"node_modules/@isaacs/balanced-match": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||||
|
|
@ -3053,12 +3060,40 @@
|
||||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/assert": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.2",
|
||||||
|
"is-nan": "^1.3.2",
|
||||||
|
"object-is": "^1.1.5",
|
||||||
|
"object.assign": "^4.1.4",
|
||||||
|
"util": "^0.12.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/async-limiter": {
|
"node_modules/async-limiter": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
|
||||||
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
|
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/available-typed-arrays": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"possible-typed-array-names": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-jest": {
|
"node_modules/babel-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||||
|
|
@ -3277,6 +3312,12 @@
|
||||||
"@babel/core": "^7.0.0"
|
"@babel/core": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/badgin": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
|
@ -3473,6 +3514,53 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/call-bind": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.0",
|
||||||
|
"es-define-property": "^1.0.0",
|
||||||
|
"get-intrinsic": "^1.2.4",
|
||||||
|
"set-function-length": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/camelcase": {
|
"node_modules/camelcase": {
|
||||||
"version": "6.3.0",
|
"version": "6.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
|
||||||
|
|
@ -3881,6 +3969,23 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/define-data-property": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-define-property": "^1.0.0",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/define-lazy-prop": {
|
"node_modules/define-lazy-prop": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
|
||||||
|
|
@ -3890,6 +3995,23 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/define-properties": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"define-data-property": "^1.0.1",
|
||||||
|
"has-property-descriptors": "^1.0.0",
|
||||||
|
"object-keys": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
|
@ -3945,6 +4067,20 @@
|
||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
|
@ -3990,6 +4126,36 @@
|
||||||
"stackframe": "^1.3.4"
|
"stackframe": "^1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
|
@ -4106,6 +4272,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-application": {
|
||||||
|
"version": "7.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
|
||||||
|
"integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-asset": {
|
"node_modules/expo-asset": {
|
||||||
"version": "12.0.12",
|
"version": "12.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz",
|
||||||
|
|
@ -4268,6 +4443,26 @@
|
||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-notifications": {
|
||||||
|
"version": "0.32.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.16.tgz",
|
||||||
|
"integrity": "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/image-utils": "^0.8.8",
|
||||||
|
"@ide/backoff": "^1.0.0",
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"assert": "^2.0.0",
|
||||||
|
"badgin": "^1.1.5",
|
||||||
|
"expo-application": "~7.0.8",
|
||||||
|
"expo-constants": "~18.0.13"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-server": {
|
"node_modules/expo-server": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
|
||||||
|
|
@ -4438,6 +4633,21 @@
|
||||||
"integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==",
|
"integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/for-each": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
|
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-callable": "^1.2.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/freeport-async": {
|
"node_modules/freeport-async": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz",
|
||||||
|
|
@ -4485,6 +4695,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/generator-function": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gensync": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
|
|
@ -4503,6 +4722,30 @@
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-package-type": {
|
"node_modules/get-package-type": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
||||||
|
|
@ -4512,6 +4755,19 @@
|
||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/getenv": {
|
"node_modules/getenv": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz",
|
||||||
|
|
@ -4565,6 +4821,18 @@
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
|
@ -4580,6 +4848,45 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-property-descriptors": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-define-property": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
|
@ -4767,12 +5074,40 @@
|
||||||
"loose-envify": "^1.0.0"
|
"loose-envify": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-arguments": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"has-tostringtag": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-arrayish": {
|
"node_modules/is-arrayish": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/is-callable": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-core-module": {
|
"node_modules/is-core-module": {
|
||||||
"version": "2.16.1",
|
"version": "2.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||||
|
|
@ -4812,6 +5147,41 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-generator-function": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.4",
|
||||||
|
"generator-function": "^2.0.0",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"safe-regex-test": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-nan": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.0",
|
||||||
|
"define-properties": "^1.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-number": {
|
"node_modules/is-number": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
|
|
@ -4830,6 +5200,39 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-regex": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-typed-array": {
|
||||||
|
"version": "1.1.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
|
||||||
|
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"which-typed-array": "^1.1.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-wsl": {
|
"node_modules/is-wsl": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||||
|
|
@ -5576,6 +5979,15 @@
|
||||||
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
|
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/memoize-one": {
|
"node_modules/memoize-one": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||||
|
|
@ -6195,6 +6607,51 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-is": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.7",
|
||||||
|
"define-properties": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-keys": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object.assign": {
|
||||||
|
"version": "4.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
|
||||||
|
"integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.8",
|
||||||
|
"call-bound": "^1.0.3",
|
||||||
|
"define-properties": "^1.2.1",
|
||||||
|
"es-object-atoms": "^1.0.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"object-keys": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||||
|
|
@ -6521,6 +6978,15 @@
|
||||||
"node": ">=4.0.0"
|
"node": ">=4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/possible-typed-array-names": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.4.49",
|
"version": "8.4.49",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
||||||
|
|
@ -7177,6 +7643,23 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-regex-test": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"is-regex": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sax": {
|
"node_modules/sax": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
|
||||||
|
|
@ -7306,6 +7789,23 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-function-length": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"define-data-property": "^1.1.4",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-intrinsic": "^1.2.4",
|
||||||
|
"gopd": "^1.0.1",
|
||||||
|
"has-property-descriptors": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|
@ -8039,6 +8539,19 @@
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util": {
|
||||||
|
"version": "0.12.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
|
||||||
|
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"is-arguments": "^1.0.4",
|
||||||
|
"is-generator-function": "^1.0.7",
|
||||||
|
"is-typed-array": "^1.1.3",
|
||||||
|
"which-typed-array": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/utils-merge": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
|
@ -8149,6 +8662,27 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-typed-array": {
|
||||||
|
"version": "1.1.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||||
|
"integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"available-typed-arrays": "^1.0.7",
|
||||||
|
"call-bind": "^1.0.8",
|
||||||
|
"call-bound": "^1.0.4",
|
||||||
|
"for-each": "^0.3.5",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-tostringtag": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wonka": {
|
"node_modules/wonka": {
|
||||||
"version": "6.3.5",
|
"version": "6.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,32 @@
|
||||||
{
|
{
|
||||||
"name": "mobile",
|
"name": "mobile",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"dev": "sh ./scripts/start-dev.sh",
|
"dev": "sh ./scripts/start-dev.sh",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios"
|
"ios": "expo run:ios"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.12.0",
|
"@react-navigation/bottom-tabs": "^7.12.0",
|
||||||
"@react-navigation/native": "^7.1.28",
|
"@react-navigation/native": "^7.1.28",
|
||||||
"@react-navigation/native-stack": "^7.12.0",
|
"@react-navigation/native-stack": "^7.12.0",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-dev-client": "^6.0.20",
|
"expo-dev-client": "^6.0.20",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-notifications": "~0.32.16",
|
||||||
"react": "19.1.0",
|
"expo-status-bar": "~3.0.9",
|
||||||
"react-native": "0.81.5",
|
"react": "19.1.0",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native": "0.81.5",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-screens": "~4.16.0"
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
},
|
"react-native-screens": "~4.16.0"
|
||||||
"devDependencies": {
|
},
|
||||||
"@types/react": "~19.1.0",
|
"devDependencies": {
|
||||||
"typescript": "~5.9.2"
|
"@types/react": "~19.1.0",
|
||||||
},
|
"typescript": "~5.9.2"
|
||||||
"private": true
|
},
|
||||||
|
"private": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Linking } from "react-native";
|
import { Linking } from "react-native";
|
||||||
import {
|
import {
|
||||||
NavigationContainer,
|
NavigationContainer,
|
||||||
|
|
@ -7,10 +7,12 @@ import {
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import * as Notifications from "expo-notifications";
|
||||||
import AppNavigator from "./navigation/AppNavigator";
|
import AppNavigator from "./navigation/AppNavigator";
|
||||||
import type { RootStackParamList } from "./navigation/types";
|
import type { RootStackParamList } from "./navigation/types";
|
||||||
import { SessionProvider, useSession } from "./state/session-context";
|
import { SessionProvider, useSession } from "./state/session-context";
|
||||||
import { getNavigationTheme, useTheme } from "./theme";
|
import { getNavigationTheme, useTheme } from "./theme";
|
||||||
|
import { parseNotificationTarget, type NotificationTarget } from "./notifications";
|
||||||
|
|
||||||
function extractGameId(url: string): string | null {
|
function extractGameId(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
|
|
@ -45,6 +47,8 @@ function RootNavigationGate() {
|
||||||
const [navReady, setNavReady] = useState(false);
|
const [navReady, setNavReady] = useState(false);
|
||||||
const lastTargetRef = useRef<keyof RootStackParamList | null>(null);
|
const lastTargetRef = useRef<keyof RootStackParamList | null>(null);
|
||||||
const lastLinkRef = useRef<string | null>(null);
|
const lastLinkRef = useRef<string | null>(null);
|
||||||
|
const pendingNotificationRef = useRef<NotificationTarget | null>(null);
|
||||||
|
const lastNotificationIdRef = useRef<string | null>(null);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const navigationTheme = getNavigationTheme(theme);
|
const navigationTheme = getNavigationTheme(theme);
|
||||||
const linking = useMemo<LinkingOptions<RootStackParamList>>(
|
const linking = useMemo<LinkingOptions<RootStackParamList>>(
|
||||||
|
|
@ -78,6 +82,62 @@ function RootNavigationGate() {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const processPendingNotification = useCallback(() => {
|
||||||
|
const pending = pendingNotificationRef.current;
|
||||||
|
if (!pending) return;
|
||||||
|
if (!navReady || !navigationRef.isReady()) return;
|
||||||
|
if (manager.sessionId !== pending.sessionId) return;
|
||||||
|
if (!manager.session || manager.session.status !== "active") return;
|
||||||
|
pendingNotificationRef.current = null;
|
||||||
|
|
||||||
|
if (pending.type === "chat") {
|
||||||
|
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
|
||||||
|
const targetTab = manager.isBanker ? "BankerChat" : "PlayerChat";
|
||||||
|
navigationRef.navigate(
|
||||||
|
targetStack as never,
|
||||||
|
{
|
||||||
|
screen: targetTab,
|
||||||
|
params: {
|
||||||
|
screen: "ChatThread",
|
||||||
|
params: { chatId: pending.chatId },
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
|
||||||
|
const targetTab = manager.isBanker ? "BankerDashboard" : "PlayerHome";
|
||||||
|
navigationRef.navigate(
|
||||||
|
targetStack as never,
|
||||||
|
{ screen: targetTab } as never,
|
||||||
|
);
|
||||||
|
}, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]);
|
||||||
|
|
||||||
|
const handleNotificationResponse = useCallback(
|
||||||
|
(response: Notifications.NotificationResponse | null) => {
|
||||||
|
if (!response) return;
|
||||||
|
const identifier = response.notification.request.identifier;
|
||||||
|
if (identifier && lastNotificationIdRef.current === identifier) return;
|
||||||
|
if (identifier) {
|
||||||
|
lastNotificationIdRef.current = identifier;
|
||||||
|
}
|
||||||
|
const target = parseNotificationTarget(
|
||||||
|
response.notification.request.content.data,
|
||||||
|
);
|
||||||
|
if (!target) return;
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log(
|
||||||
|
`[push] target=${target.type} session=${target.sessionId}` +
|
||||||
|
(target.type === "chat" ? ` chat=${target.chatId}` : ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pendingNotificationRef.current = target;
|
||||||
|
processPendingNotification();
|
||||||
|
},
|
||||||
|
[processPendingNotification],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!navReady || !navigationRef.isReady()) return;
|
if (!navReady || !navigationRef.isReady()) return;
|
||||||
|
|
||||||
|
|
@ -113,6 +173,27 @@ function RootNavigationGate() {
|
||||||
navigationRef,
|
navigationRef,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
processPendingNotification();
|
||||||
|
}, [processPendingNotification]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
handleNotificationResponse(response);
|
||||||
|
});
|
||||||
|
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||||
|
(response) => {
|
||||||
|
handleNotificationResponse(response);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [handleNotificationResponse]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationContainer
|
<NavigationContainer
|
||||||
ref={navigationRef}
|
ref={navigationRef}
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,14 @@ const translations = {
|
||||||
"entry.yourNameOptional": "Your name (optional)",
|
"entry.yourNameOptional": "Your name (optional)",
|
||||||
"entry.requestTakeover": "Request takeover",
|
"entry.requestTakeover": "Request takeover",
|
||||||
"entry.noDummies": "No dummies available yet.",
|
"entry.noDummies": "No dummies available yet.",
|
||||||
|
"entry.takeoverPending": "Waiting for the banker to approve your takeover.",
|
||||||
"entry.createTitle": "Create a session",
|
"entry.createTitle": "Create a session",
|
||||||
"entry.bankerName": "Banker name",
|
"entry.bankerName": "Banker name",
|
||||||
"entry.openVault": "Open the vault",
|
"entry.openVault": "Open the vault",
|
||||||
"entry.alert.enterCode": "Enter a session code",
|
"entry.alert.enterCode": "Enter a session code",
|
||||||
"entry.alert.sessionNotFound": "Session not found",
|
"entry.alert.sessionNotFound": "Session not found",
|
||||||
"entry.alert.selectDummy": "Select a dummy player",
|
"entry.alert.selectDummy": "Select a dummy player",
|
||||||
|
"entry.alert.takeoverFailed": "Unable to request takeover. Please try again.",
|
||||||
"lobby.title": "Lobby",
|
"lobby.title": "Lobby",
|
||||||
"lobby.code": "Code: {code}",
|
"lobby.code": "Code: {code}",
|
||||||
"lobby.startGame": "Start game",
|
"lobby.startGame": "Start game",
|
||||||
|
|
@ -108,6 +110,9 @@ const translations = {
|
||||||
"banker.tools.note": "Note",
|
"banker.tools.note": "Note",
|
||||||
"banker.tools.dummyName": "Dummy name",
|
"banker.tools.dummyName": "Dummy name",
|
||||||
"banker.tools.startingBalance": "Starting balance",
|
"banker.tools.startingBalance": "Starting balance",
|
||||||
|
"banker.takeoverApprovals": "Takeover approvals",
|
||||||
|
"banker.wants": "wants {name}",
|
||||||
|
"banker.approve": "Approve",
|
||||||
"banker.stateTitle": "GameState",
|
"banker.stateTitle": "GameState",
|
||||||
"banker.stateSubtitle": "Export or restore the current session.",
|
"banker.stateSubtitle": "Export or restore the current session.",
|
||||||
"banker.downloadState": "Export GameState",
|
"banker.downloadState": "Export GameState",
|
||||||
|
|
@ -193,12 +198,14 @@ const translations = {
|
||||||
"entry.yourNameOptional": "Votre nom (optionnel)",
|
"entry.yourNameOptional": "Votre nom (optionnel)",
|
||||||
"entry.requestTakeover": "Demander la reprise",
|
"entry.requestTakeover": "Demander la reprise",
|
||||||
"entry.noDummies": "Aucun dummy disponible pour le moment.",
|
"entry.noDummies": "Aucun dummy disponible pour le moment.",
|
||||||
|
"entry.takeoverPending": "En attente de l'approbation du banquier.",
|
||||||
"entry.createTitle": "Créer une session",
|
"entry.createTitle": "Créer une session",
|
||||||
"entry.bankerName": "Nom du banquier",
|
"entry.bankerName": "Nom du banquier",
|
||||||
"entry.openVault": "Ouvrir le coffre",
|
"entry.openVault": "Ouvrir le coffre",
|
||||||
"entry.alert.enterCode": "Entrez un code de session",
|
"entry.alert.enterCode": "Entrez un code de session",
|
||||||
"entry.alert.sessionNotFound": "Session introuvable",
|
"entry.alert.sessionNotFound": "Session introuvable",
|
||||||
"entry.alert.selectDummy": "Sélectionnez un dummy",
|
"entry.alert.selectDummy": "Sélectionnez un dummy",
|
||||||
|
"entry.alert.takeoverFailed": "Impossible de demander la reprise. Réessayez.",
|
||||||
"lobby.title": "Lobby",
|
"lobby.title": "Lobby",
|
||||||
"lobby.code": "Code : {code}",
|
"lobby.code": "Code : {code}",
|
||||||
"lobby.startGame": "Démarrer la partie",
|
"lobby.startGame": "Démarrer la partie",
|
||||||
|
|
@ -259,6 +266,9 @@ const translations = {
|
||||||
"banker.tools.note": "Note",
|
"banker.tools.note": "Note",
|
||||||
"banker.tools.dummyName": "Nom du dummy",
|
"banker.tools.dummyName": "Nom du dummy",
|
||||||
"banker.tools.startingBalance": "Solde de départ",
|
"banker.tools.startingBalance": "Solde de départ",
|
||||||
|
"banker.takeoverApprovals": "Approbations de reprise",
|
||||||
|
"banker.wants": "veut {name}",
|
||||||
|
"banker.approve": "Approuver",
|
||||||
"banker.stateTitle": "État de partie",
|
"banker.stateTitle": "État de partie",
|
||||||
"banker.stateSubtitle": "Exportez ou restaurez la session.",
|
"banker.stateSubtitle": "Exportez ou restaurez la session.",
|
||||||
"banker.downloadState": "Exporter l'état",
|
"banker.downloadState": "Exporter l'état",
|
||||||
|
|
|
||||||
75
mobile/src/notifications.ts
Normal file
75
mobile/src/notifications.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import * as Notifications from "expo-notifications";
|
||||||
|
|
||||||
|
export type NotificationTarget =
|
||||||
|
| { type: "chat"; sessionId: string; chatId: string }
|
||||||
|
| { type: "transactions"; sessionId: string };
|
||||||
|
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function parseNotificationTarget(data: unknown): NotificationTarget | null {
|
||||||
|
if (!data || typeof data !== "object") return null;
|
||||||
|
const record = data as Record<string, unknown>;
|
||||||
|
const type = record.type;
|
||||||
|
const sessionId = record.sessionId;
|
||||||
|
if (typeof type !== "string" || typeof sessionId !== "string" || !sessionId.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (type === "transactions") {
|
||||||
|
return { type: "transactions", sessionId };
|
||||||
|
}
|
||||||
|
if (type === "chat") {
|
||||||
|
const chatId = record.chatId;
|
||||||
|
if (typeof chatId !== "string" || !chatId.trim()) return null;
|
||||||
|
return { type: "chat", sessionId, chatId };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerForPushNotificationsAsync(): Promise<
|
||||||
|
| {
|
||||||
|
token: string;
|
||||||
|
platform: "ios" | "android";
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const existing = await Notifications.getPermissionsAsync();
|
||||||
|
let finalStatus = existing.status;
|
||||||
|
if (finalStatus !== "granted") {
|
||||||
|
const requested = await Notifications.requestPermissionsAsync();
|
||||||
|
finalStatus = requested.status;
|
||||||
|
}
|
||||||
|
if (finalStatus !== "granted") {
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log("[push] permission not granted");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
await Notifications.setNotificationChannelAsync("default", {
|
||||||
|
name: "default",
|
||||||
|
importance: Notifications.AndroidImportance.MAX,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const deviceToken = await Notifications.getDevicePushTokenAsync();
|
||||||
|
if (deviceToken.type !== "ios" && deviceToken.type !== "android") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log(`[push] registered device token=${deviceToken.data}`);
|
||||||
|
}
|
||||||
|
return { token: deviceToken.data, platform: deviceToken.type };
|
||||||
|
} catch (error) {
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log("[push] registration failed", error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -127,6 +127,12 @@ export default function BankerToolsScreen() {
|
||||||
>([]);
|
>([]);
|
||||||
const [importJson, setImportJson] = useState("");
|
const [importJson, setImportJson] = useState("");
|
||||||
const [autoSaveStatus, setAutoSaveStatus] = useState<string | null>(null);
|
const [autoSaveStatus, setAutoSaveStatus] = useState<string | null>(null);
|
||||||
|
const pendingRequests = useMemo(
|
||||||
|
() =>
|
||||||
|
manager.session?.takeoverRequests.filter((request) => request.status === "pending") ??
|
||||||
|
[],
|
||||||
|
[manager.session?.takeoverRequests],
|
||||||
|
);
|
||||||
|
|
||||||
const autoSaveKey = `negopoly:autosave:${manager.sessionId}`;
|
const autoSaveKey = `negopoly:autosave:${manager.sessionId}`;
|
||||||
const autoSaveSettingsKey = `${autoSaveKey}:settings`;
|
const autoSaveSettingsKey = `${autoSaveKey}:settings`;
|
||||||
|
|
@ -617,6 +623,48 @@ export default function BankerToolsScreen() {
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{pendingRequests.length > 0 ? (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>{t("banker.takeoverApprovals")}</Text>
|
||||||
|
<View style={styles.takeoverList}>
|
||||||
|
{pendingRequests.map((request) => {
|
||||||
|
const requester = manager.session?.players.find(
|
||||||
|
(player) => player.id === request.requesterId,
|
||||||
|
);
|
||||||
|
const dummy = manager.session?.players.find(
|
||||||
|
(player) => player.id === request.dummyId,
|
||||||
|
);
|
||||||
|
const requesterName =
|
||||||
|
requester?.name ?? request.requesterName ?? t("common.player");
|
||||||
|
return (
|
||||||
|
<View key={request.id} style={styles.takeoverRow}>
|
||||||
|
<View style={styles.takeoverMeta}>
|
||||||
|
<Text style={styles.takeoverName}>{requesterName}</Text>
|
||||||
|
<Text style={styles.takeoverSub}>
|
||||||
|
{t("banker.wants", { name: dummy?.name ?? t("common.dummy") })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.buttonSmall}
|
||||||
|
onPress={() =>
|
||||||
|
manager.sendMessage({
|
||||||
|
type: "banker_takeover_approve",
|
||||||
|
sessionId: manager.sessionId,
|
||||||
|
bankerId: manager.me?.id,
|
||||||
|
dummyId: request.dummyId,
|
||||||
|
requesterId: request.requesterId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonSmallText}>{t("banker.approve")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.cardTitle}>{t("banker.tools.blackout")}</Text>
|
<Text style={styles.cardTitle}>{t("banker.tools.blackout")}</Text>
|
||||||
<View style={styles.switchRow}>
|
<View style={styles.switchRow}>
|
||||||
|
|
@ -1035,6 +1083,30 @@ const createStyles = (theme: AppTheme) =>
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.borderMuted,
|
borderBottomColor: theme.colors.borderMuted,
|
||||||
},
|
},
|
||||||
|
takeoverList: {
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
takeoverRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.borderMuted,
|
||||||
|
},
|
||||||
|
takeoverMeta: {
|
||||||
|
flex: 1,
|
||||||
|
paddingRight: 12,
|
||||||
|
},
|
||||||
|
takeoverName: {
|
||||||
|
fontWeight: "600",
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
takeoverSub: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.colors.textMuted,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
buttonSmall: {
|
buttonSmall: {
|
||||||
backgroundColor: theme.colors.secondary,
|
backgroundColor: theme.colors.secondary,
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ export default function EntryScreen() {
|
||||||
const [takeoverName, setTakeoverName] = useState("");
|
const [takeoverName, setTakeoverName] = useState("");
|
||||||
const [takeoverDummyId, setTakeoverDummyId] = useState("");
|
const [takeoverDummyId, setTakeoverDummyId] = useState("");
|
||||||
const [showDummyOptions, setShowDummyOptions] = useState(false);
|
const [showDummyOptions, setShowDummyOptions] = useState(false);
|
||||||
|
const [takeoverToken, setTakeoverToken] = useState<string | null>(null);
|
||||||
|
const [takeoverWaiting, setTakeoverWaiting] = useState(false);
|
||||||
|
|
||||||
const dummyOptions = useMemo(
|
const dummyOptions = useMemo(
|
||||||
() => joinPreview?.players.filter((player) => player.isDummy) ?? [],
|
() => joinPreview?.players.filter((player) => player.isDummy) ?? [],
|
||||||
|
|
@ -100,6 +102,8 @@ export default function EntryScreen() {
|
||||||
setJoinName("");
|
setJoinName("");
|
||||||
setTakeoverName("");
|
setTakeoverName("");
|
||||||
setTakeoverDummyId("");
|
setTakeoverDummyId("");
|
||||||
|
setTakeoverToken(null);
|
||||||
|
setTakeoverWaiting(false);
|
||||||
manager.fetchSessionPreview(code).then((preview) => {
|
manager.fetchSessionPreview(code).then((preview) => {
|
||||||
if (!preview) {
|
if (!preview) {
|
||||||
Alert.alert(t("entry.alert.sessionNotFound"));
|
Alert.alert(t("entry.alert.sessionNotFound"));
|
||||||
|
|
@ -124,22 +128,54 @@ export default function EntryScreen() {
|
||||||
Alert.alert(t("entry.alert.selectDummy"));
|
Alert.alert(t("entry.alert.selectDummy"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await manager.joinSession(
|
setTakeoverWaiting(true);
|
||||||
|
const selectedDummy = joinPreview.players.find((player) => player.id === takeoverDummyId);
|
||||||
|
const fallbackName = takeoverName.trim() || selectedDummy?.name || "";
|
||||||
|
const token = await manager.requestTakeoverToken(
|
||||||
joinPreview.code,
|
joinPreview.code,
|
||||||
takeoverName.trim() || t("common.guest"),
|
takeoverDummyId,
|
||||||
|
fallbackName,
|
||||||
);
|
);
|
||||||
if (data) {
|
if (!token) {
|
||||||
manager.setPendingTakeoverId(takeoverDummyId);
|
setTakeoverWaiting(false);
|
||||||
navigation.replace("Lobby");
|
if (manager.error) {
|
||||||
|
Alert.alert(manager.error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setTakeoverToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (joinStep === "code" || !joinPreview) {
|
if (joinStep === "code" || !joinPreview) {
|
||||||
setShowDummyOptions(false);
|
setShowDummyOptions(false);
|
||||||
|
setTakeoverToken(null);
|
||||||
|
setTakeoverWaiting(false);
|
||||||
}
|
}
|
||||||
}, [joinStep, joinPreview]);
|
}, [joinStep, joinPreview]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!takeoverToken || !joinPreview) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const poll = async () => {
|
||||||
|
const data = await manager.claimTakeover(joinPreview.code, takeoverToken);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (data) {
|
||||||
|
setTakeoverWaiting(false);
|
||||||
|
setTakeoverToken(null);
|
||||||
|
navigation.replace("Lobby");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timeout = setTimeout(poll, 2000);
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [takeoverToken, joinPreview, manager, navigation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={styles.scroll} contentContainerStyle={contentStyle}>
|
<ScrollView style={styles.scroll} contentContainerStyle={contentStyle}>
|
||||||
<Text style={styles.title}>{t("app.name")}</Text>
|
<Text style={styles.title}>{t("app.name")}</Text>
|
||||||
|
|
@ -194,51 +230,72 @@ export default function EntryScreen() {
|
||||||
<Text style={styles.helper}>{t("entry.alreadyConnected")}</Text>
|
<Text style={styles.helper}>{t("entry.alreadyConnected")}</Text>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<View style={styles.dropdown}>
|
{takeoverWaiting ? (
|
||||||
<TouchableOpacity
|
<View style={styles.pendingBox}>
|
||||||
style={styles.dropdownButton}
|
<Text style={styles.helper}>{t("entry.takeoverPending")}</Text>
|
||||||
onPress={() => {
|
<TouchableOpacity
|
||||||
if (dummyOptions.length === 0) return;
|
style={styles.buttonSecondary}
|
||||||
setShowDummyOptions((prev) => !prev);
|
onPress={() => {
|
||||||
}}
|
setTakeoverToken(null);
|
||||||
>
|
setTakeoverWaiting(false);
|
||||||
<Text style={styles.dropdownText}>
|
}}
|
||||||
{dummyOptions.find((player) => player.id === takeoverDummyId)?.name
|
>
|
||||||
? `${dummyOptions.find((player) => player.id === takeoverDummyId)?.name} · ${takeoverDummyId}`
|
<Text style={styles.buttonSecondaryText}>{t("common.cancel")}</Text>
|
||||||
: t("entry.selectDummy")}
|
</TouchableOpacity>
|
||||||
</Text>
|
</View>
|
||||||
</TouchableOpacity>
|
) : (
|
||||||
{showDummyOptions && dummyOptions.length > 0 ? (
|
<>
|
||||||
<View style={styles.dropdownList}>
|
<View style={styles.dropdown}>
|
||||||
{dummyOptions.map((player) => (
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
style={styles.dropdownButton}
|
||||||
key={player.id}
|
onPress={() => {
|
||||||
style={[
|
if (dummyOptions.length === 0) return;
|
||||||
styles.dropdownItem,
|
setShowDummyOptions((prev) => !prev);
|
||||||
player.id === takeoverDummyId ? styles.dropdownItemActive : null,
|
}}
|
||||||
]}
|
>
|
||||||
onPress={() => {
|
<Text style={styles.dropdownText}>
|
||||||
setTakeoverDummyId(player.id);
|
{dummyOptions.find((player) => player.id === takeoverDummyId)?.name
|
||||||
setShowDummyOptions(false);
|
? `${dummyOptions.find((player) => player.id === takeoverDummyId)?.name} · ${takeoverDummyId}`
|
||||||
}}
|
: t("entry.selectDummy")}
|
||||||
>
|
</Text>
|
||||||
<Text style={styles.dropdownItemText}>{player.name}</Text>
|
</TouchableOpacity>
|
||||||
<Text style={styles.dropdownItemMeta}>{player.id}</Text>
|
{showDummyOptions && dummyOptions.length > 0 ? (
|
||||||
</TouchableOpacity>
|
<View style={styles.dropdownList}>
|
||||||
))}
|
{dummyOptions.map((player) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={player.id}
|
||||||
|
style={[
|
||||||
|
styles.dropdownItem,
|
||||||
|
player.id === takeoverDummyId
|
||||||
|
? styles.dropdownItemActive
|
||||||
|
: null,
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
setTakeoverDummyId(player.id);
|
||||||
|
setShowDummyOptions(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.dropdownItemText}>{player.name}</Text>
|
||||||
|
<Text style={styles.dropdownItemMeta}>{player.id}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
<TextInput
|
||||||
</View>
|
style={styles.input}
|
||||||
<TextInput
|
placeholder={t("entry.yourNameOptional")}
|
||||||
style={styles.input}
|
placeholderTextColor={placeholderColor}
|
||||||
placeholder={t("entry.yourNameOptional")}
|
value={takeoverName}
|
||||||
placeholderTextColor={placeholderColor}
|
onChangeText={setTakeoverName}
|
||||||
value={takeoverName}
|
/>
|
||||||
onChangeText={setTakeoverName}
|
<TouchableOpacity style={styles.buttonSecondary} onPress={handleTakeover}>
|
||||||
/>
|
<Text style={styles.buttonSecondaryText}>
|
||||||
<TouchableOpacity style={styles.buttonSecondary} onPress={handleTakeover}>
|
{t("entry.requestTakeover")}
|
||||||
<Text style={styles.buttonSecondaryText}>{t("entry.requestTakeover")}</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!takeoverDisabled && dummyOptions.length === 0 ? (
|
{!takeoverDisabled && dummyOptions.length === 0 ? (
|
||||||
|
|
@ -333,6 +390,14 @@ const createStyles = (theme: AppTheme) =>
|
||||||
backgroundColor: theme.colors.surface,
|
backgroundColor: theme.colors.surface,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
|
pendingBox: {
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: theme.colors.surfaceAlt,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.borderMuted,
|
||||||
|
},
|
||||||
dropdownItem: {
|
dropdownItem: {
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
|
|
|
||||||
|
|
@ -66,11 +66,21 @@ export default function LobbyScreen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const canStart = manager.isBanker && manager.session.status === "lobby";
|
const canStart = manager.isBanker && manager.session.status === "lobby";
|
||||||
|
const pendingTakeover = manager.session.takeoverRequests.find(
|
||||||
|
(request) =>
|
||||||
|
request.requesterId === manager.playerId && request.status === "pending",
|
||||||
|
);
|
||||||
|
const pendingRequests = manager.isBanker
|
||||||
|
? manager.session.takeoverRequests.filter((request) => request.status === "pending")
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle}>
|
||||||
<Text style={styles.title}>{t("lobby.title")}</Text>
|
<Text style={styles.title}>{t("lobby.title")}</Text>
|
||||||
<Text style={styles.subtitle}>{t("lobby.code", { code: manager.session.code })}</Text>
|
<Text style={styles.subtitle}>{t("lobby.code", { code: manager.session.code })}</Text>
|
||||||
|
{pendingTakeover ? (
|
||||||
|
<Text style={styles.helper}>{t("entry.takeoverPending")}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={manager.session.players}
|
data={manager.session.players}
|
||||||
|
|
@ -92,6 +102,47 @@ export default function LobbyScreen() {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{manager.isBanker && pendingRequests.length > 0 ? (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>{t("banker.takeoverApprovals")}</Text>
|
||||||
|
<View style={styles.takeoverList}>
|
||||||
|
{pendingRequests.map((request) => {
|
||||||
|
const requester =
|
||||||
|
manager.session.players.find((player) => player.id === request.requesterId) ??
|
||||||
|
null;
|
||||||
|
const dummy =
|
||||||
|
manager.session.players.find((player) => player.id === request.dummyId) ?? null;
|
||||||
|
const requesterName =
|
||||||
|
requester?.name ?? request.requesterName ?? t("common.player");
|
||||||
|
return (
|
||||||
|
<View key={request.id} style={styles.takeoverRow}>
|
||||||
|
<View style={styles.takeoverMeta}>
|
||||||
|
<Text style={styles.takeoverName}>{requesterName}</Text>
|
||||||
|
<Text style={styles.takeoverSub}>
|
||||||
|
{t("banker.wants", { name: dummy?.name ?? t("common.dummy") })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.buttonSmall}
|
||||||
|
onPress={() =>
|
||||||
|
manager.sendMessage({
|
||||||
|
type: "banker_takeover_approve",
|
||||||
|
sessionId: manager.sessionId,
|
||||||
|
bankerId: manager.me?.id,
|
||||||
|
dummyId: request.dummyId,
|
||||||
|
requesterId: request.requesterId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonSmallText}>{t("banker.approve")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{manager.isBanker && manager.session.status === "lobby" && (
|
{manager.isBanker && manager.session.status === "lobby" && (
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.cardTitle}>{t("lobby.addDummyTitle")}</Text>
|
<Text style={styles.cardTitle}>{t("lobby.addDummyTitle")}</Text>
|
||||||
|
|
@ -232,4 +283,39 @@ const createStyles = (theme: AppTheme) =>
|
||||||
color: theme.colors.secondaryText,
|
color: theme.colors.secondaryText,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
},
|
},
|
||||||
|
takeoverList: {
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
takeoverRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.borderMuted,
|
||||||
|
},
|
||||||
|
takeoverMeta: {
|
||||||
|
flex: 1,
|
||||||
|
paddingRight: 12,
|
||||||
|
},
|
||||||
|
takeoverName: {
|
||||||
|
fontWeight: "600",
|
||||||
|
color: theme.colors.text,
|
||||||
|
},
|
||||||
|
takeoverSub: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.colors.textMuted,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
buttonSmall: {
|
||||||
|
backgroundColor: theme.colors.secondary,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
buttonSmallText: {
|
||||||
|
color: theme.colors.secondaryText,
|
||||||
|
fontWeight: "600",
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ export type TakeoverRequest = {
|
||||||
id: string;
|
id: string;
|
||||||
dummyId: string;
|
dummyId: string;
|
||||||
requesterId: string;
|
requesterId: string;
|
||||||
|
requesterName?: string | null;
|
||||||
|
requesterToken?: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
status: "pending" | "approved" | "rejected";
|
status: "pending" | "approved" | "rejected";
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types";
|
import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types";
|
||||||
import { getApiBaseUrl, getWsUrl } from "../config/api";
|
import { getApiBaseUrl, getWsUrl } from "../config/api";
|
||||||
import { tStatic } from "../i18n";
|
import { tStatic } from "../i18n";
|
||||||
|
import { registerForPushNotificationsAsync } from "../notifications";
|
||||||
|
|
||||||
const STORAGE_KEY = "negopoly:session";
|
const STORAGE_KEY = "negopoly:session";
|
||||||
|
|
||||||
|
|
@ -36,13 +37,17 @@ export function useSessionManager() {
|
||||||
const [playerId, setPlayerId] = useState("");
|
const [playerId, setPlayerId] = useState("");
|
||||||
const [session, setSession] = useState<SessionSnapshot | null>(null);
|
const [session, setSession] = useState<SessionSnapshot | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pendingTakeoverId, setPendingTakeoverId] = useState<string | null>(null);
|
|
||||||
const [connectionState, setConnectionState] = useState<
|
const [connectionState, setConnectionState] = useState<
|
||||||
"idle" | "connecting" | "open" | "error"
|
"idle" | "connecting" | "open" | "error"
|
||||||
>("idle");
|
>("idle");
|
||||||
const [tick, setTick] = useState(0);
|
const [tick, setTick] = useState(0);
|
||||||
|
const [pushToken, setPushToken] = useState<{
|
||||||
|
token: string;
|
||||||
|
platform: "ios" | "android";
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const lastPushRegistrationRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
@ -57,11 +62,48 @@ export function useSessionManager() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
registerForPushNotificationsAsync().then((token) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setPushToken(token);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => setTick((value) => value + 1), 1000);
|
const timer = setInterval(() => setTick((value) => value + 1), 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pushToken || !sessionId || !playerId) return;
|
||||||
|
void registerPushTokenFor(sessionId, playerId);
|
||||||
|
}, [pushToken, sessionId, playerId]);
|
||||||
|
|
||||||
|
async function registerPushTokenFor(targetSessionId: string, targetPlayerId: string) {
|
||||||
|
if (!pushToken) return;
|
||||||
|
const signature = `${targetSessionId}:${targetPlayerId}:${pushToken.platform}:${pushToken.token}`;
|
||||||
|
if (lastPushRegistrationRef.current === signature) return;
|
||||||
|
lastPushRegistrationRef.current = signature;
|
||||||
|
try {
|
||||||
|
await fetch(`${getApiBaseUrl()}/api/push/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId: targetSessionId,
|
||||||
|
playerId: targetPlayerId,
|
||||||
|
token: pushToken.token,
|
||||||
|
platform: pushToken.platform,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore push registration failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId || !playerId) {
|
if (!sessionId || !playerId) {
|
||||||
setConnectionState("idle");
|
setConnectionState("idle");
|
||||||
|
|
@ -120,20 +162,55 @@ export function useSessionManager() {
|
||||||
};
|
};
|
||||||
}, [sessionId, playerId]);
|
}, [sessionId, playerId]);
|
||||||
|
|
||||||
useEffect(() => {
|
async function requestTakeover(
|
||||||
if (!pendingTakeoverId) return;
|
dummyId: string,
|
||||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
overrideSessionId?: string,
|
||||||
if (!sessionId || !playerId) return;
|
overridePlayerId?: string,
|
||||||
wsRef.current.send(
|
): Promise<string | null> {
|
||||||
JSON.stringify({
|
const targetSessionId = overrideSessionId ?? sessionId;
|
||||||
type: "takeover_request",
|
const targetPlayerId = overridePlayerId ?? playerId;
|
||||||
sessionId,
|
if (!targetSessionId || !targetPlayerId) {
|
||||||
playerId,
|
const message = tStatic("entry.alert.takeoverFailed");
|
||||||
dummyId: pendingTakeoverId,
|
setError(message);
|
||||||
}),
|
return message;
|
||||||
);
|
}
|
||||||
setPendingTakeoverId(null);
|
const payload = {
|
||||||
}, [pendingTakeoverId, sessionId, playerId]);
|
type: "takeover_request",
|
||||||
|
sessionId: targetSessionId,
|
||||||
|
playerId: targetPlayerId,
|
||||||
|
dummyId,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
wsRef.current &&
|
||||||
|
wsRef.current.readyState === WebSocket.OPEN &&
|
||||||
|
targetSessionId === sessionId &&
|
||||||
|
targetPlayerId === playerId
|
||||||
|
) {
|
||||||
|
wsRef.current.send(JSON.stringify(payload));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${getApiBaseUrl()}/api/session/${targetSessionId}/takeover`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ playerId: targetPlayerId, dummyId }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json()) as { message?: string };
|
||||||
|
const message = data.message ?? tStatic("entry.alert.takeoverFailed");
|
||||||
|
setError(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
const message = tStatic("error.connectionNotReady");
|
||||||
|
setError(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createSession(bankerName: string) {
|
async function createSession(bankerName: string) {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -193,6 +270,73 @@ export function useSessionManager() {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestTakeoverToken(
|
||||||
|
code: string,
|
||||||
|
dummyId: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
setError(null);
|
||||||
|
if (!code || !dummyId) {
|
||||||
|
setError(tStatic("entry.alert.selectDummy"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${getApiBaseUrl()}/api/session/${code}/takeover-request`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ dummyId, name }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json()) as { message?: string };
|
||||||
|
setError(data.message ?? tStatic("entry.alert.takeoverFailed"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { token?: string };
|
||||||
|
return data.token ?? null;
|
||||||
|
} catch {
|
||||||
|
setError(tStatic("error.connectionNotReady"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimTakeover(code: string, token: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${getApiBaseUrl()}/api/session/${code}/takeover-claim`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status === 409) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json()) as { message?: string };
|
||||||
|
setError(data.message ?? tStatic("entry.alert.takeoverFailed"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as JoinResponse;
|
||||||
|
setSessionId(data.sessionId);
|
||||||
|
setSessionCode(data.sessionCode);
|
||||||
|
setPlayerId(data.playerId);
|
||||||
|
await writeStoredSession({
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
sessionCode: data.sessionCode,
|
||||||
|
playerId: data.playerId,
|
||||||
|
});
|
||||||
|
await registerPushTokenFor(data.sessionId, data.playerId);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
setError(tStatic("error.connectionNotReady"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSessionPreview(code: string): Promise<SessionPreview | null> {
|
async function fetchSessionPreview(code: string): Promise<SessionPreview | null> {
|
||||||
if (!code) return null;
|
if (!code) return null;
|
||||||
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`);
|
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`);
|
||||||
|
|
@ -222,7 +366,6 @@ export function useSessionManager() {
|
||||||
setPlayerId("");
|
setPlayerId("");
|
||||||
setSession(null);
|
setSession(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setPendingTakeoverId(null);
|
|
||||||
setConnectionState("idle");
|
setConnectionState("idle");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,12 +399,14 @@ export function useSessionManager() {
|
||||||
createSession,
|
createSession,
|
||||||
joinSession,
|
joinSession,
|
||||||
fetchSessionPreview,
|
fetchSessionPreview,
|
||||||
|
requestTakeoverToken,
|
||||||
|
claimTakeover,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
resetSession,
|
resetSession,
|
||||||
leaveSession,
|
leaveSession,
|
||||||
setSessionId,
|
setSessionId,
|
||||||
setPlayerId,
|
setPlayerId,
|
||||||
setSession,
|
setSession,
|
||||||
setPendingTakeoverId,
|
requestTakeover,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
133
server/api.ts
133
server/api.ts
|
|
@ -1,6 +1,13 @@
|
||||||
import type { SessionSnapshot } from "../shared/types";
|
import type { SessionSnapshot } from "../shared/types";
|
||||||
import type { BunRequest } from "bun";
|
import type { BunRequest } from "bun";
|
||||||
import { applySnapshot, joinSession, snapshotSession } from "./domain";
|
import {
|
||||||
|
applySnapshot,
|
||||||
|
claimTakeover,
|
||||||
|
joinSession,
|
||||||
|
requestTakeover,
|
||||||
|
requestTakeoverByToken,
|
||||||
|
snapshotSession,
|
||||||
|
} from "./domain";
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
createTestPreview,
|
createTestPreview,
|
||||||
|
|
@ -10,6 +17,7 @@ import {
|
||||||
isTestSessionCode,
|
isTestSessionCode,
|
||||||
} from "./store";
|
} from "./store";
|
||||||
import { broadcastSessionState, startTestSimulation } from "./websocket";
|
import { broadcastSessionState, startTestSimulation } from "./websocket";
|
||||||
|
import { registerPushToken } from "./notifications";
|
||||||
|
|
||||||
function jsonResponse(data: unknown, status = 200): Response {
|
function jsonResponse(data: unknown, status = 200): Response {
|
||||||
return Response.json(data, { status });
|
return Response.json(data, { status });
|
||||||
|
|
@ -184,4 +192,127 @@ export const apiRoutes = {
|
||||||
return jsonResponse(previewSession(snapshot));
|
return jsonResponse(previewSession(snapshot));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/session/:id/takeover": {
|
||||||
|
async POST(req: BunRequest) {
|
||||||
|
const session = getSession(req.params.id) ?? getSessionByCode(req.params.id);
|
||||||
|
if (!session) {
|
||||||
|
return jsonResponse({ message: "Session not found" }, 404);
|
||||||
|
}
|
||||||
|
let body: { playerId?: string; dummyId?: string };
|
||||||
|
try {
|
||||||
|
body = await readJson<{ playerId?: string; dummyId?: string }>(req);
|
||||||
|
} catch {
|
||||||
|
return jsonResponse({ message: "Invalid request body" }, 400);
|
||||||
|
}
|
||||||
|
const playerId = body.playerId?.trim();
|
||||||
|
const dummyId = body.dummyId?.trim();
|
||||||
|
if (!playerId || !dummyId) {
|
||||||
|
return jsonResponse({ message: "Missing playerId or dummyId" }, 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
requestTakeover(session, playerId, dummyId);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unable to request takeover";
|
||||||
|
return jsonResponse({ message }, 400);
|
||||||
|
}
|
||||||
|
broadcastSessionState(session.id);
|
||||||
|
return jsonResponse({ ok: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/session/:id/takeover-request": {
|
||||||
|
async POST(req: BunRequest) {
|
||||||
|
const session = getSession(req.params.id) ?? getSessionByCode(req.params.id);
|
||||||
|
if (!session) {
|
||||||
|
return jsonResponse({ message: "Session not found" }, 404);
|
||||||
|
}
|
||||||
|
let body: { dummyId?: string; name?: string };
|
||||||
|
try {
|
||||||
|
body = await readJson<{ dummyId?: string; name?: string }>(req);
|
||||||
|
} catch {
|
||||||
|
return jsonResponse({ message: "Invalid request body" }, 400);
|
||||||
|
}
|
||||||
|
const dummyId = body.dummyId?.trim();
|
||||||
|
if (!dummyId) {
|
||||||
|
return jsonResponse({ message: "Missing dummyId" }, 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const token = requestTakeoverByToken(session, dummyId, body.name);
|
||||||
|
broadcastSessionState(session.id);
|
||||||
|
return jsonResponse({ token });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unable to request takeover";
|
||||||
|
return jsonResponse({ message }, 400);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/session/:id/takeover-claim": {
|
||||||
|
async POST(req: BunRequest) {
|
||||||
|
const session = getSession(req.params.id) ?? getSessionByCode(req.params.id);
|
||||||
|
if (!session) {
|
||||||
|
return jsonResponse({ message: "Session not found" }, 404);
|
||||||
|
}
|
||||||
|
let body: { token?: string };
|
||||||
|
try {
|
||||||
|
body = await readJson<{ token?: string }>(req);
|
||||||
|
} catch {
|
||||||
|
return jsonResponse({ message: "Invalid request body" }, 400);
|
||||||
|
}
|
||||||
|
const token = body.token?.trim();
|
||||||
|
if (!token) {
|
||||||
|
return jsonResponse({ message: "Missing token" }, 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const playerId = claimTakeover(session, token);
|
||||||
|
broadcastSessionState(session.id);
|
||||||
|
return jsonResponse({
|
||||||
|
sessionId: session.id,
|
||||||
|
sessionCode: session.code,
|
||||||
|
playerId,
|
||||||
|
role: "player",
|
||||||
|
status: session.status,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unable to claim takeover";
|
||||||
|
if (message === "Takeover not approved") {
|
||||||
|
return jsonResponse({ message }, 409);
|
||||||
|
}
|
||||||
|
return jsonResponse({ message }, 400);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/push/register": {
|
||||||
|
async POST(req: BunRequest) {
|
||||||
|
let body: { sessionId?: string; playerId?: string; token?: string; platform?: string };
|
||||||
|
try {
|
||||||
|
body = await readJson<{
|
||||||
|
sessionId?: string;
|
||||||
|
playerId?: string;
|
||||||
|
token?: string;
|
||||||
|
platform?: string;
|
||||||
|
}>(req);
|
||||||
|
} catch {
|
||||||
|
return jsonResponse({ message: "Invalid request body" }, 400);
|
||||||
|
}
|
||||||
|
const sessionId = body.sessionId?.trim();
|
||||||
|
const playerId = body.playerId?.trim();
|
||||||
|
const token = body.token?.trim();
|
||||||
|
const platform = body.platform?.trim();
|
||||||
|
if (!sessionId || !playerId || !token || !platform) {
|
||||||
|
return jsonResponse({ message: "Missing sessionId, playerId, token, or platform" }, 400);
|
||||||
|
}
|
||||||
|
if (platform !== "ios" && platform !== "android") {
|
||||||
|
return jsonResponse({ message: "Invalid platform" }, 400);
|
||||||
|
}
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return jsonResponse({ message: "Session not found" }, 404);
|
||||||
|
}
|
||||||
|
const player = session.players.get(playerId);
|
||||||
|
if (!player) {
|
||||||
|
return jsonResponse({ message: "Player not found" }, 404);
|
||||||
|
}
|
||||||
|
registerPushToken(sessionId, playerId, token, platform);
|
||||||
|
return jsonResponse({ ok: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -426,6 +426,31 @@ export function requestTakeover(
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requestTakeoverByToken(
|
||||||
|
session: Session,
|
||||||
|
dummyId: string,
|
||||||
|
requesterName?: string | null,
|
||||||
|
): string {
|
||||||
|
ensureOpenSession(session);
|
||||||
|
const dummy = getPlayer(session, dummyId);
|
||||||
|
if (!dummy.isDummy) {
|
||||||
|
throw new DomainError("Selected player is not available for takeover");
|
||||||
|
}
|
||||||
|
const token = createId();
|
||||||
|
const name = requesterName?.trim() || null;
|
||||||
|
const request: TakeoverRequest = {
|
||||||
|
id: createId(),
|
||||||
|
dummyId,
|
||||||
|
requesterId: token,
|
||||||
|
requesterName: name,
|
||||||
|
requesterToken: token,
|
||||||
|
createdAt: now(),
|
||||||
|
status: "pending",
|
||||||
|
};
|
||||||
|
session.takeoverRequests.unshift(request);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
export function approveTakeover(
|
export function approveTakeover(
|
||||||
session: Session,
|
session: Session,
|
||||||
bankerId: string,
|
bankerId: string,
|
||||||
|
|
@ -435,7 +460,6 @@ export function approveTakeover(
|
||||||
ensureOpenSession(session);
|
ensureOpenSession(session);
|
||||||
ensureBanker(session, bankerId);
|
ensureBanker(session, bankerId);
|
||||||
const dummy = getPlayer(session, dummyId);
|
const dummy = getPlayer(session, dummyId);
|
||||||
const requester = getPlayer(session, requesterId);
|
|
||||||
if (!dummy.isDummy) {
|
if (!dummy.isDummy) {
|
||||||
throw new DomainError("Selected player is not a dummy");
|
throw new DomainError("Selected player is not a dummy");
|
||||||
}
|
}
|
||||||
|
|
@ -448,6 +472,23 @@ export function approveTakeover(
|
||||||
if (!targetRequest) {
|
if (!targetRequest) {
|
||||||
throw new DomainError("No takeover request found");
|
throw new DomainError("No takeover request found");
|
||||||
}
|
}
|
||||||
|
const requester = session.players.get(requesterId);
|
||||||
|
if (!requester && targetRequest.requesterToken) {
|
||||||
|
targetRequest.status = "approved";
|
||||||
|
session.takeoverRequests.forEach((request) => {
|
||||||
|
if (
|
||||||
|
request.dummyId === dummyId &&
|
||||||
|
request.status === "pending" &&
|
||||||
|
request.requesterId !== requesterId
|
||||||
|
) {
|
||||||
|
request.status = "rejected";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return dummy.id;
|
||||||
|
}
|
||||||
|
if (!requester) {
|
||||||
|
throw new DomainError("Requester not found");
|
||||||
|
}
|
||||||
targetRequest.status = "approved";
|
targetRequest.status = "approved";
|
||||||
// Reject any other pending requests for the same dummy.
|
// Reject any other pending requests for the same dummy.
|
||||||
session.takeoverRequests.forEach((request) => {
|
session.takeoverRequests.forEach((request) => {
|
||||||
|
|
@ -469,6 +510,29 @@ export function approveTakeover(
|
||||||
return dummy.id;
|
return dummy.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function claimTakeover(session: Session, requesterToken: string): string {
|
||||||
|
ensureOpenSession(session);
|
||||||
|
const targetRequest = session.takeoverRequests.find(
|
||||||
|
(request) =>
|
||||||
|
request.requesterToken === requesterToken && request.status === "approved",
|
||||||
|
);
|
||||||
|
if (!targetRequest) {
|
||||||
|
throw new DomainError("Takeover not approved");
|
||||||
|
}
|
||||||
|
const dummy = getPlayer(session, targetRequest.dummyId);
|
||||||
|
if (!dummy.isDummy) {
|
||||||
|
throw new DomainError("Dummy already taken");
|
||||||
|
}
|
||||||
|
dummy.isDummy = false;
|
||||||
|
dummy.connected = true;
|
||||||
|
dummy.name = targetRequest.requesterName?.trim() || dummy.name;
|
||||||
|
dummy.lastActiveAt = now();
|
||||||
|
session.takeoverRequests = session.takeoverRequests.filter(
|
||||||
|
(request) => request.id !== targetRequest.id,
|
||||||
|
);
|
||||||
|
return dummy.id;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureActiveSession(session: Session): void {
|
function ensureActiveSession(session: Session): void {
|
||||||
ensureOpenSession(session);
|
ensureOpenSession(session);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
582
server/notifications.ts
Normal file
582
server/notifications.ts
Normal file
|
|
@ -0,0 +1,582 @@
|
||||||
|
import type { ChatMessage, Transaction } from "../shared/types";
|
||||||
|
import type { Session } from "./types";
|
||||||
|
import { createPrivateKey, sign } from "crypto";
|
||||||
|
import { existsSync, readFileSync } from "fs";
|
||||||
|
import { connect } from "node:http2";
|
||||||
|
|
||||||
|
type Platform = "ios" | "android";
|
||||||
|
|
||||||
|
type PushMessage = {
|
||||||
|
to: string;
|
||||||
|
platform: Platform;
|
||||||
|
title?: string;
|
||||||
|
body: string;
|
||||||
|
data?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlayerTokens = {
|
||||||
|
ios: Set<string>;
|
||||||
|
android: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokensBySession = new Map<string, Map<string, PlayerTokens>>();
|
||||||
|
|
||||||
|
const apnsKeyId = process.env.APNS_KEY_ID || "";
|
||||||
|
const apnsTeamId = process.env.APNS_TEAM_ID || "";
|
||||||
|
const apnsBundleId = process.env.APNS_BUNDLE_ID || "";
|
||||||
|
const apnsEnv = process.env.APNS_ENV === "production" ? "production" : "sandbox";
|
||||||
|
const apnsPrivateKey = process.env.APNS_PRIVATE_KEY || "";
|
||||||
|
const apnsPrivateKeyPath = process.env.APNS_PRIVATE_KEY_PATH || "";
|
||||||
|
|
||||||
|
const fcmProjectId = process.env.FCM_PROJECT_ID || "";
|
||||||
|
const fcmClientEmail = process.env.FCM_CLIENT_EMAIL || "";
|
||||||
|
const fcmPrivateKey = process.env.FCM_PRIVATE_KEY || "";
|
||||||
|
const fcmServerKey = process.env.FCM_SERVER_KEY || "";
|
||||||
|
const pushDebug = Boolean(process.env.PUSH_DEBUG);
|
||||||
|
|
||||||
|
let cachedApnsToken: { value: string; issuedAt: number } | null = null;
|
||||||
|
let cachedFcmToken: { value: string; expiresAt: number } | null = null;
|
||||||
|
let cachedApnsPrivateKey: string | null = null;
|
||||||
|
|
||||||
|
function formatMoney(amount: number): string {
|
||||||
|
const value = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(
|
||||||
|
Math.abs(amount),
|
||||||
|
);
|
||||||
|
return `₦${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReason(note?: string | null): string {
|
||||||
|
const trimmed = note?.trim();
|
||||||
|
return trimmed && trimmed.length > 0 ? trimmed : "No reason provided";
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactText(value: string, maxLength: number): string {
|
||||||
|
const clean = value.replace(/\s+/g, " ").trim();
|
||||||
|
if (clean.length <= maxLength) return clean;
|
||||||
|
return `${clean.slice(0, maxLength - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionTokens(
|
||||||
|
sessionId: string,
|
||||||
|
playerIds: string[],
|
||||||
|
platform: Platform,
|
||||||
|
): string[] {
|
||||||
|
const sessionTokens = tokensBySession.get(sessionId);
|
||||||
|
if (!sessionTokens) return [];
|
||||||
|
const combined = new Set<string>();
|
||||||
|
playerIds.forEach((playerId) => {
|
||||||
|
const tokens = sessionTokens.get(playerId);
|
||||||
|
if (!tokens) return;
|
||||||
|
const bucket = platform === "ios" ? tokens.ios : tokens.android;
|
||||||
|
bucket.forEach((token) => combined.add(token));
|
||||||
|
});
|
||||||
|
return Array.from(combined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugSessionTokens(sessionId: string): void {
|
||||||
|
const sessionTokens = tokensBySession.get(sessionId);
|
||||||
|
if (!sessionTokens) {
|
||||||
|
console.log(`[push][debug] session=${sessionId} tokens=none`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dump: Record<string, { ios: string[]; android: string[] }> = {};
|
||||||
|
sessionTokens.forEach((tokens, playerId) => {
|
||||||
|
dump[playerId] = {
|
||||||
|
ios: Array.from(tokens.ios),
|
||||||
|
android: Array.from(tokens.android),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log(`[push][debug] session=${sessionId} tokens=${JSON.stringify(dump)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(value: string | Buffer): string {
|
||||||
|
const buffer = typeof value === "string" ? Buffer.from(value) : value;
|
||||||
|
return buffer
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKey(value: string): string {
|
||||||
|
if (!value) return value;
|
||||||
|
return value.includes("\\n") ? value.replace(/\\n/g, "\n") : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeData(data?: Record<string, unknown>): Record<string, string> | undefined {
|
||||||
|
if (!data) return undefined;
|
||||||
|
const entries = Object.entries(data).map(([key, value]) => [key, `${value ?? ""}`]);
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApnsPrivateKey(): string {
|
||||||
|
if (cachedApnsPrivateKey !== null) return cachedApnsPrivateKey;
|
||||||
|
const candidates = [apnsPrivateKey, apnsPrivateKeyPath].filter(Boolean);
|
||||||
|
let resolved = "";
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (candidate.includes("BEGIN")) {
|
||||||
|
resolved = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
try {
|
||||||
|
resolved = readFileSync(candidate, "utf8");
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// Ignore read failures; fallback to raw string.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cachedApnsPrivateKey = resolved || apnsPrivateKey;
|
||||||
|
return cachedApnsPrivateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApnsJwt(): string | null {
|
||||||
|
const resolvedKey = resolveApnsPrivateKey();
|
||||||
|
if (!apnsKeyId || !apnsTeamId || !apnsBundleId || !resolvedKey) {
|
||||||
|
if (pushDebug) {
|
||||||
|
const missing = [
|
||||||
|
!apnsKeyId && "APNS_KEY_ID",
|
||||||
|
!apnsTeamId && "APNS_TEAM_ID",
|
||||||
|
!apnsBundleId && "APNS_BUNDLE_ID",
|
||||||
|
!resolvedKey && "APNS_PRIVATE_KEY",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
console.log(`[push][apns] skipped: missing ${missing || "credentials"}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (cachedApnsToken && now - cachedApnsToken.issuedAt < 50 * 60) {
|
||||||
|
return cachedApnsToken.value;
|
||||||
|
}
|
||||||
|
const header = base64UrlEncode(JSON.stringify({ alg: "ES256", kid: apnsKeyId }));
|
||||||
|
const payload = base64UrlEncode(JSON.stringify({ iss: apnsTeamId, iat: now }));
|
||||||
|
const unsigned = `${header}.${payload}`;
|
||||||
|
const key = createPrivateKey(normalizeKey(resolvedKey));
|
||||||
|
const signature = sign("SHA256", Buffer.from(unsigned), {
|
||||||
|
key,
|
||||||
|
dsaEncoding: "ieee-p1363",
|
||||||
|
});
|
||||||
|
const token = `${unsigned}.${base64UrlEncode(signature)}`;
|
||||||
|
cachedApnsToken = { value: token, issuedAt: now };
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendApnsMessage(message: PushMessage): Promise<void> {
|
||||||
|
let jwt: string | null = null;
|
||||||
|
try {
|
||||||
|
jwt = createApnsJwt();
|
||||||
|
} catch (error) {
|
||||||
|
if (pushDebug) {
|
||||||
|
console.warn("[push][apns] failed to build JWT", error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!jwt) return;
|
||||||
|
if (pushDebug) {
|
||||||
|
const shortToken = message.to.slice(0, 12);
|
||||||
|
console.log(`[push][apns] sending token=${shortToken}… env=${apnsEnv}`);
|
||||||
|
}
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
aps: {
|
||||||
|
alert: { title: message.title ?? "Negopoly", body: message.body },
|
||||||
|
sound: "default",
|
||||||
|
},
|
||||||
|
...message.data,
|
||||||
|
};
|
||||||
|
const host =
|
||||||
|
apnsEnv === "production" ? "api.push.apple.com" : "api.sandbox.push.apple.com";
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const client = connect(`https://${host}`);
|
||||||
|
let responseStatus: number | null = null;
|
||||||
|
let responseText = "";
|
||||||
|
const req = client.request({
|
||||||
|
":method": "POST",
|
||||||
|
":path": `/3/device/${message.to}`,
|
||||||
|
authorization: `bearer ${jwt}`,
|
||||||
|
"apns-topic": apnsBundleId,
|
||||||
|
"apns-push-type": "alert",
|
||||||
|
"apns-priority": "10",
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (pushDebug) {
|
||||||
|
console.warn("[push][apns] request timeout");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
req.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore stream close errors.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
client.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore client close errors.
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}, 7000);
|
||||||
|
|
||||||
|
req.setEncoding("utf8");
|
||||||
|
req.on("response", (headers) => {
|
||||||
|
const status = headers[":status"];
|
||||||
|
if (typeof status === "number") {
|
||||||
|
responseStatus = status;
|
||||||
|
} else if (typeof status === "string") {
|
||||||
|
const parsed = Number(status);
|
||||||
|
responseStatus = Number.isNaN(parsed) ? null : parsed;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
responseText += chunk;
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (pushDebug && responseStatus) {
|
||||||
|
console.log(`[push][apns] ${responseStatus}`);
|
||||||
|
}
|
||||||
|
if (responseStatus && responseStatus >= 400) {
|
||||||
|
console.warn(
|
||||||
|
`[push][apns] ${responseStatus} ${responseText}`.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
client.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore client close errors.
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
req.on("error", (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (pushDebug) {
|
||||||
|
console.warn("[push][apns] request failed", error);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
client.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore client close errors.
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
client.on("error", (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (pushDebug) {
|
||||||
|
console.warn("[push][apns] client error", error);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
client.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore client close errors.
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.end(JSON.stringify(payload));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFcmAccessToken(): Promise<string | null> {
|
||||||
|
if (!fcmProjectId || !fcmClientEmail || !fcmPrivateKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (cachedFcmToken && cachedFcmToken.expiresAt - now > 60) {
|
||||||
|
return cachedFcmToken.value;
|
||||||
|
}
|
||||||
|
const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
||||||
|
const payload = base64UrlEncode(
|
||||||
|
JSON.stringify({
|
||||||
|
iss: fcmClientEmail,
|
||||||
|
scope: "https://www.googleapis.com/auth/firebase.messaging",
|
||||||
|
aud: "https://oauth2.googleapis.com/token",
|
||||||
|
iat: now,
|
||||||
|
exp: now + 3600,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const unsigned = `${header}.${payload}`;
|
||||||
|
const key = createPrivateKey(normalizeKey(fcmPrivateKey));
|
||||||
|
const signature = sign("RSA-SHA256", Buffer.from(unsigned), key);
|
||||||
|
const assertion = `${unsigned}.${base64UrlEncode(signature)}`;
|
||||||
|
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||||
|
assertion,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = (await response.json()) as { access_token?: string; expires_in?: number };
|
||||||
|
if (!data.access_token) return null;
|
||||||
|
cachedFcmToken = {
|
||||||
|
value: data.access_token,
|
||||||
|
expiresAt: now + (data.expires_in ?? 3600),
|
||||||
|
};
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFcmMessage(message: PushMessage): Promise<void> {
|
||||||
|
if (fcmServerKey) {
|
||||||
|
const response = await fetch("https://fcm.googleapis.com/fcm/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `key=${fcmServerKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
to: message.to,
|
||||||
|
notification: { title: message.title ?? "Negopoly", body: message.body },
|
||||||
|
data: message.data ?? {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (pushDebug) {
|
||||||
|
console.log(`[push][fcm-legacy] ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text().catch(() => "");
|
||||||
|
console.warn(
|
||||||
|
`[push][fcm-legacy] ${response.status} ${response.statusText} ${detail}`.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accessToken = await getFcmAccessToken();
|
||||||
|
if (!accessToken) return;
|
||||||
|
const response = await fetch(
|
||||||
|
`https://fcm.googleapis.com/v1/projects/${fcmProjectId}/messages:send`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: {
|
||||||
|
token: message.to,
|
||||||
|
notification: { title: message.title ?? "Negopoly", body: message.body },
|
||||||
|
data: message.data ?? {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (pushDebug) {
|
||||||
|
console.log(`[push][fcm] ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text().catch(() => "");
|
||||||
|
console.warn(
|
||||||
|
`[push][fcm] ${response.status} ${response.statusText} ${detail}`.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendPushMessages(messages: PushMessage[]): Promise<void> {
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.platform === "ios") {
|
||||||
|
await sendApnsMessage(message);
|
||||||
|
} else {
|
||||||
|
await sendFcmMessage(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuePushMessages(messages: PushMessage[]): void {
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
if (pushDebug) {
|
||||||
|
const counts = messages.reduce(
|
||||||
|
(acc, message) => {
|
||||||
|
acc.total += 1;
|
||||||
|
if (message.platform === "ios") acc.ios += 1;
|
||||||
|
if (message.platform === "android") acc.android += 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ total: 0, ios: 0, android: 0 },
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[push][debug] queued messages total=${counts.total} ios=${counts.ios} android=${counts.android}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
void sendPushMessages(messages).catch(() => {
|
||||||
|
// Ignore push failures to avoid breaking gameplay flow.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPushToken(
|
||||||
|
sessionId: string,
|
||||||
|
playerId: string,
|
||||||
|
token: string,
|
||||||
|
platform: Platform,
|
||||||
|
): void {
|
||||||
|
const normalized = token.trim();
|
||||||
|
if (!normalized) return;
|
||||||
|
let sessionTokens = tokensBySession.get(sessionId);
|
||||||
|
if (!sessionTokens) {
|
||||||
|
sessionTokens = new Map();
|
||||||
|
tokensBySession.set(sessionId, sessionTokens);
|
||||||
|
}
|
||||||
|
let playerTokens = sessionTokens.get(playerId);
|
||||||
|
if (!playerTokens) {
|
||||||
|
playerTokens = { ios: new Set(), android: new Set() };
|
||||||
|
sessionTokens.set(playerId, playerTokens);
|
||||||
|
}
|
||||||
|
if (platform === "ios") {
|
||||||
|
playerTokens.ios.add(normalized);
|
||||||
|
} else {
|
||||||
|
playerTokens.android.add(normalized);
|
||||||
|
}
|
||||||
|
if (pushDebug) {
|
||||||
|
console.log(`[push] registered ${platform} token for ${sessionId}:${playerId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPushTokensForSession(sessionId: string): void {
|
||||||
|
tokensBySession.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyTransaction(session: Session, transaction: Transaction): void {
|
||||||
|
const reason = formatReason(transaction.note);
|
||||||
|
if (transaction.kind === "banker_adjust") {
|
||||||
|
const targetId = transaction.toId;
|
||||||
|
if (!targetId) return;
|
||||||
|
if (pushDebug) {
|
||||||
|
debugSessionTokens(session.id);
|
||||||
|
console.log(`[push][debug] banker_adjust target=${targetId}`);
|
||||||
|
}
|
||||||
|
const signed = `${transaction.amount >= 0 ? "+" : "-"}${formatMoney(transaction.amount)}`;
|
||||||
|
const body = `Balance adjusted: ${signed} : ${reason}`;
|
||||||
|
const data = normalizeData({ type: "transactions", sessionId: session.id });
|
||||||
|
const iosTokens = getSessionTokens(session.id, [targetId], "ios");
|
||||||
|
const androidTokens = getSessionTokens(session.id, [targetId], "android");
|
||||||
|
if (pushDebug && iosTokens.length + androidTokens.length === 0) {
|
||||||
|
console.log(`[push] no tokens for banker_adjust target=${targetId}`);
|
||||||
|
}
|
||||||
|
const messages = [
|
||||||
|
...iosTokens.map((token) => ({
|
||||||
|
to: token,
|
||||||
|
platform: "ios" as const,
|
||||||
|
title: "Negopoly",
|
||||||
|
body,
|
||||||
|
data,
|
||||||
|
})),
|
||||||
|
...androidTokens.map((token) => ({
|
||||||
|
to: token,
|
||||||
|
platform: "android" as const,
|
||||||
|
title: "Negopoly",
|
||||||
|
body,
|
||||||
|
data,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
queuePushMessages(messages);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromId = transaction.fromId;
|
||||||
|
const toId = transaction.toId;
|
||||||
|
if (!fromId || !toId) return;
|
||||||
|
|
||||||
|
const fromName = session.players.get(fromId)?.name ?? "Player";
|
||||||
|
const toName = session.players.get(toId)?.name ?? "Player";
|
||||||
|
const amount = formatMoney(transaction.amount);
|
||||||
|
const receiveBody = `Received ${amount} from ${fromName}: ${reason}`;
|
||||||
|
const sentBody = `Sent ${amount} to ${toName}: ${reason}`;
|
||||||
|
const data = normalizeData({ type: "transactions", sessionId: session.id });
|
||||||
|
|
||||||
|
const outgoingTokensIos = getSessionTokens(session.id, [fromId], "ios");
|
||||||
|
const incomingTokensIos = getSessionTokens(session.id, [toId], "ios");
|
||||||
|
const outgoingTokensAndroid = getSessionTokens(session.id, [fromId], "android");
|
||||||
|
const incomingTokensAndroid = getSessionTokens(session.id, [toId], "android");
|
||||||
|
if (pushDebug) {
|
||||||
|
debugSessionTokens(session.id);
|
||||||
|
console.log(
|
||||||
|
`[push][debug] transaction kind=${transaction.kind} from=${fromId} to=${toId} outgoing=${outgoingTokensIos.length + outgoingTokensAndroid.length} incoming=${incomingTokensIos.length + incomingTokensAndroid.length}`,
|
||||||
|
);
|
||||||
|
if (outgoingTokensIos.length + outgoingTokensAndroid.length === 0) {
|
||||||
|
console.log(`[push] no tokens for transfer sender=${fromId}`);
|
||||||
|
}
|
||||||
|
if (incomingTokensIos.length + incomingTokensAndroid.length === 0) {
|
||||||
|
console.log(`[push] no tokens for transfer recipient=${toId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
...outgoingTokensIos.map((token) => ({
|
||||||
|
to: token,
|
||||||
|
platform: "ios" as const,
|
||||||
|
title: "Negopoly",
|
||||||
|
body: sentBody,
|
||||||
|
data,
|
||||||
|
})),
|
||||||
|
...incomingTokensIos.map((token) => ({
|
||||||
|
to: token,
|
||||||
|
platform: "ios" as const,
|
||||||
|
title: "Negopoly",
|
||||||
|
body: receiveBody,
|
||||||
|
data,
|
||||||
|
})),
|
||||||
|
...outgoingTokensAndroid.map((token) => ({
|
||||||
|
to: token,
|
||||||
|
platform: "android" as const,
|
||||||
|
title: "Negopoly",
|
||||||
|
body: sentBody,
|
||||||
|
data,
|
||||||
|
})),
|
||||||
|
...incomingTokensAndroid.map((token) => ({
|
||||||
|
to: token,
|
||||||
|
platform: "android" as const,
|
||||||
|
title: "Negopoly",
|
||||||
|
body: receiveBody,
|
||||||
|
data,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
queuePushMessages(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyChat(session: Session, message: ChatMessage): void {
|
||||||
|
const sender = session.players.get(message.fromId);
|
||||||
|
const senderName = sender?.name ?? "Player";
|
||||||
|
const chatId = message.groupId ?? "global";
|
||||||
|
let groupName = "Global";
|
||||||
|
let recipients: string[] = [];
|
||||||
|
|
||||||
|
if (message.groupId === null) {
|
||||||
|
recipients = Array.from(session.players.keys());
|
||||||
|
} else {
|
||||||
|
const group = session.groups.find((item) => item.id === message.groupId);
|
||||||
|
if (!group) return;
|
||||||
|
groupName = group.name;
|
||||||
|
recipients = group.memberIds.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
recipients = recipients.filter((id) => id !== message.fromId);
|
||||||
|
if (recipients.length === 0) return;
|
||||||
|
|
||||||
|
const preview = compactText(message.body, 90);
|
||||||
|
const body = `${senderName}#${groupName}: ${preview}`;
|
||||||
|
const data = normalizeData({ type: "chat", sessionId: session.id, chatId });
|
||||||
|
const tokensIos = getSessionTokens(session.id, recipients, "ios");
|
||||||
|
const tokensAndroid = getSessionTokens(session.id, recipients, "android");
|
||||||
|
if (pushDebug && tokensIos.length + tokensAndroid.length === 0) {
|
||||||
|
console.log(`[push] no tokens for chat recipients=${recipients.join(",")}`);
|
||||||
|
}
|
||||||
|
const messages = [
|
||||||
|
...tokensIos.map((token) => ({
|
||||||
|
to: token,
|
||||||
|
platform: "ios" as const,
|
||||||
|
title: "Negopoly",
|
||||||
|
body,
|
||||||
|
data,
|
||||||
|
})),
|
||||||
|
...tokensAndroid.map((token) => ({
|
||||||
|
to: token,
|
||||||
|
platform: "android" as const,
|
||||||
|
title: "Negopoly",
|
||||||
|
body,
|
||||||
|
data,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
queuePushMessages(messages);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Session } from "./types";
|
import type { Session } from "./types";
|
||||||
import type { Player, SessionSnapshot } from "../shared/types";
|
import type { Player, SessionSnapshot } from "../shared/types";
|
||||||
import { createId, createSessionCode, now, DEFAULT_START_BALANCE } from "./util";
|
import { createId, createSessionCode, now, DEFAULT_START_BALANCE } from "./util";
|
||||||
|
import { clearPushTokensForSession } from "./notifications";
|
||||||
|
|
||||||
const sessions = new Map<string, Session>();
|
const sessions = new Map<string, Session>();
|
||||||
const sessionsByCode = new Map<string, string>();
|
const sessionsByCode = new Map<string, string>();
|
||||||
|
|
@ -170,6 +171,7 @@ export function removeSession(id: string): void {
|
||||||
}
|
}
|
||||||
sessions.delete(id);
|
sessions.delete(id);
|
||||||
sessionsByCode.delete(session.code);
|
sessionsByCode.delete(session.code);
|
||||||
|
clearPushTokensForSession(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listSessions(): Session[] {
|
export function listSessions(): Session[] {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
} from "./domain";
|
} from "./domain";
|
||||||
import { getSession, removeSession } from "./store";
|
import { getSession, removeSession } from "./store";
|
||||||
import { now } from "./util";
|
import { now } from "./util";
|
||||||
|
import { notifyChat, notifyTransaction } from "./notifications";
|
||||||
|
|
||||||
const socketsBySession = new Map<string, Set<WebSocket>>();
|
const socketsBySession = new Map<string, Set<WebSocket>>();
|
||||||
const metaBySocket = new WeakMap<WebSocket, { sessionId: string; playerId: string }>();
|
const metaBySocket = new WeakMap<WebSocket, { sessionId: string; playerId: string }>();
|
||||||
|
|
@ -65,7 +66,8 @@ function runTestTransfer(sessionId: string): void {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
transfer(session, from.id, to.id, amount, null);
|
const transaction = transfer(session, from.id, to.id, amount, null);
|
||||||
|
notifyTransaction(session, transaction);
|
||||||
sendStateToSession(session);
|
sendStateToSession(session);
|
||||||
break;
|
break;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -175,17 +177,36 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v
|
||||||
|
|
||||||
function handleMessage(session: Session, message: ClientMessage): void {
|
function handleMessage(session: Session, message: ClientMessage): void {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case "chat_send":
|
case "chat_send": {
|
||||||
addChatMessage(session, message.playerId, message.body, message.groupId);
|
const chat = addChatMessage(session, message.playerId, message.body, message.groupId);
|
||||||
|
notifyChat(session, chat);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
case "transfer":
|
case "transfer":
|
||||||
transfer(session, message.playerId, message.toPlayerId, message.amount, message.note);
|
{
|
||||||
|
const transaction = transfer(
|
||||||
|
session,
|
||||||
|
message.playerId,
|
||||||
|
message.toPlayerId,
|
||||||
|
message.amount,
|
||||||
|
message.note,
|
||||||
|
);
|
||||||
|
notifyTransaction(session, transaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "banker_adjust": {
|
||||||
|
const transaction = bankerAdjust(
|
||||||
|
session,
|
||||||
|
message.bankerId,
|
||||||
|
message.targetId,
|
||||||
|
message.amount,
|
||||||
|
message.note,
|
||||||
|
);
|
||||||
|
notifyTransaction(session, transaction);
|
||||||
return;
|
return;
|
||||||
case "banker_adjust":
|
}
|
||||||
bankerAdjust(session, message.bankerId, message.targetId, message.amount, message.note);
|
case "banker_force_transfer": {
|
||||||
return;
|
const transaction = bankerForceTransfer(
|
||||||
case "banker_force_transfer":
|
|
||||||
bankerForceTransfer(
|
|
||||||
session,
|
session,
|
||||||
message.bankerId,
|
message.bankerId,
|
||||||
message.fromId,
|
message.fromId,
|
||||||
|
|
@ -193,7 +214,9 @@ function handleMessage(session: Session, message: ClientMessage): void {
|
||||||
message.amount,
|
message.amount,
|
||||||
message.note,
|
message.note,
|
||||||
);
|
);
|
||||||
|
notifyTransaction(session, transaction);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
case "banker_blackout":
|
case "banker_blackout":
|
||||||
setBlackout(session, message.bankerId, message.active, message.reason);
|
setBlackout(session, message.bankerId, message.active, message.reason);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ export type TakeoverRequest = {
|
||||||
id: string;
|
id: string;
|
||||||
dummyId: string;
|
dummyId: string;
|
||||||
requesterId: string;
|
requesterId: string;
|
||||||
|
requesterName?: string | null;
|
||||||
|
requesterToken?: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
status: "pending" | "approved" | "rejected";
|
status: "pending" | "approved" | "rejected";
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue