@thenoughtyfox/noughty-tours-web-sdk
v0.0.91
Published
Noughty Tours SDK — authenticate with your own JWTs and read your virtual tours.
Readme
@thenoughtyfox/noughty-tours-web-sdk
Embed Noughty Tours virtual tours in your own application.
The package ships two things: a typed data client for the Noughty Tours API, and React components that render the tours it returns.
The client returns plain data and renders nothing, so you can build whatever interface your product needs on top of it. If you would rather not build one, four ready-made React components cover the common screens:
| Component | Screen |
| -------------- | --------------------------------------------------------- |
| ToursList | A browsable list of every tour |
| TourManager | Floors, rooms and scans, with editing |
| Viewer | The panorama viewer, including authoring controls |
| PublicViewer | The read-only viewer for a public tour, addressed by slug |
They read through the same client, so mixing the two is fine — use a component where it fits and drop to the API where it does not.
Full reference for both, including every component prop:
docs/index.html.
Example apps — a complete working integration, in two halves:
| Repository | What it shows | | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | noughty-tours-web-demo | A React app wiring up the client and all four components | | noughty-tours-web-backend-demo | The backend half — mints tokens and publishes a JWK Set |
Install
npm install @thenoughtyfox/noughty-tours-web-sdkreact and react-dom are optional peer dependencies, required only for the
React components.
Quick start
import { NoughtyToursController } from "@thenoughtyfox/noughty-tours-web-sdk";
const tnf = new NoughtyToursController({
getBearerToken: async () => {
// Call your own backend. It signs a short-lived token with your
// private key and returns it. We never see the key.
const res = await fetch("/api/tnf-token", { method: "POST" });
const { access_token } = await res.json();
return access_token;
}
});
const tours = await tnf.tours.list();
const tour = await tnf.tours.get(tours[0].id);Viewer configuration
Presentation settings live in viewerConfig on the client, so a host configures
its look once and every viewer beneath the provider picks it up.
const tnf = new NoughtyToursController({
getBearerToken,
viewerConfig: {
logoUrl: "https://your-cdn.com/logo.jpg",
isLogoSticky: true
}
});| Setting | Type | Description |
| -------------- | ---------------- | ------------------------------------------------------------------------------------- |
| logoUrl | string \| null | Image drawn over the panorama poles. null and undefined mean no logo. |
| isLogoSticky | boolean | Whether the logo turns with the visitor so it always reads upright. Defaults to true. |
A capture never covers the full sphere — the phone can see neither straight up
nor straight down — so logoUrl hides that gap behind your own mark. The image
must be served with permissive CORS headers: it is uploaded as a WebGL texture,
and a blocked one is skipped with a console warning rather than breaking the
view.
isLogoSticky: false pins the logo to the panorama instead, giving it a fixed
bearing that rotates out of true as the visitor looks around.
Read the settings from your own components with useViewerConfig(). Every field
is optional and additive, so new settings arrive without disturbing callers that
do not use them.
For finer control over the poles — coverage angle, or capping only one — pass
the poleCap prop to Viewer / PublicViewer; it overrides logoUrl.
Authentication
getBearerToken() is awaited immediately before every authenticated request and
its result is sent as Authorization: Bearer <token>. Because it runs per call, it
is the natural place to refresh an expired token — cache the token in your own code
if you would rather not pay a round trip each time.
PublicViewer is the exception: it loads the tour from the public endpoint without
a token, so visitors with no session can open a public tour.
Requests abort after 15 seconds.
Registering your issuer
Tokens are signed with your own key, and we verify them by fetching your JWK Set — so we need to know where to look. Send the Noughty Tours team your issuer (your backend's origin) and your JWKS endpoint before you start. We only fetch key sets from issuers we know about, so until that is done a correctly signed token is still rejected.
| You send us | Example |
| ------------- | ------------------------------------------------ |
| Issuer | https://your-backend.com |
| JWKS endpoint | https://your-backend.com/.well-known/jwks.json |
Rotating a signing key needs no coordination — that is what kid is for — but
tell us before you move your issuer or JWKS URL, because every token breaks
until we have the new one.
Sign aud as the API origin you are calling —
https://api.noughtyreality.com/tours/v1. The SDK has that origin built in, so
this is the one place the value is written out by hand, and a mismatch rejects
every token.
The full backend recipe, including a mintToken() example and the claims we
verify, is in docs/index.html.
Resources
A NoughtyToursController exposes four resources.
| Resource | Purpose |
| -------- | ---------------------------------------------- |
| tours | Tours, including their floors, rooms and scans |
| floors | Floor metadata and floor-plan documents |
| rooms | Room metadata |
| scans | Panorama pose, orientation and thumbnails |
await tnf.tours.list();
await tnf.tours.get(tourId);
await tnf.tours.getBySlug(slug);
await tnf.tours.update(tourId, { name: "Riverside apartment" });
await tnf.tours.delete(tourId);
await tnf.floors.update(floorId, { name: "Ground floor" });
await tnf.floors.delete(floorId);
await tnf.floors.uploadPlan(floorId, JSON.stringify(plan));
await tnf.rooms.update(roomId, { name: "Master bedroom", type: "bedroom" });
await tnf.rooms.delete(roomId);
await tnf.scans.update(scanId, { theta: Math.PI / 2 });
await tnf.scans.delete(scanId);
await tnf.scans.uploadThumbnail(scanId, file);React components
Import the stylesheet once, then wrap your tree in two providers —
NoughtyToursProvider supplies the client, I18nProvider supplies the language.
import {
NoughtyToursController,
NoughtyToursProvider,
I18nProvider,
ToursList
} from "@thenoughtyfox/noughty-tours-web-sdk";
import "@thenoughtyfox/noughty-tours-web-sdk/style.css";
const tnf = new NoughtyToursController({ getBearerToken });
export function App() {
return (
<NoughtyToursProvider client={tnf}>
<I18nProvider defaultLocale="en">
<ToursList onSelect={tour => navigate(`/tours/${tour.id}`)} />
</I18nProvider>
</NoughtyToursProvider>
);
}Each component fetches its own data, so you pass identifiers rather than loaded objects. They are page-level screens sized to the full window height — give each one its own route.
<ToursList onSelect={tour => navigate(`/tours/${tour.id}`)} />
<TourManager
tourId={tourId}
onBack={() => navigate("/tours")}
onDeleted={() => navigate("/tours")}
onStartTour={scanId => navigate(`/tours/${tourId}/view?scan=${scanId ?? ""}`)}
publicURLForSlug={slug => `https://example.com/t/${slug}`}
/>
<Viewer
tourId={tourId}
initialScanId={scanId}
onBackToManager={() => navigate(`/tours/${tourId}`)}
/>
<PublicViewer slug={slug} />Styling
Every rule in the stylesheet is scoped to a .nt-root class that the components
put on their own root elements. The stylesheet cannot affect the rest of your
page, and your own CSS reset cannot reach inside the components. Nothing to
configure, and Tailwind is not required.
Localisation
English, Romanian, Russian and German ship built in. LOCALES lists them if you
want your own picker; useTranslation reads the active language.
import { LOCALES, useTranslation } from "@thenoughtyfox/noughty-tours-web-sdk";
const { locale, setLocale, t } = useTranslation();Pass locale and onLocaleChange to I18nProvider to control the language
yourself. The SDK never writes to storage — persist the choice from
onLocaleChange if it should survive a reload. Override individual strings with
messages:
<I18nProvider
defaultLocale="en"
messages={{ en: { "tour.startTour": "View property" } }}
>Scan status
A scan only has images once processing reaches FINISHED. Filter on it before
rendering — panoramaURL, panoramaLowResURL and thumbnailURL are null
until then.
| Status | Meaning |
| ------------ | --------------------------------------------- |
| EMPTY | Created, nothing uploaded yet |
| QUEUED | Uploaded and waiting to be processed |
| PROCESSING | Being processed |
| FINISHED | Ready; image URLs are populated |
| FAILED | Processing failed; no images will be produced |
const viewable = room.scans.filter(scan => scan.status === "FINISHED");Public tours
Setting a tour's visibility to PUBLIC mints a slug; setting it PRIVATE
revokes the slug. A tour with a slug can be read without an authenticated user
through getBySlug, which is what the public viewer runs on.
const updated = await tnf.tours.update(tourId, { visibility: "PUBLIC" });
// updated.slug is now setRead slug from the update response rather than assuming its value. A link
built from a revoked slug stops resolving.
Uploads
Thumbnails and floor plans go straight to storage through a presigned POST, so
files never pass through the API. uploadThumbnail and uploadPlan handle both
steps; getThumbnailUploadURL and getPlanUploadURL expose the presigned target
if you need to drive the upload yourself.
Size limits are enforced client-side before any request is sent:
| Upload | Limit | Content type |
| -------------- | ------ | ------------------ |
| Scan thumbnail | 500 KB | image |
| Floor plan | 1 MB | application/json |
Error handling
Any non-2xx response rejects with an ApiError.
import { ApiError } from "@thenoughtyfox/noughty-tours-web-sdk";
try {
await tnf.tours.get(tourId);
} catch (error) {
if (error instanceof ApiError && error.statusCode === 404) {
// Tour does not exist, or this token cannot see it.
}
throw error;
}| Property | Type | Notes |
| ------------ | -------- | ----------------------------------------------------- |
| message | string | Server-supplied, or "An error occurred" |
| code | string | Server-supplied, or "UNKNOWN"; "TIMEOUT" on abort |
| statusCode | number | HTTP status; 408 on timeout |
Uploads that exceed their size limit reject with a plain Error before the
request is made.
Running the demo
demo/ holds a working end-to-end setup: a backend that mints tokens
(demo/backend) and a frontend that renders the components against it
(demo/landing). Run the three steps in order — the landing app needs both the
built SDK and a running backend.
This demo is deliberately plain — vanilla JS and createElement, no build
framework — so it shows the SDK with nothing else in the way, and it links the
SDK from source so changes appear as you edit. For a conventional integration
installed from npm instead, use the two public example repositories above:
noughty-tours-web-demo
for the frontend and
noughty-tours-web-backend-demo
for the backend.
1. Build the SDK
The landing app depends on the SDK through file:../../, which npm installs as
a symlink to this repository. The exports map points at dist/, so there has
to be a build before anything can import it.
npm install
npm run build2. Backend — mints the tokens
cd demo/backend
npm install
npm run devIt listens on 5050, hardcoded in
demo/backend/src/main.ts — the same origin
demo/landing/client.js calls. Change both together
if you move it.
| Route | Purpose |
| ---------------------------- | ----------------------------------------- |
| POST /auth/tnf-token | Mints a short-lived ES256 JWT |
| GET /.well-known/jwks.json | Publishes the public key for verification |
Making the JWKS reachable
We verify your tokens by fetching your JWKS server-side, so localhost will not
resolve. Tunnel it:
ngrok http 5050Then set issuer in demo/backend/src/config.ts
to the tunnel URL — it has to match the iss claim on the tokens you mint, and
is where we look for the key set. Confirm it serves:
curl https://<your-tunnel>/.well-known/jwks.jsonaudience and defaultOrganisationId in that same file are the API you are
calling and the organisation the token acts for.
3. Landing — renders the components
cd demo/landing
npm install
npm run devOn http://localhost:6060, with a page per component:
| Page | Component |
| --------------------------------- | ------------------------------------------------------------------------------- |
| / | Links to the rest |
| /tours-list.html | ToursList |
| /tour.html?id=<tourId> | TourManager |
| /viewer.html?id=<tourId> | Viewer (add &scanId= to open a specific scan) |
| /public-viewer.html?slug=<slug> | PublicViewer (add &scanId= likewise) |
| /floor-plan.html?id=<tourId> | FloorPlanPage (add &floorId= to open a floor, &create=1 for a blank plan) |
Start at / and click through — ToursList links each tour to its manager, and
the manager links on to the viewer and, through "Edit plan", the floor plan
editor.
TypeScript
Types are exported from the package root:
import type {
PropertyItem,
PropertyListItem,
Floor,
Room,
Scan,
ScanStatus,
RoomType,
Visibility
} from "@thenoughtyfox/noughty-tours-web-sdk";