feat: self-serve signup Phase 2 (frontend cutover) #162
@@ -10,6 +10,7 @@ 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 usageApi } from './usage'
|
||||
export { default as adminApi } from './admin'
|
||||
export { treeMarkdownApi } from './treeMarkdown'
|
||||
export { default as analyticsApi } from './analytics'
|
||||
|
||||
23
frontend/src/api/usage.ts
Normal file
23
frontend/src/api/usage.ts
Normal 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
|
||||
44
frontend/src/hooks/useFeature.test.ts
Normal file
44
frontend/src/hooks/useFeature.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useFeature } from './useFeature'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
|
||||
describe('useFeature', () => {
|
||||
beforeEach(() => {
|
||||
useBillingStore.setState({
|
||||
subscription: null,
|
||||
planBilling: null,
|
||||
planLimits: {},
|
||||
enabledFeatures: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns false when flag absent', () => {
|
||||
const { result } = renderHook(() => useFeature('does_not_exist'))
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when flag is enabled', () => {
|
||||
useBillingStore.setState({ enabledFeatures: { ai_builder: true } })
|
||||
const { result } = renderHook(() => useFeature('ai_builder'))
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when flag is explicitly disabled', () => {
|
||||
useBillingStore.setState({ enabledFeatures: { ai_builder: false } })
|
||||
const { result } = renderHook(() => useFeature('ai_builder'))
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('updates when store changes (subscribes to store)', () => {
|
||||
const { result } = renderHook(() => useFeature('foo'))
|
||||
expect(result.current).toBe(false)
|
||||
|
||||
act(() => {
|
||||
useBillingStore.setState({ enabledFeatures: { foo: true } })
|
||||
})
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
})
|
||||
16
frontend/src/hooks/useFeature.ts
Normal file
16
frontend/src/hooks/useFeature.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
|
||||
/**
|
||||
* Returns whether a feature flag is enabled for the current account.
|
||||
*
|
||||
* Reads from `useBillingStore.enabledFeatures`, which is populated by
|
||||
* `GET /billing/state`. Returns `false` when the flag is absent (closed-by-default).
|
||||
*
|
||||
* The hook subscribes to the store so updates from `refetch()` propagate
|
||||
* without manual refetch in the component.
|
||||
*/
|
||||
export function useFeature(flagKey: string): boolean {
|
||||
return useBillingStore((state) => Boolean(state.enabledFeatures[flagKey]))
|
||||
}
|
||||
|
||||
export default useFeature
|
||||
112
frontend/src/hooks/useFeatureLimit.test.ts
Normal file
112
frontend/src/hooks/useFeatureLimit.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useFeatureLimit, clearUsageCache } from './useFeatureLimit'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
|
||||
vi.mock('@/api/usage', () => ({
|
||||
usageApi: {
|
||||
getCount: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { usageApi } from '@/api/usage'
|
||||
|
||||
const mockedGetCount = vi.mocked(usageApi.getCount)
|
||||
|
||||
describe('useFeatureLimit', () => {
|
||||
beforeEach(() => {
|
||||
clearUsageCache()
|
||||
mockedGetCount.mockReset()
|
||||
useBillingStore.setState({
|
||||
subscription: null,
|
||||
planBilling: null,
|
||||
planLimits: {},
|
||||
enabledFeatures: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('transitions isLoading -> loaded', async () => {
|
||||
useBillingStore.setState({ planLimits: { active_users: 10 } })
|
||||
mockedGetCount.mockResolvedValueOnce({ used: 4 })
|
||||
|
||||
const { result } = renderHook(() => useFeatureLimit('active_users'))
|
||||
|
||||
// Non-blocking initial state.
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
expect(result.current.used).toBe(0)
|
||||
expect(result.current.limit).toBe(10)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.used).toBe(4)
|
||||
expect(result.current.limit).toBe(10)
|
||||
expect(result.current.percentage).toBe(40)
|
||||
expect(result.current.isAtLimit).toBe(false)
|
||||
})
|
||||
|
||||
it('flags isAtLimit when used >= limit', async () => {
|
||||
useBillingStore.setState({ planLimits: { seats: 3 } })
|
||||
mockedGetCount.mockResolvedValueOnce({ used: 3 })
|
||||
|
||||
const { result } = renderHook(() => useFeatureLimit('seats'))
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
|
||||
expect(result.current.isAtLimit).toBe(true)
|
||||
expect(result.current.percentage).toBe(100)
|
||||
})
|
||||
|
||||
it('returns null percentage when limit is null (unlimited)', async () => {
|
||||
useBillingStore.setState({ planLimits: { sessions: null } })
|
||||
mockedGetCount.mockResolvedValueOnce({ used: 7 })
|
||||
|
||||
const { result } = renderHook(() => useFeatureLimit('sessions'))
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
|
||||
expect(result.current.limit).toBe(null)
|
||||
expect(result.current.percentage).toBe(null)
|
||||
expect(result.current.isAtLimit).toBe(false)
|
||||
})
|
||||
|
||||
it('resets isLoading=true synchronously when `field` prop changes', async () => {
|
||||
useBillingStore.setState({ planLimits: { max_trees: 5, max_users: 10 } })
|
||||
mockedGetCount.mockResolvedValueOnce({ used: 2 }) // for max_trees
|
||||
mockedGetCount.mockResolvedValueOnce({ used: 3 }) // for max_users (slow)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ field }: { field: string }) => useFeatureLimit(field),
|
||||
{ initialProps: { field: 'max_trees' } },
|
||||
)
|
||||
|
||||
// First field resolves.
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
expect(result.current.used).toBe(2)
|
||||
expect(result.current.limit).toBe(5)
|
||||
|
||||
// Switch field. Next render must report isLoading=true (no stale data
|
||||
// bleed-through) before the new fetch resolves.
|
||||
rerender({ field: 'max_users' })
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
expect(result.current.used).toBe(0)
|
||||
expect(result.current.limit).toBe(10)
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
expect(result.current.used).toBe(3)
|
||||
expect(result.current.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('degrades to used=0 on fetch error (404 from missing endpoint)', async () => {
|
||||
useBillingStore.setState({ planLimits: { active_users: 5 } })
|
||||
mockedGetCount.mockRejectedValueOnce(new Error('Request failed with status 404'))
|
||||
|
||||
const { result } = renderHook(() => useFeatureLimit('active_users'))
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
|
||||
expect(result.current.used).toBe(0)
|
||||
expect(result.current.limit).toBe(5)
|
||||
expect(result.current.percentage).toBe(0)
|
||||
})
|
||||
})
|
||||
125
frontend/src/hooks/useFeatureLimit.ts
Normal file
125
frontend/src/hooks/useFeatureLimit.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
import { usageApi } from '@/api/usage'
|
||||
|
||||
const CACHE_TTL_MS = 60 * 1000
|
||||
|
||||
interface CacheEntry {
|
||||
used: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>()
|
||||
|
||||
/** Clear the usage cache (call on logout to prevent stale data across users). */
|
||||
export function clearUsageCache() {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
export interface FeatureLimitResult {
|
||||
used: number
|
||||
limit: number | null
|
||||
/** null when limit is null (unlimited) or unknown */
|
||||
percentage: number | null
|
||||
isAtLimit: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
function coerceLimit(raw: unknown): number | null {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
|
||||
if (raw === null || raw === undefined) return null
|
||||
// The store types planLimits as Record<string, unknown>; the backend
|
||||
// currently returns numbers, but defensively handle string ints too.
|
||||
if (typeof raw === 'string') {
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns progress against a quantitative plan limit.
|
||||
*
|
||||
* `limit` comes from `useBillingStore.planLimits[field]`, which is read
|
||||
* synchronously from the store. `used` is fetched lazily from
|
||||
* `GET /api/v1/usage/{field}` on mount and cached for 60s in a module-level
|
||||
* map keyed by field.
|
||||
*
|
||||
* Render is non-blocking: the hook returns `isLoading=true` (with `used=0`)
|
||||
* until the usage fetch resolves. On 404 or any error the hook degrades to
|
||||
* `used=0` with `isLoading=false` rather than surfacing the error — the
|
||||
* `/usage/{field}` endpoint is not yet implemented on the backend (planned).
|
||||
*/
|
||||
export function useFeatureLimit(field: string): FeatureLimitResult {
|
||||
const limit = useBillingStore((state) => coerceLimit(state.planLimits[field]))
|
||||
|
||||
// Initialize from cache on first mount only; subsequent `field` changes
|
||||
// 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 freshNow = existing && Date.now() - existing.timestamp < CACHE_TTL_MS
|
||||
if (freshNow) {
|
||||
setUsed(existing!.used)
|
||||
setIsLoading(false)
|
||||
} else {
|
||||
setUsed(0)
|
||||
setIsLoading(true)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const existing = cache.get(field)
|
||||
if (existing && Date.now() - existing.timestamp < CACHE_TTL_MS) {
|
||||
setUsed(existing.used)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setIsLoading(true)
|
||||
usageApi
|
||||
.getCount(field)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
cache.set(field, { used: result.used, timestamp: Date.now() })
|
||||
setUsed(result.used)
|
||||
})
|
||||
.catch(() => {
|
||||
// TODO: backend /usage/{field} endpoint not yet implemented (planned).
|
||||
// 404s and other errors degrade to used=0 silently — no toast.
|
||||
if (cancelled) return
|
||||
setUsed(0)
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return
|
||||
setIsLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [field])
|
||||
|
||||
const percentage =
|
||||
limit === null || limit <= 0 ? null : Math.min(100, Math.round((used / limit) * 100))
|
||||
const isAtLimit = limit !== null && used >= limit
|
||||
|
||||
return { used, limit, percentage, isAtLimit, isLoading }
|
||||
}
|
||||
|
||||
export default useFeatureLimit
|
||||
131
frontend/src/hooks/useTrialBanner.test.ts
Normal file
131
frontend/src/hooks/useTrialBanner.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useTrialBanner } from './useTrialBanner'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
import type { SubscriptionState } from '@/types/billing'
|
||||
|
||||
const FROZEN_NOW = new Date('2026-05-06T00:00:00Z')
|
||||
|
||||
function makeSub(overrides: Partial<SubscriptionState>): SubscriptionState {
|
||||
return {
|
||||
status: 'trialing',
|
||||
plan: 'starter',
|
||||
current_period_start: '2026-05-01T00:00:00Z',
|
||||
current_period_end: null,
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: null,
|
||||
has_pro_entitlement: false,
|
||||
is_paid: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function setSubscription(overrides: Partial<SubscriptionState>) {
|
||||
useBillingStore.setState({ subscription: makeSub(overrides) })
|
||||
}
|
||||
|
||||
describe('useTrialBanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(FROZEN_NOW)
|
||||
useBillingStore.setState({
|
||||
subscription: null,
|
||||
planBilling: null,
|
||||
planLimits: {},
|
||||
enabledFeatures: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('stage matches subscription state matrix', () => {
|
||||
it('returns null when subscription is null (no flicker on initial load)', () => {
|
||||
const { result } = renderHook(() => useTrialBanner())
|
||||
expect(result.current.stage).toBe(null)
|
||||
expect(result.current.daysRemaining).toBe(null)
|
||||
})
|
||||
|
||||
it('complimentary status -> complimentary stage', () => {
|
||||
setSubscription({ status: 'complimentary' })
|
||||
const { result } = renderHook(() => useTrialBanner())
|
||||
expect(result.current.stage).toBe('complimentary')
|
||||
})
|
||||
|
||||
it('active status -> paid stage', () => {
|
||||
setSubscription({ status: 'active' })
|
||||
const { result } = renderHook(() => useTrialBanner())
|
||||
expect(result.current.stage).toBe('paid')
|
||||
})
|
||||
|
||||
it('past_due status -> past_due stage', () => {
|
||||
setSubscription({ status: 'past_due' })
|
||||
const { result } = renderHook(() => useTrialBanner())
|
||||
expect(result.current.stage).toBe('past_due')
|
||||
})
|
||||
|
||||
it('canceled status -> canceled stage', () => {
|
||||
setSubscription({ status: 'canceled' })
|
||||
const { result } = renderHook(() => useTrialBanner())
|
||||
expect(result.current.stage).toBe('canceled')
|
||||
})
|
||||
|
||||
it('trialing >3 days remaining -> pristine', () => {
|
||||
// 7 days from frozen now.
|
||||
setSubscription({
|
||||
status: 'trialing',
|
||||
current_period_end: '2026-05-13T00:00:00Z',
|
||||
})
|
||||
const { result } = renderHook(() => useTrialBanner())
|
||||
expect(result.current.stage).toBe('pristine')
|
||||
expect(result.current.daysRemaining).toBe(7)
|
||||
})
|
||||
|
||||
it('trialing 1-3 days remaining -> warning', () => {
|
||||
// 2 days from frozen now.
|
||||
setSubscription({
|
||||
status: 'trialing',
|
||||
current_period_end: '2026-05-08T00:00:00Z',
|
||||
})
|
||||
const { result } = renderHook(() => useTrialBanner())
|
||||
expect(result.current.stage).toBe('warning')
|
||||
expect(result.current.daysRemaining).toBe(2)
|
||||
})
|
||||
|
||||
it('trialing exactly 24 hours remaining -> warning (boundary, not urgent)', () => {
|
||||
// Exactly 1.0 fractional day from frozen now — must sit on the warning
|
||||
// side per spec (1–3 days inclusive of 1).
|
||||
setSubscription({
|
||||
status: 'trialing',
|
||||
current_period_end: '2026-05-07T00:00:00Z',
|
||||
})
|
||||
const { result } = renderHook(() => useTrialBanner())
|
||||
expect(result.current.stage).toBe('warning')
|
||||
expect(result.current.daysRemaining).toBe(1)
|
||||
})
|
||||
|
||||
it('trialing <1 day remaining -> urgent', () => {
|
||||
// 12 hours from frozen now -> Math.ceil(0.5) = 1 day.
|
||||
setSubscription({
|
||||
status: 'trialing',
|
||||
current_period_end: '2026-05-06T12:00:00Z',
|
||||
})
|
||||
const { result } = renderHook(() => useTrialBanner())
|
||||
expect(result.current.stage).toBe('urgent')
|
||||
expect(result.current.daysRemaining).toBe(1)
|
||||
})
|
||||
|
||||
it('trialing past period_end -> expired', () => {
|
||||
setSubscription({
|
||||
status: 'trialing',
|
||||
current_period_end: '2026-05-01T00:00:00Z',
|
||||
})
|
||||
const { result } = renderHook(() => useTrialBanner())
|
||||
expect(result.current.stage).toBe('expired')
|
||||
expect(result.current.daysRemaining).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
86
frontend/src/hooks/useTrialBanner.ts
Normal file
86
frontend/src/hooks/useTrialBanner.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
|
||||
export type TrialBannerStage =
|
||||
| 'pristine'
|
||||
| 'warning'
|
||||
| 'urgent'
|
||||
| 'expired'
|
||||
| 'complimentary'
|
||||
| 'paid'
|
||||
| 'past_due'
|
||||
| 'canceled'
|
||||
|
||||
export interface TrialBannerResult {
|
||||
stage: TrialBannerStage | null
|
||||
daysRemaining: number | null
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Derives the trial-banner display stage from the current subscription.
|
||||
*
|
||||
* Returns `{ stage: null, daysRemaining: null }` when subscription data is
|
||||
* not yet loaded — this prevents the banner flickering on initial render.
|
||||
*
|
||||
* Subscribes to `useBillingStore` so updates from `refetch()` after a Stripe
|
||||
* checkout propagate automatically.
|
||||
*/
|
||||
export function useTrialBanner(): TrialBannerResult {
|
||||
const subscription = useBillingStore((state) => state.subscription)
|
||||
|
||||
if (!subscription) {
|
||||
return { stage: null, daysRemaining: null }
|
||||
}
|
||||
|
||||
switch (subscription.status) {
|
||||
case 'complimentary':
|
||||
return { stage: 'complimentary', daysRemaining: null }
|
||||
case 'active':
|
||||
return { stage: 'paid', daysRemaining: null }
|
||||
case 'past_due':
|
||||
return { stage: 'past_due', daysRemaining: null }
|
||||
case 'canceled':
|
||||
return { stage: 'canceled', daysRemaining: null }
|
||||
case 'trialing': {
|
||||
const end = subscription.current_period_end
|
||||
? new Date(subscription.current_period_end).getTime()
|
||||
: null
|
||||
if (end === null || Number.isNaN(end)) {
|
||||
// Trialing without a period end is malformed; treat as expired so the
|
||||
// upgrade prompt still surfaces rather than silently swallowing it.
|
||||
return { stage: 'expired', daysRemaining: null }
|
||||
}
|
||||
const now = Date.now()
|
||||
if (end <= now) {
|
||||
return { stage: 'expired', daysRemaining: 0 }
|
||||
}
|
||||
const msRemaining = end - now
|
||||
// Use fractional days for stage thresholds so exactly 24h remaining
|
||||
// sits on the warning side (1.0), not urgent. The displayed integer
|
||||
// countdown still uses Math.ceil so "0.5 days" renders as "1 day".
|
||||
const fractionalDays = msRemaining / MS_PER_DAY
|
||||
const daysRemaining = Math.ceil(fractionalDays)
|
||||
// Spec thresholds:
|
||||
// >3 days remaining → pristine
|
||||
// 1–3 days → warning (inclusive of exactly 1)
|
||||
// <1 day → urgent
|
||||
let stage: TrialBannerStage = 'pristine'
|
||||
if (fractionalDays < 1) stage = 'urgent'
|
||||
else if (fractionalDays <= 3) stage = 'warning'
|
||||
return { stage, daysRemaining }
|
||||
}
|
||||
case 'incomplete':
|
||||
// Not in the spec's matrix; surface as null so the banner stays hidden
|
||||
// until checkout actually resolves.
|
||||
return { stage: null, daysRemaining: null }
|
||||
default: {
|
||||
// Defensive fallthrough for unknown statuses — keep the banner hidden.
|
||||
const _exhaustive: never = subscription.status as never
|
||||
void _exhaustive
|
||||
return { stage: null, daysRemaining: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default useTrialBanner
|
||||
Reference in New Issue
Block a user