Aller au contenu

Développer un module de sélection de sièges

Ce guide explique comment développer l'interface qui permet à un utilisateur de choisir ses places dans une page de vente StockTicket. Vous livrez cette interface sous forme de WebComponent, un élément HTML personnalisé créé par votre code JavaScript. Le module de vente charge et affiche cet élément dans sa page.

La sélection sur plan couvre aussi bien le choix d'un siège numéroté que celui d'une quantité dans une zone en placement libre, assise ou debout : par exemple, trois places dans la zone « Parterre debout ». Votre composant peut proposer les deux sur un même plan ; le mécanisme de session, de confirmation et d'application au panier reste le même.

Pour lire le plan et enregistrer les choix, votre composant utilise le SDK StockTicket, une bibliothèque JavaScript qui prend en charge les échanges avec StockTicket. Vous développez l'affichage et les interactions ; StockTicket décide quelles places et quels tarifs peuvent être attribués.

Le guide du module de vente décrit l'autre côté de l'intégration. Pour les principes généraux de déclaration d'un module, consulter Développement de modules.

1. Responsabilités et prérequis

Dans les contrats, EventDate désigne une représentation et eventDateId son identifiant.

Le parcours à réaliser

  1. Le module de vente demande à StockTicket d'ouvrir une session de sélection, qui rassemble les choix pour une représentation et un contexte de vente donnés.
  2. Il charge votre composant et lui transmet les informations nécessaires pour accéder à cette session.
  3. Votre composant se connecte avec le SDK, lit le plan et affiche les places et les tarifs proposés.
  4. À chaque ajout, retrait ou changement de tarif, votre composant demande à StockTicket de modifier la sélection et affiche le résultat accepté. Les nouvelles places acceptées sont bloquées temporairement pour cette session.
  5. L'utilisateur termine par OK pour confirmer ses choix, ou par Annuler pour les abandonner. Votre composant demande d'abord cette fin à StockTicket, puis en communique le résultat au module de vente.
  6. Après OK, le module de vente demande lui-même l'application de la sélection au panier. Votre composant ne constitue pas le panier et ne valide pas l'achat.
flowchart LR
    V[Module de vente] -->|Charge le module JavaScript ES| C[WebComponent du fournisseur]
    V -->|Ouvre la session avec son authentification| S[StockTicket]
    V -->|Transmet les informations de connexion à la session| C
    C <-->|SDK : plan, sélection, fin| S
    C -->|Résultat OK ou Annuler confirmé| V
    V -->|Demande l'application des choix au panier| S

Qui implémente quoi ?

  • Le module de vente porte l'authentification, le contexte du client et le panier. Il ouvre la session et demande l'application au panier.
  • Votre WebComponent affiche le plan et la sélection, relie les actions de l'utilisateur aux méthodes du SDK et informe le module de vente lorsque le parcours se termine. Il ne reçoit jamais la clé du module de vente.
  • StockTicket contrôle les accès et les tarifs, arbitre le stock, conserve la session et confirme les résultats.

Le SDK utilise SignalR, le protocole de communication avec StockTicket. Une connexion est le canal technique qui transporte ces échanges ; elle peut être rétablie après une coupure sans ouvrir une nouvelle session de sélection. La session, elle, conserve les choix côté StockTicket.

Pour livrer votre composant, prévoir un hébergement HTTPS pour son module JavaScript. Le paragraphe 2 décrit sa déclaration et le paragraphe 7 fournit un exemple complet.

2. Déclarer, configurer et publier le module

La déclaration permet à StockTicket de fournir votre composant aux modules de vente. Une définition de module décrit votre produit et son URL JavaScript ; une instance est l'utilisation de ce produit que le gestionnaire associe à une représentation. La configuration ci-dessous se prépare avec le gestionnaire StockTicket et l'intégrateur du module de vente.

  1. Déclarer une définition de module de type SeatSelection dans StockTicket.
  2. Renseigner WebComponentUrl dans la configuration de cette définition : URL HTTPS absolue du fichier JavaScript à charger. Ce fichier fournira la fonction de création du composant décrite au paragraphe 4.
  3. Créer une instance de ce module. Elle utilise l'URL définie pour le produit, sans la surcharger.
  4. Associer l'instance à la représentation, via SeatSelectionModuleInstanceId, dans sa configuration avec le plan de salle.
  5. Activer AllowSeatSelection sur la définition du module de vente. Les deux types de modules de vente utilisent cet indicateur, désactivé par défaut.

Votre livraison est un fichier JavaScript à importer, pas une page à ouvrir dans un iframe. Pour sélectionner des places, votre composant appellera directement StockTicket par le SDK ; il n'est pas nécessaire d'exposer un serveur fournisseur que StockTicket appellerait.

Si ce fichier est hébergé sur un autre domaine que la page de vente, son serveur doit en autoriser le chargement inter-origines (CORS). La page de vente doit également permettre ce chargement dans sa politique de sécurité du contenu (CSP). Les contrôles de déploiement sont regroupés au paragraphe 10.

3. Charger le SDK

Le SDK est un module JavaScript ES accessible à l'adresse seat-selection/js/seat-selection.js sur le serveur de l'API StockTicket. Il exporte notamment SeatSelectionClient, qui gère les échanges avec StockTicket, et SeatSelectionClientError, qui permet de traiter ses erreurs.

import { SeatSelectionClient, SeatSelectionClientError }
    from "https://api.example.test/seat-selection/js/seat-selection.js";

Remplacer le domaine d'exemple par celui de l'API utilisée. Si elle est hébergée sous un chemin, le conserver : pour https://api.example.test/stk/, importer https://api.example.test/stk/seat-selection/js/seat-selection.js.

La version minifiée seat-selection.min.js, dans le même répertoire, expose la même API. Le SDK inclut son client SignalR ; aucune bibliothèque SignalR supplémentaire n'est à charger.

Pour les projets TypeScript, les déclarations de types sont également disponibles sur le serveur de l'API, à l'adresse seat-selection/dist/js/seat-selection.d.ts. Ces fichiers décrivent les classes, méthodes et données du SDK pour l'autocomplétion et la vérification des types à la compilation.

L'import rend le SDK disponible sans ouvrir de connexion. Le paragraphe suivant décrit les informations que le module de vente transmet à votre composant pour établir cette connexion.

4. Contrat du WebComponent

Ce contrat définit comment le module de vente crée votre interface, lui donne accès à la session, puis récupère son résultat. C'est vous qui implémentez ces échanges dans le WebComponent. Le SDK prend en charge les échanges réseau avec StockTicket, pas l'intégration de votre interface dans la page de vente.

Recevoir les paramètres et créer l'élément

Après avoir ouvert une session, le module de vente importe votre fichier JavaScript et appelle la fonction createSeatSelectionElement() que vous exportez. Cette fonction crée votre élément HTML, sans encore l'insérer dans la page.

Le module de vente affecte ensuite deux propriétés JavaScript à cet élément :

  • hubUrl : l'adresse du canal SignalR StockTicket auquel le SDK doit se connecter ; elle est indépendante de l'adresse de votre fichier JavaScript.
  • token : le jeton délégué qui autorise votre composant à consulter le plan et à modifier les choix de cette session. Ce n'est pas la clé d'API du module de vente.

La signature à implémenter dans votre module est donc :

interface SeatSelectionWebComponent extends HTMLElement {
    hubUrl: string;
    token: string;
}

export function createSeatSelectionElement(): SeatSelectionWebComponent;

Choisir et enregistrer votre propre nom de balise HTML, par exemple example-seat-picker. Le module de vente n'a pas besoin de connaître ce nom : il passe uniquement par votre fonction de création. L'exemple du paragraphe 7 montre l'enregistrement et la fonction exportée.

Connecter le composant à la session

Le module de vente insère ensuite l'élément dans le document. Votre composant peut alors utiliser les deux valeurs reçues pour créer le client SDK et établir la connexion :

const client = new SeatSelectionClient(hubUrl, token);
const state = await client.start();

state est l'état de la session lu sur StockTicket. Pour une session en cours, votre composant peut maintenant charger le plan puis proposer des modifications, comme décrit aux paragraphes 5 et 6.

Utiliser les valeurs reçues sans reconstruire l'adresse du canal.
L'élément reste sans connexion tant qu'il n'a pas reçu ces valeurs et n'a pas été inséré dans le document.

Terminer les choix et informer le module de vente

Votre interface propose une action OK et une action Annuler. Relier ces actions aux méthodes du SDK :

  1. Sur OK, appeler await client.finish("Ok") ; sur Annuler, appeler await client.finish("Cancel").
  2. Attendre l'état retourné par StockTicket : c'est lui qui indique si la session est effectivement terminée. Un clic sur le bouton ne suffit pas à le prouver ; par exemple, la requête peut échouer pendant une coupure réseau.
  3. Communiquer le résultat confirmé au module de vente par un événement navigateur émis sur votre élément HTML. Cet événement reste dans la page : ce n'est ni une requête réseau ni une notification SignalR.

Après OK, StockTicket peut fournir une référence de sélection, une chaîne opaque qui désigne les choix confirmés. Le module de vente utilisera cette référence pour demander leur application au panier. Votre composant doit la transmettre telle quelle, sans construire lui-même le panier.

En JavaScript, un CustomEvent est un événement navigateur qui transporte ses données dans une propriété nommée detail. Voici les événements que votre composant doit émettre et que le module de vente écoute :

Événement émis par votre composant Quand et avec quelles données
stk-seatselection-ok Après confirmation de OK. Si StockTicket fournit une référence : detail: { hasSelection: true, selectionReference }. Sinon : detail: { hasSelection: false }.
stk-seatselection-cancel Après confirmation de l'abandon, sans donnée à transmettre.
stk-seatselection-expired Lorsque le SDK confirme l'expiration, sans détail ni résultat OK/Cancel déjà transmis. Le module de vente peut fermer l'interface et expliquer que le délai est écoulé.
stk-seatselection-error Lorsqu'une opération échoue et que le module de vente doit en être informé : detail: { code }. Le traitement des erreurs et la liste des codes sont décrits au paragraphe 9. Une erreur ne termine pas automatiquement la session.

hasSelection indique donc la présence d'une référence à appliquer, pas simplement celle d'un siège dans la liste. Par exemple, si l'utilisateur retire toutes les places déjà réservées dans son panier, StockTicket fournit une référence pour appliquer ces retraits, même si la sélection finale est vide.

Émettre ces événements avec bubbles: true, pour permettre leur propagation dans le DOM, et composed: true, pour qu'ils puissent traverser un Shadow DOM. Le Shadow DOM est l'arbre HTML privé qu'un composant peut utiliser pour isoler son affichage des styles de la page.

La logique de fin doit aussi fonctionner après une coupure : le SDK peut retrouver un OK ou un Cancel déjà confirmé par StockTicket. Votre code émet alors l'événement correspondant, mais une seule fois pour cette session et cette instance de composant. Le paragraphe 7 montre cette logique dans l'écouteur statechange ; le paragraphe 8 explique la reprise.

Gérer le cycle de vie de l'élément

Votre composant démarre uniquement une fois inséré dans le document et muni de hubUrl et token, comme dans #restart() au paragraphe 7. Le jeton reste identique pendant cette session : aucune rotation ni demande de prolongation n'est à implémenter.

  • Si le module de vente retire l'élément du document, votre composant doit arrêter son client et libérer ses ressources. Ne pas appeler finish("Cancel") à cette occasion : fermer l'interface ne signifie pas que l'utilisateur a demandé l'abandon de ses choix.

5. Lire le plan et les tarifs

Une fois connecté, votre composant a besoin de trois ensembles de données pour construire son interface : le plan pour placer les zones et sièges, les tarifs autorisés pour proposer les prix, et la sélection courante pour montrer les choix déjà enregistrés. Le SDK les fournit dans une même vue, retournée par getView(options).

Cette lecture ne réserve aucune place. Elle sert à afficher une situation ; les demandes d'ajout seront faites séparément au paragraphe 6.

const view = await client.getView({ includeSeats: true });

Cet appel demande le détail des sièges numérotés. Pour adapter la quantité de données à votre affichage, les options disponibles sont :

Option Défaut Effet
includePlan true Retourne sections et les définitions graphiques seatTypes.
includePrices true Retourne le détail des tarifs dans les catégories.
includeSeats false Retourne le détail rows → seats des zones numérotées.
viewport absent ou null Rectangle global { x, y, width, height } ; largeur et hauteur positives. Les zones intersectées sont retournées entières.

Construire l'affichage du plan

Le plan est organisé en sections, puis en zones. Une zone numérotée contient des rangées, elles-mêmes composées de sièges : sections → zones → rows → seats. Une zone libre représente au contraire un ensemble de places à choisir par quantité.

Pour un rendu graphique, les coordonnées de chaque élément sont relatives à son parent : composer les positions et rotations des sections, zones et rangées pour dessiner les sièges. zone.svgPath fournit le contour de zone ; seat.seatTypeId permet de retrouver son dessin SVG dans view.seatTypes. Chaque type de siège porte seatTypeName, isPRM pour les places destinées aux personnes à mobilité réduite, et svgIcon pour le dessin.

Proposer les tarifs et montrer les choix existants

Les tarifs sont regroupés dans view.priceCategories. Les identifiants de catégorie présents sur les sièges ou les zones permettent de retrouver les propositions correspondantes. Les catégories restent référencées même si vous n'avez pas demandé le détail des prix.

priceCategories[].prices contient les tarifs autorisés, leur identifiant priceCategoryItemId, leur libellé, isDefault pour le choix par défaut, les paliers de prix prices[] (minQuantity, price) et les limites éventuelles maxAvailable et maxTicketsInBasket. Utiliser ces données pour construire le choix de tarif. Leur présence ne garantit pas l'attribution future d'une place : StockTicket vérifiera le stock et les limites au moment de la demande. Les prix définitifs seront recalculés sur le contenu accepté dans le panier.

view.selection contient les choix déjà enregistrés lors de cette lecture. Si le module de vente a ouvert la session avec un panier existant, cette sélection peut déjà contenir les places réservées pour la représentation. Les opérations suivantes actualisent aussi client.state.selection, qui fournit la sélection confirmée la plus récente. Chaque élément contient place pour l'adresse, quantity, unitPrice, priceCategoryId et priceCategoryItemId.

L'état d'un siège dans le plan et sa présence dans votre sélection répondent à deux questions différentes : « peut-il être attribué maintenant ? » et « fait-il partie de mes choix ? ». Une place déjà réservée au panier peut donc être Occupied dans le plan et figurer dans la sélection. Dessiner la sélection à partir de cette liste, pas à partir de la seule disponibilité.

Valeurs de disponibilité et de placement

Champ Valeur exacte Signification et traitement
seat.availability, zone.availability Closed Fermé structurellement ou inaccessible dans le contexte courant ; conserver l'élément à l'affichage sans permettre son ajout.
mêmes champs Occupied Accessible, mais stock insuffisant ; ne pas proposer un nouvel ajout sur ce seul instantané.
mêmes champs Available Accessible avec stock positif au moment de la lecture ; l'ajout reste soumis à arbitrage serveur.
zone.placementType SeatingFree Assis en placement libre : sélectionner une quantité à l'adresse section/zone.
même champ SeatingPlaced Sièges numérotés : utiliser l'adresse complète section/zone/rangée/siège et une quantité de un.
même champ Standing Debout : sélectionner une quantité dans la zone, sans rangée ni siège.
même champ null ou absent Mode non renseigné ; ne pas inventer une valeur ni choisir implicitement un traitement.

placementType est une chaîne TypeScript issue de PlacementTypeEnum côté serveur. Les valeurs ci-dessus sont les noms exacts, pas leurs traductions ni leurs numéros.

6. Modifier la sélection

Relier les actions de votre interface à client.mutate(items) : un clic sur un siège demande un ajout, le retrait d'un choix demande une suppression, et le choix d'un autre tarif demande un changement de tarif. Le tableau items peut regrouper plusieurs modifications.

StockTicket traite ces demandes et renvoie les résultats. Votre interface doit afficher les choix acceptés par le serveur, et pas considérer le clic comme une réservation réussie. Les nouveaux ajouts acceptés créent des blocages provisoires : les places sont retenues temporairement pour la session, sans être encore ajoutées au panier. Leur durée et leur expiration sont expliquées au paragraphe 8.

Une même demande peut réussir partiellement. Le résultat distingue :

  • handled : la demande a-t-elle pu être traitée dans son ensemble ?
  • results : pour chaque modification demandée, success indique l'acceptation, quantity la quantité modifiée et issues les avertissements ou refus à afficher. index retrouve la position de cette modification dans la demande.
  • state : l'état de sélection confirmé, également accessible dans client.state lorsque le SDK l'a reçu.

L'exemple suivant ajoute un siège au tarif choisi puis affiche les messages reçus. Les identifiants sont illustratifs : les prendre dans la vue et dans la sélection confirmée. afficherMessage désigne votre fonction d'affichage de texte, pas une méthode du SDK.

// Ajoute un siège numéroté avec un tarif choisi dans la vue.
const result = await client.mutate([{
    kind: "Add",
    place: { sectionId: "section-1", zoneId: "zone-1", rowId: "rangee-1", seatId: "siege-23" },
    quantity: 1,
    priceCategoryId: "categorie-1",
    priceCategoryItemId: "tarif-1"
}]);

// Chaque demande peut réussir partiellement ; lire tous les résultats.
if (!result.handled) afficherMessage(result.message ?? "Demande non traitée.");
for (const item of result.results) {
    for (const issue of item.issues) afficherMessage(`${issue.label} : ${issue.message}`);
    // item.index désigne l'entrée ; item.success et item.quantity décrivent son résultat.
}

Pour deux places libres, utiliser { kind: "Add", place: { sectionId, zoneId }, quantity: 2, priceCategoryId, priceCategoryItemId }. Pour retirer, reprendre l'adresse et les références tarifaires de l'élément dans client.state.selection. Pour changer de tarif, conserver le tarif actuel et ajouter newPriceCategoryItemId.

SeatSelectionMutationItem.kind Champs et traitement
Add Adresse, quantité et éventuellement catégorie/tarif. Sans tarif explicite, STK choisit le premier tarif autorisé par défaut, sinon le premier autorisé.
Remove Adresse, quantité et références tarifaires de l'élément à retirer ; ne pas envoyer la quantité d'une autre sélection.
ChangePrice Adresse, quantité, catégorie, tarif actuel dans priceCategoryItemId et nouveau dans newPriceCategoryItemId. Un refus conserve le choix précédent.

Une commande accepte au plus 100 éléments, avec une quantité de 1 à 1 000 par élément ; un siège numéroté exige 1. Un élément est accepté entièrement ou refusé ; un refus n'annule pas les autres éléments. handled: true signifie que la commande est traitée, pas que toutes les places sont acceptées. handled: false est un échec global de traitement, expliqué par message.

Le SDK prend aussi en charge l'identification technique des demandes pour permettre leur reprise après une coupure. La procédure à suivre si une réponse est perdue est décrite au paragraphe 8.

Tous les signalements individuels

Un avertissement informe l'utilisateur sans refuser la modification ; une erreur individuelle refuse cette modification sans annuler les autres. Ils sont décrits par results[].issues[], avec severity, code, number, label et message. Les textes peuvent être personnalisés par le serveur : afficher leur contenu comme du texte, mais prendre les décisions sur les codes.

code exact number severity Signification et traitement
WarnOrphanSeat 1000 Warning Avertissement de siège isolé ; indique que la sélection actuelle laisse un siège isolé et que la validation pourrait échouer.
WarnCustom 1999 Warning Information personnalisée ; afficher sans transformer l'acceptation en refus.
ErrInvalidRequest 2000 Error Adresse, quantité ou opération incorrecte ; corriger la demande.
ErrClosed 2001 Error Placement fermé ou inaccessible ; ne pas forcer l'ajout.
ErrUnavailable 2002 Error Stock ou quota insuffisant ; proposer un autre choix ou une relecture.
ErrAlreadySelected 2003 Error Siège déjà présent ; repartir de l'état confirmé.
ErrNotSelected 2004 Error Quantité non présente pour cette adresse et ce tarif ; relire la sélection.
ErrPriceUnavailable 2005 Error Tarif inaccessible ; recharger les propositions autorisées.
ErrCustom 2999 Error Refus personnalisé ; afficher l'explication serveur.

Les seules valeurs de severity sont Warning (ne refuse pas l'élément) et Error (refuse l'élément). Une liste vide ne comporte aucun signalement. Ne pas confondre ces erreurs individuelles avec une exception globale du SDK.

7. Exemple de WebComponent qui sélectionne réellement un siège

Cet exemple réunit les étapes précédentes dans un fichier JavaScript : il reçoit les paramètres du module de vente, se connecte, affiche une liste de sièges numérotés avec leurs tarifs, puis permet un ajout, un retrait et une fin par OK ou Annuler. Il effectue de vraies demandes à StockTicket ; vous pouvez remplacer sa liste par votre rendu graphique sans changer les échanges.

Le choix de sièges numérotés limite uniquement cet exemple, pas les possibilités du module. Pour une zone en placement libre, votre interface propose une quantité à l'adresse section/zone, sans rangée ni siège, selon les demandes décrites au paragraphe 6.

Lire l'exemple

Le parcours utile tient en quatre échanges. Les extraits ci-dessous expliquent ces échanges ; le module complet, avec les mêmes appels, constitue une lecture complémentaire juste après.

// 1. Le module de vente fournit ces deux propriétés au WebComponent.
const client = new SeatSelectionClient(hubUrl, token);
const state = await client.start();
if (state.status === "Active") await client.getView({ includeSeats: true });

// 2. Un clic sur un siège envoie son adresse et le tarif choisi dans la vue.
// « place », « priceCategoryId » et « priceCategoryItemId » viennent de cette vue.
const result = await client.mutate([{
    kind: "Add", quantity: 1, place, priceCategoryId, priceCategoryItemId
}]);
// Le résultat indique les acceptations et refus ; client.state porte les choix confirmés.

// 3. OK demande une confirmation serveur, pas un ajout au panier.
await client.finish("Ok");
// L'écouteur statechange du module complet transmet ensuite la fin confirmée
// au module de vente, y compris lorsqu'elle est retrouvée après une coupure.

// 4. Le retrait du composant ferme les ressources, sans envoyer Cancel.
await client.stop();

Deux familles d'événements restent distinctes : le SDK émet ses événements sur client pour actualiser votre interface ; votre WebComponent émet les événements stk-seatselection-* sur son élément HTML pour informer le module de vente. Le paragraphe 8 décrit les situations qui déclenchent les événements SDK.

Dans le module complet, # est la syntaxe JavaScript des champs et méthodes privés, accessibles uniquement à l'intérieur de la classe. Ce n'est ni une convention StockTicket ni une API du SDK. #restart() gère le cycle de vie, #run() encadre les actions et #render() construit l'interface minimale.

Module complet à publier

Adapter le domaine du SDK, publier ce fichier comme module ES et renseigner son URL dans WebComponentUrl.

Le code suivant ajoute le cycle de vie du navigateur et une interface minimale autour de ces appels. Les commentaires séparent la connexion, les notifications, les actions et le rendu HTML.

import { SeatSelectionClient, SeatSelectionClientError }
    from "https://api.example.test/seat-selection/js/seat-selection.js";

class ExampleSeatPicker extends HTMLElement {
    #hubUrl = "";
    #token = "";
    #client;
    #generation = 0;
    #cleanup = Promise.resolve();
    #completedSession;
    #busy = false;
    #view;
    #root = this.attachShadow({ mode: "open" });
    #message = document.createElement("p");
    #content = document.createElement("div");

    constructor() {
        super();
        this.#message.setAttribute("role", "status");
        this.#root.append(this.#message, this.#content);
    }

    // Les propriétés sont affectées par le module de vente, avant l'insertion de l'élément.
    get hubUrl() { return this.#hubUrl; }
    set hubUrl(value) { this.#hubUrl = value; void this.#restart(); }
    get token() { return this.#token; }
    set token(value) { this.#token = value; void this.#restart(); }
    connectedCallback() { void this.#restart(); }
    disconnectedCallback() { void this.#restart(); }

    async #restart() {
        // Ignore les démarrages devenus obsolètes et attend l'arrêt du client précédent.
        const generation = ++this.#generation;
        const previous = this.#client;
        this.#client = undefined;
        this.#view = undefined;
        this.#cleanup = this.#cleanup.then(() => previous?.stop()).catch(() => {});
        await this.#cleanup;
        if (generation !== this.#generation || !this.isConnected || !this.#hubUrl || !this.#token) return;
        let client;
        try { client = new SeatSelectionClient(this.#hubUrl, this.#token); }
        catch (error) { this.#error(error); return; }
        this.#client = client;
        this.#busy = false;
        // Cet état vient de StockTicket ; un simple clic sur OK ou Annuler n'émet pas de résultat.
        client.addEventListener("statechange", event => {
            if (this.#client !== client) return;
            const state = event.detail;
            if (state.status !== "Active" && this.#completedSession !== state.sessionId) {
                this.#completedSession = state.sessionId;
                const detail = state.hasSelection ? {
                    hasSelection: true,
                    selectionReference: state.selectionReference
                } : { hasSelection: false };
                // Cet événement navigateur transmet la fin confirmée à la page de vente.
                this.dispatchEvent(new CustomEvent(state.status === "Ok"
                    ? "stk-seatselection-ok" : "stk-seatselection-cancel", {
                    detail: state.status === "Ok" ? detail : undefined,
                    bubbles: true, composed: true
                }));
                // Attend que la réponse de fin soit rendue avant de fermer le canal.
                queueMicrotask(() => { void client.stop().catch(() => {}); });
            }
            this.#render();
        });
        client.addEventListener("connectionchange", () => {
            if (this.#client === client) this.#render();
        });
        client.addEventListener("viewchange", event => {
            if (this.#client !== client) return;
            // Garde une copie locale modifiable, distincte de la dernière vue relue par le SDK.
            this.#view = structuredClone(event.detail);
            this.#render();
        });
        client.addEventListener("error", event => {
            if (this.#client === client) this.#error(event.detail);
        });
        client.addEventListener("movements", event => {
            if (this.#client === client) this.#applyMovements(event.detail);
        });
        client.addEventListener("sessionexpired", () => {
            if (this.#client !== client) return;
            this.#message.textContent = "Le délai de sélection est écoulé : recommencez depuis la vente.";
            if (this.#completedSession !== client.state?.sessionId) {
                this.#completedSession = client.state?.sessionId;
                this.dispatchEvent(new CustomEvent("stk-seatselection-expired", { bubbles: true, composed: true }));
            }
            this.#render();
        });
        await this.#run(async current => {
            const state = await current.start();
            if (state.status === "Active") await current.getView({ includeSeats: true });
        });
    }

    #error(error) {
        const code = error instanceof SeatSelectionClientError ? error.code : "TransportUnavailable";
        this.#message.textContent = `Sélection indisponible (${code}).`;
        this.dispatchEvent(new CustomEvent("stk-seatselection-error", {
            detail: { code }, bubbles: true, composed: true
        }));
    }

    async #run(action) {
        const client = this.#client;
        if (!client || this.#busy) return;
        this.#busy = true;
        this.#render();
        try { await action(client); }
        catch (error) { if (this.#client === client) this.#error(error); }
        finally {
            if (this.#client === client) { this.#busy = false; this.#render(); }
        }
    }

    #button(parent, label, enabled, action) {
        const button = document.createElement("button");
        button.type = "button";
        button.textContent = label;
        button.disabled = !enabled || this.#busy;
        button.addEventListener("click", () => { void this.#run(action); });
        parent.append(button);
    }

    async #mutate(client, items) {
        // Attend le résultat individuel avant d'afficher le choix comme confirmé.
        const result = await client.mutate(items);
        this.#message.textContent = result.handled
            ? result.results.flatMap(item => item.issues.map(issue => issue.message)).join(" — ") || "Choix enregistré."
            : result.message || "Demande non traitée.";
        // Cette interface simple relit après ses propres commandes pour les stocks et tarifs.
        // Les notifications des autres utilisateurs, elles, sont appliquées localement ci-dessous.
        await client.getView({ includeSeats: true });
    }

    #applyMovements(batch) {
        if (!this.#view || batch.eventDateId !== this.#view.eventDateId) return;
        for (const { place, kind } of batch.movements) {
            const section = this.#view.sections?.find(value => value.sectionId === place.sectionId);
            const zone = section?.zones.find(value => value.zoneId === place.zoneId);
            const row = zone?.rows?.find(value => value.rowId === place.rowId);
            const seat = row?.seats.find(value => value.seatId === place.seatId);
            // Ignore les sièges hors vue et préserve toujours les fermetures commerciales.
            if (!seat || seat.availability === "Closed") continue;
            // Un mouvement de stock ne remplace pas nos propres choix confirmés.
            const selected = this.#client.state.selection.some(item =>
                item.place.sectionId === place.sectionId && item.place.zoneId === place.zoneId
                && item.place.rowId === place.rowId && item.place.seatId === place.seatId);
            if (!selected) seat.availability = kind === "Taken" ? "Occupied" : "Available";
        }
        this.#render();
    }

    #render() {
        const client = this.#client;
        this.#content.replaceChildren();
        if (!client) return;
        const active = client.state?.status === "Active";
        const ready = active && client.connectionStatus === "Ready" && !client.hasPendingFinish;
        this.#button(this.#content, "Reprendre la connexion", active, current => current.start());
        this.#button(this.#content, "Recharger la vue", ready,
            current => current.getView({ includeSeats: true }));
        for (const item of client.state?.selection ?? []) {
            this.#button(this.#content, `Retirer ${item.place.seatId ?? item.place.zoneId} (${item.quantity})`, ready,
                current => this.#mutate(current, [{ ...item, kind: "Remove" }]));
        }
        // La disponibilité vient de notre vue locale ; les choix viennent de client.state.
        const view = this.#view;
        for (const section of view?.sections ?? []) for (const zone of section.zones) {
            for (const row of zone.rows ?? []) for (const seat of row.seats) {
                const line = document.createElement("div");
                const label = document.createElement("span");
                label.textContent = `${zone.name ?? zone.zoneId} / ${row.rowNumber ?? row.rowId} / ${seat.seatNumber ?? seat.seatId} : ${seat.availability} `;
                const category = view.priceCategories.find(value => value.priceCategoryId === seat.priceCategoryId);
                // Retient le tarif par défaut pour ne pas alourdir l'exemple avec un sélecteur.
                const price = category?.prices?.find(value => value.isDefault) ?? category?.prices?.[0];
                line.append(label);
                this.#button(line, `Ajouter (${price?.label ?? "sans tarif"})`, ready && seat.availability === "Available" && !!price,
                    current => this.#mutate(current, [{
                        kind: "Add", quantity: 1,
                        place: { sectionId: section.sectionId, zoneId: zone.zoneId, rowId: row.rowId, seatId: seat.seatId },
                        priceCategoryId: seat.priceCategoryId, priceCategoryItemId: price.priceCategoryItemId
                    }]));
                this.#content.append(line);
            }
        }
        this.#button(this.#content, "OK", ready, current => current.finish("Ok"));
        this.#button(this.#content, "Annuler", ready, current => current.finish("Cancel"));
    }
}

export function createSeatSelectionElement() {
    const tag = "example-seat-picker";
    const registered = customElements.get(tag);
    if (!registered) customElements.define(tag, ExampleSeatPicker);
    else if (registered !== ExampleSeatPicker) throw new Error("Nom de composant déjà utilisé.");
    return new ExampleSeatPicker();
}

Adapter l'exemple à votre interface

Le dessin du plan, le sélecteur de tarifs, le choix des quantités libres et le changement de tarif restent à adapter à votre produit. L'exemple choisit le tarif par défaut, ou le premier tarif proposé. Les données et demandes correspondantes sont décrites aux paragraphes 5 et 6.

Conserver en revanche la séparation entre l'action de l'utilisateur, sa confirmation par StockTicket et l'événement destiné au module de vente. Dans l'exemple, les boutons OK et Annuler appellent seulement finish() ; l'écouteur statechange émet le résultat quand il reçoit la fin confirmée. Il couvre ainsi aussi une fin retrouvée après une coupure.

stop() ferme définitivement une instance de client : l'utiliser pour libérer ses ressources, comme au retrait de l'élément ou au remplacement du jeton. Pour reprendre après une simple coupure, conserver au contraire le client et suivre la procédure du paragraphe suivant.

8. Notifications, expiration et reprise

Pendant que l'utilisateur choisit, d'autres utilisateurs peuvent prendre des places, vos blocages peuvent expirer et la connexion peut être interrompue. Votre composant doit donc suivre plus que les réponses à ses propres boutons.

Le SDK vous informe de ces changements par les événements émis sur client. Installer les écouteurs avec client.addEventListener(...), comme dans l'exemple précédent, puis redessiner les informations concernées. Les données associées à chaque événement se trouvent dans event.detail.

Suivre l'état et actualiser l'affichage

Événement SDK Exemple de situation Données et action du composant
connectionchange Le réseau coupe, puis le SDK rétablit le canal. detail contient l'état technique décrit au paragraphe 9. Désactiver les choix jusqu'à Ready et afficher la reprise en cours.
statechange Un ajout est confirmé ou un OK est retrouvé après une coupure. detail et client.state contiennent l'état serveur actualisé. Redessiner les choix et leur échéance ; transmettre une fin confirmée une seule fois au module de vente.
viewchange Votre code appelle getView(), ou le SDK recharge la dernière vue après reconnexion. detail et client.view contiennent la nouvelle vue. Remplacer la copie locale du plan puis redessiner. Cet événement n'est pas émis pour chaque mouvement.
movements Un autre utilisateur prend le siège A12, ou un panier restitue B8. { eventDateId, movements } indique les sièges concernés. Actualiser localement ceux de votre vue selon les règles ci-dessous ; le SDK ne modifie pas client.view à votre place.
sessionexpired L'utilisateur laisse passer le délai sans activité prolongeant la session. { sessionId, expiresAtUtc } confirme la fin définitive du parcours. Désactiver les actions et émettre une seule fois stk-seatselection-expired si aucun résultat terminal n'a déjà été transmis. Ne pas reprendre cette session ni modifier le panier.
error La restauration automatique échoue, par exemple parce que la session est devenue indisponible. detail est un SeatSelectionClientError. Afficher un message selon son code. Les appels explicites comme mutate() signalent aussi leurs échecs par rejet de leur promesse : les intercepter avec try/catch.

Appliquer les mouvements sans recharger le plan

Les mouvements sont diffusés aux composants connectés à la même représentation, sans filtrage selon les sièges affichés ni leurs accès commerciaux. Un message peut donc concerner un siège hors de votre vue ou fermé à la vente dans votre contexte. La session à l'origine du mouvement ne reçoit pas ses propres mouvements : ses réponses de mutation et son état confirment ses choix.

Ces notifications concernent uniquement les sièges des zones à placement numéroté. Aucun mouvement de quantité n'est diffusé pour les zones en placement libre.

Pour chaque mouvement, rechercher le siège par son adresse complète dans votre copie locale du plan. S'il est absent, ignorer le mouvement. S'il est Closed, conserver cette valeur, quelle que soit la notification : une restitution de stock ne change pas les droits commerciaux. Conserver aussi vos propres choix confirmés séparément, dans client.state.selection.

movements[].kind Traitement
Taken Un siège a été pris. Pour un siège visible et commercialement accessible, actualiser la disponibilité locale en Occupied.
Released Un siège a été restitué. Pour un siège visible et commercialement accessible, actualiser la disponibilité locale en Available. Ne jamais transformer Closed en Available. Cette indication reste un instantané : une nouvelle demande peut être refusée si le stock a changé entre-temps.

Le traitement normal redessine seulement les sièges concernés, sans appel à getView(). Une relecture complète reste utile après une coupure ou pour une actualisation explicitement demandée. Les mouvements n'apportent ni nouveaux tarifs ni règles commerciales.

sequence donne l'ordre des mouvements reçus ; les trous sont normaux, notamment parce que vos propres mouvements ne vous sont pas renvoyés. Le SDK trie et écarte déjà les mouvements qu'il a traités. occuredAt contient la date et l'heure UTC auxquelles StockTicket a enregistré le mouvement pour sa diffusion, par exemple 2026-07-15T14:30:00Z. Ce n'est ni l'heure de réception dans votre composant ni une échéance de réservation. Les notifications peuvent être regroupées avant leur envoi. Dans holdsexpired, occuredAt indique le moment où StockTicket a constaté et traité la restitution des blocages expirés.

Reprendre après une coupure ou une réponse perdue

La reprise doit conserver les choix sans rejouer un ajout comme une nouvelle demande. Le SDK conserve donc en mémoire l'identifiant de la dernière modification dont il attend la réponse. S'il retrouve cette demande après une coupure, il la représente à StockTicket avec le même identifiant ; StockTicket peut retourner le résultat déjà traité sans ajouter les places une deuxième fois.

Votre composant doit :

  1. Désactiver les actions de sélection pendant que le SDK rétablit la connexion et relit la session.
  2. Laisser la reconnexion automatique s'effectuer. Si elle n'aboutit pas et que la délégation reste valide, proposer await client.start() sur la même instance pour reprendre.
  3. Attendre l'état technique Ready, vérifier que la session est encore Active, puis réactiver les choix. Si le SDK retrouve au contraire une fin, restituer ce résultat au module de vente comme au paragraphe 4.
sequenceDiagram
    participant C as WebComponent et SDK
    participant S as StockTicket
    C->>S: Mutation
    S--xC: Réponse perdue
    Note over C,S: Coupure puis reconnexion de la même instance SDK
    C->>S: Lecture de session
    C->>S: Résolution de la commande incertaine
    S-->>C: Résultat déjà traité ou traitement unique
    C->>S: Relecture de la dernière vue demandée
    S-->>C: Vue actualisée
    Note over C: viewchange puis Ready si session encore active

Pour une session active, le SDK résout les demandes incertaines, relit l'état et recharge la dernière vue demandée avec ses options avant de revenir à Ready. Votre écouteur viewchange peut donc redessiner le plan restauré.

Ne pas refaire volontairement un ajout parce que sa réponse a été perdue : deux appels distincts ajoutant chacun deux places libres peuvent ajouter quatre places. La conservation d'une demande incertaine concerne la même instance du SDK ; recharger la page ne conserve pas cette demande locale.

Les mouvements manqués ne sont pas rejoués depuis un historique. Une perte silencieuse sur une connexion restée ouverte n'est pas détectée automatiquement ; proposer une relecture par getView() si votre interface doit pouvoir forcer la mise à jour.

Afficher l'échéance unique du parcours

Pour afficher un compte à rebours, utiliser client.state.expiresAtUtc. Cette échéance unique couvre la session, son autorisation, tous ses nouveaux blocages et sa référence après OK. StockTicket configure une seule durée, cinq minutes par défaut.

L'ouverture, la première connexion confirmée au hub, un ajout créant réellement au moins un nouveau blocage et le premier OK repoussent l'échéance à maintenant plus cette durée. Un ajout de quantité en placement libre compte comme un siège numéroté. Toutes les places précédemment bloquées partagent la nouvelle échéance.

Les reconnexions suivantes, lectures, retraits, changements de tarif, ajouts refusés, répétitions de commande et répétitions d'OK ne prolongent rien. Il n'y a ni échéance indépendante par place ni appel volontaire de prolongation par le module de vente. Le jeton ne change pas.

Afficher la date retournée par le serveur, sans la recalculer à partir des clics. OK ne modifie pas le panier : son transfert doit encore aboutir avant cette échéance. Un OK ne réactive jamais une session déjà expirée.

À expiration, StockTicket retire définitivement la session, ferme son autorisation et restitue les nouveaux blocages. Le SDK signale sessionexpired puis ferme ses ressources. Il n'existe plus de parcours actif avec des choix expirés à retirer. Les réservations initiales du panier et les places déjà transférées restent intactes.

Une notification peut être perdue si le navigateur est déconnecté. Le SDK interroge le serveur à l'échéance connue ; une erreur de transport seule ne prouve pas l'expiration, notamment si la réponse d'un ajout est incertaine. Laisser la même instance résoudre sa reprise ; ne pas recréer automatiquement une session. Cancel ou révocation libèrent aussi les nouveaux blocages. Une déconnexion ou un retrait du DOM ne vaut pas Cancel.

9. Référence complète des états et erreurs

Cette section sert de référence pour traiter les résultats dans votre code. Les états de session indiquent si l'utilisateur peut encore modifier ses choix ; les états de connexion indiquent si le client est prêt à communiquer ; les codes d'erreur expliquent l'échec d'une opération. Les valeurs sont sensibles à la casse et s'échangent sous forme de chaînes, pas de numéros.

Session et demande de fin

state.status Signification et traitement
Active Sélection en cours. Ce n'est pas une valeur autorisée pour finish.
Ok Choix figé ; transmettre le résultat au module de vente. Ne constitue pas le panier.
Cancel Abandon confirmé ; transmettre Cancel et quitter le parcours de sélection.

finish(status) et SeatSelectionFinishRequest.status acceptent uniquement Ok ou Cancel. Une répétition de la même fin retrouve le résultat ; demander l'autre fin produit AlreadyFinished.

Connexion : SeatSelectionConnectionStatus

Valeur Traitement
Disconnected Pas de canal actif ; proposer une reprise par start() sur la même instance.
Connecting Connexion en cours ; attendre.
Synchronizing Lecture et réconciliation serveur en cours ; attendre avant de modifier la sélection.
Ready Synchronisation terminée ; vérifier aussi que la session est Active et qu'aucune fin n'est incertaine.
Reconnecting Reconnexion automatique en cours ; ne pas recréer des commandes d'ajout.
Closed Instance arrêtée par stop() ; créer une nouvelle instance si le module de vente fournit encore une délégation valide.

Erreurs serveur et SDK

Une erreur globale peut empêcher la lecture, la modification ou la fin de session. Dans votre composant, intercepter les rejets des promesses du SDK avec try/catch et lire SeatSelectionClientError.code. Écouter aussi l'événement SDK error pour les échecs de reprise automatique. L'exemple du paragraphe 7 regroupe leur traitement dans #error().

Afficher le code ou un message adapté, puis informer la page de vente par l'événement navigateur stk-seatselection-error décrit au paragraphe 4. Cet événement ne signifie pas que l'utilisateur a annulé : ne pas le convertir en Cancel. Les refus et avertissements portant seulement sur une place restent dans results[].issues, selon le paragraphe 6.

Les champs error des réponses de session et de vue utilisent SeatSelectionSessionErrorEnum, soit les quatre premières lignes du tableau. Le SDK expose ces valeurs ainsi que ses cinq codes supplémentaires via SeatSelectionClientError.code ; votre composant utilise les mêmes codes dans stk-seatselection-error.detail.code.

Valeur exacte Origine Traitement
None Réponse serveur Aucune erreur ; ne pas afficher comme échec.
InvalidRequest Serveur Corriger les paramètres ; ne pas répéter aveuglément la même demande.
SessionUnavailable Serveur Session inutilisable ; demander au module de vente de reprendre le parcours.
AlreadyFinished Serveur/SDK Session déjà terminée ou fin contradictoire ; utiliser l'état confirmé, pas une nouvelle sélection.
InvalidConfiguration SDK URL du hub ou jeton incorrect ; corriger les données de lancement.
TransportUnavailable SDK Canal indisponible ; proposer une reprise si la délégation reste valide.
ProtocolError SDK Réponse non conforme ; interrompre l'action concernée et diagnostiquer les versions sans publier les données sensibles.
PendingFinish SDK Une fin de statut opposé est encore incertaine ; résoudre la fin déjà demandée.
Closed SDK Instance arrêtée ; ne plus la réutiliser.
Expired SDK Expiration terminale ; désactiver les actions et restituer l'événement d'expiration au module de vente, sans annoncer une annulation.

Les droits contenus dans la délégation sont contrôlés par StockTicket. Votre composant n'a ni portée ni combinaison de drapeaux à construire dans ses requêtes SDK.

10. Sécurité et publication du composant

Dans votre composant et son hébergement

  • Conserver le jeton uniquement dans les données JavaScript nécessaires au client. Ne pas le recopier dans une URL construite par votre code, un attribut HTML, un événement, un stockage persistant ou un journal. Les exemples transmettent uniquement un code neutre en cas d'erreur.
  • Utiliser textContent pour les libellés et messages serveur : un texte contenant une balise HTML doit rester du texte, pas devenir du code exécuté par la page.
  • Servir votre module JavaScript et ses imports en HTTPS avec un type MIME JavaScript correct. Si la page de vente est sur un autre domaine, autoriser leur lecture par CORS.
  • Utiliser hubUrl tel qu'il est fourni en entrée : son domaine et son chemin peuvent être différents de ceux de l'API StockTicket. Ne pas reconstruire cette adresse.

Le fournisseur du composant et le développeur du module de vente n'ont pas à se connaître ni à coordonner leurs implémentations. Chacun respecte le contrat public : module ES et fabrique, propriétés hubUrl/token, événements de résultat et cycle de vie décrits dans ce guide.