Skip to content

Commit 1f1f4f0

Browse files
authored
Merge pull request #130 from fells-code/docs/identity-cost-calculator
docs: add an identity cost calculator
2 parents d262dbc + 1002a0a commit 1f1f4f0

5 files changed

Lines changed: 793 additions & 6 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
---
2+
// The static tables are the no-JavaScript version of the calculator, so they print every
3+
// published price in pricing.ts and nothing the script could not also show.
4+
import {
5+
type Auth0Plan,
6+
auth0MauSteps,
7+
auth0Plans,
8+
okta,
9+
selfHost,
10+
selfHostMonthlyUsd,
11+
sources,
12+
usd,
13+
} from './pricing'
14+
15+
const plansFor = (useCase: 'b2c' | 'b2b') => auth0Plans.filter((plan) => plan.useCase === useCase)
16+
17+
type Cell = { text: string; salesUrl?: string }
18+
19+
const cell = (plan: Auth0Plan, mau: number): Cell => {
20+
const tier = plan.tiers.find((t) => mau <= t.upToMau)
21+
if (tier) return { text: usd(tier.monthlyUsd) }
22+
if (plan.aboveTiers === 'unavailable') return { text: 'Not on plan' }
23+
return { text: 'Contact sales', salesUrl: sources.auth0Sales.url }
24+
}
25+
26+
const steps = [
27+
...auth0MauSteps.map((mau) => ({ label: mau.toLocaleString('en-US'), mau })),
28+
{ label: 'Above 30,000', mau: Number.POSITIVE_INFINITY },
29+
]
30+
31+
const selfHostSummary = `${usd(selfHostMonthlyUsd)} per month at any MAU (${selfHost.provider} ${usd(
32+
selfHost.app.monthlyUsd,
33+
)} Droplet plus ${usd(selfHost.database.monthlyUsd)} managed PostgreSQL, checked ${
34+
selfHost.app.source.checked
35+
})`
36+
---
37+
38+
<div class="cost-calc not-content">
39+
<form class="cost-calc-form" data-cost-calc hidden>
40+
<label>
41+
<span>Monthly active users</span>
42+
<input name="mau" type="number" min="1" step="1" value="5000" inputmode="numeric" required />
43+
</label>
44+
<fieldset>
45+
<legend>Use case</legend>
46+
<label><input type="radio" name="useCase" value="b2c" checked /> B2C</label>
47+
<label><input type="radio" name="useCase" value="b2b" /> B2B</label>
48+
</fieldset>
49+
<label>
50+
<span>Enterprise connections (SAML, AD/LDAP, and similar)</span>
51+
<input
52+
name="enterpriseConnections"
53+
type="number"
54+
min="0"
55+
step="1"
56+
value="0"
57+
inputmode="numeric"
58+
/>
59+
</label>
60+
<label>
61+
<span>MFA</span>
62+
<select name="mfa">
63+
<option value="none">None</option>
64+
<option value="otp">Authenticator app (OTP) or Duo</option>
65+
<option value="enterprise">SMS, voice, email, push, or WebAuthn keys</option>
66+
</select>
67+
</label>
68+
</form>
69+
70+
<div class="cost-calc-results" data-cost-calc-results aria-live="polite" hidden></div>
71+
72+
<div data-cost-calc-static>
73+
<p>
74+
Auth0 list prices per month, monthly billing, from <a href={sources.auth0.url}
75+
>auth0.com/pricing</a
76+
> (checked {sources.auth0.checked}). A MAU count between two steps uses the next step up.
77+
</p>
78+
{
79+
(['b2c', 'b2b'] as const).map((useCase) => (
80+
<table>
81+
<caption>Auth0 {useCase === 'b2c' ? 'B2C' : 'B2B'} plans</caption>
82+
<thead>
83+
<tr>
84+
<th scope="col">MAU up to</th>
85+
{plansFor(useCase).map((plan) => (
86+
<th scope="col">{plan.name}</th>
87+
))}
88+
<th scope="col">Enterprise</th>
89+
</tr>
90+
</thead>
91+
<tbody>
92+
{steps.map(({ label, mau }) => (
93+
<tr>
94+
<th scope="row">{label}</th>
95+
{plansFor(useCase).map((plan) => {
96+
const { text, salesUrl } = cell(plan, mau)
97+
return <td>{salesUrl ? <a href={salesUrl}>{text}</a> : text}</td>
98+
})}
99+
<td>
100+
<a href={sources.auth0Sales.url}>Contact sales</a>
101+
</td>
102+
</tr>
103+
))}
104+
</tbody>
105+
</table>
106+
))
107+
}
108+
<p>
109+
Okta Customer Identity, from <a href={sources.okta.url}>okta.com/pricing</a> (checked {
110+
sources.okta.checked
111+
}): the Enterprise base platform starts at {usd(okta.basePlatformFromUsd)} per month, {
112+
okta.billing
113+
}, with usage-priced add-ons quoted by sales. The B2C Suite and B2B Suite are <a
114+
href={sources.oktaSales.url}>contact sales</a
115+
>.
116+
</p>
117+
<p>Seamless Auth self-hosted: {selfHostSummary}.</p>
118+
</div>
119+
</div>
120+
121+
<script>
122+
import { calculate, type Inputs, type Mfa, type Row, usd, type UseCase } from './pricing'
123+
124+
const form = document.querySelector<HTMLFormElement>('[data-cost-calc]')
125+
const results = document.querySelector<HTMLElement>('[data-cost-calc-results]')
126+
127+
const priceText = (row: Row): string => {
128+
switch (row.quote.kind) {
129+
case 'price':
130+
return `${usd(row.quote.usd)} / month`
131+
case 'from':
132+
return `From ${usd(row.quote.usd)} / month`
133+
case 'contact':
134+
return 'Contact sales'
135+
case 'unavailable':
136+
return 'Not on this plan'
137+
}
138+
}
139+
140+
const readInputs = (data: FormData): Inputs => {
141+
const whole = (value: FormDataEntryValue | null, min: number) =>
142+
Math.max(min, Math.floor(Number(value) || 0))
143+
return {
144+
mau: whole(data.get('mau'), 1),
145+
useCase: (data.get('useCase') === 'b2b' ? 'b2b' : 'b2c') as UseCase,
146+
enterpriseConnections: whole(data.get('enterpriseConnections'), 0),
147+
mfa: (['none', 'otp', 'enterprise'].includes(String(data.get('mfa')))
148+
? data.get('mfa')
149+
: 'none') as Mfa,
150+
}
151+
}
152+
153+
const render = () => {
154+
if (!form || !results) return
155+
const rows = calculate(readInputs(new FormData(form)))
156+
const table = document.createElement('table')
157+
table.innerHTML =
158+
'<caption>Monthly list price for these inputs</caption>' +
159+
'<thead><tr><th scope="col">Vendor</th><th scope="col">Plan</th>' +
160+
'<th scope="col">Price</th><th scope="col">Basis</th></tr></thead>'
161+
const body = document.createElement('tbody')
162+
for (const row of rows) {
163+
const tr = document.createElement('tr')
164+
tr.dataset.kind = row.quote.kind
165+
const vendor = document.createElement('td')
166+
vendor.textContent = row.vendor
167+
const plan = document.createElement('td')
168+
plan.textContent = row.plan
169+
const price = document.createElement('td')
170+
if (row.quote.kind === 'contact' && row.salesUrl) {
171+
const link = document.createElement('a')
172+
link.href = row.salesUrl
173+
link.textContent = priceText(row)
174+
price.append(link)
175+
} else {
176+
price.textContent = priceText(row)
177+
}
178+
const notes = document.createElement('td')
179+
notes.textContent = row.quote.notes.join(' ')
180+
tr.append(vendor, plan, price, notes)
181+
body.append(tr)
182+
}
183+
table.append(body)
184+
results.replaceChildren(table)
185+
}
186+
187+
if (form && results) {
188+
form.hidden = false
189+
results.hidden = false
190+
form.addEventListener('input', render)
191+
form.addEventListener('submit', (event) => event.preventDefault())
192+
render()
193+
}
194+
</script>
195+
196+
<style>
197+
.cost-calc {
198+
margin-block: 1.5rem;
199+
}
200+
.cost-calc-form {
201+
display: grid;
202+
gap: 1rem;
203+
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
204+
padding: 1rem;
205+
border: 1px solid var(--sl-color-gray-5);
206+
border-radius: 0.5rem;
207+
background: var(--sl-color-bg-nav);
208+
}
209+
.cost-calc-form[hidden],
210+
.cost-calc-results[hidden] {
211+
display: none;
212+
}
213+
.cost-calc-form label,
214+
.cost-calc-form fieldset {
215+
display: flex;
216+
flex-direction: column;
217+
gap: 0.35rem;
218+
font-size: var(--sl-text-sm);
219+
color: var(--sl-color-gray-2);
220+
}
221+
.cost-calc-form fieldset {
222+
border: 0;
223+
margin: 0;
224+
padding: 0;
225+
}
226+
.cost-calc-form fieldset label {
227+
flex-direction: row;
228+
align-items: center;
229+
gap: 0.4rem;
230+
}
231+
.cost-calc-form input[type='number'],
232+
.cost-calc-form select {
233+
padding: 0.4rem 0.5rem;
234+
border: 1px solid var(--sl-color-gray-4);
235+
border-radius: 0.25rem;
236+
background: var(--sl-color-black);
237+
color: var(--sl-color-white);
238+
font: inherit;
239+
}
240+
.cost-calc :global(table) {
241+
width: 100%;
242+
margin-block: 1rem;
243+
border-collapse: collapse;
244+
font-size: var(--sl-text-sm);
245+
}
246+
.cost-calc :global(caption) {
247+
text-align: start;
248+
font-weight: 600;
249+
color: var(--sl-color-white);
250+
padding-block-end: 0.5rem;
251+
}
252+
.cost-calc :global(th),
253+
.cost-calc :global(td) {
254+
padding: 0.45rem 0.6rem;
255+
border-bottom: 1px solid var(--sl-color-gray-5);
256+
text-align: start;
257+
vertical-align: top;
258+
}
259+
.cost-calc :global(a) {
260+
color: var(--sl-color-text-accent);
261+
}
262+
.cost-calc p {
263+
font-size: var(--sl-text-sm);
264+
margin-block: 0.75rem;
265+
}
266+
.cost-calc :global([data-kind='unavailable']) td:nth-child(3) {
267+
color: var(--sl-color-gray-3);
268+
}
269+
.cost-calc-results :global(tr:last-child td) {
270+
font-weight: 600;
271+
}
272+
.cost-calc-results {
273+
overflow-x: auto;
274+
}
275+
[data-cost-calc-static] {
276+
overflow-x: auto;
277+
}
278+
</style>

0 commit comments

Comments
 (0)