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:
Zookeeper Lee
2025-05-06 15:07:22 -04:00
committed by GitHub
parent 7b0ea5078c
commit 8fb1563f2d
24 changed files with 4247 additions and 77 deletions

View File

@ -0,0 +1,3 @@
{
"presets": ["babel-preset-vite"]
}

View 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)
})

View 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

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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 })

View File

@ -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>

View 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>
)
}

View 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>
)
}

View File

@ -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

View File

@ -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()}

View File

@ -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])
}

View File

@ -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()

View File

@ -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(

View File

@ -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

View File

@ -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',
},
})
}

View File

@ -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 = () => {

View File

@ -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
}

View File

@ -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 {

View 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>

View File

@ -4,4 +4,5 @@ export const ACTOR_IDS = {
SYSTEM_IO: 'systemIO',
ENGINE_STREAM: 'engine_stream',
COMMAND_BAR: 'command_bar',
BILLING: 'billing',
} as const

View File

@ -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"

View File

@ -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: {