fix(frontend): satisfy phase 2 lint checks
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# CURRENT_TASK.md
|
# CURRENT_TASK.md
|
||||||
|
|
||||||
**Active task:** Self-serve signup Phase 2 — code complete on `feat/self-serve-signup-phase-2` (HEAD `c75ce0c`). PR not yet opened. Next: review/merge, then Phase O manual ops (Stripe live setup, internal validation, flag flip). See `.ai/HANDOFF.md` for the resume point.
|
**Active task:** Self-serve signup Phase 2 — PR #162 is open on `feat/self-serve-signup-phase-2`. Current focus is resolving its failing Gitea checks. Phase O manual ops (Stripe live setup, internal validation, flag flip) remain pending after review/merge. See `.ai/HANDOFF.md` for the resume point.
|
||||||
|
|
||||||
## Recently shipped
|
## Recently shipped
|
||||||
|
|
||||||
|
|||||||
@@ -2,72 +2,56 @@
|
|||||||
|
|
||||||
# HANDOFF.md
|
# HANDOFF.md
|
||||||
|
|
||||||
**Last updated:** 2026-05-07 (PR #162 CI runner setup fixes applied; cutover ops still pending)
|
**Last updated:** 2026-05-07 (PR #162 CI investigation/fixes)
|
||||||
|
|
||||||
**Active task:** None mid-flight. Branch `feat/self-serve-signup-phase-2` (HEAD `502c0a4`) is ready to PR.
|
**Active task:** PR #162 (`feat/self-serve-signup-phase-2`) is open in Gitea. Current session is resolving its failing checks.
|
||||||
|
|
||||||
## Where this session ended
|
## Where this session ended
|
||||||
|
|
||||||
Tasks 27–44 of Phase 2 implemented across 18 commits on `feat/self-serve-signup-phase-2`, branched off `main` (`f918b76`). Each task went through implementer + spec review + code-quality review per `superpowers:subagent-driven-development`. Cross-cutting review caught one redirect bug (fixed in-flight). After initial handoff, external code review surfaced four more real bugs — all fixed (commits `5e0c9d2`, `3630dd5`, `06200fa`, `502c0a4`):
|
PR #162 originally failed quickly in Gitea CI. Public Gitea status metadata was available, but job logs redirected to login and no `GITEA_TOKEN` was present. The branch was pushed over SSH.
|
||||||
|
|
||||||
1. **OAuth refresh tokens never stored** (Phase 1 latent): `_store_refresh_token` extracted to module-public `store_refresh_token`; both Google + Microsoft callbacks now persist the JTI before returning. Test covers callback → `/auth/refresh` round-trip.
|
Fixed environment drift first:
|
||||||
2. **OAuth callback never marked store authenticated** (Phase 2 introduced): `setTokens` now sets `isAuthenticated: true`. Verified safe for the refresh-interceptor caller.
|
|
||||||
3. **Stripe webhook idempotency could permanently drop failed events** (Phase 1 latent): `apply_subscription_event` now adds StripeEvent + runs handler + commits atomically; handler exception → rollback → Stripe retries. Inner `_handle_*` commits removed.
|
|
||||||
4. **Missing `/account/billing` and `/account/billing/select-plan` pages** (Phase 2 oversight): all the new billing CTAs (TrialPill, UpgradePrompt, NextStepCard, SetupChecklist) linked to non-existent routes. Built BillingPage (subscription summary + Manage-billing → portal session) and SelectPlanPage (plan cards + checkout flow).
|
|
||||||
|
|
||||||
**Backend additions (Phase I, Tasks 27–31):**
|
- Standardized backend native/dev/CI Python on 3.12.13 to match Docker.
|
||||||
- `BillingService.open_customer_portal` + `GET /billing/portal-session` (allowlisted for canceled/unverified users).
|
- Added `.python-version`.
|
||||||
- `PATCH /users/me/onboarding-step` + `POST /users/me/onboarding-dismiss-rest`.
|
- Rebuilt `backend/venv` from pyenv Python 3.12.13 and verified native `pytest --version` / `alembic --version` with explicit local env.
|
||||||
- `POST /sales-leads` (public, 5/hr/IP rate limit, fire-and-forget notification email, server-side PostHog event stub).
|
- Updated Gitea CI backend/e2e Python setup to 3.12.
|
||||||
- `/admin/plan-limits` GET/PUT round-trips `plan_billing` (Stripe IDs, prices, public/archived flags) in one transaction; `BillingService.invalidate_billing_cache` no-op stub for future cache.
|
|
||||||
- `GET /config/public` (`{self_serve_enabled, oauth_providers}`); register-endpoint gate now `REQUIRE_INVITE_CODE and not SELF_SERVE_ENABLED and not invite_code`.
|
|
||||||
- `GET /accounts/invites/{code}/lookup` (Phase 36).
|
|
||||||
- OAuth callback extended to honor `account_invite_code` + `invited_email` for invitee-via-OAuth path; rejects existing-email user with `email_already_registered_use_login`.
|
|
||||||
- `GET /plans/public` (Phase 42).
|
|
||||||
- `POST /beta-signup` returns 307 to `${FRONTEND_URL}/register?from=beta`.
|
|
||||||
- `OnboardingStatus` extended with `email_verified` + `shop_setup_done`; `UserResponse` exposes `onboarding_step_completed` + `onboarding_dismissed`.
|
|
||||||
|
|
||||||
**Frontend additions:**
|
Fixed Gitea runner assumptions next:
|
||||||
- `useBillingStore` (Zustand, polled 60s via `useBillingPoll` mounted in `AppLayout`), `useFeature` / `useFeatureLimit` / `useTrialBanner` hooks, `FeatureGate` / `UpgradePrompt` / `EmailVerificationGate` components.
|
|
||||||
- `RegisterPage` redesign: OAuth (Google/Microsoft) buttons + invite-code-conditional. `OAuthCallbackPage` at `/auth/{google,microsoft}/callback` with CSRF state validation. `useAppConfig` hook (consumes `/config/public`, falls back to `VITE_SELF_SERVE_ENABLED`).
|
|
||||||
- `AcceptInvitePage` at `/accept-invite?code=...` (locked email, 3 sign-in options, `/?welcome=teammate` on success).
|
|
||||||
- `EmailVerificationBanner` refactored to design-system tokens + grace-period hide; `EmailVerificationWall` polished; `VerifyEmailPage` at `/verify-email?token=...` (single-fire guard).
|
|
||||||
- `WelcomeRouter` + `WelcomeStep1/2/3` at `/welcome*`. PSA tile UI + bulk invite emails per row.
|
|
||||||
- `TrialPill` in topbar (pristine/warning/urgent/expired/paid/complimentary/past_due/canceled).
|
|
||||||
- `NextStepCard` + `SetupChecklist` (replaces orphaned `OnboardingChecklist`); unified list, no SOLO/TEAM split, no Script Builder item.
|
|
||||||
- `PricingPage` at `/pricing` (B-style, 3 plan cards, hardcoded comparison v1).
|
|
||||||
- `ContactSalesPage` at `/contact-sales`. `LandingPage` got "See pricing" CTA + replaced beta-signup form with `<Link to="/register?from=beta">`.
|
|
||||||
|
|
||||||
New env vars (Vite ARG/ENV in Dockerfile + `.env.example`): `VITE_SELF_SERVE_ENABLED`, `VITE_GOOGLE_CLIENT_ID`, `VITE_MS_CLIENT_ID`, `VITE_OAUTH_REDIRECT_BASE`, `VITE_CALENDLY_URL`. Backend env: `SALES_LEAD_RECIPIENT_EMAIL` (default `sales@resolutionflow.com`).
|
- Added `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs.
|
||||||
|
- Pushed `fix(ci): set up node in gitea workflow`.
|
||||||
|
|
||||||
## Resume point — DO THIS NEXT
|
Local frontend validation then exposed real lint failures in Phase 2 React code under the current lint stack. The current WIP fixes:
|
||||||
|
|
||||||
1. **Review the branch and open a PR.** 18 commits, all squashed-or-not at user discretion. Branch `feat/self-serve-signup-phase-2` from `main`.
|
- `react-refresh/only-export-components` for exported pure helpers used by tests/shared invite OAuth code.
|
||||||
2. **Phase O — manual operational tasks** (Tasks 45–47 from the plan):
|
- `react-hooks/set-state-in-effect` warnings where local state intentionally mirrors route/config/cache state.
|
||||||
- **Task 45:** Stripe live-mode setup (Products, Prices, Customer Portal, webhook endpoint + signing secret) and Railway prod env vars (`STRIPE_*`, `OAUTH_REDIRECT_BASE`, `GOOGLE_CLIENT_*`, `MS_CLIENT_*`). Run `python -m scripts.sync_stripe_plan_ids` to populate `plan_billing` rows with live Stripe IDs.
|
- `react-hooks/purity` warnings from `Date.now()` during render.
|
||||||
- **Task 46:** Internal validation pass via `INTERNAL_TESTER_EMAILS` allowlist (per-email bypass of `SELF_SERVE_ENABLED=false`). Backend support for the allowlist itself is NOT implemented — needs a small addition to `auth.py`/`config.py` if the validation pass requires it. Otherwise validate in test mode with the flag flipped temporarily.
|
- Redundant loading-state write in pricing page.
|
||||||
- **Task 47:** Flip `SELF_SERVE_ENABLED=true` (backend) + `VITE_SELF_SERVE_ENABLED=true` (frontend rebuild). Watch PostHog funnel + Stripe webhook error rate.
|
|
||||||
3. **Followups deferred during Phase 2** (logged below).
|
|
||||||
|
|
||||||
## Followups deferred from Phase 2
|
Validation after those frontend changes:
|
||||||
|
|
||||||
- **PostHog server-side capture infrastructure missing.** `/sales-leads` calls `_capture_posthog_event` which lazy-imports `app.core.analytics.posthog` (no-op if module absent). Wire up the real PostHog server SDK before cutover — needs `app/core/analytics.py` with a configured client, plus `python-posthog` dep.
|
- `docker exec -w /app resolutionflow_frontend npm run lint` passed.
|
||||||
- **Frontend `/usage/{field}` endpoint missing.** `useFeatureLimit` lazy-fetches `apiClient.get('/usage/' + field)` — endpoint doesn't exist; hook silently falls back to `used=0`. Build the backend endpoint before any UI surface relies on real usage counts.
|
- `docker exec -w /app resolutionflow_frontend npm run test:coverage` passed (`198` tests).
|
||||||
- **Pre-existing dual subscription stores** (`authStore.subscription` legacy + `billingStore.subscription` new). `Subscription.status` union in `frontend/src/types/account.ts` is missing `'complimentary'`. Phase 2 didn't introduce — but worth deprecating the old store before any UI reads `complimentary` from the wrong source.
|
- `docker exec -w /app -e NODE_OPTIONS=--max-old-space-size=4096 resolutionflow_frontend npm run build` passed.
|
||||||
- **`INTERNAL_TESTER_EMAILS` per-email allowlist** (Task 46): backend support not implemented. Add when Phase O Task 46 needs it.
|
|
||||||
- **`accept-invite` not gated by `self_serve_enabled`** — by design (invites work in any mode). Confirm if pilot wants stricter gating.
|
|
||||||
- **`UserResponse.onboarding_step_completed` validator** suggested by reviewer (reject `complete` without data) — skipped, spec types `data?` as optional.
|
|
||||||
- **Welcome wizard behind `EmailVerificationGate`:** Day 7+ unverified users hit the wall on `/welcome/*` and have no path back into the wizard until they verify. Per spec — wall says "Resend verification" → user verifies → wall closes → wizard resumes. If product wants a different UX, allowlist `/welcome/*` in the gate.
|
|
||||||
- **`SelectPlanPage` seat input doesn't validate against plan `max_seats`.** Stripe Checkout will reject if exceeded; nicer UX would cap client-side.
|
|
||||||
- **`BillingPage` Manage-billing button always enabled** — clicking with no `stripe_customer_id` falls back to a toast. By design; could disable + tooltip if billing-state payload were extended with that flag.
|
|
||||||
|
|
||||||
## Environment notes (carry-forward)
|
Known local noise:
|
||||||
|
|
||||||
- Code-server LXC now standardizes on Python 3.12. `.python-version` pins 3.12.13 to match the Docker image; native backend work should use `backend/venv` built from that interpreter. `pytest --version` and `alembic --version` pass natively when `DEBUG=true SECRET_KEY=ci-test-secret-key-not-for-production` are supplied to avoid local `.env` validation failures.
|
- React `act(...)` warnings appeared in existing tests during coverage but did not fail the suite.
|
||||||
- Gitea CI now sets up Python 3.12 for backend/e2e and Node 20 for frontend/e2e explicitly; do not rely on runner ambient toolchains.
|
- Vite emitted large chunk warnings during build.
|
||||||
- Pytest: `docker exec resolutionflow_backend pytest tests/<file> -v --override-ini="addopts="`.
|
- Unrelated dirty/untracked files remain and should not be staged unless explicitly requested: `docker-compose.dev.yml`, `.env.example`, `abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`, `core.*`, `docs/architecture/`, `docs/tutorials/`.
|
||||||
- Vitest: `docker exec -w /app resolutionflow_frontend npm test -- <path> --run`.
|
|
||||||
- TS build: `docker exec -w /app resolutionflow_frontend npx tsc -b`.
|
## Resume point
|
||||||
- Alembic: `docker exec -w /app resolutionflow_backend alembic ...`. Never `--rev-id`.
|
|
||||||
- No `gh` CLI — Gitea API via `$GITEA_TOKEN` for PR/issue work.
|
1. Commit the frontend lint fixes and `.ai/` handoff updates with the required Codex trailer.
|
||||||
- Single alembic head: `c6cbfc534fad` (from Phase 1). Phase 2 added no migrations.
|
2. Push `feat/self-serve-signup-phase-2`.
|
||||||
|
3. Poll Gitea PR #162 statuses for the new head SHA:
|
||||||
|
`curl -fsSL https://gitea.resolutionflow.com/api/v1/repos/chihlasm/resolutionflow/statuses/<sha> | python -m json.tool`
|
||||||
|
4. If statuses are still pending, report that local frontend CI is green and Gitea runner work is queued/running. If a check fails, public statuses may show only the context/description; logs require authenticated Gitea access.
|
||||||
|
|
||||||
|
## Carry-forward
|
||||||
|
|
||||||
|
- Phase O manual ops remain pending after PR review/merge: Stripe live setup, internal validation, feature-flag flip.
|
||||||
|
- Backend env: `SALES_LEAD_RECIPIENT_EMAIL`.
|
||||||
|
- Frontend env: `VITE_SELF_SERVE_ENABLED`, `VITE_GOOGLE_CLIENT_ID`, `VITE_MS_CLIENT_ID`, `VITE_OAUTH_REDIRECT_BASE`, `VITE_CALENDLY_URL`.
|
||||||
|
- Single alembic head remains `c6cbfc534fad`; Phase 2 added no migrations.
|
||||||
|
|||||||
@@ -336,3 +336,13 @@
|
|||||||
- Files touched: `.ai/*.md` (created), `CLAUDE.md` (rewritten), `AGENTS.md` (created), `SESSION-HANDOFF.md` (deleted).
|
- Files touched: `.ai/*.md` (created), `CLAUDE.md` (rewritten), `AGENTS.md` (created), `SESSION-HANDOFF.md` (deleted).
|
||||||
- Follow-up (same day): Codex review pass flagged stale SaaS-role claim and incomplete file-listings carried over from the pre-migration CLAUDE.md. Verified against `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`, `backend/app/api/deps.py`, `backend/app/api/router.py`, and `backend/app/services/psa/`. Corrected PROJECT_CONTEXT.md role hierarchy (`super_admin > owner > engineer > viewer`, not `team_admin`), added `require_account_owner` / `require_team_admin` to deps list, replaced stale endpoint comment with a summary pointing at `api/router.py`, added `exceptions.py` + `ticket_context.py` to the PSA file list. Also replaced seed-example content in `CURRENT_TASK.md` and `TODO.md` with clearer empty-state sentinels.
|
- Follow-up (same day): Codex review pass flagged stale SaaS-role claim and incomplete file-listings carried over from the pre-migration CLAUDE.md. Verified against `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`, `backend/app/api/deps.py`, `backend/app/api/router.py`, and `backend/app/services/psa/`. Corrected PROJECT_CONTEXT.md role hierarchy (`super_admin > owner > engineer > viewer`, not `team_admin`), added `require_account_owner` / `require_team_admin` to deps list, replaced stale endpoint comment with a summary pointing at `api/router.py`, added `exceptions.py` + `ticket_context.py` to the PSA file list. Also replaced seed-example content in `CURRENT_TASK.md` and `TODO.md` with clearer empty-state sentinels.
|
||||||
- Branch cleanup (same day): committed pending test-isolation work as `b14a16a chore(tests): gate RLS tests behind RUN_RLS_TESTS flag`, new Phase 9 review doc as `b3506b5 docs(pilot): phase 9 review issues`, and `.remember/` gitignore entry as `b3be1e0 chore: ignore .remember/ skill runtime state`. Deleted `docs/landing-handoff/` (prepared for external design work, not meant to live in the repo). Working tree clean; 3 cleanup commits unpushed.
|
- Branch cleanup (same day): committed pending test-isolation work as `b14a16a chore(tests): gate RLS tests behind RUN_RLS_TESTS flag`, new Phase 9 review doc as `b3506b5 docs(pilot): phase 9 review issues`, and `.remember/` gitignore entry as `b3be1e0 chore: ignore .remember/ skill runtime state`. Deleted `docs/landing-handoff/` (prepared for external design work, not meant to live in the repo). Working tree clean; 3 cleanup commits unpushed.
|
||||||
|
|
||||||
|
## 2026-05-07 UTC — Codex — Resolve PR #162 CI failures
|
||||||
|
|
||||||
|
- Investigated Gitea PR #162 failing checks for `feat/self-serve-signup-phase-2`. Public status metadata was available, but job logs required Gitea login and no token was present.
|
||||||
|
- Standardized backend development/CI Python on 3.12.13 to match the Docker image: added `.python-version`, updated Gitea CI Python setup, rebuilt the local backend virtualenv, and verified native `pytest` / `alembic` command availability with explicit local env.
|
||||||
|
- Added explicit Node 20 setup to Gitea frontend and e2e jobs so CI no longer depends on the runner's ambient Node installation.
|
||||||
|
- Reproduced the remaining frontend failure locally. Lint failed on Phase 2 React code because the current eslint stack flags exported pure helpers, render-time `Date.now()`, and effect-driven state synchronization.
|
||||||
|
- Patched the affected frontend surfaces narrowly: dashboard helper exports, app-config cache handling, feature-limit cache/fetch state, trial-banner time capture, invite/OAuth route error state, pricing loading state, and OAuth authorize URL helper export.
|
||||||
|
- Verified sequential frontend CI locally in Docker: `npm run lint` passed, `npm run test:coverage` passed (`198` tests), and `npm run build` passed with only Vite chunk-size warnings.
|
||||||
|
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `.ai/*`, `README.md`, `DEV-ENV.md`, and the frontend lint-fix files under `frontend/src/components/dashboard`, `frontend/src/hooks`, and `frontend/src/pages`.
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const PLAN_GATE_STAGES: ReadonlyArray<TrialBannerStage> = [
|
|||||||
* Pure helper — picks the highest-priority incomplete item, or `null` when
|
* Pure helper — picks the highest-priority incomplete item, or `null` when
|
||||||
* all relevant items are done. Exported for direct unit testing.
|
* all relevant items are done. Exported for direct unit testing.
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- pure helper exported for focused unit tests
|
||||||
export function pickNextStep(
|
export function pickNextStep(
|
||||||
status: OnboardingStatus,
|
status: OnboardingStatus,
|
||||||
trialStage: TrialBannerStage | null,
|
trialStage: TrialBannerStage | null,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const PLAN_GATE_STAGES: ReadonlyArray<TrialBannerStage> = [
|
|||||||
'expired',
|
'expired',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- pure helper exported for focused unit tests
|
||||||
export function buildChecklistItems(
|
export function buildChecklistItems(
|
||||||
status: OnboardingStatus,
|
status: OnboardingStatus,
|
||||||
trialStage: TrialBannerStage | null,
|
trialStage: TrialBannerStage | null,
|
||||||
|
|||||||
@@ -66,10 +66,7 @@ export function useAppConfig(): UseAppConfigResult {
|
|||||||
const [config, setConfig] = useState<PublicConfig | null>(cached)
|
const [config, setConfig] = useState<PublicConfig | null>(cached)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cached) {
|
if (cached) return
|
||||||
setConfig(cached)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let active = true
|
let active = true
|
||||||
const handler = (c: PublicConfig) => {
|
const handler = (c: PublicConfig) => {
|
||||||
if (active) setConfig(c)
|
if (active) setConfig(c)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useBillingStore } from '@/store/billingStore'
|
import { useBillingStore } from '@/store/billingStore'
|
||||||
import { usageApi } from '@/api/usage'
|
import { usageApi } from '@/api/usage'
|
||||||
|
|
||||||
@@ -53,61 +53,38 @@ function coerceLimit(raw: unknown): number | null {
|
|||||||
export function useFeatureLimit(field: string): FeatureLimitResult {
|
export function useFeatureLimit(field: string): FeatureLimitResult {
|
||||||
const limit = useBillingStore((state) => coerceLimit(state.planLimits[field]))
|
const limit = useBillingStore((state) => coerceLimit(state.planLimits[field]))
|
||||||
|
|
||||||
// Initialize from cache on first mount only; subsequent `field` changes
|
const [state, setState] = useState(() => {
|
||||||
// are handled inside the effect below so the render-phase result reflects
|
|
||||||
// the new field synchronously (no stale `used`/`isLoading` for one tick).
|
|
||||||
const initialCached = useRef<CacheEntry | undefined>(undefined)
|
|
||||||
if (initialCached.current === undefined) {
|
|
||||||
initialCached.current = cache.get(field)
|
|
||||||
}
|
|
||||||
const initialFresh =
|
|
||||||
initialCached.current && Date.now() - initialCached.current.timestamp < CACHE_TTL_MS
|
|
||||||
const [used, setUsed] = useState<number>(initialFresh ? initialCached.current!.used : 0)
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(!initialFresh)
|
|
||||||
|
|
||||||
// Track the field that the current `used`/`isLoading` state describes.
|
|
||||||
// When `field` changes, we synchronously reset state in render so callers
|
|
||||||
// never see stale data for the previous field.
|
|
||||||
const stateField = useRef<string>(field)
|
|
||||||
if (stateField.current !== field) {
|
|
||||||
stateField.current = field
|
|
||||||
const existing = cache.get(field)
|
const existing = cache.get(field)
|
||||||
const freshNow = existing && Date.now() - existing.timestamp < CACHE_TTL_MS
|
const fresh = existing && Date.now() - existing.timestamp < CACHE_TTL_MS
|
||||||
if (freshNow) {
|
return {
|
||||||
setUsed(existing!.used)
|
field,
|
||||||
setIsLoading(false)
|
used: fresh ? existing.used : 0,
|
||||||
} else {
|
isLoading: !fresh,
|
||||||
setUsed(0)
|
|
||||||
setIsLoading(true)
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const existing = cache.get(field)
|
const existing = cache.get(field)
|
||||||
if (existing && Date.now() - existing.timestamp < CACHE_TTL_MS) {
|
if (existing && Date.now() - existing.timestamp < CACHE_TTL_MS) {
|
||||||
setUsed(existing.used)
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync local hook state with fresh module cache on field change
|
||||||
setIsLoading(false)
|
setState({ field, used: existing.used, isLoading: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setIsLoading(true)
|
setState({ field, used: 0, isLoading: true })
|
||||||
usageApi
|
usageApi
|
||||||
.getCount(field)
|
.getCount(field)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
cache.set(field, { used: result.used, timestamp: Date.now() })
|
cache.set(field, { used: result.used, timestamp: Date.now() })
|
||||||
setUsed(result.used)
|
setState({ field, used: result.used, isLoading: false })
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// TODO: backend /usage/{field} endpoint not yet implemented (planned).
|
// TODO: backend /usage/{field} endpoint not yet implemented (planned).
|
||||||
// 404s and other errors degrade to used=0 silently — no toast.
|
// 404s and other errors degrade to used=0 silently — no toast.
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setUsed(0)
|
setState({ field, used: 0, isLoading: false })
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (cancelled) return
|
|
||||||
setIsLoading(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -115,6 +92,8 @@ export function useFeatureLimit(field: string): FeatureLimitResult {
|
|||||||
}
|
}
|
||||||
}, [field])
|
}, [field])
|
||||||
|
|
||||||
|
const used = state.field === field ? state.used : 0
|
||||||
|
const isLoading = state.field === field ? state.isLoading : true
|
||||||
const percentage =
|
const percentage =
|
||||||
limit === null || limit <= 0 ? null : Math.min(100, Math.round((used / limit) * 100))
|
limit === null || limit <= 0 ? null : Math.min(100, Math.round((used / limit) * 100))
|
||||||
const isAtLimit = limit !== null && used >= limit
|
const isAtLimit = limit !== null && used >= limit
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useBillingStore } from '@/store/billingStore'
|
import { useBillingStore } from '@/store/billingStore'
|
||||||
|
|
||||||
export type TrialBannerStage =
|
export type TrialBannerStage =
|
||||||
@@ -28,6 +29,7 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000
|
|||||||
*/
|
*/
|
||||||
export function useTrialBanner(): TrialBannerResult {
|
export function useTrialBanner(): TrialBannerResult {
|
||||||
const subscription = useBillingStore((state) => state.subscription)
|
const subscription = useBillingStore((state) => state.subscription)
|
||||||
|
const [now] = useState(() => Date.now())
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
return { stage: null, daysRemaining: null }
|
return { stage: null, daysRemaining: null }
|
||||||
@@ -51,7 +53,6 @@ export function useTrialBanner(): TrialBannerResult {
|
|||||||
// upgrade prompt still surfaces rather than silently swallowing it.
|
// upgrade prompt still surfaces rather than silently swallowing it.
|
||||||
return { stage: 'expired', daysRemaining: null }
|
return { stage: 'expired', daysRemaining: null }
|
||||||
}
|
}
|
||||||
const now = Date.now()
|
|
||||||
if (end <= now) {
|
if (end <= now) {
|
||||||
return { stage: 'expired', daysRemaining: 0 }
|
return { stage: 'expired', daysRemaining: 0 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function AcceptInvitePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- route changes without a code should replace stale lookup state
|
||||||
setLookup({ status: 'missing-code' })
|
setLookup({ status: 'missing-code' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function OAuthCallbackPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (oauthError) {
|
if (oauthError) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- callback URL validation maps directly into local error state
|
||||||
setError(`OAuth error: ${oauthError}`)
|
setError(`OAuth error: ${oauthError}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,7 +182,6 @@ export function PricingPage() {
|
|||||||
if (!appConfig.self_serve_enabled) return
|
if (!appConfig.self_serve_enabled) return
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setLoading(true)
|
|
||||||
plansApi
|
plansApi
|
||||||
.getPublic()
|
.getPublic()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ function randomState(): string {
|
|||||||
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('')
|
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build provider authorize URL. Exported for tests. */
|
/** Build provider authorize URL. Exported for tests and invite OAuth handoff. */
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- pure helper shared with AcceptInvitePage and unit tests
|
||||||
export function buildOAuthAuthorizeUrl(
|
export function buildOAuthAuthorizeUrl(
|
||||||
provider: 'google' | 'microsoft',
|
provider: 'google' | 'microsoft',
|
||||||
state: string,
|
state: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user