npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@suiteop/storefront

v0.3.0

Published

Typed client, React hooks and headless components for the SuiteOp Storefront API.

Readme

@suiteop/storefront

Typed client, React hooks and headless components for the SuiteOp Storefront API: the API a direct-booking site uses to list properties, check availability, quote a stay and book it.

npm install @suiteop/storefront zod

zod 4 is a dependency (the response schemas run on it). react 18 or 19 is an optional peer, needed only for @suiteop/storefront/react and @suiteop/storefront/components.

Client

import { createStorefrontClient } from '@suiteop/storefront'

const storefront = createStorefrontClient({ publishableKey: 'pk_live_eu_…' })

const { items, total } = await storefront.listings.list({ location: 'lisbon', limit: 20 })
const availability = await storefront.availability.get(items[0].id, {
  checkIn: '2027-03-01',
  checkOut: '2027-03-04',
  guests: 2,
})
const quote = await storefront.quotes.create({
  listingId: items[0].id,
  checkIn: '2027-03-01',
  checkOut: '2027-03-04',
  guests: 2,
})
const session = await storefront.checkoutSessions.create({ quoteRef: quote.quoteRef })

| Option | Default | | | ---------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | publishableKey | required | pk_{live\|test}_{us\|eu\|apac}_…. A secret sk_ key is refused. | | baseUrl | from the key | pk_live_ → https://api-<region>.suiteop.com; pk_test_ → https://api-<region>-staging.suiteop.com. 'same-origin' → the page's own origin (SuiteOp-hosted sites). Any URL → that host. | | timeoutMs | 10 s reads, 20 s quotes / checkout | Per request. | | locale | none | Sent as Accept-Language for API-side copy. | | fetch | globalThis.fetch | |

Every method takes a last { signal } option; aborting rejects with the signal's AbortError. Responses are validated against the API's own schemas, so a shape this version does not know fails loudly (invalid_response) instead of rendering undefined. Reads are retried once on a network failure or a 5xx; quotes, checkout and contact are never retried.

checkoutSessions.create sends an Idempotency-Key and returns it on the session, because <suiteop-checkout idempotency-key> needs the same one. Pass your own, or let the client keep one per quote and rate plan: a retry after a timeout or network error reaches the same session and the same payment hold. The key is released only when a new session is safe: a session that comes back expired or cancelled, a create refused with idempotency_key_reused (the key belongs to another quote), or a confirm error that ends the session (session_expired, or requiresNewSession). Every other create refusal keeps it: the same key may already name a live session (a replay, or a concurrent create that won), and where none exists a retry is simply a new create. A failed session or booking keeps the key: the guest may already have been charged, so a retry replays the dead session and your UI should show the failure. checkoutSessions.forget(quoteRef, ratePlanId) releases the key explicitly — never call it after a failed result, or a retry can take a second payment. A failed create carries the key in error.details.idempotencyKey.

A site can add custom fields and upsells to its checkout. <suiteop-checkout> shows them and sends the guest's choices itself, so a host page creates the session exactly as above. A session carries extras: the upsell lines the API priced and heldTotalMinor, the total the hold is for. Show that total, never one computed on the page. With extras the guest pays for a session the element created, so a page that prints a total of its own takes it from the element's suiteop-checkout:ready event (detail.totalMinor, detail.currency, present on the payment step). A booking's totalMinor is what was paid, and lines lists the extras inside it.

Requires Node 20+ outside browsers (global fetch and crypto).

Money is an integer …Minor field (the amount × 100) with a lower-case ISO 4217 currency; upper-case it yourself for display.

Content

A site declares the content its property manager edits in the SuiteOp dashboard — collections (a blog, press mentions), page sections (a home-page banner) and extra fields on buildings or areas — in its own code, and reads the values per request:

// src/suiteop.content.ts
import { defineContent, field } from '@suiteop/storefront/content'

export default defineContent({
  blog: {
    kind: 'collection',
    label: 'Blog',
    titleField: 'title',
    routable: true, // each post gets a slug
    drafts: true, // posts are published explicitly
    order: 'newest',
    fields: {
      title: field.text({ label: 'Title', required: true, maxLength: 150 }),
      cover: field.image({ label: 'Cover photo' }),
      body: field.richText({ label: 'Article', required: true }),
    },
  },
  home_hero: {
    kind: 'section',
    label: 'Home — top banner',
    fields: {
      title: field.text({ label: 'Headline' }),
      photos: field.gallery({ label: 'Photos' }),
    },
  },
  area_details: {
    kind: 'fields',
    attachesTo: 'area',
    label: 'Area details',
    fields: { best_for: field.text({ label: 'Best for' }) },
  },
})
import { createContentReader } from '@suiteop/storefront/content'
import definition from './suiteop.content'

const content = createContentReader(storefront, definition)
const { items } = await content.collection('blog').list({ limit: 10 }) // newest first
const post = await content.collection('blog').get(slug) // null: draft, scheduled or unknown
const hero = await content.section('home_hero').get()
const [area] = await storefront.locations.list()
const bestFor = content.fieldsOf('area_details', area).best_for // string | null

Field types: text, richText (Markdown — render it with raw HTML disabled), image, gallery, number, boolean, date (YYYY-MM-DD), option, link ({ url, label }), reference / references ({ kind, id, slug }, to: 'listing' | 'building' | 'area' | 'collection:<key>'). Every value is typed from the declaration; any single value may be null (not filled in yet, or no longer fitting its field after a code change) and lists may be empty. Text is in the client's locale with the site's default language as fallback.

With suiteop({ content: './src/suiteop.content.ts' }) in astro.config.mjs, the build emits the model at /_suiteop/content-model.json and the SuiteOp deploy registers it: new types and fields appear in the dashboard; ones the code stops declaring are hidden, and nothing a manager typed is ever deleted. Add routes: { '/blog/[slug]': 'collection:blog' } for the sitemap to list each published post.

Errors

Every failure is a StorefrontError with status (0 when nothing answered), code, requestId and details. Branch on code (StorefrontErrorCode lists the known ones; an unknown code from a newer API is still a string) or on the subclasses:

| Class | When | Extra fields | | ------------------------ | --------------------------- | -------------------------------------------------------------------------------------- | | PriceChangedError | price_changed (409) | quote — the fresh quote at session creation, null at confirm; requiresNewSession | | WrongRegionError | wrong_region (421) | region, baseUrl | | RateLimitedError | rate_limited (429) | retryAfterSec, scope (key / ip / pms) | | StorefrontTimeoutError | no answer in time | code: 'timeout' | | StorefrontNetworkError | the request never completed | code: 'network' |

StorefrontConfigError (not a StorefrontError) means the SDK is set up wrong: a bad key, a bad baseUrl, a hook outside its provider.

React

import {
  SuiteOpProvider,
  useListing,
  useAvailability,
  useQuote,
  useCheckoutSession,
} from '@suiteop/storefront/react'

;<SuiteOpProvider client={storefront}>
  <Listing id={id} />
</SuiteOpProvider>

Hooks return AsyncState<T> (idle / loading / success / error). Identical concurrent reads under one provider share one request, reused for 30 seconds; a read whose last consumer unmounts is aborted. useCheckoutSession goes through the client's per-quote idempotency key, so a double click opens one session, and exposes priceChanged when the price moved.

Components

@suiteop/storefront/components ships DateRangePicker, GuestPicker, Gallery and Map: unstyled, no CSS, keyboard- and screen-reader-complete. Every element takes a class (className, classNames) and exposes data-state. The only copy is English defaults in defaultLabels; pass labels with your translations. Map loads no map library — it hands markers and bounds to its children render prop so you can use MapLibre, Google Maps or anything else.

Checkout

The card form and booking confirmation run in the <suiteop-checkout> element, served by the SuiteOp storefront host as a content-hashed script with a Subresource Integrity hash; card data never touches your code or this package.

import { SuiteOpCheckout } from '@suiteop/storefront/react'

const session = await storefront.checkoutSessions.create({ quoteRef: quote.quoteRef })

;<SuiteOpCheckout
  checkoutSessionId={session.id}
  quoteRef={quote.quoteRef}
  idempotencyKey={session.idempotencyKey}
  onComplete={(booking) => showConfirmation(booking)}
  onPriceChanged={() => requote()}
  onError={({ code, requiresNewSession }) => showError(code, requiresNewSession)}
/>

The publishable key and API host come from the <SuiteOpProvider> client. On a SuiteOp-hosted site the bundle loads from the page's own origin; a self-hosted site passes its storefront host as checkoutOrigin (e.g. https://<site>.suiteop.site). Without React, call loadCheckout({ origin }) from @suiteop/storefront/checkout and render the element yourself; outside a browser it resolves without doing anything. A bundle that cannot be loaded rejects (or reaches onError) with checkout_unavailable.

Astro sites

// suiteop.config.ts — validated at build time; an invalid theme fails the build
import { defineSuiteOpConfig } from '@suiteop/storefront/config'
export default defineSuiteOpConfig({
  brand: { name: 'Acme Stays', logoUrl: null, faviconUrl: null } /* … */,
})

// astro.config.mjs
import { suiteop } from '@suiteop/storefront/astro'
export default defineConfig({ integrations: [suiteop()] })

suiteop() writes /_suiteop/manifest.json after every build: the prerendered pages (path, language, title, last change — pages marked noindex are left out) and the on-demand routes that expand from catalogue data (/listing/[id], /building/[id]). /_suiteop/* is reserved for the platform; do not add pages under it.

On SuiteOp hosting, getSiteContext(Astro.locals) (@suiteop/storefront/astro) returns the per-request site props the edge passes (publishable key, API bases; null elsewhere). The checkout element loads with loadCheckout() (§Checkout).

Events

The platform requires three page events: page_view on every page, view_item on a listing and begin_checkout on the checkout page. analyticsFor(publishableKey) returns the document's one analytics instance (createAnalytics builds a separate one); it batches events and posts them, with the key, to the SuiteOp edge's /_suiteop/events, filling each event's id, time, path and referrer:

import { analyticsFor } from '@suiteop/storefront'

const analytics = analyticsFor(publishableKey)
analytics.pageView()
analytics.track({ name: 'view_item', listingId: listing.id })
analytics.track({ name: 'begin_checkout', checkoutSessionId: session.id })

Under <SuiteOpProvider> the provider sends page_view itself, and useTrack() returns the same instance's track. Off SuiteOp hosting the edge route is absent and the instance goes quiet.

Versioning

The public surface is the three entry points' exports plus the StorefrontError.code strings.

  • Patch — fixes, no type change.
  • Minor — additive: new methods, new optional fields, new error subclasses for an existing code. SuiteOp-hosted sites receive minors automatically, after their contract check passes.
  • Major — any removal or rename, a new required input, a peer range change, or dropping a Storefront API version. Opt-in only.

Additive API changes do not break an installed SDK: status, reason and payment-provider values it does not know yet are passed through as plain strings (typed Known | (string & {})), so handle the unknown case in any switch. Each release also sends the Storefront API version it was built against (X-SuiteOp-Api-Version); the API reserves that header for pinning older clients to older behaviour in future, and does not act on it yet. The previous major receives security fixes for 12 months after the next one ships. 0.x releases are pre-stable: a minor may break.

See CHANGELOG.md for each release.