Billing UI (Nightly & Dev only) and Jest component unit testing (#6640)
A bajillion commits hi * all the shit i'll git reset origin/main && git add -p . later * fmt * wip * fmt * rebase; fmt; tsc; lint; * fmt * Add jest tests * fmt * ok * add nightly checks * More is_nightly checks * be happy codespell * Make vitest ignore my shit * nightly OR debug; try vitest fixing again * Add this back * fix * Update src/components/LowerRightControls.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/LowerRightControls.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/LowerRightControls.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/BillingDialog.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update tailwind.config.js Co-authored-by: Frank Noirot <frank@zoo.dev> * Update tailwind.config.js Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/BillingRemaining.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/BillingRemaining.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/CustomIcon.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/CustomIcon.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/CustomIcon.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/CustomIcon.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/BillingRemaining.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * Update src/components/BillingRemaining.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * fixes * tid bits * Fix tests * color * Update src/components/BillingRemaining.tsx Co-authored-by: Frank Noirot <frank@zoo.dev> * fix someone else's problem --------- Co-authored-by: Frank Noirot <frank@zoo.dev>
This commit is contained in:
3
jest-component-unit-tests/babel.config.json
Normal file
3
jest-component-unit-tests/babel.config.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"presets": ["babel-preset-vite"]
|
||||
}
|
299
jest-component-unit-tests/billing.test.tsx
Normal file
299
jest-component-unit-tests/billing.test.tsx
Normal file
@ -0,0 +1,299 @@
|
||||
// Test runner
|
||||
import {expect, test, beforeAll, afterEach, afterAll} from '@jest/globals';
|
||||
|
||||
// Render mocking, DOM querying
|
||||
import {act, render, within} from '@testing-library/react'
|
||||
import '@testing-library/jest-dom' // Required for .toHaveStyle
|
||||
|
||||
// Request mocking
|
||||
import {http, HttpResponse} from 'msw'
|
||||
import {setupServer} from 'msw/node'
|
||||
|
||||
// React and XState code to test
|
||||
import { Models } from '@kittycad/lib'
|
||||
import { createActor } from 'xstate'
|
||||
import {
|
||||
BillingRemaining,
|
||||
BillingRemainingMode,
|
||||
} from '@src/components/BillingRemaining'
|
||||
import { BillingDialog, } from '@src/components/BillingDialog'
|
||||
import { BILLING_CONTEXT_DEFAULTS, billingMachine, BillingTransition } from '@src/machines/billingMachine'
|
||||
|
||||
// Setup basic request mocking
|
||||
const server = setupServer()
|
||||
beforeAll(() => server.listen())
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
// Data ripped from docs.zoo.dev
|
||||
const createUserPaymentBalanceResponse = (opts: {
|
||||
monthlyApiCreditsRemaining,
|
||||
stableApiCreditsRemaining,
|
||||
}): Models['CustomerBalance_type'] => ({
|
||||
"created_at": "2025-05-05T16:05:47.317Z",
|
||||
"id": "de607b7e-90ba-4977-8561-16e8a9ea0e50",
|
||||
"map_id": "d7f7de34-9bc3-4b8b-9951-cdee03fc792d",
|
||||
"modeling_app_enterprise_price": {
|
||||
"type": "enterprise"
|
||||
},
|
||||
"monthly_api_credits_remaining": opts.monthlyApiCreditsRemaining,
|
||||
"monthly_api_credits_remaining_monetary_value": "22.47",
|
||||
"stable_api_credits_remaining": opts.stableApiCreditsRemaining,
|
||||
"stable_api_credits_remaining_monetary_value": "18.91",
|
||||
"subscription_details": undefined,
|
||||
"subscription_id": "Hnd3jalJkHA3lb1YexOTStZtPYHTM",
|
||||
"total_due": "100.08",
|
||||
"updated_at": "2025-05-05T16:05:47.317Z"
|
||||
})
|
||||
|
||||
const createOrgResponse = (opts: {
|
||||
}): Models['Org_type'] => ({
|
||||
"allow_users_in_domain_to_auto_join": true,
|
||||
"billing_email": "m@dN9MCH.com",
|
||||
"billing_email_verified": "2025-05-05T18:52:02.021Z",
|
||||
"block": "payment_method_failed",
|
||||
"can_train_on_data": true,
|
||||
"created_at": "2025-05-05T18:52:02.021Z",
|
||||
"domain": "Ctxde1hpG8xTvvlef5SEPm7",
|
||||
"id": "78432284-8660-46bf-ac65-d00bf9b18c3e",
|
||||
"image": "https://Rt0.yK.com/R2SoRtl/tpUdckyDJ",
|
||||
"name": "AevRR4w42KdkA487dh",
|
||||
"phone": "+1-696-641-2790",
|
||||
"stripe_id": "sCfjVscpLyOBYUWO7Vlx",
|
||||
"updated_at": "2025-05-05T18:52:02.021Z"
|
||||
})
|
||||
|
||||
const createUserPaymentSubscriptionsResponse = (opts: {
|
||||
monthlyPayAsYouGoApiCreditsTotal,
|
||||
name,
|
||||
}): Models['ZooProductSubscriptions_type'] => ({
|
||||
"modeling_app": {
|
||||
"annual_discount": 10,
|
||||
"description": "1ztERftrU3L3yOnv5epTLcM",
|
||||
"endpoints_included": [
|
||||
"modeling"
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"info": "zZcZKHejXabT5HMZDkSkDGD2bfzkAt"
|
||||
}
|
||||
],
|
||||
"monthly_pay_as_you_go_api_credits": opts.monthlyPayAsYouGoApiCreditsTotal,
|
||||
"monthly_pay_as_you_go_api_credits_monetary_value": "55.85",
|
||||
"name": opts.name,
|
||||
"pay_as_you_go_api_credit_price": "18.49",
|
||||
"price": {
|
||||
"interval": "year",
|
||||
"price": "50.04",
|
||||
"type": "per_user"
|
||||
},
|
||||
"share_links": [
|
||||
"password_protected"
|
||||
],
|
||||
"support_tier": "community",
|
||||
"training_data_behavior": "default_on",
|
||||
"type": {
|
||||
"saml_sso": true,
|
||||
"type": "organization"
|
||||
},
|
||||
"zoo_tools_included": [
|
||||
"text_to_cad"
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
test('Shows a loading spinner when uninitialized credit count', async () => {
|
||||
server.use(
|
||||
http.get('*/user/payment/balance', (req, res, ctx) => {
|
||||
return HttpResponse.json({})
|
||||
}),
|
||||
http.get('*/user/payment/subscriptions', (req, res, ctx) => {
|
||||
return HttpResponse.json({})
|
||||
}),
|
||||
http.get('*/org', (req, res, ctx) => {
|
||||
return new HttpResponse(403)
|
||||
}),
|
||||
)
|
||||
|
||||
const billingActor = createActor(billingMachine, { input: BILLING_CONTEXT_DEFAULTS }).start()
|
||||
|
||||
const { queryByTestId } = render(<BillingRemaining
|
||||
mode={BillingRemainingMode.ProgressBarFixed}
|
||||
billingActor={billingActor}
|
||||
/>)
|
||||
|
||||
await expect(queryByTestId('spinner')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows the total credits for Unknown subscription', async () => {
|
||||
const data = {
|
||||
balance: {
|
||||
monthlyApiCreditsRemaining: 10,
|
||||
stableApiCreditsRemaining: 25,
|
||||
},
|
||||
subscriptions: {
|
||||
monthlyPayAsYouGoApiCreditsTotal: 20,
|
||||
name: "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
server.use(
|
||||
http.get('*/user/payment/balance', (req, res, ctx) => {
|
||||
return HttpResponse.json(createUserPaymentBalanceResponse(data.balance))
|
||||
}),
|
||||
http.get('*/user/payment/subscriptions', (req, res, ctx) => {
|
||||
return HttpResponse.json(createUserPaymentSubscriptionsResponse(data.subscriptions))
|
||||
}),
|
||||
http.get('*/org', (req, res, ctx) => {
|
||||
return new HttpResponse(403)
|
||||
}),
|
||||
)
|
||||
|
||||
const billingActor = createActor(billingMachine, { input: BILLING_CONTEXT_DEFAULTS }).start()
|
||||
|
||||
const { queryByTestId } = render(<BillingRemaining
|
||||
mode={BillingRemainingMode.ProgressBarFixed}
|
||||
billingActor={billingActor}
|
||||
/>)
|
||||
|
||||
await act(() => {
|
||||
billingActor.send({ type: BillingTransition.Update, apiToken: "it doesn't matter wtf this is :)" })
|
||||
})
|
||||
|
||||
const totalCredits = data.balance.monthlyApiCreditsRemaining + data.balance.stableApiCreditsRemaining
|
||||
await expect(billingActor.getSnapshot().context.credits).toBe(totalCredits)
|
||||
await within(queryByTestId('billing-credits')).getByText(totalCredits)
|
||||
})
|
||||
|
||||
test('Progress bar reflects ratio left of Free subscription', async () => {
|
||||
const data = {
|
||||
balance: {
|
||||
monthlyApiCreditsRemaining: 10,
|
||||
stableApiCreditsRemaining: 0,
|
||||
},
|
||||
subscriptions: {
|
||||
monthlyPayAsYouGoApiCreditsTotal: 20,
|
||||
name: "free",
|
||||
}
|
||||
}
|
||||
|
||||
server.use(
|
||||
http.get('*/user/payment/balance', (req, res, ctx) => {
|
||||
return HttpResponse.json(createUserPaymentBalanceResponse(data.balance))
|
||||
}),
|
||||
http.get('*/user/payment/subscriptions', (req, res, ctx) => {
|
||||
return HttpResponse.json(createUserPaymentSubscriptionsResponse(data.subscriptions))
|
||||
}),
|
||||
http.get('*/org', (req, res, ctx) => {
|
||||
return new HttpResponse(403)
|
||||
}),
|
||||
)
|
||||
|
||||
const billingActor = createActor(billingMachine, { input: BILLING_CONTEXT_DEFAULTS }).start()
|
||||
|
||||
const { queryByTestId } = render(<BillingRemaining
|
||||
mode={BillingRemainingMode.ProgressBarFixed}
|
||||
billingActor={billingActor}
|
||||
/>)
|
||||
|
||||
await act(() => {
|
||||
billingActor.send({ type: BillingTransition.Update, apiToken: "it doesn't matter wtf this is :)" })
|
||||
})
|
||||
|
||||
const totalCredits = data.balance.monthlyApiCreditsRemaining + data.balance.stableApiCreditsRemaining
|
||||
const monthlyCredits = data.subscriptions.monthlyPayAsYouGoApiCreditsTotal
|
||||
const context = billingActor.getSnapshot().context
|
||||
await expect(context.credits).toBe(totalCredits)
|
||||
await expect(context.allowance).toBe(monthlyCredits)
|
||||
|
||||
await within(queryByTestId('billing-credits')).getByText(totalCredits)
|
||||
await expect(queryByTestId('billing-remaining-progress-bar-inner')).toHaveStyle({
|
||||
width: "50.00%"
|
||||
})
|
||||
})
|
||||
test('Shows infinite credits for Pro subscription', async () => {
|
||||
const data = {
|
||||
// These are all ignored
|
||||
balance: {
|
||||
monthlyApiCreditsRemaining: 10,
|
||||
stableApiCreditsRemaining: 0,
|
||||
},
|
||||
subscriptions: {
|
||||
// This should be ignored because it's Pro tier.
|
||||
monthlyPayAsYouGoApiCreditsTotal: 20,
|
||||
name: "pro",
|
||||
}
|
||||
}
|
||||
|
||||
server.use(
|
||||
http.get('*/user/payment/balance', (req, res, ctx) => {
|
||||
return HttpResponse.json(createUserPaymentBalanceResponse(data.balance))
|
||||
}),
|
||||
http.get('*/user/payment/subscriptions', (req, res, ctx) => {
|
||||
return HttpResponse.json(createUserPaymentSubscriptionsResponse(data.subscriptions))
|
||||
}),
|
||||
http.get('*/org', (req, res, ctx) => {
|
||||
return new HttpResponse(403)
|
||||
}),
|
||||
)
|
||||
|
||||
const billingActor = createActor(billingMachine, { input: BILLING_CONTEXT_DEFAULTS }).start()
|
||||
|
||||
const { queryByTestId } = render(<BillingRemaining
|
||||
mode={BillingRemainingMode.ProgressBarFixed}
|
||||
billingActor={billingActor}
|
||||
/>)
|
||||
|
||||
await act(() => {
|
||||
billingActor.send({ type: BillingTransition.Update, apiToken: "aosetuhsatuh" })
|
||||
})
|
||||
|
||||
await expect(queryByTestId('infinity')).toBeVisible()
|
||||
// You can't do `.not.toBeVisible` folks. When the query fails it's because
|
||||
// no element could be found. toBeVisible should be used on an element
|
||||
// that's found but may not be visible due to `display` or others.
|
||||
await expect(queryByTestId('billing-remaining-progress-bar-inline')).toBe(null)
|
||||
})
|
||||
test('Shows infinite credits for Enterprise subscription', async () => {
|
||||
const data = {
|
||||
// These are all ignored, user is part of an org.
|
||||
balance: {
|
||||
monthlyApiCreditsRemaining: 10,
|
||||
stableApiCreditsRemaining: 0,
|
||||
},
|
||||
subscriptions: {
|
||||
// This should be ignored because it's Pro tier.
|
||||
monthlyPayAsYouGoApiCreditsTotal: 20,
|
||||
// This should be ignored because the user is part of an Org.
|
||||
name: "free",
|
||||
}
|
||||
}
|
||||
|
||||
server.use(
|
||||
http.get('*/user/payment/balance', (req, res, ctx) => {
|
||||
return HttpResponse.json(createUserPaymentBalanceResponse(data.balance))
|
||||
}),
|
||||
http.get('*/user/payment/subscriptions', (req, res, ctx) => {
|
||||
return HttpResponse.json(createUserPaymentSubscriptionsResponse(data.subscriptions))
|
||||
}),
|
||||
// Ok finally the first use of an org lol
|
||||
http.get('*/org', (req, res, ctx) => {
|
||||
return HttpResponse.json(createOrgResponse())
|
||||
}),
|
||||
)
|
||||
|
||||
const billingActor = createActor(billingMachine, { input: BILLING_CONTEXT_DEFAULTS }).start()
|
||||
|
||||
const { queryByTestId } = render(<BillingRemaining
|
||||
mode={BillingRemainingMode.ProgressBarFixed}
|
||||
billingActor={billingActor}
|
||||
/>)
|
||||
|
||||
await act(() => {
|
||||
billingActor.send({ type: BillingTransition.Update, apiToken: "aosetuhsatuh" })
|
||||
})
|
||||
|
||||
// The result should be the same as Pro users.
|
||||
await expect(queryByTestId('infinity')).toBeVisible()
|
||||
await expect(queryByTestId('billing-remaining-progress-bar-inline')).toBe(null)
|
||||
})
|
19
jest-component-unit-tests/jest.config.ts
Normal file
19
jest-component-unit-tests/jest.config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { pathsToModuleNameMapper } from 'ts-jest'
|
||||
// In the following statement, replace `./tsconfig` with the path to your `tsconfig` file
|
||||
// which contains the path mapping (ie the `compilerOptions.paths` option):
|
||||
import { compilerOptions } from './tsconfig.json'
|
||||
import type { Config } from 'jest'
|
||||
|
||||
const jestConfig: Config = {
|
||||
// [...]
|
||||
preset: "ts-jest",
|
||||
transform: {
|
||||
"^.+\.tsx?$": ["ts-jest",{ babelConfig: true }],
|
||||
},
|
||||
testEnvironment: "jest-fixed-jsdom",
|
||||
// TAG: paths, path, baseUrl, alias
|
||||
// This is necessary to use tsconfig path aliases.
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/../' }),
|
||||
}
|
||||
|
||||
export default jestConfig
|
35
jest-component-unit-tests/tsconfig.json
Normal file
35
jest-component-unit-tests/tsconfig.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"noErrorTruncation": true,
|
||||
"baseUrl": "../../",
|
||||
"paths": {
|
||||
"@kittycad/codemirror-lsp-client": [
|
||||
"./packages/codemirror-lsp-client/src/index.ts"
|
||||
],
|
||||
"@kittycad/codemirror-lang-kcl": [
|
||||
"./packages/codemirror-lang-kcl/src/index.ts"
|
||||
],
|
||||
"@rust/*": ["./rust/*"],
|
||||
"@e2e/*": ["./e2e/*"],
|
||||
"@src/*": ["./src/*"],
|
||||
"@public/*": ["./public/*"],
|
||||
"@root/*": ["./*"]
|
||||
},
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"composite": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
3442
package-lock.json
generated
3442
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@ -31,7 +31,7 @@
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@headlessui/tailwindcss": "^0.2.2",
|
||||
"@kittycad/lib": "2.0.28",
|
||||
"@kittycad/lib": "^2.0.34",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@lezer/lr": "^1.4.1",
|
||||
"@million/lint": "^1.0.14",
|
||||
@ -133,7 +133,8 @@
|
||||
"test-setup": "npm install && npm run build:wasm",
|
||||
"test": "vitest --mode development",
|
||||
"test:snapshots": "PLATFORM=web NODE_ENV=development playwright test --config=playwright.config.ts --grep=@snapshot --trace=on --shard=1/1",
|
||||
"test:unit": "vitest run --mode development --exclude **/kclSamples.test.ts",
|
||||
"test:unit": "vitest run --mode development --exclude **/kclSamples.test.ts --exclude **/jest-component-unit-tests/*",
|
||||
"test:unit:components": "jest -c jest-component-unit-tests/jest.config.ts --rootDir jest-component-unit-tests/",
|
||||
"test:unit:kcl-samples": "vitest run --mode development ./src/lang/kclSamples.test.ts",
|
||||
"test:playwright:electron": "playwright test --config=playwright.electron.config.ts --grep-invert=@snapshot",
|
||||
"test:playwright:electron:local": "npm run tronb:vite:dev && NODE_ENV=development playwright test --config=playwright.electron.config.ts --grep-invert=@snapshot --grep-invert=\"$(curl --silent https://test-analysis-bot.hawk-dinosaur.ts.net/projects/KittyCAD/modeling-app/tests/disabled/regex)\"",
|
||||
@ -156,6 +157,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@electron-forge/cli": "^7.8.0",
|
||||
"@electron-forge/plugin-fuses": "^7.8.0",
|
||||
@ -166,11 +168,12 @@
|
||||
"@lezer/generator": "^1.7.3",
|
||||
"@nabla/vite-plugin-eslint": "^2.0.5",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@testing-library/jest-dom": "^5.14.1",
|
||||
"@testing-library/react": "^15.0.2",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/electron": "^1.6.10",
|
||||
"@types/isomorphic-fetch": "^0.0.39",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "^22.14.1",
|
||||
@ -188,6 +191,7 @@
|
||||
"@vitest/web-worker": "^3.1.2",
|
||||
"@xstate/cli": "^0.5.17",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-preset-vite": "^1.1.3",
|
||||
"dpdm": "^3.14.0",
|
||||
"electron": "^34.1.1",
|
||||
"electron-builder": "^26.0.12",
|
||||
@ -204,7 +208,11 @@
|
||||
"happy-dom": "^17.4.4",
|
||||
"http-server": "^14.1.1",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fixed-jsdom": "^0.0.9",
|
||||
"kill-port": "^2.0.1",
|
||||
"msw": "^2.7.6",
|
||||
"node-fetch": "^3.3.2",
|
||||
"openapi-typescript": "^7.6.1",
|
||||
"pixelmatch": "^5.3.0",
|
||||
@ -213,6 +221,7 @@
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"setimmediate": "^1.0.5",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"ts-jest": "^29.3.2",
|
||||
"ts-node": "^10.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
|
13
src/App.tsx
13
src/App.tsx
@ -27,12 +27,18 @@ import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
|
||||
import { isDesktop } from '@src/lib/isDesktop'
|
||||
import { PATHS } from '@src/lib/paths'
|
||||
import { takeScreenshotOfVideoStreamCanvas } from '@src/lib/screenshot'
|
||||
import { sceneInfra, codeManager, kclManager } from '@src/lib/singletons'
|
||||
import {
|
||||
billingActor,
|
||||
sceneInfra,
|
||||
codeManager,
|
||||
kclManager,
|
||||
} from '@src/lib/singletons'
|
||||
import { maybeWriteToDisk } from '@src/lib/telemetry'
|
||||
import type { IndexLoaderData } from '@src/lib/types'
|
||||
import { engineStreamActor, useSettings, useToken } from '@src/lib/singletons'
|
||||
import { commandBarActor } from '@src/lib/singletons'
|
||||
import { EngineStreamTransition } from '@src/machines/engineStreamMachine'
|
||||
import { BillingTransition } from '@src/machines/billingMachine'
|
||||
import { CommandBarOpenButton } from '@src/components/CommandBarOpenButton'
|
||||
import { ShareButton } from '@src/components/ShareButton'
|
||||
import {
|
||||
@ -144,6 +150,11 @@ export function App() {
|
||||
}, [lastCommandType, loaderData?.project?.path])
|
||||
|
||||
useEffect(() => {
|
||||
// Not too useful for regular flows but on modeling view refresh,
|
||||
// fetch the token count. The regular flow is the count is initialized
|
||||
// by the Projects view.
|
||||
billingActor.send({ type: BillingTransition.Update, apiToken: authToken })
|
||||
|
||||
// When leaving the modeling scene, cut the engine stream.
|
||||
return () => {
|
||||
engineStreamActor.send({ type: EngineStreamTransition.Pause })
|
||||
|
14
src/Root.tsx
14
src/Root.tsx
@ -8,8 +8,22 @@ import { RouteProvider } from '@src/components/RouteProvider'
|
||||
import { KclContextProvider } from '@src/lang/KclProvider'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { isDesktop } from '@src/lib/isDesktop'
|
||||
import { billingActor, useToken } from '@src/lib/singletons'
|
||||
import { BillingTransition } from '@src/machines/billingMachine'
|
||||
|
||||
// Root component will live for the entire applications runtime
|
||||
// This is a great place to add polling code.
|
||||
function RootLayout() {
|
||||
const apiToken = useToken()
|
||||
|
||||
// Because credits can be spent outside the app, and they also take time to
|
||||
// calculate, we have to poll to have an updated amount.
|
||||
// 5s should be reasonable. 2s for round trip network time and 3s for general
|
||||
// computation...
|
||||
setInterval(() => {
|
||||
billingActor.send({ type: BillingTransition.Update, apiToken })
|
||||
}, 5000)
|
||||
|
||||
return (
|
||||
<OpenInDesktopAppHandler>
|
||||
<RouteProvider>
|
||||
|
54
src/components/BillingDialog.tsx
Normal file
54
src/components/BillingDialog.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { useSelector } from '@xstate/react'
|
||||
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
|
||||
import { CustomIcon } from '@src/components/CustomIcon'
|
||||
import {
|
||||
BillingRemaining,
|
||||
BillingRemainingMode,
|
||||
} from '@src/components/BillingRemaining'
|
||||
|
||||
import type { BillingActor } from '@src/machines/billingMachine'
|
||||
|
||||
export const BillingDialog = (props: { billingActor: BillingActor }) => {
|
||||
const billingContext = useSelector(
|
||||
props.billingActor,
|
||||
({ context }) => context
|
||||
)
|
||||
const hasUnlimited = billingContext.credits === Infinity
|
||||
|
||||
return (
|
||||
<div className="bg-ml-green fg-ml-black flex flex-row rounded-lg p-4 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="rounded bg-ml-black p-1">
|
||||
{hasUnlimited ? (
|
||||
<CustomIcon name="infinity" className="!text-ml-white w-5 h-5" />
|
||||
) : (
|
||||
<CustomIcon name="star" className="!text-ml-white w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="font-bold text-ml-black h-5 py-1">
|
||||
{hasUnlimited ? 'Unlimited Text-to-CAD' : 'Upgrade your plan'}
|
||||
</div>
|
||||
<div className="text-ml-grey">
|
||||
{hasUnlimited
|
||||
? 'You have unlimited use on your paid plan.'
|
||||
: 'for unlimited usage of Text-to-CAD and more!'}
|
||||
</div>
|
||||
<BillingRemaining
|
||||
mode={BillingRemainingMode.ProgressBarStretch}
|
||||
billingActor={props.billingActor}
|
||||
/>
|
||||
<a
|
||||
className="bg-ml-black text-ml-white rounded-lg text-center p-1 cursor-pointer"
|
||||
href="https://zoo.dev/api-pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={openExternalBrowserIfDesktop()}
|
||||
>
|
||||
Upgrade
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
144
src/components/BillingRemaining.tsx
Normal file
144
src/components/BillingRemaining.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { Spinner } from '@src/components/Spinner'
|
||||
import { useSelector } from '@xstate/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { CustomIcon } from '@src/components/CustomIcon'
|
||||
import type { BillingActor } from '@src/machines/billingMachine'
|
||||
|
||||
export enum BillingRemainingMode {
|
||||
ProgressBarFixed,
|
||||
ProgressBarStretch,
|
||||
}
|
||||
|
||||
export interface BillingRemainingProps {
|
||||
mode: BillingRemainingMode
|
||||
billingActor: BillingActor
|
||||
}
|
||||
|
||||
const Error = (props: { error: Error }) => {
|
||||
const [showMessage, setShowMessage] = useState(false)
|
||||
|
||||
const fadedBg = 'rgba(127, 127, 127, 1)'
|
||||
const fadedFg = 'rgba(255, 255, 255, 1)'
|
||||
const colors = {
|
||||
color: fadedFg,
|
||||
stroke: fadedFg,
|
||||
fill: fadedFg,
|
||||
backgroundColor: fadedBg,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setShowMessage(true)}
|
||||
onMouseLeave={() => setShowMessage(false)}
|
||||
>
|
||||
{showMessage && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded p-1"
|
||||
style={{ ...colors, position: 'relative', top: -32 }}
|
||||
>
|
||||
{props.error.toString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded" style={colors}>
|
||||
<CustomIcon name="exclamationMark" className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ProgressBar = (props: { max: number; value: number }) => {
|
||||
const ratio = props.value / props.max
|
||||
|
||||
return (
|
||||
<div className="h-1.5 rounded w-full border-ml-black bg-ml-black border">
|
||||
<div
|
||||
data-testid="billing-remaining-progress-bar-inner"
|
||||
className="bg-ml-green rounded-full"
|
||||
style={{
|
||||
width: Math.min(100, ratio * 100).toFixed(2) + '%',
|
||||
height: '100%',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BillingCredit = (props: { amount: number }) => {
|
||||
return props.amount === Infinity ? (
|
||||
<CustomIcon data-testid="infinity" name="infinity" className="w-5 h-5" />
|
||||
) : Number.isNaN(props.amount) || props.amount === undefined ? (
|
||||
<Spinner className="text-inherit w-4 h-4" />
|
||||
) : (
|
||||
Math.max(0, Math.trunc(props.amount))
|
||||
)
|
||||
}
|
||||
|
||||
export const BillingRemaining = (props: BillingRemainingProps) => {
|
||||
const billingContext = useSelector(
|
||||
props.billingActor,
|
||||
({ context }) => context
|
||||
)
|
||||
|
||||
const isFlex = props.mode === BillingRemainingMode.ProgressBarStretch
|
||||
const cssWrapper = [
|
||||
'bg-ml-green',
|
||||
'select-none',
|
||||
'cursor-pointer',
|
||||
'py-1',
|
||||
'rounded',
|
||||
'!no-underline',
|
||||
'text-xs',
|
||||
'!text-chalkboard-100',
|
||||
'dark:!text-chalkboard-0',
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="billing-remaining"
|
||||
className={[isFlex ? 'flex flex-col gap-1' : 'px-2']
|
||||
.concat(cssWrapper)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
{billingContext.error && <Error error={billingContext.error} />}
|
||||
{!isFlex &&
|
||||
(typeof billingContext.credits === 'number' ? (
|
||||
<div className="font-mono" data-testid="billing-credits">
|
||||
<BillingCredit amount={billingContext.credits} />
|
||||
</div>
|
||||
) : (
|
||||
<Spinner className="text-inherit w-4 h-4" />
|
||||
))}
|
||||
{billingContext.credits !== Infinity && (
|
||||
<div className={[isFlex ? 'flex-grow' : 'w-9'].join(' ')}>
|
||||
<ProgressBar
|
||||
max={billingContext.allowance ?? 1}
|
||||
value={billingContext.credits ?? 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isFlex && (
|
||||
<div className="flex flex-row gap-1">
|
||||
{typeof billingContext.credits === 'number' ? (
|
||||
billingContext.credits !== Infinity ? (
|
||||
<>{billingContext.credits} credits remaining this month</>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
<Spinner className="text-inherit w-4 h-4" />{' '}
|
||||
<span>Fetching remaining credits...</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1394,6 +1394,22 @@ const CustomIconMap = {
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
infinity: (
|
||||
<svg viewBox="0 0 13 6" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M10 0C11.6567 0.000169571 12.9999 1.34331 13 3C13 4.65671 11.6567 5.99983 10 6C9.08871 6 8.27123 5.59359 7.72168 4.95312V4.95215L6.58398 3.64941L5.43262 4.95703L5.43164 4.95605C4.88213 5.59421 4.06758 6 3.1582 6C1.50144 5.99992 0.15825 4.65677 0.158203 3C0.158279 1.34326 1.50146 7.54041e-05 3.1582 0C4.06727 0 4.88213 0.405203 5.43164 1.04297H5.43262L6.58496 2.34961L7.72461 1.04492C8.27414 0.406002 9.08999 0 10 0ZM3.1582 0.857422C1.97485 0.857497 1.0157 1.81664 1.01562 3C1.01567 4.18338 1.97483 5.1425 3.1582 5.14258C3.80891 5.14258 4.3915 4.85326 4.78516 4.39453L4.78906 4.38965L6.01562 3L4.78906 1.61035L4.78516 1.60547C4.3915 1.1468 3.80885 0.857422 3.1582 0.857422ZM10 0.857422C9.34921 0.857422 8.76573 1.14664 8.37207 1.60547L8.37012 1.6084L7.15527 3L8.37012 4.3916L8.37305 4.39453H8.37207C8.76573 4.85333 9.34924 5.14258 10 5.14258C11.1833 5.14241 12.1425 4.18332 12.1426 3C12.1425 1.8167 11.1833 0.857591 10 0.857422Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
star: (
|
||||
<svg viewBox="0 0 14 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M8.93445 5.33667H13.6571L13.951 6.24097L10.1298 9.01636L11.5897 13.5085L10.8202 14.0671L6.99988 11.2908L3.17957 14.0671L2.41003 13.5085L3.86902 9.01636L0.0487061 6.24097L0.342651 5.33667H5.06531L6.52429 0.845459H7.47546L8.93445 5.33667ZM5.90417 5.99097L5.42859 6.33667H1.88074L4.75085 8.42163L4.9325 8.98022L3.83582 12.3533L6.70593 10.2693H7.29382L10.163 12.3533L9.06726 8.98022L9.2489 8.42163L12.119 6.33667H8.57117L8.09558 5.99097L6.99988 2.61792L5.90417 5.99097Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
} as const
|
||||
|
||||
export type CustomIconName = keyof typeof CustomIconMap
|
||||
|
@ -1,4 +1,11 @@
|
||||
import { Link, type NavigateFunction, useLocation } from 'react-router-dom'
|
||||
import { Popover } from '@headlessui/react'
|
||||
import {
|
||||
BillingRemaining,
|
||||
BillingRemainingMode,
|
||||
} from '@src/components/BillingRemaining'
|
||||
import { BillingDialog } from '@src/components/BillingDialog'
|
||||
|
||||
import { CustomIcon } from '@src/components/CustomIcon'
|
||||
import { HelpMenu } from '@src/components/HelpMenu'
|
||||
import { NetworkHealthIndicator } from '@src/components/NetworkHealthIndicator'
|
||||
@ -7,7 +14,13 @@ import Tooltip from '@src/components/Tooltip'
|
||||
import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
|
||||
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
|
||||
import { PATHS } from '@src/lib/paths'
|
||||
import { APP_VERSION, getReleaseUrl } from '@src/routes/utils'
|
||||
import {
|
||||
APP_VERSION,
|
||||
IS_NIGHTLY_OR_DEBUG,
|
||||
getReleaseUrl,
|
||||
} from '@src/routes/utils'
|
||||
|
||||
import { billingActor } from '@src/lib/singletons'
|
||||
|
||||
export function LowerRightControls({
|
||||
children,
|
||||
@ -26,6 +39,22 @@ export function LowerRightControls({
|
||||
<section className="fixed bottom-2 right-2 flex flex-col items-end gap-3 pointer-events-none">
|
||||
{children}
|
||||
<menu className="flex items-center justify-end gap-3 pointer-events-auto">
|
||||
{IS_NIGHTLY_OR_DEBUG && (
|
||||
<Popover className="relative">
|
||||
<Popover.Button className="p-0 !border-transparent">
|
||||
<BillingRemaining
|
||||
mode={BillingRemainingMode.ProgressBarFixed}
|
||||
billingActor={billingActor}
|
||||
/>
|
||||
<Tooltip position="top" contentClassName="text-xs">
|
||||
Text-to-CAD credits
|
||||
</Tooltip>
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="absolute right-0 left-auto bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch rounded-lg shadow-lg text-sm">
|
||||
<BillingDialog billingActor={billingActor} />
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
)}
|
||||
<a
|
||||
onClick={openExternalBrowserIfDesktop(getReleaseUrl())}
|
||||
href={getReleaseUrl()}
|
||||
|
@ -5,7 +5,13 @@ import {
|
||||
joinOSPaths,
|
||||
safeEncodeForRouterPaths,
|
||||
} from '@src/lib/paths'
|
||||
import { systemIOActor, useSettings, useToken } from '@src/lib/singletons'
|
||||
import {
|
||||
billingActor,
|
||||
systemIOActor,
|
||||
useSettings,
|
||||
useToken,
|
||||
} from '@src/lib/singletons'
|
||||
import { BillingTransition } from '@src/machines/billingMachine'
|
||||
import {
|
||||
useHasListedProjects,
|
||||
useProjectDirectoryPath,
|
||||
@ -148,7 +154,11 @@ export function SystemIOMachineLogicListenerDesktop() {
|
||||
token,
|
||||
isProjectNew,
|
||||
settings: { highlightEdges: settings.modeling.highlightEdges.current },
|
||||
}).catch(reportRejection)
|
||||
})
|
||||
.then(() => {
|
||||
billingActor.send({ type: BillingTransition.Update, apiToken: token })
|
||||
})
|
||||
.catch(reportRejection)
|
||||
}, [requestedTextToCadGeneration])
|
||||
}
|
||||
|
||||
|
@ -8,7 +8,8 @@ import { CREATE_FILE_URL_PARAM } from '@src/lib/constants'
|
||||
import { submitAndAwaitTextToKclSystemIO } from '@src/lib/textToCad'
|
||||
import { reportRejection } from '@src/lib/trap'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useSettings, useToken } from '@src/lib/singletons'
|
||||
import { billingActor, useSettings, useToken } from '@src/lib/singletons'
|
||||
import { BillingTransition } from '@src/machines/billingMachine'
|
||||
|
||||
export function SystemIOMachineLogicListenerWeb() {
|
||||
const clearURLParams = useClearURLParams()
|
||||
@ -52,7 +53,11 @@ export function SystemIOMachineLogicListenerWeb() {
|
||||
token,
|
||||
isProjectNew,
|
||||
settings: { highlightEdges: settings.modeling.highlightEdges.current },
|
||||
}).catch(reportRejection)
|
||||
})
|
||||
.then(() => {
|
||||
billingActor.send({ type: BillingTransition.Update, apiToken: token })
|
||||
})
|
||||
.catch(reportRejection)
|
||||
}, [requestedTextToCadGeneration])
|
||||
|
||||
useClearQueryParams()
|
||||
|
@ -28,6 +28,7 @@ describe('UserSidebarMenu tests', () => {
|
||||
last_name: 'User',
|
||||
can_train_on_data: false,
|
||||
is_service_account: false,
|
||||
deletion_scheduled: false,
|
||||
}
|
||||
|
||||
render(
|
||||
@ -66,6 +67,7 @@ describe('UserSidebarMenu tests', () => {
|
||||
name: '',
|
||||
can_train_on_data: false,
|
||||
is_service_account: false,
|
||||
deletion_scheduled: false,
|
||||
}
|
||||
|
||||
render(
|
||||
@ -97,6 +99,7 @@ describe('UserSidebarMenu tests', () => {
|
||||
image: '',
|
||||
can_train_on_data: false,
|
||||
is_service_account: false,
|
||||
deletion_scheduled: false,
|
||||
}
|
||||
|
||||
render(
|
||||
|
@ -1,6 +1,6 @@
|
||||
// env vars were centralised so they could be mocked in jest
|
||||
// but isn't needed anymore with vite, so is now just a convention
|
||||
|
||||
// It turns out import.meta.env is a really fucky env var passing method.
|
||||
// It's purely generated by Vite and nothing else.
|
||||
// For Jest tests, we use babel to deal with it (it's a Syntax error otherwise)
|
||||
// @ts-ignore: TS1343
|
||||
const env = window.electron?.process.env ?? import.meta.env
|
||||
|
||||
|
@ -135,6 +135,8 @@ export const revolveAxisValidator = async ({
|
||||
target: sketchSelection,
|
||||
// Gotcha: Playwright will fail with larger tolerances, need to use a smaller one.
|
||||
tolerance: 1e-7,
|
||||
// WARNING: I'm not sure this is what it should be.
|
||||
opposite: 'None',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { VITE_KC_API_BASE_URL } from '@src/env'
|
||||
|
||||
import EditorManager from '@src/editor/manager'
|
||||
import { KclManager } from '@src/lang/KclSingleton'
|
||||
import CodeManager from '@src/lang/codeManager'
|
||||
@ -16,6 +18,10 @@ import { createActor, setup, assign } from 'xstate'
|
||||
import { isDesktop } from '@src/lib/isDesktop'
|
||||
import { createSettings } from '@src/lib/settings/initialSettings'
|
||||
import { authMachine } from '@src/machines/authMachine'
|
||||
import {
|
||||
BILLING_CONTEXT_DEFAULTS,
|
||||
billingMachine,
|
||||
} from '@src/machines/billingMachine'
|
||||
import {
|
||||
engineStreamContextCreate,
|
||||
engineStreamMachine,
|
||||
@ -110,13 +116,15 @@ if (typeof window !== 'undefined') {
|
||||
},
|
||||
})
|
||||
}
|
||||
const { AUTH, SETTINGS, SYSTEM_IO, ENGINE_STREAM, COMMAND_BAR } = ACTOR_IDS
|
||||
const { AUTH, SETTINGS, SYSTEM_IO, ENGINE_STREAM, COMMAND_BAR, BILLING } =
|
||||
ACTOR_IDS
|
||||
const appMachineActors = {
|
||||
[AUTH]: authMachine,
|
||||
[SETTINGS]: settingsMachine,
|
||||
[SYSTEM_IO]: isDesktop() ? systemIOMachineDesktop : systemIOMachineWeb,
|
||||
[ENGINE_STREAM]: engineStreamMachine,
|
||||
[COMMAND_BAR]: commandBarMachine,
|
||||
[BILLING]: billingMachine,
|
||||
} as const
|
||||
|
||||
const appMachine = setup({
|
||||
@ -169,6 +177,15 @@ const appMachine = setup({
|
||||
commands: [],
|
||||
},
|
||||
}),
|
||||
billingActor: ({ spawn }) =>
|
||||
spawn(BILLING, {
|
||||
id: BILLING,
|
||||
systemId: BILLING,
|
||||
input: {
|
||||
...BILLING_CONTEXT_DEFAULTS,
|
||||
urlUserService: VITE_KC_API_BASE_URL,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
})
|
||||
@ -213,6 +230,8 @@ export const engineStreamActor =
|
||||
|
||||
export const commandBarActor = appActor.getSnapshot().context.commandBarActor!
|
||||
|
||||
export const billingActor = appActor.system.get(BILLING)
|
||||
|
||||
const cmdBarStateSelector = (state: SnapshotFrom<typeof commandBarActor>) =>
|
||||
state
|
||||
export const useCommandBarState = () => {
|
||||
|
@ -10,6 +10,7 @@ import type { settingsMachine } from '@src/machines/settingsMachine'
|
||||
import type { systemIOMachine } from '@src/machines/systemIO/systemIOMachine'
|
||||
import type { ActorRefFrom } from 'xstate'
|
||||
import type { commandBarMachine } from '@src/machines/commandBarMachine'
|
||||
import type { BillingActor } from '@src/machines/billingMachine'
|
||||
|
||||
export type IndexLoaderData = {
|
||||
code: string | null
|
||||
@ -134,4 +135,5 @@ export type AppMachineContext = {
|
||||
systemIOActor?: ActorRefFrom<typeof systemIOMachine>
|
||||
engineStreamActor?: ActorRefFrom<typeof engineStreamMachine>
|
||||
commandBarActor?: ActorRefFrom<typeof commandBarMachine>
|
||||
billingActor?: BillingActor
|
||||
}
|
||||
|
@ -38,6 +38,7 @@ const LOCAL_USER: Models['User_type'] = {
|
||||
last_name: 'User',
|
||||
can_train_on_data: false,
|
||||
is_service_account: false,
|
||||
deletion_scheduled: false,
|
||||
}
|
||||
|
||||
export interface UserContext {
|
||||
|
149
src/machines/billingMachine.ts
Normal file
149
src/machines/billingMachine.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import type { Models } from '@kittycad/lib'
|
||||
import crossPlatformFetch from '@src/lib/crossPlatformFetch'
|
||||
import type { ActorRefFrom } from 'xstate'
|
||||
import { assign, fromPromise, setup } from 'xstate'
|
||||
import { err } from '@src/lib/trap'
|
||||
|
||||
export enum BillingState {
|
||||
Updating = 'updating',
|
||||
Waiting = 'waiting',
|
||||
}
|
||||
|
||||
export enum BillingTransition {
|
||||
Update = 'update',
|
||||
Wait = 'wait',
|
||||
}
|
||||
|
||||
export interface BillingContext {
|
||||
credits: undefined | number
|
||||
allowance: undefined | number
|
||||
error: undefined | Error
|
||||
urlUserService: string
|
||||
}
|
||||
|
||||
export interface BillingUpdateEvent {
|
||||
type: BillingTransition.Update
|
||||
apiToken: string
|
||||
}
|
||||
|
||||
export const BILLING_CONTEXT_DEFAULTS: BillingContext = Object.freeze({
|
||||
credits: undefined,
|
||||
allowance: undefined,
|
||||
error: undefined,
|
||||
urlUserService: '',
|
||||
})
|
||||
|
||||
export const billingMachine = setup({
|
||||
types: {
|
||||
context: {} as BillingContext,
|
||||
input: {} as BillingContext,
|
||||
events: {} as BillingUpdateEvent,
|
||||
},
|
||||
actors: {
|
||||
[BillingTransition.Update]: fromPromise(
|
||||
async ({
|
||||
input,
|
||||
}: { input: { context: BillingContext; event: BillingUpdateEvent } }) => {
|
||||
const billingOrError: Models['CustomerBalance_type'] | number | Error =
|
||||
await crossPlatformFetch(
|
||||
`${input.context.urlUserService}/user/payment/balance`,
|
||||
{ method: 'GET' },
|
||||
input.event.apiToken
|
||||
)
|
||||
|
||||
if (typeof billingOrError === 'number' || err(billingOrError)) {
|
||||
return Promise.reject(billingOrError)
|
||||
}
|
||||
const billing: Models['CustomerBalance_type'] = billingOrError
|
||||
|
||||
const subscriptionsOrError:
|
||||
| Models['ZooProductSubscriptions_type']
|
||||
| number
|
||||
| Error = await crossPlatformFetch(
|
||||
`${input.context.urlUserService}/user/payment/subscriptions`,
|
||||
{ method: 'GET' },
|
||||
input.event.apiToken
|
||||
)
|
||||
|
||||
const orgOrError: Models['Org_type'] | number | Error =
|
||||
await crossPlatformFetch(
|
||||
`${input.context.urlUserService}/org`,
|
||||
{ method: 'GET' },
|
||||
input.event.apiToken
|
||||
)
|
||||
|
||||
let credits =
|
||||
Number(billing.monthly_api_credits_remaining) +
|
||||
Number(billing.stable_api_credits_remaining)
|
||||
let allowance = undefined
|
||||
|
||||
// If user is part of an org, the endpoint will return data.
|
||||
if (typeof orgOrError !== 'number' && !err(orgOrError)) {
|
||||
credits = Infinity
|
||||
// Otherwise they are on a Pro or Free subscription
|
||||
} else if (
|
||||
typeof subscriptionsOrError !== 'number' &&
|
||||
!err(subscriptionsOrError)
|
||||
) {
|
||||
const subscriptions: Models['ZooProductSubscriptions_type'] =
|
||||
subscriptionsOrError
|
||||
if (subscriptions.modeling_app.name === 'pro') {
|
||||
credits = Infinity
|
||||
} else {
|
||||
allowance = Number(
|
||||
subscriptions.modeling_app.monthly_pay_as_you_go_api_credits
|
||||
)
|
||||
}
|
||||
}
|
||||
// If nothing matches, we show a credit total.
|
||||
|
||||
return {
|
||||
error: undefined,
|
||||
credits,
|
||||
allowance,
|
||||
}
|
||||
}
|
||||
),
|
||||
},
|
||||
}).createMachine({
|
||||
initial: BillingState.Waiting,
|
||||
context: (args) => args.input,
|
||||
states: {
|
||||
[BillingState.Waiting]: {
|
||||
on: {
|
||||
[BillingTransition.Update]: {
|
||||
target: BillingState.Updating,
|
||||
},
|
||||
},
|
||||
},
|
||||
[BillingState.Updating]: {
|
||||
invoke: {
|
||||
src: BillingTransition.Update,
|
||||
input: (args: {
|
||||
context: BillingContext
|
||||
event: BillingUpdateEvent
|
||||
}) => ({
|
||||
context: args.context,
|
||||
event: args.event,
|
||||
}),
|
||||
onDone: [
|
||||
{
|
||||
target: BillingState.Waiting,
|
||||
actions: assign(({ event }) => event.output),
|
||||
},
|
||||
],
|
||||
// If request failed for billing, go back into waiting state,
|
||||
// and signal to the user there's an issue regarding the service.
|
||||
onError: [
|
||||
{
|
||||
target: BillingState.Waiting,
|
||||
// Yep, this is hard to follow. XState, why!
|
||||
actions: assign({ error: ({ event }) => event.error as Error }),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export type BillingActor = ActorRefFrom<typeof billingMachine>
|
@ -4,4 +4,5 @@ export const ACTOR_IDS = {
|
||||
SYSTEM_IO: 'systemIO',
|
||||
ENGINE_STREAM: 'engine_stream',
|
||||
COMMAND_BAR: 'command_bar',
|
||||
BILLING: 'billing',
|
||||
} as const
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
|
||||
import type { FormEvent, HTMLProps } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
@ -18,21 +19,30 @@ import {
|
||||
ProjectSearchBar,
|
||||
useProjectSearch,
|
||||
} from '@src/components/ProjectSearchBar'
|
||||
import { BillingDialog } from '@src/components/BillingDialog'
|
||||
import { useCreateFileLinkQuery } from '@src/hooks/useCreateFileLinkQueryWatcher'
|
||||
import { useMenuListener } from '@src/hooks/useMenu'
|
||||
import { isDesktop } from '@src/lib/isDesktop'
|
||||
import { PATHS } from '@src/lib/paths'
|
||||
import { markOnce } from '@src/lib/performance'
|
||||
import type { Project } from '@src/lib/project'
|
||||
import { codeManager, kclManager } from '@src/lib/singletons'
|
||||
import {
|
||||
getNextSearchParams,
|
||||
getSortFunction,
|
||||
getSortIcon,
|
||||
} from '@src/lib/sorting'
|
||||
import { reportRejection } from '@src/lib/trap'
|
||||
import { authActor, systemIOActor, useSettings } from '@src/lib/singletons'
|
||||
import { commandBarActor } from '@src/lib/singletons'
|
||||
import {
|
||||
useToken,
|
||||
commandBarActor,
|
||||
codeManager,
|
||||
kclManager,
|
||||
authActor,
|
||||
billingActor,
|
||||
systemIOActor,
|
||||
useSettings,
|
||||
} from '@src/lib/singletons'
|
||||
import { BillingTransition } from '@src/machines/billingMachine'
|
||||
import {
|
||||
useCanReadWriteProjectDirectory,
|
||||
useFolders,
|
||||
@ -62,12 +72,14 @@ type ReadWriteProjectState = {
|
||||
// as defined in Router.tsx, so we can use the desktop APIs and types.
|
||||
const Home = () => {
|
||||
const readWriteProjectDir = useCanReadWriteProjectDirectory()
|
||||
const apiToken = useToken()
|
||||
|
||||
// Only create the native file menus on desktop
|
||||
useEffect(() => {
|
||||
if (isDesktop()) {
|
||||
window.electron.createHomePageMenu().catch(reportRejection)
|
||||
}
|
||||
billingActor.send({ type: BillingTransition.Update, apiToken })
|
||||
}, [])
|
||||
|
||||
// Keep a lookout for a URL query string that invokes the 'import file from URL' command
|
||||
@ -342,6 +354,13 @@ const Home = () => {
|
||||
</li>
|
||||
</ul>
|
||||
<ul className="flex flex-col">
|
||||
{IS_NIGHTLY_OR_DEBUG && (
|
||||
<li className="contents">
|
||||
<div className="my-2">
|
||||
<BillingDialog billingActor={billingActor} />
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
<li className="contents">
|
||||
<ActionButton
|
||||
Element="externalLink"
|
||||
|
@ -33,6 +33,10 @@ module.exports = {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: `oklch(var(--_primary) / <alpha-value>)`,
|
||||
'ml-green': '#29FFA4',
|
||||
'ml-black': 'var(--chalkboard-100)',
|
||||
'ml-white': '#FFFFFF',
|
||||
'ml-grey': 'var(--chalkboard-80)',
|
||||
...themeColors,
|
||||
},
|
||||
fontFamily: {
|
||||
|
Reference in New Issue
Block a user