fix(web): scope prerender CSS and finalize prod deploy updates
This commit is contained in:
@@ -11,6 +11,11 @@ POSTGRES_DB=pulsescore
|
||||
# Server
|
||||
PORT=8080
|
||||
|
||||
# Auth (required in production)
|
||||
JWT_SECRET=CHANGEME_use_a_long_random_secret
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=168h
|
||||
|
||||
# CORS — comma-separated list of allowed origins
|
||||
CORS_ALLOWED_ORIGINS=https://galdr.subcult.tv
|
||||
|
||||
|
||||
112
.github/workflows/ci.yml
vendored
112
.github/workflows/ci.yml
vendored
@@ -1,70 +1,70 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
go:
|
||||
name: Go (lint, test, build)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
env:
|
||||
POSTGRES_USER: pulsescore
|
||||
POSTGRES_PASSWORD: pulsescore
|
||||
POSTGRES_DB: pulsescore_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U pulsescore -d pulsescore_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
go:
|
||||
name: Go (lint, test, build)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: pulsescore
|
||||
POSTGRES_PASSWORD: pulsescore
|
||||
POSTGRES_DB: pulsescore_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U pulsescore -d pulsescore_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
DATABASE_URL: postgres://pulsescore:pulsescore@localhost:5432/pulsescore_test?sslmode=disable
|
||||
run: go test ./... -race -coverprofile=coverage.out -covermode=atomic
|
||||
- name: Test
|
||||
env:
|
||||
DATABASE_URL: postgres://pulsescore:pulsescore@localhost:5432/pulsescore_test?sslmode=disable
|
||||
run: go test ./... -race -coverprofile=coverage.out -covermode=atomic
|
||||
|
||||
- name: Build
|
||||
run: go build -o bin/pulsescore-api ./cmd/api
|
||||
- name: Build
|
||||
run: go build -o bin/pulsescore-api ./cmd/api
|
||||
|
||||
react:
|
||||
name: React (lint, build)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
react:
|
||||
name: React (lint, build)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
114
.github/workflows/deploy-prod.yml
vendored
114
.github/workflows/deploy-prod.yml
vendored
@@ -1,69 +1,69 @@
|
||||
name: Deploy Production
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Git ref to deploy (branch, tag, or commit SHA)
|
||||
required: true
|
||||
default: main
|
||||
run_migrations:
|
||||
description: Request migrations during deploy (currently a no-op)
|
||||
required: true
|
||||
type: choice
|
||||
default: "false"
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Git ref to deploy (branch, tag, or commit SHA)
|
||||
required: true
|
||||
default: main
|
||||
run_migrations:
|
||||
description: Request migrations during deploy (currently a no-op)
|
||||
required: true
|
||||
type: choice
|
||||
default: 'false'
|
||||
options:
|
||||
- 'false'
|
||||
- 'true'
|
||||
|
||||
concurrency:
|
||||
group: production-deploy
|
||||
cancel-in-progress: false
|
||||
group: production-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to VPS
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Validate required secrets
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
||||
VPS_APP_DIR: ${{ secrets.VPS_APP_DIR }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for key in VPS_HOST VPS_USER VPS_SSH_KEY VPS_APP_DIR; do
|
||||
if [ -z "${!key}" ]; then
|
||||
echo "::error::Missing required repository secret: $key"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
deploy:
|
||||
name: Deploy to VPS
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Validate required secrets
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
||||
VPS_APP_DIR: ${{ secrets.VPS_APP_DIR }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for key in VPS_HOST VPS_USER VPS_SSH_KEY VPS_APP_DIR; do
|
||||
if [ -z "${!key}" ]; then
|
||||
echo "::error::Missing required repository secret: $key"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Deploy over SSH
|
||||
uses: appleboy/ssh-action@v1.2.2
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USER }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
port: ${{ secrets.VPS_PORT != '' && secrets.VPS_PORT || 22 }}
|
||||
script_stop: true
|
||||
command_timeout: 45m
|
||||
script: |
|
||||
set -eu
|
||||
- name: Deploy over SSH
|
||||
uses: appleboy/ssh-action@v1.2.2
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USER }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
port: ${{ secrets.VPS_PORT != '' && secrets.VPS_PORT || 22 }}
|
||||
script_stop: true
|
||||
command_timeout: 45m
|
||||
script: |
|
||||
set -eu
|
||||
|
||||
cd "${{ secrets.VPS_APP_DIR }}"
|
||||
cd "${{ secrets.VPS_APP_DIR }}"
|
||||
|
||||
git fetch --prune origin
|
||||
git fetch --prune origin
|
||||
|
||||
TARGET_REF="${{ inputs.ref }}"
|
||||
if git show-ref --verify --quiet "refs/remotes/origin/${TARGET_REF}"; then
|
||||
git checkout "$TARGET_REF"
|
||||
git reset --hard "origin/${TARGET_REF}"
|
||||
else
|
||||
git checkout "$TARGET_REF"
|
||||
fi
|
||||
TARGET_REF="${{ inputs.ref }}"
|
||||
if git show-ref --verify --quiet "refs/remotes/origin/${TARGET_REF}"; then
|
||||
git checkout "$TARGET_REF"
|
||||
git reset --hard "origin/${TARGET_REF}"
|
||||
else
|
||||
git checkout "$TARGET_REF"
|
||||
fi
|
||||
|
||||
chmod +x ./scripts/deploy/vps-deploy.sh
|
||||
RUN_MIGRATIONS="${{ inputs.run_migrations }}" ./scripts/deploy/vps-deploy.sh
|
||||
chmod +x ./scripts/deploy/vps-deploy.sh
|
||||
RUN_MIGRATIONS="${{ inputs.run_migrations }}" ./scripts/deploy/vps-deploy.sh
|
||||
|
||||
5
Makefile
5
Makefile
@@ -2,7 +2,7 @@
|
||||
migrate-up migrate-down migrate-down-all migrate-create seed \
|
||||
dev-db dev-db-down dev dev-stop \
|
||||
web-install web-dev web-build web-lint web-format web-format-check web-preview \
|
||||
docker-build docker-up docker-down docker-logs \
|
||||
docker-build docker-up deploy-prod docker-down docker-logs \
|
||||
check help
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -106,6 +106,9 @@ docker-build: ## Build production Docker images
|
||||
docker-up: ## Start production stack
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
deploy-prod: ## Deploy production stack (build + remove orphan containers)
|
||||
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans
|
||||
|
||||
docker-down: ## Stop production stack
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
|
||||
|
||||
16
README.md
16
README.md
@@ -31,7 +31,7 @@ pulse-score/
|
||||
|
||||
- **Backend:** Go (net/http)
|
||||
- **Frontend:** React 19, TypeScript, Vite, TailwindCSS v4
|
||||
- **Database:** PostgreSQL 18
|
||||
- **Database:** PostgreSQL 16
|
||||
- **Deployment:** Docker, Nginx, VPS
|
||||
|
||||
## Prerequisites
|
||||
@@ -79,8 +79,8 @@ PulseScore ships to production through a manual GitHub Actions workflow:
|
||||
- Workflow: `.github/workflows/deploy-prod.yml`
|
||||
- Trigger: **Actions → Deploy Production → Run workflow**
|
||||
- Inputs:
|
||||
- `ref` (branch/tag/SHA to deploy)
|
||||
- `run_migrations` (reserved; currently no-op)
|
||||
- `ref` (branch/tag/SHA to deploy)
|
||||
- `run_migrations` (reserved; currently no-op)
|
||||
|
||||
### Required GitHub repository secrets
|
||||
|
||||
@@ -140,12 +140,12 @@ PulseScore now includes a dedicated Stripe billing domain (separate from Stripe
|
||||
|
||||
- Plan catalog: `free`, `growth`, `scale` (`internal/billing/plans.go`)
|
||||
- Protected billing APIs:
|
||||
- `GET /api/v1/billing/subscription`
|
||||
- `POST /api/v1/billing/checkout` (admin)
|
||||
- `POST /api/v1/billing/portal-session` (admin)
|
||||
- `POST /api/v1/billing/cancel` (admin)
|
||||
- `GET /api/v1/billing/subscription`
|
||||
- `POST /api/v1/billing/checkout` (admin)
|
||||
- `POST /api/v1/billing/portal-session` (admin)
|
||||
- `POST /api/v1/billing/cancel` (admin)
|
||||
- Public billing webhook:
|
||||
- `POST /api/v1/webhooks/stripe-billing`
|
||||
- `POST /api/v1/webhooks/stripe-billing`
|
||||
|
||||
### Required production billing env vars
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
image: postgres:16-alpine
|
||||
container_name: pulsescore-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -1,58 +1,73 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:18-alpine
|
||||
container_name: pulsescore-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./scripts/init-db:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- pulsescore-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: pulsescore-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./scripts/init-db:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- pulsescore-net
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}',
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: pulsescore-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: "8080"
|
||||
ENVIRONMENT: production
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||
RATE_LIMIT_RPM: ${RATE_LIMIT_RPM:-100}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- pulsescore-net
|
||||
- web
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: pulsescore-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: '8080'
|
||||
ENVIRONMENT: production
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||
RATE_LIMIT_RPM: ${RATE_LIMIT_RPM:-100}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_ACCESS_TTL: ${JWT_ACCESS_TTL:-15m}
|
||||
JWT_REFRESH_TTL: ${JWT_REFRESH_TTL:-168h}
|
||||
STRIPE_BILLING_SECRET_KEY: ${STRIPE_BILLING_SECRET_KEY}
|
||||
STRIPE_BILLING_PUBLISHABLE_KEY: ${STRIPE_BILLING_PUBLISHABLE_KEY}
|
||||
STRIPE_BILLING_WEBHOOK_SECRET: ${STRIPE_BILLING_WEBHOOK_SECRET}
|
||||
STRIPE_BILLING_PORTAL_RETURN_URL: ${STRIPE_BILLING_PORTAL_RETURN_URL}
|
||||
STRIPE_BILLING_PRICE_GROWTH_MONTHLY: ${STRIPE_BILLING_PRICE_GROWTH_MONTHLY}
|
||||
STRIPE_BILLING_PRICE_GROWTH_ANNUAL: ${STRIPE_BILLING_PRICE_GROWTH_ANNUAL}
|
||||
STRIPE_BILLING_PRICE_SCALE_MONTHLY: ${STRIPE_BILLING_PRICE_SCALE_MONTHLY}
|
||||
STRIPE_BILLING_PRICE_SCALE_ANNUAL: ${STRIPE_BILLING_PRICE_SCALE_ANNUAL}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- pulsescore-net
|
||||
- web
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: Dockerfile
|
||||
container_name: pulsescore-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
networks:
|
||||
- web
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: Dockerfile
|
||||
container_name: pulsescore-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
networks:
|
||||
- web
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
pgdata:
|
||||
|
||||
networks:
|
||||
pulsescore-net:
|
||||
driver: bridge
|
||||
web:
|
||||
external: true
|
||||
pulsescore-net:
|
||||
driver: bridge
|
||||
web:
|
||||
external: true
|
||||
|
||||
@@ -257,28 +257,29 @@ function layoutHtml({
|
||||
${cssTags.join("\n ")}
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { margin: 0; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #fff; color: #0f172a; }
|
||||
.page { max-width: 1120px; margin: 0 auto; padding: 40px 24px; }
|
||||
.muted { color: #475569; }
|
||||
.chip { display: inline-block; margin-right: 8px; margin-bottom: 8px; border: 1px solid #cbd5e1; border-radius: 9999px; padding: 4px 12px; font-size: 12px; color: #334155; }
|
||||
.hero { border: 1px solid #c7d2fe; border-radius: 16px; padding: 24px; background: #eef2ff; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(240px,1fr)); gap: 12px; margin-top: 20px; }
|
||||
.card { border: 1px solid #e2e8f0; border-radius: 14px; padding: 16px; background: #fff; }
|
||||
.card a { color: #1d4ed8; text-decoration: none; }
|
||||
.card a:hover { text-decoration: underline; }
|
||||
.cta { display: inline-block; margin-right: 10px; margin-top: 12px; background: #4f46e5; color: #fff; text-decoration: none; padding: 10px 16px; border-radius: 10px; font-weight: 600; }
|
||||
.cta.secondary { background: transparent; color: #1e293b; border: 1px solid #cbd5e1; }
|
||||
body { margin: 0; }
|
||||
.seo-prerender { font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #fff; color: #0f172a; min-height: 100vh; }
|
||||
.seo-prerender .page { max-width: 1120px; margin: 0 auto; padding: 40px 24px; }
|
||||
.seo-prerender .muted { color: #475569; }
|
||||
.seo-prerender .chip { display: inline-block; margin-right: 8px; margin-bottom: 8px; border: 1px solid #cbd5e1; border-radius: 9999px; padding: 4px 12px; font-size: 12px; color: #334155; }
|
||||
.seo-prerender .hero { border: 1px solid #c7d2fe; border-radius: 16px; padding: 24px; background: #eef2ff; }
|
||||
.seo-prerender .grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(240px,1fr)); gap: 12px; margin-top: 20px; }
|
||||
.seo-prerender .card { border: 1px solid #e2e8f0; border-radius: 14px; padding: 16px; background: #fff; }
|
||||
.seo-prerender .card a { color: #1d4ed8; text-decoration: none; }
|
||||
.seo-prerender .card a:hover { text-decoration: underline; }
|
||||
.seo-prerender .cta { display: inline-block; margin-right: 10px; margin-top: 12px; background: #4f46e5; color: #fff; text-decoration: none; padding: 10px 16px; border-radius: 10px; font-weight: 600; }
|
||||
.seo-prerender .cta.secondary { background: transparent; color: #1e293b; border: 1px solid #cbd5e1; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #020617; color: #e2e8f0; }
|
||||
.hero { background: #1e1b4b; border-color: #4338ca; }
|
||||
.card { background: #0f172a; border-color: #1e293b; }
|
||||
.muted, .chip { color: #cbd5e1; border-color: #334155; }
|
||||
.card a, .cta.secondary { color: #c7d2fe; }
|
||||
.seo-prerender { background: #020617; color: #e2e8f0; }
|
||||
.seo-prerender .hero { background: #1e1b4b; border-color: #4338ca; }
|
||||
.seo-prerender .card { background: #0f172a; border-color: #1e293b; }
|
||||
.seo-prerender .muted, .seo-prerender .chip { color: #cbd5e1; border-color: #334155; }
|
||||
.seo-prerender .card a, .seo-prerender .cta.secondary { color: #c7d2fe; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">${body}</div>
|
||||
<div id="root"><div class="seo-prerender">${body}</div></div>
|
||||
${scriptTags.join("\n ")}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -174,8 +174,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
applySession(data);
|
||||
// Retry original request with new token
|
||||
originalRequest.headers = originalRequest.headers ?? {};
|
||||
originalRequest.headers.Authorization =
|
||||
`Bearer ${data.tokens.access_token}`;
|
||||
originalRequest.headers.Authorization = `Bearer ${data.tokens.access_token}`;
|
||||
return api.request(originalRequest);
|
||||
} catch {
|
||||
clearSession();
|
||||
|
||||
@@ -57,15 +57,18 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
[removeToast],
|
||||
);
|
||||
|
||||
const success = useCallback((msg: string) => addToast("success", msg), [
|
||||
addToast,
|
||||
]);
|
||||
const error = useCallback((msg: string) => addToast("error", msg), [
|
||||
addToast,
|
||||
]);
|
||||
const warning = useCallback((msg: string) => addToast("warning", msg), [
|
||||
addToast,
|
||||
]);
|
||||
const success = useCallback(
|
||||
(msg: string) => addToast("success", msg),
|
||||
[addToast],
|
||||
);
|
||||
const error = useCallback(
|
||||
(msg: string) => addToast("error", msg),
|
||||
[addToast],
|
||||
);
|
||||
const warning = useCallback(
|
||||
(msg: string) => addToast("warning", msg),
|
||||
[addToast],
|
||||
);
|
||||
const info = useCallback((msg: string) => addToast("info", msg), [addToast]);
|
||||
|
||||
const value = useMemo<ToastContextValue>(
|
||||
|
||||
@@ -48,7 +48,9 @@ function normalizeBuckets(rawBuckets: unknown): ScoreDistributionBucket[] {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getScoreDistributionCached(options?: { force?: boolean }) {
|
||||
export async function getScoreDistributionCached(options?: {
|
||||
force?: boolean;
|
||||
}) {
|
||||
const force = options?.force === true;
|
||||
|
||||
if (force) {
|
||||
@@ -57,11 +59,7 @@ export async function getScoreDistributionCached(options?: { force?: boolean })
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (
|
||||
!force &&
|
||||
cachedBuckets &&
|
||||
now - lastFetchedAtMs < CACHE_TTL_MS
|
||||
) {
|
||||
if (!force && cachedBuckets && now - lastFetchedAtMs < CACHE_TTL_MS) {
|
||||
return cachedBuckets;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@ export default function NotificationsTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const toast = useToast();
|
||||
const mutedSet = useMemo(() => new Set(prefs?.muted_rule_ids ?? []), [
|
||||
prefs?.muted_rule_ids,
|
||||
]);
|
||||
const mutedSet = useMemo(
|
||||
() => new Set(prefs?.muted_rule_ids ?? []),
|
||||
[prefs?.muted_rule_ids],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
|
||||
@@ -14,7 +14,9 @@ const ScoringTab = lazy(() => import("@/pages/settings/ScoringTab"));
|
||||
const BillingTab = lazy(() => import("@/pages/settings/BillingTab"));
|
||||
const TeamTab = lazy(() => import("@/pages/settings/TeamTab"));
|
||||
const AlertsTab = lazy(() => import("@/pages/settings/AlertsTab"));
|
||||
const NotificationsTab = lazy(() => import("@/pages/settings/NotificationsTab"));
|
||||
const NotificationsTab = lazy(
|
||||
() => import("@/pages/settings/NotificationsTab"),
|
||||
);
|
||||
const StripeCallbackPage = lazy(
|
||||
() => import("@/pages/settings/StripeCallbackPage"),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user