Skip to content
This repository was archived by the owner on Jan 16, 2026. It is now read-only.

Commit b6e925d

Browse files
Add billing counter component to sidebar (#234)
* WIP: Add billing counter component to sidebar Fixes #233 * Clean up and add ref * Add `ml-*` colors to tailwind.config.js to match ZDS * Use className prop to remove border radius * Override limited state component text with TTC-specific messaging * First pass at hooking up real data * Add @kittycad/react-shared 0.1.4 * Move logic to billing.ts file * Clean up and TODO * Fix lint * Clean up --------- Co-authored-by: Frank Noirot <frankjohnson1993@gmail.com>
1 parent 96daa53 commit b6e925d

File tree

8 files changed

+1419
-380
lines changed

8 files changed

+1419
-380
lines changed

package-lock.json

Lines changed: 1208 additions & 375 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,18 @@
5151
"type": "module",
5252
"dependencies": {
5353
"@kittycad/lib": "2.0.40",
54+
"@kittycad/react-shared": "^0.1.4",
5455
"@threlte/core": "^6.1.0",
5556
"@threlte/extras": "^7.3.0",
5657
"@types/core-js": "^2.5.8",
58+
"@types/react": "^18.3.17",
59+
"@types/react-dom": "^18.3.5",
5760
"@vercel/analytics": "^1.1.1",
5861
"@vercel/speed-insights": "^1.0.2",
5962
"core-js-pure": "^3.35.0",
6063
"object.groupby": "^1.0.1",
64+
"react": "^18.3.1",
65+
"react-dom": "^18.3.1",
6166
"svelte-autosize": "^1.1.0",
6267
"three": "^0.160.0"
6368
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script lang="ts">
2+
import type { BillingDialogProps } from '@kittycad/react-shared'
3+
import { createElement } from 'react'
4+
import { createRoot, type Root } from 'react-dom/client'
5+
import { onDestroy, onMount } from 'svelte'
6+
7+
// TODO: Figure out what we do with this boilerplate, or go with a lib
8+
// got this from:
9+
// - https://joyofcode.xyz/using-react-libraries-in-svelte
10+
// - https://pandemicode.dev/using-react-within-your-svelte-applications-3b1f2a75aefc
11+
let htmlElement: HTMLElement
12+
let reactRoot: Root
13+
14+
onMount(() => {
15+
const props = $$props as BillingDialogProps
16+
try {
17+
reactRoot = createRoot(htmlElement)
18+
import('@kittycad/react-shared').then(({ BillingDialog }) => {
19+
const element = createElement(BillingDialog, { ...props })
20+
reactRoot.render(element)
21+
})
22+
} catch (err) {
23+
console.log('Failed to mount', { err })
24+
}
25+
})
26+
27+
onDestroy(() => {
28+
try {
29+
if (reactRoot) {
30+
reactRoot.unmount()
31+
}
32+
} catch (err) {
33+
console.log('Failed to destroy', { err })
34+
}
35+
})
36+
</script>
37+
38+
<div bind:this={htmlElement} class="root-el" />

src/components/InfiniteScroll.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@
5656
if (component || elementScroll) {
5757
const element = elementScroll ? elementScroll : component.parentNode
5858
59-
element?.removeEventListener('scroll', null)
60-
element?.removeEventListener('resize', null)
59+
element?.removeEventListener('scroll', onScroll)
60+
element?.removeEventListener('resize', onScroll)
6161
}
6262
})
6363
</script>

src/components/Sidebar.svelte

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
import Close from 'components/Icons/Close.svelte'
33
import Sidebar from 'components/Icons/Sidebar.svelte'
44
import GenerationList from 'components/GenerationList.svelte'
5+
import BillingDialog from './BillingDialog.svelte'
56
import { page } from '$app/stores'
7+
import { env } from '$lib/env'
68
79
export let className = ''
10+
export let credits: number | undefined
11+
export let allowance: number | undefined
812
let isSidebarOpen = false
913
1014
// Close the sidebar on navigation
@@ -34,6 +38,18 @@
3438
<div class="flex-auto overflow-hidden border-b border-chalkboard-30 dark:border-chalkboard-90">
3539
<GenerationList />
3640
</div>
41+
<div>
42+
<BillingDialog
43+
upgradeHref={env.VITE_SITE_BASE_URL + '/design-studio-pricing'}
44+
{credits}
45+
{allowance}
46+
className="rounded-none"
47+
text={{
48+
heading: { limited: 'Get more Text-to-CAD credits' },
49+
paragraph: { limited: 'Upgrade your plan, starting at $20!' }
50+
}}
51+
/>
52+
</div>
3753
</div>
3854
</nav>
3955

src/lib/billing.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { Models } from '@kittycad/lib/types'
2+
import { env } from '$lib/env'
3+
4+
// Stolen from modeling-app, need to see what we do with those
5+
enum Tier {
6+
Free = 'free',
7+
Pro = 'pro',
8+
Organization = 'organization',
9+
Unknown = 'unknown'
10+
}
11+
12+
type OrgOrError = Models['Org_type'] | number | Error
13+
type SubscriptionsOrError = Models['ZooProductSubscriptions_type'] | number | Error
14+
type TierBasedOn = {
15+
orgOrError: OrgOrError
16+
subscriptionsOrError: SubscriptionsOrError
17+
}
18+
19+
type ExcludeErr<T> = Exclude<T, Error>
20+
export function isErr<T>(value: ExcludeErr<T> | Error): value is Error {
21+
return value instanceof Error
22+
}
23+
24+
const toTierFrom = (args: TierBasedOn): Tier => {
25+
if (typeof args.orgOrError !== 'number' && !('error_code' in args.orgOrError)) {
26+
return Tier.Organization
27+
} else if (typeof args.subscriptionsOrError !== 'number' && !isErr(args.subscriptionsOrError)) {
28+
const subscriptions: Models['ZooProductSubscriptions_type'] = args.subscriptionsOrError
29+
if (subscriptions.modeling_app.name === 'pro') {
30+
return Tier.Pro
31+
} else {
32+
return Tier.Free
33+
}
34+
}
35+
36+
return Tier.Unknown
37+
}
38+
39+
/**
40+
* Copied logic from https://github.com/KittyCAD/modeling-app/blob/49d40f28b703505743f90948a38ede929d4f28e0/src/machines/billingMachine.ts#L91
41+
*/
42+
export async function getBillingInfo(token: string) {
43+
const billingOrError: Models['CustomerBalance_type'] | number | Error = await fetch(
44+
`${env.VITE_API_BASE_URL}/user/payment/balance`,
45+
{
46+
method: 'GET',
47+
headers: {
48+
'Content-Type': 'application/json',
49+
Authorization: `Bearer ${token}`
50+
}
51+
}
52+
)
53+
.then((res) => res.json())
54+
.catch((e) => {
55+
console.error('Error fetching balance:', e)
56+
})
57+
58+
if (typeof billingOrError === 'number') {
59+
return new Error('Received error code: ' + billingOrError)
60+
}
61+
62+
if (isErr(billingOrError)) {
63+
return billingOrError
64+
}
65+
66+
const billing: Models['CustomerBalance_type'] = billingOrError
67+
68+
const subscriptionsOrError: Models['ZooProductSubscriptions_type'] | number | Error = await fetch(
69+
`${env.VITE_API_BASE_URL}/user/payment/subscriptions`,
70+
{
71+
method: 'GET',
72+
headers: {
73+
'Content-Type': 'application/json',
74+
Authorization: `Bearer ${token}`
75+
}
76+
}
77+
)
78+
.then((res) => res.json())
79+
.catch((e) => {
80+
console.error('Error fetching subscriptions:', e)
81+
})
82+
83+
if (typeof subscriptionsOrError === 'number') {
84+
return new Error('Received error code: ' + subscriptionsOrError)
85+
}
86+
87+
if (isErr(subscriptionsOrError)) {
88+
return subscriptionsOrError
89+
}
90+
91+
const orgOrError: Models['Org_type'] | number | Error = await fetch(
92+
`${env.VITE_API_BASE_URL}/org`,
93+
{
94+
method: 'GET',
95+
headers: {
96+
'Content-Type': 'application/json',
97+
Authorization: `Bearer ${token}`
98+
}
99+
}
100+
)
101+
.then((res) => res.json())
102+
.catch((e) => {
103+
console.error('Error fetching org:', e)
104+
})
105+
106+
const tier = toTierFrom({
107+
orgOrError,
108+
subscriptionsOrError
109+
})
110+
111+
let credits =
112+
Number(billing.monthly_api_credits_remaining) + Number(billing.stable_api_credits_remaining)
113+
let allowance = undefined
114+
115+
switch (tier) {
116+
case Tier.Organization:
117+
case Tier.Pro:
118+
credits = Infinity
119+
break
120+
case Tier.Free:
121+
// TS too dumb Tier.Free has the same logic
122+
if (typeof subscriptionsOrError !== 'number' && !isErr(subscriptionsOrError)) {
123+
allowance = Number(subscriptionsOrError.modeling_app.monthly_pay_as_you_go_api_credits)
124+
}
125+
break
126+
case Tier.Unknown:
127+
break
128+
}
129+
130+
return {
131+
tier,
132+
credits,
133+
allowance
134+
}
135+
}

src/routes/(sidebarLayout)/+layout.server.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { hooksUserMocks, isUserMock } from '$lib/mocks.js'
44
import { SIGN_OUT_PARAM } from '$lib/paths.js'
55
import { redirect } from '@sveltejs/kit'
66
import { env } from '$lib/env'
7+
import { getBillingInfo, isErr } from '$lib/billing'
78

89
export const load = async ({ cookies, request, url, fetch }) => {
910
if (url.searchParams.get(SIGN_OUT_PARAM)) {
@@ -46,10 +47,21 @@ export const load = async ({ cookies, request, url, fetch }) => {
4647
}
4748
}
4849

49-
// Return the user and token
50+
const billing = await getBillingInfo(token)
51+
if (isErr(billing)) {
52+
console.error('Error fetching billing info:', billing)
53+
return {
54+
user: currentUser,
55+
token: token
56+
}
57+
}
58+
5059
return {
5160
user: currentUser,
52-
token: token
61+
token: token,
62+
tier: billing.tier,
63+
credits: billing.credits,
64+
allowance: billing.allowance
5365
}
5466
}
5567

src/routes/(sidebarLayout)/+layout.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<div class="h-screen overflow-hidden flex flex-col" style="height: 100dvh;">
1010
<AppHeader user={data ? data.user : undefined} />
1111
<div class="pane-layout">
12-
<Sidebar className="md:w-80" />
12+
<Sidebar className="md:w-80" credits={data.credits} allowance={data.allowance} />
1313
<main>
1414
<div class="main-content">
1515
<slot />

0 commit comments

Comments
 (0)