Setup
import {
provideContext,
createSubscriptionService,
createApiSubscriptionAdapter,
createMockSubscriptionAdapter,
SubscriptionServiceContext,
} from "@juo/blocks";
// Production
provideContext(
root,
SubscriptionServiceContext,
createSubscriptionService(createApiSubscriptionAdapter({ baseUrl: "/api/v1" })),
);
// Development / Storybook
provideContext(
root,
SubscriptionServiceContext,
createSubscriptionService(createMockSubscriptionAdapter()),
);
Shape
type SubscriptionService = {
// Reactive state
current: Signal<Subscription | null>;
hasPendingBilling: Signal<boolean>;
// Reads
search(options?: SearchOptions): Promise<Result<PaginatedList<Subscription>>>;
getById(id: string): Promise<Result<Subscription | null>>;
getCurrentSubscriptionId(): string | null;
getDefaultSubscriptionId(): Promise<Result<string | null>>;
getAvailablePlans(subscriptionId: string): Promise<Result<SellingPlan[]>>;
getUpsellProducts(params: { subscriptionId: string }): Promise<Result<UpsellProduct[]>>;
// Items
addItem(subscriptionId: string, item: PostableSubscriptionItem): Promise<Result>;
removeItem(subscriptionId: string, itemId: string): Promise<Result>;
addUpsellProduct(params: {
subscriptionId: string;
variantId: string;
quantity: number;
oneTime?: boolean;
}): Promise<Result>;
// Discounts
addDiscount(subscriptionId: string, code: string): Promise<Result>;
removeDiscount(subscriptionId: string, discountId: string): Promise<Result>;
// Lifecycle
reschedule(subscriptionId: string, newDate: string): Promise<Result<Subscription>>;
pause(subscriptionId: string): Promise<Result<Subscription>>;
resume(subscriptionId: string): Promise<Result<Subscription>>;
reactivate(subscriptionId: string): Promise<Result<Subscription>>;
cancel(subscriptionId: string, reason?: string): Promise<Result<Subscription>>;
};
Subscription carries items, discounts, status (active | paused | canceled | expired | failed), nextDeliveryDate, frequency, and the owning customerId.
Reactive state
| Signal | Description |
|---|---|
current | The currently selected subscription, or null if none is loaded. Updates after mutations. |
hasPendingBilling | true when a billing attempt is in-flight. Use this to guard mutations that would conflict with billing. |
Example: pause / resume toggle
import {
defineBlock,
effect,
untracked,
injectContext,
SubscriptionServiceContext,
} from "@juo/blocks";
defineBlock("PauseToggle", {
group: "theme",
schema: { /* ... */ } as const,
initialValue: () => ({ props: {} }),
renderer(block) {
const el = document.createElement("button");
const subscription = injectContext(el, SubscriptionServiceContext);
el.addEventListener("click", async () => {
const current = subscription.current.value;
if (!current || subscription.hasPendingBilling.value) return;
const result = current.status === "paused"
? await subscription.resume(current.id)
: await subscription.pause(current.id);
if (result._tag === "Failure") console.error(result.error);
});
effect(() => {
const current = subscription.current.value;
const pending = subscription.hasPendingBilling.value;
untracked(() => {
el.textContent = current?.status === "paused" ? "Resume" : "Pause";
el.disabled = pending || !current;
});
});
return el;
},
});
Example: adding a discount
async function applyCode(id: string, code: string) {
const result = await subscription.addDiscount(id, code);
if (result._tag === "Failure") {
throw new Error("Invalid code");
}
// subscription.current updates automatically
}