diff --git a/k8s/base/app-deployment.yaml b/k8s/base/app-deployment.yaml
index 455e093..f3fec21 100644
--- a/k8s/base/app-deployment.yaml
+++ b/k8s/base/app-deployment.yaml
@@ -96,6 +96,11 @@ spec:
configMapKeyRef:
name: flxn-config
key: vite_spotify_redirect_uri
+ - name: COOKIE_DOMAIN
+ valueFrom:
+ configMapKeyRef:
+ name: flxn-config
+ key: cookie_domain
resources:
requests:
diff --git a/k8s/overlays/dev/configmap.yaml b/k8s/overlays/dev/configmap.yaml
index 4a45b99..04db0ba 100644
--- a/k8s/overlays/dev/configmap.yaml
+++ b/k8s/overlays/dev/configmap.yaml
@@ -12,3 +12,4 @@ data:
vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback"
s3_endpoint: "https://s3.yohler.net"
s3_bucket: "flxn-dev"
+ cookie_domain: "dev.flexxon.app"
diff --git a/k8s/overlays/prod/configmap.yaml b/k8s/overlays/prod/configmap.yaml
index faf5cb5..1aa7fc3 100644
--- a/k8s/overlays/prod/configmap.yaml
+++ b/k8s/overlays/prod/configmap.yaml
@@ -12,3 +12,4 @@ data:
vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback"
s3_endpoint: "https://s3.yohler.net"
s3_bucket: "flxn-prod"
+ cookie_domain: "flexxon.app"
diff --git a/public/site.webmanifest b/public/site.webmanifest
index 5b6c80f..642a4c5 100644
--- a/public/site.webmanifest
+++ b/public/site.webmanifest
@@ -1,17 +1,28 @@
{
- "name": "FLXN IX",
"short_name": "FLXN",
+ "name": "FLXN",
+ "description": "Register for FLXN and view FLXN stats",
"icons": [
{
"src": "/favicon.png",
+ "type": "image/png",
"sizes": "192x192",
- "type": "image/png"
+ "purpose": "any maskable"
},
{
"src": "/favicon.png",
+ "type": "image/png",
"sizes": "512x512",
- "type": "image/png"
+ "purpose": "any maskable"
}
],
- "display": "standalone"
+ "start_url": "/",
+ "display": "standalone",
+ "theme_color": "#1e293b",
+ "background_color": "#0f172a",
+ "orientation": "portrait-primary",
+ "scope": "/",
+ "categories": ["games", "social", "beer pong"],
+ "prefer_related_applications": false,
+ "shortcuts": []
}
diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx
index 03b49f4..64f63ff 100644
--- a/src/app/routes/__root.tsx
+++ b/src/app/routes/__root.tsx
@@ -12,6 +12,7 @@ import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
import { AuthContextType } from "@/contexts/auth-context";
import Providers from "@/features/core/components/providers";
import { SessionMonitor } from "@/components/session-monitor";
+import { IOSInstallPrompt } from "@/components/ios-install-prompt";
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries";
@@ -47,6 +48,12 @@ export const Route = createRootRouteWithContext<{
{ property: 'og:type', content: 'website' },
{ property: 'og:site_name', content: 'FLXN IX' },
{ property: 'og:image', content: 'https://flexxon.app/favicon.png' },
+ { property: 'og:image:width', content: '512' },
+ { property: 'og:image:height', content: '512' },
+ { name: 'mobile-web-app-capable', content: 'yes' },
+ { name: 'apple-mobile-web-app-capable', content: 'yes' },
+ { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
+ { name: 'apple-mobile-web-app-title', content: 'FLXN' },
],
links: [
{
@@ -128,6 +135,7 @@ function RootComponent() {
+
diff --git a/src/components/ios-install-prompt.tsx b/src/components/ios-install-prompt.tsx
new file mode 100644
index 0000000..7d4a140
--- /dev/null
+++ b/src/components/ios-install-prompt.tsx
@@ -0,0 +1,59 @@
+import { useEffect, useState } from 'react'
+import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
+import { DownloadIcon, XIcon } from '@phosphor-icons/react'
+
+export function IOSInstallPrompt() {
+ const [show, setShow] = useState(false)
+ const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
+
+ useEffect(() => {
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
+ const isAndroid = /Android/.test(navigator.userAgent)
+
+ const isInStandaloneMode =
+ window.matchMedia('(display-mode: standalone)').matches ||
+ ('standalone' in window.navigator && (window.navigator as any).standalone)
+
+ const hasBeenDismissed = localStorage.getItem('pwa-install-prompt-dismissed') === 'true'
+
+ if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) {
+ setPlatform(isIOS ? 'ios' : 'android')
+ const timer = setTimeout(() => setShow(true), 3000)
+ return () => clearTimeout(timer)
+ }
+ }, [])
+
+ const handleDismiss = () => {
+ localStorage.setItem('pwa-install-prompt-dismissed', 'true')
+ setShow(false)
+ }
+
+ if (!show || !platform) return null
+
+ const instructions = platform === 'ios'
+ ? 'Tap Share → Add to Home Screen'
+ : 'Tap Menu (⋮) → Add to Home screen'
+
+ return (
+
+
+
+
+
+
+
+ Please install FLXN • This will save me Twilio credits as you won't be signed out!
+
+
+ {instructions}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/session-monitor.tsx b/src/components/session-monitor.tsx
index ee4e3ec..9fc8963 100644
--- a/src/components/session-monitor.tsx
+++ b/src/components/session-monitor.tsx
@@ -1,10 +1,12 @@
import { useEffect, useRef } from 'react';
+import { useNavigate } from '@tanstack/react-router';
import { doesSessionExist } from 'supertokens-web-js/recipe/session';
import { getOrCreateRefreshPromise } from '@/lib/supertokens/client';
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session';
import { logger } from '@/lib/supertokens';
export function SessionMonitor() {
+ const navigate = useNavigate();
const lastRefreshTimeRef = useRef(0);
const REFRESH_COOLDOWN = 30 * 1000;
@@ -49,12 +51,14 @@ export function SessionMonitor() {
}
};
+ handleVisibilityChange();
+
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
- }, []);
+ }, [navigate]);
return null;
}
diff --git a/src/hooks/use-is-pwa.ts b/src/hooks/use-is-pwa.ts
new file mode 100644
index 0000000..a46d545
--- /dev/null
+++ b/src/hooks/use-is-pwa.ts
@@ -0,0 +1,15 @@
+import { useEffect, useState } from 'react'
+
+export function useIsPWA(): boolean {
+ const [isPWA, setIsPWA] = useState(false)
+
+ useEffect(() => {
+ const isStandalone = window.matchMedia('(display-mode: standalone)').matches
+
+ const isIOSStandalone = 'standalone' in window.navigator && (window.navigator as any).standalone
+
+ setIsPWA(isStandalone || isIOSStandalone)
+ }, [])
+
+ return isPWA
+}
diff --git a/src/lib/logger/index.ts b/src/lib/logger/index.ts
index f9067fe..d9453f2 100644
--- a/src/lib/logger/index.ts
+++ b/src/lib/logger/index.ts
@@ -46,7 +46,7 @@ class Logger {
constructor(context?: string, options: LoggerOptions = {}) {
this.context = context;
this.options = {
- enabled: import.meta.env.NODE_ENV !== "production",
+ enabled: true,
showTimestamp: true,
collapsed: true,
colors: true,
@@ -75,27 +75,44 @@ class Logger {
const groupLabel = `${timestamp}${style.label}${context} │ ${label}`;
- const group = this.options.collapsed
- ? console.groupCollapsed
- : console.group;
+ // In server environment (no window), use simple console.log instead of groups
+ const isServer = typeof window === "undefined";
- if (this.options.colors && typeof window !== "undefined") {
- group(`%c${groupLabel}`, `color: ${style.color}; font-weight: bold;`);
- } else {
- group(groupLabel);
- }
-
- if (data !== undefined) {
- console.log(data);
- }
-
- if (rest.length > 0) {
- for (const item of rest) {
- console.log(item);
+ if (isServer) {
+ // Server-side: Simple formatted output (no console.group in Node.js)
+ console.log(groupLabel);
+ if (data !== undefined) {
+ console.log(JSON.stringify(data, null, 2));
}
- }
+ if (rest.length > 0) {
+ for (const item of rest) {
+ console.log(JSON.stringify(item, null, 2));
+ }
+ }
+ } else {
+ // Browser: Use console.group with colors
+ const group = this.options.collapsed
+ ? console.groupCollapsed
+ : console.group;
- console.groupEnd();
+ if (this.options.colors) {
+ group(`%c${groupLabel}`, `color: ${style.color}; font-weight: bold;`);
+ } else {
+ group(groupLabel);
+ }
+
+ if (data !== undefined) {
+ console.log(data);
+ }
+
+ if (rest.length > 0) {
+ for (const item of rest) {
+ console.log(item);
+ }
+ }
+
+ console.groupEnd();
+ }
}
info(label: string, data?: any, ...rest: any[]): void {
diff --git a/src/lib/supertokens/recipes/start-session.ts b/src/lib/supertokens/recipes/start-session.ts
index f2c6bf1..66d7b5b 100644
--- a/src/lib/supertokens/recipes/start-session.ts
+++ b/src/lib/supertokens/recipes/start-session.ts
@@ -5,6 +5,40 @@ import SuperTokens from "supertokens-node";
export async function getSessionForStart(request: Request, options?: { sessionRequired?: boolean }) {
ensureSuperTokensBackend();
+
+ const cookieHeader = request.headers.get('cookie');
+ if (cookieHeader) {
+ const tokens = cookieHeader.match(/sAccessToken=([^;]+)/g);
+ if (tokens && tokens.length > 1) {
+ logger.warn(`Detected ${tokens.length} duplicate sAccessToken cookies, cleaning up`);
+
+ const parsedTokens = tokens.map(tokenStr => {
+ const token = tokenStr.replace('sAccessToken=', '');
+ try {
+ const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
+ return { token, exp: payload.exp, iat: payload.iat };
+ } catch (e) {
+ logger.error('Failed to parse token', e);
+ return { token, exp: 0, iat: 0 };
+ }
+ });
+
+ parsedTokens.sort((a, b) => b.exp - a.exp);
+ const freshestToken = parsedTokens[0];
+
+ logger.info(`Using freshest token: exp=${freshestToken.exp}, iat=${freshestToken.iat}`);
+
+ const cleanedCookie = cookieHeader
+ .split(';')
+ .filter(c => !c.trim().startsWith('sAccessToken='))
+ .join(';') + `; sAccessToken=${freshestToken.token}`;
+
+ const cleanedHeaders = new Headers(request.headers);
+ cleanedHeaders.set('cookie', cleanedCookie);
+ request = new Request(request, { headers: cleanedHeaders });
+ }
+ }
+
try {
const session = await getSessionForSSR(request);
diff --git a/src/lib/supertokens/server.ts b/src/lib/supertokens/server.ts
index be2d736..f5119ab 100644
--- a/src/lib/supertokens/server.ts
+++ b/src/lib/supertokens/server.ts
@@ -25,6 +25,7 @@ export const backendConfig = (): TypeInput => {
cookieSameSite: "lax",
cookieSecure: process.env.NODE_ENV === "production",
cookieDomain: process.env.COOKIE_DOMAIN || undefined,
+ olderCookieDomain: undefined,
antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
// Debug only