feat: self-serve signup Phase 2 (frontend cutover) (#162)
Some checks failed
CI / e2e (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled

Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
This commit was merged in pull request #162.
This commit is contained in:
2026-05-07 18:42:20 +00:00
committed by chihlasm
parent f918b766b0
commit f1be3abcc5
123 changed files with 11563 additions and 559 deletions

View File

@@ -1,6 +1,22 @@
import apiClient from './client'
import type { Account, SubscriptionDetails, AccountMember, AccountInvite } from '@/types'
export interface BulkInviteRow {
email: string
role: 'engineer' | 'viewer'
expires_in_days?: number
}
export interface BulkInviteFailure {
email: string
error: string
}
export interface BulkInviteResponse {
created: AccountInvite[]
failed: BulkInviteFailure[]
}
export const accountsApi = {
async getMyAccount(): Promise<Account> {
const response = await apiClient.get<Account>('/accounts/me')
@@ -39,6 +55,18 @@ export const accountsApi = {
return response.data
},
/**
* Create multiple invites in one call (used by the welcome wizard step 3).
* Per-row failures land in `failed[]`; successes in `created[]`.
*/
async bulkInvite(invites: BulkInviteRow[]): Promise<BulkInviteResponse> {
const response = await apiClient.post<BulkInviteResponse>(
'/accounts/me/invites/bulk',
{ invites },
)
return response.data
},
async getInvites(): Promise<AccountInvite[]> {
const response = await apiClient.get<AccountInvite[]>('/accounts/me/invites')
return response.data

View File

@@ -1,6 +1,13 @@
import apiClient from './client'
import type { Token, User, UserCreate, UserLogin, UserUpdate } from '@/types'
export interface OAuthCallbackResponse {
access_token: string
refresh_token: string
token_type: string
is_new_user: boolean
}
export const authApi = {
async register(data: UserCreate): Promise<User> {
const response = await apiClient.post<User>('/auth/register', data)
@@ -71,6 +78,36 @@ export const authApi = {
async verifyEmail(token: string): Promise<void> {
await apiClient.post('/auth/email/verify', { token })
},
async googleCallback(
code: string,
options?: { accountInviteCode?: string; invitedEmail?: string },
): Promise<OAuthCallbackResponse> {
const response = await apiClient.post<OAuthCallbackResponse>(
'/auth/google/callback',
{
code,
account_invite_code: options?.accountInviteCode,
invited_email: options?.invitedEmail,
},
)
return response.data
},
async microsoftCallback(
code: string,
options?: { accountInviteCode?: string; invitedEmail?: string },
): Promise<OAuthCallbackResponse> {
const response = await apiClient.post<OAuthCallbackResponse>(
'/auth/microsoft/callback',
{
code,
account_invite_code: options?.accountInviteCode,
invited_email: options?.invitedEmail,
},
)
return response.data
},
}
export default authApi

View File

@@ -0,0 +1,79 @@
import { AxiosError } from 'axios'
import apiClient from './client'
import {
BillingPortalError,
type BillingPortalErrorCode,
type BillingPortalSessionResponse,
type BillingStateApiResponse,
type BillingStatePayload,
type CheckoutSessionRequest,
type CheckoutSessionResponse,
} from '@/types/billing'
/**
* Single boundary where the snake_case backend payload is transformed
* into the camelCase shape used by the rest of the frontend.
*
* Keeping the transform here means the store, hooks, and components
* never see snake_case keys.
*/
function transformBillingState(raw: BillingStateApiResponse): BillingStatePayload {
return {
subscription: raw.subscription ?? null,
planBilling: raw.plan_billing ?? null,
planLimits: raw.plan_limits ?? {},
enabledFeatures: raw.enabled_features ?? {},
}
}
export const billingApi = {
async getState(): Promise<BillingStatePayload> {
const response = await apiClient.get<BillingStateApiResponse>('/billing/state')
return transformBillingState(response.data)
},
/**
* Request a Stripe Customer Portal session URL for the active account.
*
* Throws a typed `BillingPortalError` when:
* - HTTP 503 → `stripe_not_configured` (server-side Stripe is disabled)
* - HTTP 400 + `error: 'no_stripe_customer'` → account hasn't been billed yet
*
* Other errors (5xx, network) propagate as the underlying AxiosError.
*/
async getPortalSession(): Promise<BillingPortalSessionResponse> {
try {
const response = await apiClient.get<BillingPortalSessionResponse>(
'/billing/portal-session',
)
return response.data
} catch (err) {
if (err instanceof AxiosError && err.response) {
const { status, data } = err.response
const code: BillingPortalErrorCode | null =
status === 503
? 'stripe_not_configured'
: status === 400 && data?.detail?.error === 'no_stripe_customer'
? 'no_stripe_customer'
: null
if (code) {
throw new BillingPortalError(code)
}
}
throw err
}
},
async createCheckoutSession(
payload: CheckoutSessionRequest,
): Promise<CheckoutSessionResponse> {
const response = await apiClient.post<CheckoutSessionResponse>(
'/billing/checkout-session',
payload,
)
return response.data
},
}
export default billingApi

View File

@@ -0,0 +1,15 @@
import apiClient from './client'
export interface PublicConfig {
self_serve_enabled: boolean
oauth_providers: string[]
}
export const configApi = {
async getPublic(): Promise<PublicConfig> {
const response = await apiClient.get<PublicConfig>('/config/public')
return response.data
},
}
export default configApi

View File

@@ -9,6 +9,16 @@ export { default as foldersApi } from './folders'
export { default as stepsApi } from './steps'
export { default as stepCategoriesApi } from './stepCategories'
export { default as accountsApi } from './accounts'
export { default as billingApi } from './billing'
export { default as plansApi } from './plans'
export type { PublicPlanResponse } from './plans'
export { default as salesApi } from './sales'
export type {
SalesLeadCreatePayload,
SalesLeadCreateResponse,
SalesLeadSource,
} from './sales'
export { default as usageApi } from './usage'
export { default as adminApi } from './admin'
export { treeMarkdownApi } from './treeMarkdown'
export { default as analyticsApi } from './analytics'

View File

@@ -1,11 +1,30 @@
import apiClient from './client'
import type { InviteCodeValidation } from '@/types'
/** Public response from GET /accounts/invites/{code}/lookup. */
export interface AccountInviteLookup {
account_name: string
inviter_name: string
invited_email: string
role: string
}
export const inviteApi = {
async validateCode(code: string): Promise<InviteCodeValidation> {
const response = await apiClient.get<InviteCodeValidation>(`/invites/validate/${code}`)
return response.data
},
/** Public lookup of an account invite code — no auth required. Used by
* /accept-invite to render the "Join {account} on ResolutionFlow" card.
* Resolves to 404 with `invite_invalid_or_expired_or_revoked` for any
* invalid state. */
async lookupAccountInvite(code: string): Promise<AccountInviteLookup> {
const response = await apiClient.get<AccountInviteLookup>(
`/accounts/invites/${encodeURIComponent(code)}/lookup`,
)
return response.data
},
}
export default inviteApi

View File

@@ -4,11 +4,15 @@ export interface OnboardingStatus {
created_flow: boolean
ran_session: boolean
exported_session: boolean
/** @deprecated Phase 2 — kept for backward-compat. New UI no longer branches on this. */
tried_ai_assistant: boolean
invited_teammate: boolean
connected_psa: boolean
is_team_user: boolean
dismissed: boolean
// Phase 2 (Task 41) — drive the unified next-step card + checklist.
email_verified: boolean
shop_setup_done: boolean
}
export async function getOnboardingStatus(): Promise<OnboardingStatus> {
@@ -19,3 +23,51 @@ export async function getOnboardingStatus(): Promise<OnboardingStatus> {
export async function dismissOnboarding(): Promise<void> {
await apiClient.post('/users/onboarding-status/dismiss')
}
// --- Welcome wizard (Phase 2) ---------------------------------------------
export type WizardStep = 1 | 2 | 3
export type WizardAction = 'complete' | 'skip'
export type TeamSizeBucket = '1-2' | '3-5' | '6-10' | '11-25' | '26+'
export type RoleAtSignup = 'owner' | 'lead_tech' | 'tech' | 'other'
export type PrimaryPsa = 'connectwise' | 'autotask' | 'halopsa' | 'none'
export interface OnboardingStepData {
// Step 1
company_name?: string
team_size_bucket?: TeamSizeBucket
role_at_signup?: RoleAtSignup
// Step 2
primary_psa?: PrimaryPsa
}
export interface OnboardingStepRequest {
step: WizardStep
action: WizardAction
data?: OnboardingStepData
}
export interface OnboardingStepResponse {
onboarding_step_completed: number | null
onboarding_dismissed: boolean
}
export const onboardingApi = {
getStatus: getOnboardingStatus,
dismiss: dismissOnboarding,
/** Persist welcome-wizard progress for the current user. */
async updateStep(payload: OnboardingStepRequest): Promise<OnboardingStepResponse> {
const response = await apiClient.patch<OnboardingStepResponse>(
'/users/me/onboarding-step',
payload,
)
return response.data
},
/** Skip the rest of the welcome wizard — sets users.onboarding_dismissed=TRUE. */
async dismissRest(): Promise<OnboardingStepResponse> {
const response = await apiClient.post<OnboardingStepResponse>(
'/users/me/onboarding-dismiss-rest',
)
return response.data
},
}

22
frontend/src/api/plans.ts Normal file
View File

@@ -0,0 +1,22 @@
import apiClient from './client'
export interface PublicPlanResponse {
plan: string
display_name: string
description: string | null
monthly_price_cents: number | null
annual_price_cents: number | null
max_seats: number | null
sort_order: number
is_public: boolean
}
export const plansApi = {
/** Public plan catalog for the marketing /pricing page. No auth. */
async getPublic(): Promise<PublicPlanResponse[]> {
const response = await apiClient.get<PublicPlanResponse[]>('/plans/public')
return response.data
},
}
export default plansApi

32
frontend/src/api/sales.ts Normal file
View File

@@ -0,0 +1,32 @@
import apiClient from './client'
export type SalesLeadSource = 'pricing_page' | 'register_footer' | 'landing_page'
export interface SalesLeadCreatePayload {
email: string
name: string
company: string
team_size?: string
message?: string
source: SalesLeadSource
posthog_distinct_id?: string
}
export interface SalesLeadCreateResponse {
id: string
status: 'received'
}
export const salesApi = {
/**
* Public Talk-to-Sales submission. No auth required. Rate-limited per IP
* server-side (5/hour). Server emits PostHog `talk_to_sales_form_submitted`
* — frontend should NOT also fire this event.
*/
async createLead(payload: SalesLeadCreatePayload): Promise<SalesLeadCreateResponse> {
const response = await apiClient.post<SalesLeadCreateResponse>('/sales-leads', payload)
return response.data
},
}
export default salesApi

23
frontend/src/api/usage.ts Normal file
View File

@@ -0,0 +1,23 @@
import apiClient from './client'
/**
* Usage counters API.
*
* TODO: backend `/usage/{field}` endpoint not yet implemented (planned).
* Tracked under self-serve signup Phase 2 — Task 33 calls this lazily; today
* it 404s and the consuming hook (`useFeatureLimit`) cleanly degrades to
* `used = 0`.
*/
export const usageApi = {
/**
* Fetch the current count for a usage field (e.g. `active_users`,
* `flowpilot_sessions_this_month`). The field name is the same key used in
* `BillingState.planLimits`.
*/
async getCount(field: string): Promise<{ used: number }> {
const response = await apiClient.get<{ used: number }>(`/usage/${field}`)
return response.data
},
}
export default usageApi