@soba-so/react 0.0.10 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * and the code disagree about what a thing is called.
7
7
  */
8
8
  export type MachineState = "online" | "busy" | "offline";
9
- export type CostClass = "user-hardware" | "user-subscription" | "open-weights" | "frontier";
9
+ export type CostClass = "user-hardware" | "user-subscription" | "user-api-key" | "open-weights" | "frontier";
10
10
  export interface Runtime {
11
11
  id: string;
12
12
  display_name: string;
@@ -97,6 +97,9 @@ export interface Plan {
97
97
  interval: string;
98
98
  unit: string;
99
99
  included_units: number | null;
100
+ /** What happens past the allowance: `stop` or `meter`. The difference
101
+ * between a wall and a bill, and not guessable from the price. */
102
+ overage: string;
100
103
  /** Dollars per unit past the allowance. */
101
104
  overage_per_unit: number;
102
105
  trial_days: number;
@@ -106,6 +109,18 @@ export interface Plan {
106
109
  route_allow: string[] | null;
107
110
  /** Whether it can be taken without a payment rail. */
108
111
  selectable: boolean;
112
+ /**
113
+ * WHAT CONNECTING THEIR OWN COMPUTE EARNS THEM.
114
+ *
115
+ * `none` | `unlimited` | `included` | `credit`. The one dimension no other
116
+ * pricing component has a column for, and the reason a free plan on this
117
+ * product can be worth choosing.
118
+ */
119
+ connected_reward: string;
120
+ /** The larger allowance an `included` reward grants. Null otherwise. */
121
+ connected_included_units: number | null;
122
+ /** Dollars back per interval on a `credit` reward. Zero otherwise. */
123
+ connected_credit: number;
109
124
  }
110
125
  export interface CurrentPlan {
111
126
  plan_id: string;
@@ -0,0 +1,98 @@
1
+ import type { CurrentPlan, Plan } from "./types.js";
2
+ /**
3
+ * THE ALLOWANCE, WORKED OUT ONCE, FOR WHOEVER IS DRAWING IT.
4
+ *
5
+ * `<SobaCurrentPlan />` is one rendering of this. The arithmetic is the part
6
+ * apps get wrong, and it is worth saying exactly why: it lives across two
7
+ * objects and four fields that disagree about what zero means.
8
+ *
9
+ * - `included_units: null` is unlimited. `0` is not. A falsy check reads them
10
+ * identically, which is how an unlimited plan starts advertising no
11
+ * allowance at all.
12
+ * - Past the allowance is a BILL on `meter` and a WALL on `stop`, and nothing
13
+ * in the price says which.
14
+ * - `connected_reward: "included"` RAISES the ceiling to
15
+ * `connected_included_units` while a machine is attached, awake or not,
16
+ * because a larger allowance is a promise about the month.
17
+ * - `connected_reward: "unlimited"` raises nothing. It takes the runs their own
18
+ * AI SERVES out of the count, so the ceiling stays the plan's and the number
19
+ * simply stops rising for those runs.
20
+ * - `credit` touches neither: it settles at renewal.
21
+ *
22
+ * Those last three mirror `allowanceFor` in `@soba-platform/core`, which is the
23
+ * rule the gateway actually enforces — so a card drawn from this and the run it
24
+ * describes cannot disagree about what is left.
25
+ */
26
+ export type AllowanceState =
27
+ /** No plan is assigned, so there is no ceiling of this kind at all. */
28
+ "none"
29
+ /** A trial clock is running, which outranks every other thing to say. */
30
+ | "trial"
31
+ /** No ceiling: an unlimited plan. */
32
+ | "unlimited"
33
+ /** Inside the allowance. */
34
+ | "within"
35
+ /** Past it, and being billed. A bill is not a wall. */
36
+ | "metered"
37
+ /** Past it, and stopped until it resets. */
38
+ | "spent";
39
+ export interface CurrentPlanReward {
40
+ line: string;
41
+ /** The reward is live right now, because something is attached. */
42
+ inForce: boolean;
43
+ /** Not a reward at all: this plan cannot run until something is connected,
44
+ * and nothing is. The state `connected_reward` cannot express. */
45
+ required: boolean;
46
+ /**
47
+ * Something is attached right now, whatever it is doing.
48
+ *
49
+ * Not the same question as `inForce`: a `credit` plan with a laptop paired
50
+ * has a machine and no reward running yet, and a card that reads only the
51
+ * reward would draw that account as though it owned no computer. This is
52
+ * what decides whether the control beside the line ASKS for a machine or
53
+ * NAMES the one there is.
54
+ */
55
+ connected: boolean;
56
+ /**
57
+ * Whether there is anything to press about it.
58
+ *
59
+ * True only while connecting would CHANGE something: a cap this plan would
60
+ * lift, or a requirement it has not met, with nothing attached. False once
61
+ * something is — repeating an offer to somebody who took it is the screen
62
+ * telling them it did not work — and false for a credit, which settles at
63
+ * renewal and cannot be acted on today.
64
+ */
65
+ actionable: boolean;
66
+ /** Raise the connect flow. The same one flow the provider mounts, so a
67
+ * billing page with this and a pricing grid cannot put two on screen. */
68
+ open(): void;
69
+ }
70
+ export interface CurrentPlanView {
71
+ plan: Plan | null;
72
+ assignment: CurrentPlan | null;
73
+ loading: boolean;
74
+ error: Error | null;
75
+ state: AllowanceState;
76
+ /** Whole days, rounded up, because half a day left is a day left to whoever
77
+ * is reading — and zero days left while the trial runs is a lie. */
78
+ trialDaysLeft: number | null;
79
+ /** The ceiling that actually applies, raised where an `included` reward is in
80
+ * force. Null is unlimited. */
81
+ ceiling: number | null;
82
+ /** True when `ceiling` is larger than the plan's own because of a machine. */
83
+ raised: boolean;
84
+ /** Runs their own AI serves are not counted against the ceiling at all. */
85
+ uncounted: boolean;
86
+ used: number;
87
+ /** Against `ceiling`, floored at zero. Null when unlimited. */
88
+ remaining: number | null;
89
+ /** 0–100 against `ceiling`, or null when there is nothing to be a share of. */
90
+ percent: number | null;
91
+ unlimited: boolean;
92
+ periodEnd: Date | null;
93
+ reward: CurrentPlanReward | null;
94
+ /** One sentence, already chosen from the state above. The thing a billing
95
+ * page has to get right and the reason this hook exists. */
96
+ summary: string;
97
+ }
98
+ export declare function useCurrentPlan(): CurrentPlanView;
@@ -0,0 +1,32 @@
1
+ import { type Launch } from "@soba-so/connect-ui";
2
+ /**
3
+ * THE PRESS THAT PUTS SOBA CONNECT IN FRONT OF SOMEBODY, and the one honest
4
+ * thing a page can say about it afterwards.
5
+ *
6
+ * Two surfaces need this and they draw it differently — the nudge above a
7
+ * composer puts the line under its detail, the wall puts it under its actions —
8
+ * so this is the behaviour without the markup, the same split `useRunLimit` and
9
+ * `<RunLimit />` already have.
10
+ *
11
+ * WHY THERE IS A NOTE AT ALL. No browser will tell a page whether a scheme
12
+ * handler exists; the only observable consequence of one is that something else
13
+ * takes the foreground, which is what `showSobaConnect` watches for. So a press
14
+ * can be followed by nothing happening, and a button that can do nothing
15
+ * without saying so is a button people press twice and then give up on.
16
+ *
17
+ * AND WHY IT SAYS SO LITTLE. `absent` means less here than it does in the
18
+ * connect flow: the machine that went quiet is very often NOT the computer this
19
+ * browser is on — a phone, a work laptop, the desktop in another room — and on
20
+ * that one there is nothing to open and nothing wrong. So the note reports what
21
+ * was seen and names the likeliest reason, and never concludes that the app is
22
+ * missing. `unknown` says nothing: the tab was already in the background, so
23
+ * the signal means nothing and inventing a sentence from it would be worse than
24
+ * the silence.
25
+ */
26
+ export declare function useOpenSobaConnect(): {
27
+ open: () => void;
28
+ /** What the attempt looked like, or null before one. */
29
+ launch: Launch | null;
30
+ /** The one line worth showing, or null when there is nothing honest to say. */
31
+ note: string | null;
32
+ };
@@ -0,0 +1,115 @@
1
+ import type { Plan } from "./types.js";
2
+ /**
3
+ * THE PRICE LIST AS A DECISION, NOT AS MARKUP.
4
+ *
5
+ * `<SobaPlans />` is one rendering of this, and it is the rendering that will
6
+ * never match the cards already on a customer's page. Everything that is
7
+ * genuinely OURS about a pricing surface lives here instead — and it is more
8
+ * than it looks, which is why every app that drew its own got at least one of
9
+ * these wrong:
10
+ *
11
+ * - **Free is selected, priced is checked out.** `select` answers 402 for
12
+ * anything with a price, so calling the wrong one is an error either way.
13
+ * - **The connect row has two reasons.** A plan that REWARDS connecting, and a
14
+ * plan that REQUIRES it — the second carries `connected_reward: "none"` and
15
+ * is invisible to anyone reading the reward alone, which is how the one card
16
+ * where a machine is mandatory ends up with no way to attach one.
17
+ * - **A credit is never a button.** It is settled at renewal against the share
18
+ * of the period their own compute served; there is nothing to press today.
19
+ * - **Which control is loud.** On a plan that is already theirs with nothing
20
+ * connected, the connect button is the only thing on the card that does
21
+ * anything, so it is the primary one and the plan's own control is not.
22
+ * - **Who pays for the compute.** The classes the user already owns cost the
23
+ * app nothing at the moment of a run, and that difference is the product.
24
+ *
25
+ * An app rendering its own pricing page reads this and draws it. Ours draws it
26
+ * too, from the same values, so the two cannot drift.
27
+ */
28
+ export interface PlanPrice {
29
+ /** Already formatted: `$19`, or `Free`. */
30
+ amount: string;
31
+ /** `month`, or null on a free plan — where an app usually writes "forever". */
32
+ interval: string | null;
33
+ free: boolean;
34
+ }
35
+ export interface PlanCompute {
36
+ costClass: string;
37
+ /** What the class is called on a pricing page, not in the router. */
38
+ label: string;
39
+ /** The user already pays for this one, so a run on it costs the app nothing.
40
+ * The whole argument of the product, as a boolean. */
41
+ owned: boolean;
42
+ /** The runtimes that satisfy it, for a caller drawing vendor marks. Empty for
43
+ * the classes we buy, which is an answer rather than a gap: a vendor mark
44
+ * there would imply they could bring something they cannot. */
45
+ runtimes: string[];
46
+ }
47
+ export interface PlanConnect {
48
+ /** `Connect your own` for a reward, `To use this` for a requirement. */
49
+ key: string;
50
+ line: string;
51
+ /** Whether this plan rewards connecting (rather than requiring it). */
52
+ reward: boolean;
53
+ /**
54
+ * Whether there is anything to press, which is narrower than whether the line
55
+ * is worth reading: only on the plan they are ON, only while nothing is
56
+ * connected, and never for a credit. Connecting changes the allowance of the
57
+ * plan somebody holds, so the offer means nothing from a card they have not
58
+ * taken.
59
+ *
60
+ * WHEN THIS IS TRUE, THIS IS THE CARD'S CONTROL. The plan is already theirs,
61
+ * so `cta` below is a disabled "Your plan" — a button-shaped thing that does
62
+ * nothing — and drawing both put a live control above a dead one. The dead
63
+ * one is the one to drop.
64
+ */
65
+ actionable: boolean;
66
+ /** Raise the connect flow, with the reason carried into its opening words. */
67
+ open(): void;
68
+ }
69
+ export interface PlanCta {
70
+ /** `Choose`, `Subscribe for $19/month`, `Your plan`, `Switching…`. */
71
+ label: string;
72
+ disabled: boolean;
73
+ busy: boolean;
74
+ /** Take this plan: granted outright when free, Stripe when priced. Rejects
75
+ * with whatever the endpoint said; `failed` below carries the last message
76
+ * for a caller that would rather read than catch. */
77
+ take(): Promise<void>;
78
+ }
79
+ export interface PlanOffer {
80
+ plan: Plan;
81
+ current: boolean;
82
+ featured: boolean;
83
+ price: PlanPrice;
84
+ /** `2,000 runs`, or `Unlimited runs`. The figure a person compares on. */
85
+ allowance: string;
86
+ /** What follows from it: `Included each month · then $0.02 a run · 14-day
87
+ * trial`. A consequence of the figure rather than a second fact beside it. */
88
+ allowanceNote: string;
89
+ /** Null `route_allow` is any compute, an empty one is none, and both are
90
+ * sentences rather than lists. Null here means `compute` below is the list. */
91
+ computeLabel: string | null;
92
+ compute: PlanCompute[];
93
+ connect: PlanConnect | null;
94
+ cta: PlanCta;
95
+ }
96
+ export interface PlansOptions {
97
+ /** The plan the app is pushing, by id. Never derived: nothing on a plan says
98
+ * "recommended", and guessing from the price would be a component making a
99
+ * business decision for the app. Yields to `current`. */
100
+ featured?: string;
101
+ /** Handle a priced plan yourself instead of using Soba's checkout. */
102
+ onSelect?: (plan: Plan) => void;
103
+ successUrl?: string;
104
+ cancelUrl?: string;
105
+ }
106
+ export interface PlansView {
107
+ offers: PlanOffer[];
108
+ /** The plan they are on, already resolved out of `offers`. */
109
+ current: PlanOffer | null;
110
+ loading: boolean;
111
+ error: Error | null;
112
+ /** What the last `take()` came back with. Cleared by the next attempt. */
113
+ failed: string | null;
114
+ }
115
+ export declare function usePlans(options?: PlansOptions): PlansView;
@@ -0,0 +1,140 @@
1
+ import type { Plan } from "./types.js";
2
+ /**
3
+ * THE TWO FAILURES THE CONNECT UI ANSWERS, AND WHAT TO OFFER FOR EACH.
4
+ *
5
+ * `docs/errors.md` names exactly two run failures a person can do something
6
+ * about: `402 payment_required`, the plan's allowance is spent, and
7
+ * `409 no_compute`, nothing this run may use is connected and awake. Everything
8
+ * else is the app's bug, our outage, or a ceiling that is not an error.
9
+ *
10
+ * Until this hook, every app integrating Soba wrote the same dead end. Both
11
+ * example apps did it the same way — read `error.message`, throw the `code`
12
+ * away, render a red paragraph, and point at a settings page in another route.
13
+ * The information to do better was already on the wire; nothing turned it into
14
+ * an offer.
15
+ *
16
+ * SO THIS IS THE WHOLE TRIGGER LAYER, and it is deliberately headless. The
17
+ * moment of need lands inside somebody else's product, in the middle of their
18
+ * chat or their composer, and no stylesheet we ship will match the cards
19
+ * already on that screen. An app that wants ours renders `<RunLimit />`; an app
20
+ * with its own card grammar calls this and draws it, and both open the same
21
+ * modal, because the flow behind it is nine phases long and involves a
22
+ * download. A card is a trigger. The modal is the flow.
23
+ */
24
+ /** The failure codes worth answering. Anything else returns null. */
25
+ export type LimitReason = "payment_required" | "no_compute";
26
+ export interface Offer {
27
+ reason: LimitReason;
28
+ /** A heading, in the app's own voice rather than the API's. */
29
+ title: string;
30
+ /** One line under it. Already accounts for what is connected. */
31
+ body: string;
32
+ /**
33
+ * What connecting earns, from the plan itself, or null when the plan offers
34
+ * nothing for it. Null is the signal NOT to offer connecting as a way out:
35
+ * an app whose plan carries no reward would be asking for a laptop and giving
36
+ * nothing back, and a person who connects one and sees no change has been
37
+ * told something untrue by a component.
38
+ */
39
+ connectOffer: string | null;
40
+ /** The cheapest plan that would lift this, when one is for sale. */
41
+ upgradeTo: Plan | null;
42
+ /** Open the connect flow, with the reason carried into its opening copy. */
43
+ connect(): void;
44
+ /** Take `upgradeTo`: granted outright when free, Stripe otherwise. */
45
+ upgrade(): Promise<void>;
46
+ }
47
+ export interface RunLimitOptions {
48
+ /**
49
+ * Answer from the plan's own state when nothing has failed yet, rather than
50
+ * only reporting what did.
51
+ *
52
+ * Defaults to true only when the hook is called with no arguments at all.
53
+ * Pass it explicitly with an error to get both halves — what is already in
54
+ * the way now, and what refused the run once one has been tried — which is
55
+ * the shape a chat wants and the wrong shape for a button.
56
+ */
57
+ predict?: boolean;
58
+ }
59
+ /**
60
+ * Read a Soba error code out of whatever your route handed back.
61
+ *
62
+ * Accepts the parsed body (`{ error: { code } }`, the OpenAI-compatible
63
+ * envelope every Soba failure uses), a `Response`, or an `Error` carrying
64
+ * either. Exported because the alternative an app reaches for is matching on
65
+ * the message text, which changes.
66
+ */
67
+ export declare function sobaErrorCode(input: unknown): string | null;
68
+ /**
69
+ * WHAT, IF ANYTHING, IS IN THE WAY. Null when nothing is.
70
+ *
71
+ * Two ways to ask, one answer shape:
72
+ *
73
+ * **After a run failed** — pass whatever your catch block caught. Null for
74
+ * anything that is not one of the two answerable failures, so
75
+ * `{offer && <Card …/>}` is the whole integration and an unrelated error still
76
+ * renders however your app renders errors.
77
+ *
78
+ * **Before one is tried** — call it with NO ARGUMENTS, or pass
79
+ * `{ predict: true }` alongside the error to get both. It answers from what is
80
+ * already known: a plan that can only reach compute the user owns, with nothing
81
+ * connected, has nowhere to send a run, and saying so before somebody types a
82
+ * paragraph is strictly better than saying it after. This is the form a chat
83
+ * wants, because the card belongs in the stream above the composer rather than
84
+ * in the wreckage of a request.
85
+ *
86
+ * AN EMPTY ERROR SLOT IS NOT AN INVITATION TO PREDICT, and the distinction is
87
+ * the whole reason this takes an options object. `useState<unknown>(null)` is
88
+ * how every app in this repo holds the last failure, so `useRunLimit(error)`
89
+ * arrives as `null` on the very first render — and reading that as "no error
90
+ * was passed" put a card about a refused run on screen before anybody had
91
+ * pressed the button. `examples/inbox` opened on one. Only the ABSENCE of the
92
+ * argument asks for the state form now; a slot holding nothing means nothing
93
+ * has failed yet, which is the plainer reading of both.
94
+ *
95
+ * The state form is deliberately CONSERVATIVE. It reports only what the plan
96
+ * itself declares — its `route_allow` and its `overage` — and never reimplements
97
+ * the dispatch rule. Predicting a refusal is how a browser starts disagreeing
98
+ * with the server about whose turn it is to say no.
99
+ */
100
+ export declare function useRunLimit(...args: [error?: unknown, options?: RunLimitOptions]): Offer | null;
101
+ /**
102
+ * THE REFUSAL THAT IS ALREADY CERTAIN, from facts the plan states about itself.
103
+ *
104
+ * Only two things qualify, and both are the plan's own declarations rather than
105
+ * a guess at what dispatch will decide:
106
+ *
107
+ * - **Nowhere to run.** `route_allow` names nothing but classes the user owns,
108
+ * and nothing they own can serve right now. The app has told us it will not
109
+ * buy this run; there is no machine to take it. That is a `409` waiting to
110
+ * happen, and it is the plan's own ceiling saying so.
111
+ *
112
+ * `online`, NOT `connected`, and the difference is the whole of the bug this
113
+ * fixed. A paired machine that has gone quiet — a shut lid, a dropped
114
+ * connection, a worker still retrying — is `connected` and cannot take a
115
+ * thing, so an app whose plan can only reach it said nothing at all until a
116
+ * run came back 409. `online` is false for BOTH of them: nothing paired, and
117
+ * nothing paired that is awake. It is the same fact dispatch checks first
118
+ * (`candidateMachines` is scoped to `state = online`), read from the same
119
+ * place, which is why this is not the browser inventing a rule.
120
+ * - **Nothing left.** The allowance is spent and `overage` is `stop`. `meter`
121
+ * is excluded on purpose: past the allowance it is a bill, not a wall, and
122
+ * blocking somebody who agreed to pay for overage would be this component
123
+ * inventing a policy.
124
+ *
125
+ * Anything subtler — a model no supplier carries, a cost class an environment
126
+ * excludes — is left to the server, which is the only party that knows.
127
+ *
128
+ * EXPORTED FOR ITS OWN TEST AND NOT FROM `index.ts`, so it is reachable by
129
+ * `packages/react/test` and not by anybody's app. This is the whole decision
130
+ * the predicting form makes, and it is two comparisons with four ways to be
131
+ * subtly wrong; the alternative is a React harness to assert a sentence.
132
+ */
133
+ export declare function foreseen(plan: {
134
+ plan: Plan | null;
135
+ remaining: number | null;
136
+ }, compute: {
137
+ connected: boolean;
138
+ online: boolean;
139
+ loading: boolean;
140
+ }): LimitReason | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soba-so/react",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "Connect the AI your users already pay for, from your own React app.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://soba.so/docs/components",
@@ -19,13 +19,18 @@
19
19
  "!dist/**/*.map",
20
20
  "README.md"
21
21
  ],
22
+ "scripts": {
23
+ "build": "node ../../scripts/build-react.mjs",
24
+ "typecheck": "tsc -p tsconfig.json",
25
+ "prepublishOnly": "node ../../scripts/build-react.mjs"
26
+ },
22
27
  "peerDependencies": {
23
28
  "react": ">=18"
24
29
  },
25
30
  "devDependencies": {
31
+ "@soba-so/connect-ui": "workspace:*",
26
32
  "@types/react": "^19.0.0",
27
- "typescript": "^7.0.2",
28
- "@soba-so/connect-ui": "0.1.0"
33
+ "typescript": "^7.0.2"
29
34
  },
30
35
  "engines": {
31
36
  "node": ">=22"
@@ -39,9 +44,5 @@
39
44
  ],
40
45
  "publishConfig": {
41
46
  "access": "public"
42
- },
43
- "scripts": {
44
- "build": "node ../../scripts/build-react.mjs",
45
- "typecheck": "tsc -p tsconfig.json"
46
47
  }
47
- }
48
+ }