feat: optimize web performance and add production deploy workflow

This commit is contained in:
2026-02-26 02:58:43 +00:00
parent 09dffa626e
commit 4e409d0f85
15 changed files with 441 additions and 86 deletions

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
env:
POSTGRES_USER: pulsescore
POSTGRES_PASSWORD: pulsescore

69
.github/workflows/deploy-prod.yml vendored Normal file
View File

@@ -0,0 +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"
concurrency:
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
- 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 }}"
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
chmod +x ./scripts/deploy/vps-deploy.sh
RUN_MIGRATIONS="${{ inputs.run_migrations }}" ./scripts/deploy/vps-deploy.sh

View File

@@ -31,7 +31,7 @@ pulse-score/
- **Backend:** Go (net/http)
- **Frontend:** React 19, TypeScript, Vite, TailwindCSS v4
- **Database:** PostgreSQL 16
- **Database:** PostgreSQL 18
- **Deployment:** Docker, Nginx, VPS
## Prerequisites
@@ -72,6 +72,43 @@ npm run dev
The frontend starts on http://localhost:5173.
## Shipping to Production (VPS)
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)
### Required GitHub repository secrets
- `VPS_HOST` — production server hostname/IP
- `VPS_USER` — SSH user on the VPS
- `VPS_SSH_KEY` — private SSH key for deploy user
- `VPS_APP_DIR` — absolute path to repo on VPS (example: `/opt/pulse-score`)
Optional:
- `VPS_PORT` — SSH port (defaults to `22` if omitted by your SSH client/server config)
### What deployment does
The deploy workflow SSHes into your VPS and:
1. Checks out the requested `ref`
2. Ensures the external Docker network `web` exists
3. Pulls latest DB image and rebuilds API/Web images with `--pull`
4. Runs `docker compose -f docker-compose.prod.yml up -d --remove-orphans`
5. Verifies DB readiness (`pg_isready`) and API health (`/healthz`)
### Manual deploy fallback (on VPS)
If needed, you can run the same deploy logic directly on the server:
`./scripts/deploy/vps-deploy.sh`
## Development Commands
### Backend (Makefile)

View File

@@ -1,6 +1,6 @@
services:
postgres:
image: postgres:16-alpine
image: postgres:18-alpine
container_name: pulsescore-postgres
restart: unless-stopped
ports:

View File

@@ -1,6 +1,6 @@
services:
db:
image: postgres:16-alpine
image: postgres:18-alpine
container_name: pulsescore-db
restart: unless-stopped
environment:

68
scripts/deploy/vps-deploy.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -Eeuo pipefail
log() {
printf "\n[%s] %s\n" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$*"
}
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
DEPLOY_REF="${DEPLOY_REF:-}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
RUN_MIGRATIONS="${RUN_MIGRATIONS:-false}"
if [[ -n "$DEPLOY_REF" ]]; then
log "Syncing repository to ref: $DEPLOY_REF"
git fetch --prune origin
if git show-ref --verify --quiet "refs/remotes/origin/${DEPLOY_REF}"; then
git checkout "$DEPLOY_REF"
git reset --hard "origin/${DEPLOY_REF}"
else
# Allows deploying tags/commit SHAs as well.
git checkout "$DEPLOY_REF"
fi
fi
if [[ -f ".env" ]]; then
log "Loading environment from .env"
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
if ! docker network inspect web >/dev/null 2>&1; then
log "Creating external docker network: web"
docker network create web
fi
log "Pulling latest database image"
docker compose -f "$COMPOSE_FILE" pull db || true
log "Building application images"
docker compose -f "$COMPOSE_FILE" build --pull api web
log "Starting production stack"
docker compose -f "$COMPOSE_FILE" up -d --remove-orphans
if [[ "$RUN_MIGRATIONS" == "true" ]]; then
log "RUN_MIGRATIONS=true requested. No automated migration command is configured; skipping."
fi
log "Container status"
docker compose -f "$COMPOSE_FILE" ps
if [[ -n "${POSTGRES_USER:-}" && -n "${POSTGRES_DB:-}" ]]; then
log "Checking database readiness"
docker compose -f "$COMPOSE_FILE" exec -T db \
pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
fi
log "Checking API health endpoint"
docker compose -f "$COMPOSE_FILE" exec -T api \
wget --no-verbose --tries=1 --spider http://localhost:8080/healthz
log "Production deployment completed successfully."

View File

@@ -7,17 +7,13 @@ import {
ResponsiveContainer,
Legend,
} from "recharts";
import api from "@/lib/api";
import ChartSkeleton from "@/components/skeletons/ChartSkeleton";
import EmptyState from "@/components/EmptyState";
import { PieChart as PieChartIcon } from "lucide-react";
interface BucketData {
range: string;
count: number;
min_score: number;
max_score: number;
}
import {
getScoreDistributionCached,
type ScoreDistributionBucket,
} from "@/lib/scoreDistribution";
interface RiskSegment {
name: string;
@@ -25,7 +21,7 @@ interface RiskSegment {
color: string;
}
function aggregateRisk(buckets: BucketData[]): RiskSegment[] {
function aggregateRisk(buckets: ScoreDistributionBucket[]): RiskSegment[] {
let healthy = 0;
let atRisk = 0;
let critical = 0;
@@ -55,9 +51,8 @@ export default function RiskDistributionChart() {
useEffect(() => {
async function fetch() {
try {
const { data: res } = await api.get("/dashboard/score-distribution");
const buckets = res.buckets ?? res.distribution ?? res ?? [];
if (Array.isArray(buckets) && buckets.length > 0) {
const buckets = await getScoreDistributionCached();
if (buckets.length > 0) {
const agg = aggregateRisk(buckets);
if (agg.length > 0) {
setSegments(agg);

View File

@@ -9,17 +9,13 @@ import {
Cell,
LabelList,
} from "recharts";
import api from "@/lib/api";
import ChartSkeleton from "@/components/skeletons/ChartSkeleton";
import EmptyState from "@/components/EmptyState";
import { BarChart3 } from "lucide-react";
interface BucketData {
range: string;
count: number;
min_score: number;
max_score: number;
}
import {
getScoreDistributionCached,
type ScoreDistributionBucket,
} from "@/lib/scoreDistribution";
function getBarColor(minScore: number): string {
if (minScore >= 70) return "var(--chart-risk-healthy)";
@@ -28,17 +24,17 @@ function getBarColor(minScore: number): string {
}
export default function ScoreDistributionChart() {
const [data, setData] = useState<BucketData[]>([]);
const [data, setData] = useState<ScoreDistributionBucket[]>([]);
const [loading, setLoading] = useState(true);
const [empty, setEmpty] = useState(false);
useEffect(() => {
async function fetch() {
try {
const { data: res } = await api.get("/dashboard/score-distribution");
const buckets = res.buckets ?? res.distribution ?? res ?? [];
if (Array.isArray(buckets) && buckets.length > 0) {
const buckets = await getScoreDistributionCached();
if (buckets.length > 0) {
setData(buckets);
setEmpty(false);
} else {
setEmpty(true);
}

View File

@@ -11,11 +11,11 @@ function statusFor(
index: number,
stepId: OnboardingStepId,
currentStepIndex: number,
completedSteps: OnboardingStepId[],
skippedSteps: OnboardingStepId[],
completedSteps: Set<OnboardingStepId>,
skippedSteps: Set<OnboardingStepId>,
) {
if (completedSteps.includes(stepId)) return "completed";
if (skippedSteps.includes(stepId)) return "skipped";
if (completedSteps.has(stepId)) return "completed";
if (skippedSteps.has(stepId)) return "skipped";
if (index === currentStepIndex) return "active";
if (index < currentStepIndex) return "past";
return "upcoming";
@@ -27,6 +27,9 @@ export default function WizardProgress({
completedSteps,
skippedSteps,
}: WizardProgressProps) {
const completedStepSet = new Set(completedSteps);
const skippedStepSet = new Set(skippedSteps);
return (
<ol className="mb-6 grid grid-cols-1 gap-3 sm:grid-cols-5">
{steps.map((step, idx) => {
@@ -34,8 +37,8 @@ export default function WizardProgress({
idx,
step.id,
currentStepIndex,
completedSteps,
skippedSteps,
completedStepSet,
skippedStepSet,
);
const tone =

View File

@@ -38,6 +38,25 @@ export function useAuth(): AuthContextValue {
// Store refresh token in memory (not localStorage, for XSS protection)
let refreshTokenStore: string | null = null;
let refreshInFlight: Promise<AuthResponse> | null = null;
async function refreshSessionSingleFlight(): Promise<AuthResponse> {
if (!refreshTokenStore) {
throw new Error("No refresh token available");
}
if (!refreshInFlight) {
const token = refreshTokenStore;
refreshInFlight = authApi
.refresh(token)
.then(({ data }) => data)
.finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>({
@@ -52,6 +71,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const clearSession = useCallback(() => {
setState({ user: null, organization: null, accessToken: null });
refreshTokenStore = null;
refreshInFlight = null;
if (refreshTimer.current) {
clearTimeout(refreshTimer.current);
refreshTimer.current = null;
@@ -59,7 +79,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
const scheduleRefresh = useCallback(
(accessToken: string, refreshToken: string) => {
(accessToken: string) => {
if (refreshTimer.current) clearTimeout(refreshTimer.current);
// Decode JWT to get exp — schedule refresh 1 min before expiry
@@ -71,7 +91,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (refreshAt > 0) {
refreshTimer.current = setTimeout(async () => {
try {
const { data } = await authApi.refresh(refreshToken);
const data = await refreshSessionSingleFlight();
applySessionRef.current(data);
} catch {
clearSession();
@@ -93,7 +113,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
accessToken: data.tokens.access_token,
});
refreshTokenStore = data.tokens.refresh_token;
scheduleRefresh(data.tokens.access_token, data.tokens.refresh_token);
scheduleRefresh(data.tokens.access_token);
},
[scheduleRefresh],
);
@@ -110,7 +130,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return;
}
try {
const { data } = await authApi.refresh(refreshTokenStore);
const data = await refreshSessionSingleFlight();
applySession(data);
} catch {
clearSession();
@@ -140,13 +160,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const responseId = api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401 && refreshTokenStore) {
const originalRequest = error.config;
if (
error.response?.status === 401 &&
refreshTokenStore &&
originalRequest &&
!originalRequest._retry
) {
originalRequest._retry = true;
try {
const { data } = await authApi.refresh(refreshTokenStore);
const data = await refreshSessionSingleFlight();
applySession(data);
// Retry original request with new token
error.config.headers.Authorization = `Bearer ${data.tokens.access_token}`;
return api.request(error.config);
originalRequest.headers = originalRequest.headers ?? {};
originalRequest.headers.Authorization =
`Bearer ${data.tokens.access_token}`;
return api.request(originalRequest);
} catch {
clearSession();
}

View File

@@ -2,6 +2,7 @@ import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
@@ -56,12 +57,21 @@ export function ToastProvider({ children }: { children: ReactNode }) {
[removeToast],
);
const value: ToastContextValue = {
success: useCallback((msg: string) => addToast("success", msg), [addToast]),
error: useCallback((msg: string) => addToast("error", msg), [addToast]),
warning: useCallback((msg: string) => addToast("warning", msg), [addToast]),
info: useCallback((msg: string) => addToast("info", 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>(
() => ({ success, error, warning, info }),
[success, error, warning, info],
);
return (
<ToastContext.Provider value={value}>

View File

@@ -0,0 +1,88 @@
import api from "@/lib/api";
export interface ScoreDistributionBucket {
range: string;
count: number;
min_score: number;
max_score: number;
}
const CACHE_TTL_MS = 30_000;
let cachedBuckets: ScoreDistributionBucket[] | null = null;
let lastFetchedAtMs = 0;
let inFlightRequest: Promise<ScoreDistributionBucket[]> | null = null;
function toNumber(value: unknown, fallback = 0): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function extractRawBuckets(responseData: unknown): unknown {
if (Array.isArray(responseData)) return responseData;
if (responseData && typeof responseData === "object") {
const record = responseData as Record<string, unknown>;
return record.buckets ?? record.distribution ?? [];
}
return [];
}
function normalizeBuckets(rawBuckets: unknown): ScoreDistributionBucket[] {
if (!Array.isArray(rawBuckets)) return [];
return rawBuckets.map((bucket, index) => {
const record =
bucket && typeof bucket === "object"
? (bucket as Record<string, unknown>)
: {};
const minScore = toNumber(record.min_score, index * 10);
const maxScore = toNumber(record.max_score, minScore + 9);
return {
range: String(record.range ?? `${minScore}-${maxScore}`),
count: toNumber(record.count),
min_score: minScore,
max_score: maxScore,
};
});
}
export async function getScoreDistributionCached(options?: { force?: boolean }) {
const force = options?.force === true;
if (force) {
cachedBuckets = null;
lastFetchedAtMs = 0;
}
const now = Date.now();
if (
!force &&
cachedBuckets &&
now - lastFetchedAtMs < CACHE_TTL_MS
) {
return cachedBuckets;
}
if (inFlightRequest) {
return inFlightRequest;
}
inFlightRequest = (async () => {
const { data } = await api.get("/dashboard/score-distribution");
const buckets = normalizeBuckets(extractRawBuckets(data));
cachedBuckets = buckets;
lastFetchedAtMs = Date.now();
return buckets;
})().finally(() => {
inFlightRequest = null;
});
return inFlightRequest;
}
export function invalidateScoreDistributionCache() {
cachedBuckets = null;
lastFetchedAtMs = 0;
}

View File

@@ -1,15 +1,22 @@
import { useCallback, useEffect, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import api from "@/lib/api";
import { useToast } from "@/contexts/ToastContext";
import StatCard from "@/components/StatCard";
import CardSkeleton from "@/components/skeletons/CardSkeleton";
import ScoreDistributionChart from "@/components/charts/ScoreDistributionChart";
import MRRTrendChart from "@/components/charts/MRRTrendChart";
import RiskDistributionChart from "@/components/charts/RiskDistributionChart";
import AtRiskCustomersTable from "@/components/AtRiskCustomersTable";
import { Users, AlertTriangle, DollarSign, Activity } from "lucide-react";
import { formatCurrency } from "@/lib/format";
const ScoreDistributionChart = lazy(
() => import("@/components/charts/ScoreDistributionChart"),
);
const MRRTrendChart = lazy(() => import("@/components/charts/MRRTrendChart"));
const RiskDistributionChart = lazy(
() => import("@/components/charts/RiskDistributionChart"),
);
const AtRiskCustomersTable = lazy(
() => import("@/components/AtRiskCustomersTable"),
);
interface DashboardSummary {
total_customers: number;
at_risk_customers: number;
@@ -17,6 +24,26 @@ interface DashboardSummary {
average_health_score: number;
}
function ChartPanelFallback() {
return <CardSkeleton />;
}
function TablePanelFallback() {
return (
<div className="galdr-card p-6">
<div className="h-4 w-40 animate-pulse rounded bg-[color-mix(in_srgb,var(--galdr-fg-muted)_35%,transparent)]" />
<div className="mt-4 space-y-2">
{[...Array(4)].map((_, idx) => (
<div
key={idx}
className="h-10 animate-pulse rounded bg-[color-mix(in_srgb,var(--galdr-fg-muted)_25%,transparent)]"
/>
))}
</div>
</div>
);
}
export default function DashboardPage() {
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [loading, setLoading] = useState(true);
@@ -90,15 +117,23 @@ export default function DashboardPage() {
{/* Charts */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<ScoreDistributionChart />
<MRRTrendChart />
<Suspense fallback={<ChartPanelFallback />}>
<ScoreDistributionChart />
</Suspense>
<Suspense fallback={<ChartPanelFallback />}>
<MRRTrendChart />
</Suspense>
</div>
{/* Risk overview */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<RiskDistributionChart />
<Suspense fallback={<ChartPanelFallback />}>
<RiskDistributionChart />
</Suspense>
<div className="lg:col-span-2">
<AtRiskCustomersTable />
<Suspense fallback={<TablePanelFallback />}>
<AtRiskCustomersTable />
</Suspense>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
notificationPreferencesApi,
alertsApi,
@@ -14,6 +14,9 @@ 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,
]);
useEffect(() => {
async function load() {
@@ -72,16 +75,19 @@ export default function NotificationsTab() {
async function toggleMuteRule(ruleId: string) {
if (!prefs) return;
setSaving(true);
const currentlyMuted = mutedSet.has(ruleId);
const muted = prefs.muted_rule_ids ?? [];
const newMuted = muted.includes(ruleId)
const newMuted = currentlyMuted
? muted.filter((id) => id !== ruleId)
: [...muted, ruleId];
try {
const { data } = await notificationPreferencesApi.update({
muted_rule_ids: newMuted,
});
setPrefs(data);
toast.success(muted.includes(ruleId) ? "Rule unmuted" : "Rule muted");
toast.success(currentlyMuted ? "Rule unmuted" : "Rule muted");
} catch {
toast.error("Failed to update preferences");
} finally {
@@ -179,7 +185,7 @@ export default function NotificationsTab() {
</p>
<div className="space-y-3">
{rules.map((rule) => {
const isMuted = (prefs.muted_rule_ids ?? []).includes(rule.id);
const isMuted = mutedSet.has(rule.id);
return (
<div
key={rule.id}

View File

@@ -1,3 +1,4 @@
import { lazy, Suspense } from "react";
import {
Navigate,
Route,
@@ -5,17 +6,32 @@ import {
useLocation,
useNavigate,
} from "react-router-dom";
import OrganizationTab from "@/pages/settings/OrganizationTab";
import ProfileTab from "@/pages/settings/ProfileTab";
import IntegrationsTab from "@/pages/settings/IntegrationsTab";
import ScoringTab from "@/pages/settings/ScoringTab";
import BillingTab from "@/pages/settings/BillingTab";
import TeamTab from "@/pages/settings/TeamTab";
import AlertsTab from "@/pages/settings/AlertsTab";
import NotificationsTab from "@/pages/settings/NotificationsTab";
import StripeCallbackPage from "@/pages/settings/StripeCallbackPage";
import HubSpotCallbackPage from "@/pages/settings/HubSpotCallbackPage";
import IntercomCallbackPage from "@/pages/settings/IntercomCallbackPage";
const OrganizationTab = lazy(() => import("@/pages/settings/OrganizationTab"));
const ProfileTab = lazy(() => import("@/pages/settings/ProfileTab"));
const IntegrationsTab = lazy(() => import("@/pages/settings/IntegrationsTab"));
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 StripeCallbackPage = lazy(
() => import("@/pages/settings/StripeCallbackPage"),
);
const HubSpotCallbackPage = lazy(
() => import("@/pages/settings/HubSpotCallbackPage"),
);
const IntercomCallbackPage = lazy(
() => import("@/pages/settings/IntercomCallbackPage"),
);
function SettingsTabFallback() {
return (
<div className="flex min-h-[20vh] items-center justify-center text-sm text-[var(--galdr-fg-muted)]">
Loading settings
</div>
);
}
const tabs = [
{ path: "organization", label: "Organization" },
@@ -65,20 +81,22 @@ export default function SettingsPage() {
</div>
{/* Tab content */}
<Routes>
<Route path="organization" element={<OrganizationTab />} />
<Route path="profile" element={<ProfileTab />} />
<Route path="integrations" element={<IntegrationsTab />} />
<Route path="scoring" element={<ScoringTab />} />
<Route path="billing" element={<BillingTab />} />
<Route path="team" element={<TeamTab />} />
<Route path="alerts" element={<AlertsTab />} />
<Route path="notifications" element={<NotificationsTab />} />
<Route path="stripe/callback" element={<StripeCallbackPage />} />
<Route path="hubspot/callback" element={<HubSpotCallbackPage />} />
<Route path="intercom/callback" element={<IntercomCallbackPage />} />
<Route index element={<Navigate to="organization" replace />} />
</Routes>
<Suspense fallback={<SettingsTabFallback />}>
<Routes>
<Route path="organization" element={<OrganizationTab />} />
<Route path="profile" element={<ProfileTab />} />
<Route path="integrations" element={<IntegrationsTab />} />
<Route path="scoring" element={<ScoringTab />} />
<Route path="billing" element={<BillingTab />} />
<Route path="team" element={<TeamTab />} />
<Route path="alerts" element={<AlertsTab />} />
<Route path="notifications" element={<NotificationsTab />} />
<Route path="stripe/callback" element={<StripeCallbackPage />} />
<Route path="hubspot/callback" element={<HubSpotCallbackPage />} />
<Route path="intercom/callback" element={<IntercomCallbackPage />} />
<Route index element={<Navigate to="organization" replace />} />
</Routes>
</Suspense>
</div>
);
}