@koma-app/core 0.1.0

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/LICENSE.md ADDED
@@ -0,0 +1,14 @@
1
+ # Licence
2
+
3
+ © 2026 Koma (Yann Cherif). Tous droits réservés.
4
+
5
+ Ce paquet est publié sur npm pour être installé par les sites des studios clients de la plateforme
6
+ Koma (https://koma-app.fr). Son usage est autorisé uniquement avec un compte Koma actif, pour le site
7
+ du studio concerné, tel que prévu par les conditions d'utilisation de la plateforme.
8
+
9
+ Toute autre utilisation, reproduction, modification, redistribution ou création d'œuvre dérivée,
10
+ en tout ou partie, est interdite sans accord écrit préalable de Koma.
11
+
12
+ Le paquet est fourni « en l'état », sans garantie d'aucune sorte.
13
+
14
+ Contact : contact@koma-app.fr
package/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @koma-app/core
2
+
3
+ Client typé, types, formateurs et helpers du storefront **Koma**, sans React. Base de
4
+ [`@koma-app/react`](https://www.npmjs.com/package/@koma-app/react), utilisable seul par n'importe quel framework.
5
+
6
+ ```ts
7
+ import { createKomaClient, formatPrice, groupByDay } from "@koma-app/core";
8
+
9
+ const koma = createKomaClient(); // /api/v1 en même origine (site proxifié) ; sinon { baseUrl, studioKey: "pk_…" }
10
+ const studio = await koma.studio();
11
+ const courses = await koma.courses();
12
+ for (const day of groupByDay(courses, { timeZone: studio.timezone, locale: studio.locale })) {
13
+ console.log(day.heading, day.courses.map((c) => `${c.title} ${formatPrice(c.priceCents, studio.currency)}`));
14
+ }
15
+ ```
16
+
17
+ - `createKomaClient({ baseUrl?, studioKey?, fetch?, credentials? })` : `studio`, `courses`, `course`, `cardProducts`,
18
+ `otpRequest`, `otpVerify`, `me`, `logout`, `account`, `trialEligibility`, `payIntent`, `cardIntent`, `payWithCredit`,
19
+ `requestCancellation`, `contact`. Les échecs rejettent une `KomaApiError { status, code, message }`.
20
+ - Formateurs : `formatPrice`, `formatTime`, `formatDuration`, `formatDayHeading`, `formatFullDate`, `groupByDay`
21
+ (fuseau et locale du studio, jamais en dur).
22
+ - Stockage local tolérant (`koma.*`) : identité mémorisée, réservation locale par cours, verrou post-achat, contexte essai.
23
+ - Calendrier : `googleCalendarUrl`, `buildIcs`, `eventWindow`. SEO : `courseJsonLd`.
24
+
25
+ Contrat de l'API : `docs/api.md` du dépôt Koma.
@@ -0,0 +1,19 @@
1
+ import { type FormatOptions } from "./format";
2
+ import type { AgendaCourse } from "./types";
3
+ export type DayRelative = "today" | "tomorrow" | null;
4
+ export type DayGroup<C extends {
5
+ startsAt: string;
6
+ } = AgendaCourse> = {
7
+ /** YYYY-MM-DD dans le fuseau du studio. */
8
+ key: string;
9
+ relative: DayRelative;
10
+ /** « samedi 3 octobre » dans la locale du studio. */
11
+ heading: string;
12
+ courses: C[];
13
+ };
14
+ /** Groupe des cours (déjà triés par date) par jour civil du studio, avec repères Aujourd'hui / Demain. */
15
+ export declare function groupByDay<C extends {
16
+ startsAt: string;
17
+ }>(courses: C[], opts?: FormatOptions & {
18
+ now?: Date;
19
+ }): DayGroup<C>[];
package/dist/agenda.js ADDED
@@ -0,0 +1,25 @@
1
+ import { dayKey, formatDayHeading } from "./format";
2
+ /** Groupe des cours (déjà triés par date) par jour civil du studio, avec repères Aujourd'hui / Demain. */
3
+ export function groupByDay(courses, opts = {}) {
4
+ const now = opts.now ?? new Date();
5
+ const tz = opts.timeZone;
6
+ const todayKey = dayKey(now, tz);
7
+ const tomorrowKey = dayKey(new Date(now.getTime() + 24 * 3600 * 1000), tz);
8
+ const groups = [];
9
+ for (const course of courses) {
10
+ const key = dayKey(new Date(course.startsAt), tz);
11
+ const last = groups[groups.length - 1];
12
+ if (last && last.key === key) {
13
+ last.courses.push(course);
14
+ }
15
+ else {
16
+ groups.push({
17
+ key,
18
+ relative: key === todayKey ? "today" : key === tomorrowKey ? "tomorrow" : null,
19
+ heading: formatDayHeading(course.startsAt, { ...opts, now }),
20
+ courses: [course],
21
+ });
22
+ }
23
+ }
24
+ return groups;
25
+ }
@@ -0,0 +1,18 @@
1
+ export type CalendarEvent = {
2
+ uid: string;
3
+ title: string;
4
+ start: Date;
5
+ end: Date;
6
+ location: string;
7
+ description: string;
8
+ /** Identifiant du producteur dans le fichier .ics (ex. « Koma//Studio Lune »). */
9
+ prodId?: string;
10
+ };
11
+ /** Fenêtre d'un cours : fin réelle, ou début + durée par défaut si le cours n'a pas de fin. */
12
+ export declare function eventWindow(startsAt: string, endsAt: string | null, defaultMinutes?: number): {
13
+ start: Date;
14
+ end: Date;
15
+ };
16
+ export declare const calendarStamp: (d: Date) => string;
17
+ export declare function googleCalendarUrl(ev: CalendarEvent): string;
18
+ export declare function buildIcs(ev: CalendarEvent, now?: Date): string;
@@ -0,0 +1,36 @@
1
+ // Ajout au calendrier : URL Google Calendar et contenu .ics (purs, sans DOM).
2
+ /** Fenêtre d'un cours : fin réelle, ou début + durée par défaut si le cours n'a pas de fin. */
3
+ export function eventWindow(startsAt, endsAt, defaultMinutes = 75) {
4
+ const start = new Date(startsAt);
5
+ const end = endsAt ? new Date(endsAt) : new Date(start.getTime() + defaultMinutes * 60_000);
6
+ return { start, end };
7
+ }
8
+ export const calendarStamp = (d) => d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
9
+ export function googleCalendarUrl(ev) {
10
+ const params = new URLSearchParams({
11
+ action: "TEMPLATE",
12
+ text: ev.title,
13
+ dates: `${calendarStamp(ev.start)}/${calendarStamp(ev.end)}`,
14
+ location: ev.location,
15
+ details: ev.description,
16
+ });
17
+ return `https://calendar.google.com/calendar/render?${params.toString()}`;
18
+ }
19
+ const icsEscape = (s) => s.replace(/\\/g, "\\\\").replace(/[,;]/g, (c) => `\\${c}`).replace(/\n/g, "\\n");
20
+ export function buildIcs(ev, now = new Date()) {
21
+ return [
22
+ "BEGIN:VCALENDAR",
23
+ "VERSION:2.0",
24
+ `PRODID:-//${ev.prodId ?? "Koma"}//FR`,
25
+ "BEGIN:VEVENT",
26
+ `UID:${ev.uid}`,
27
+ `DTSTAMP:${calendarStamp(now)}`,
28
+ `DTSTART:${calendarStamp(ev.start)}`,
29
+ `DTEND:${calendarStamp(ev.end)}`,
30
+ `SUMMARY:${icsEscape(ev.title)}`,
31
+ `LOCATION:${icsEscape(ev.location)}`,
32
+ `DESCRIPTION:${icsEscape(ev.description)}`,
33
+ "END:VEVENT",
34
+ "END:VCALENDAR",
35
+ ].join("\r\n");
36
+ }
@@ -0,0 +1,59 @@
1
+ import type { AccountSummary, AgendaCourse, CancellationResult, CardProduct, CheckoutIdentity, ClientInfo, CreditPaymentResult, PaymentIntentInfo, PublicCourse, Studio, TrialEligibility } from "./types";
2
+ export type KomaClientOptions = {
3
+ /** Préfixe des routes storefront. Défaut `/api/v1` (site proxifié en même origine, cookie de session). */
4
+ baseUrl?: string;
5
+ /** Clé publique `pk_…` du studio, si le site n'est pas servi sur un domaine déclaré du studio. */
6
+ studioKey?: string;
7
+ /** `fetch` à utiliser (tests, SSR). Défaut : `globalThis.fetch`. */
8
+ fetch?: typeof fetch;
9
+ /** Envoi des cookies. Défaut `same-origin` (proxy) ; `include` si l'API est sur un autre domaine déclaré. */
10
+ credentials?: RequestCredentials;
11
+ };
12
+ export type KomaClient = ReturnType<typeof createKomaClient>;
13
+ /** Client HTTP typé du storefront Koma. Chaque méthode rejette avec une `KomaApiError` en cas d'échec. */
14
+ export declare function createKomaClient(options?: KomaClientOptions): {
15
+ /** Identité publique du studio (clé Stripe, essai, fuseau, prix par défaut, branding). */
16
+ studio: () => Promise<Studio>;
17
+ /** Agenda public : cours publiés, à venir, visibles dans le planning. */
18
+ courses: () => Promise<AgendaCourse[]>;
19
+ /** Détail d'un cours publié (404 sinon). */
20
+ course: (slug: string) => Promise<PublicCourse>;
21
+ /** Catalogue public des cartes actives. */
22
+ cardProducts: () => Promise<CardProduct[]>;
23
+ /** Demande un code de connexion. Sans `force`, un email inconnu ne reçoit rien (`unknownAccount`). */
24
+ otpRequest: (email: string, force?: boolean) => Promise<{
25
+ ok: true;
26
+ unknownAccount?: boolean;
27
+ }>;
28
+ otpVerify: (email: string, code: string) => Promise<{
29
+ authenticated: true;
30
+ client: ClientInfo;
31
+ }>;
32
+ /** Session en cours, ou null (401 ou réseau). */
33
+ me: () => Promise<ClientInfo | null>;
34
+ logout: () => Promise<{
35
+ ok: true;
36
+ }>;
37
+ /** Cartes actives + réservations à venir du compte connecté (401 sinon). */
38
+ account: () => Promise<AccountSummary>;
39
+ /** Confort UX : `payIntent` refait foi. */
40
+ trialEligibility: (email: string) => Promise<TrialEligibility>;
41
+ /** PaymentIntent pour un cours (plein tarif, ou essai avec `trial`). Montant calculé côté API. */
42
+ payIntent: (slug: string, identity: CheckoutIdentity, trial?: {
43
+ level: string;
44
+ }) => Promise<PaymentIntentInfo>;
45
+ /** PaymentIntent pour une carte ; `courseSlug` = combo « carte + réservation de ce cours ». */
46
+ cardIntent: (cardProductId: string, identity: CheckoutIdentity, courseSlug?: string) => Promise<PaymentIntentInfo>;
47
+ /** Réservation immédiate avec un crédit (session requise). */
48
+ payWithCredit: (slug: string, name?: string) => Promise<CreditPaymentResult>;
49
+ /** Demande d'annulation (session requise) ; le studio finalise depuis le BO. */
50
+ requestCancellation: (slug: string) => Promise<CancellationResult>;
51
+ contact: (data: {
52
+ name: string;
53
+ email: string;
54
+ message: string;
55
+ website?: string;
56
+ }) => Promise<{
57
+ success: true;
58
+ }>;
59
+ };
package/dist/client.js ADDED
@@ -0,0 +1,73 @@
1
+ import { KomaApiError, errorFromResponse } from "./errors";
2
+ /** Client HTTP typé du storefront Koma. Chaque méthode rejette avec une `KomaApiError` en cas d'échec. */
3
+ export function createKomaClient(options = {}) {
4
+ const baseUrl = (options.baseUrl ?? "/api/v1").replace(/\/$/, "");
5
+ const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
6
+ if (!doFetch)
7
+ throw new Error("@koma-app/core : aucun `fetch` disponible.");
8
+ const credentials = options.credentials ?? "same-origin";
9
+ async function request(method, path, body) {
10
+ const headers = {};
11
+ if (options.studioKey)
12
+ headers["x-studio-key"] = options.studioKey;
13
+ if (body !== undefined)
14
+ headers["content-type"] = "application/json";
15
+ let res;
16
+ try {
17
+ res = await doFetch(`${baseUrl}${path}`, {
18
+ method,
19
+ headers,
20
+ credentials,
21
+ body: body !== undefined ? JSON.stringify(body) : undefined,
22
+ });
23
+ }
24
+ catch {
25
+ throw new KomaApiError("Connexion impossible. Vérifiez votre réseau et réessayez.", 0, "NETWORK");
26
+ }
27
+ if (!res.ok)
28
+ throw await errorFromResponse(res);
29
+ if (res.status === 204)
30
+ return undefined;
31
+ return (await res.json());
32
+ }
33
+ const get = (path) => request("GET", path);
34
+ const post = (path, body = {}) => request("POST", path, body);
35
+ const enc = encodeURIComponent;
36
+ return {
37
+ /** Identité publique du studio (clé Stripe, essai, fuseau, prix par défaut, branding). */
38
+ studio: () => get("/studio"),
39
+ /** Agenda public : cours publiés, à venir, visibles dans le planning. */
40
+ courses: () => get("/courses"),
41
+ /** Détail d'un cours publié (404 sinon). */
42
+ course: (slug) => get(`/courses/${enc(slug)}`),
43
+ /** Catalogue public des cartes actives. */
44
+ cardProducts: () => get("/card-products"),
45
+ /** Demande un code de connexion. Sans `force`, un email inconnu ne reçoit rien (`unknownAccount`). */
46
+ otpRequest: (email, force = false) => post("/auth/otp/request", force ? { email, force: true } : { email }),
47
+ otpVerify: (email, code) => post("/auth/otp/verify", { email, code }),
48
+ /** Session en cours, ou null (401 ou réseau). */
49
+ me: async () => {
50
+ try {
51
+ const r = await get("/me");
52
+ return r.authenticated && r.client ? r.client : null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ },
58
+ logout: () => post("/auth/logout"),
59
+ /** Cartes actives + réservations à venir du compte connecté (401 sinon). */
60
+ account: () => get("/account"),
61
+ /** Confort UX : `payIntent` refait foi. */
62
+ trialEligibility: (email) => post("/essai/eligibility", { email }),
63
+ /** PaymentIntent pour un cours (plein tarif, ou essai avec `trial`). Montant calculé côté API. */
64
+ payIntent: (slug, identity, trial) => post("/pay/intent", { ...identity, slug, ...(trial ? { trial: true, level: trial.level } : {}) }),
65
+ /** PaymentIntent pour une carte ; `courseSlug` = combo « carte + réservation de ce cours ». */
66
+ cardIntent: (cardProductId, identity, courseSlug) => post("/pay/card-intent", { ...identity, cardProductId, ...(courseSlug ? { courseSlug } : {}) }),
67
+ /** Réservation immédiate avec un crédit (session requise). */
68
+ payWithCredit: (slug, name) => post("/pay/credit", name ? { slug, name } : { slug }),
69
+ /** Demande d'annulation (session requise) ; le studio finalise depuis le BO. */
70
+ requestCancellation: (slug) => post(`/courses/${enc(slug)}/cancel`),
71
+ contact: (data) => post("/contact", data),
72
+ };
73
+ }
@@ -0,0 +1,9 @@
1
+ /** Erreur renvoyée par l'API Koma : `{ error: "message lisible", code?: "CODE" }`. */
2
+ export declare class KomaApiError extends Error {
3
+ readonly status: number;
4
+ readonly code: string | undefined;
5
+ constructor(message: string, status: number, code?: string);
6
+ }
7
+ export declare const DEFAULT_ERROR_MESSAGE = "Une erreur est survenue.";
8
+ /** Lit le corps d'erreur JSON de l'API (tolère un corps vide ou non JSON). */
9
+ export declare function errorFromResponse(res: Response): Promise<KomaApiError>;
package/dist/errors.js ADDED
@@ -0,0 +1,28 @@
1
+ /** Erreur renvoyée par l'API Koma : `{ error: "message lisible", code?: "CODE" }`. */
2
+ export class KomaApiError extends Error {
3
+ status;
4
+ code;
5
+ constructor(message, status, code) {
6
+ super(message);
7
+ this.name = "KomaApiError";
8
+ this.status = status;
9
+ this.code = code;
10
+ }
11
+ }
12
+ export const DEFAULT_ERROR_MESSAGE = "Une erreur est survenue.";
13
+ /** Lit le corps d'erreur JSON de l'API (tolère un corps vide ou non JSON). */
14
+ export async function errorFromResponse(res) {
15
+ let message = DEFAULT_ERROR_MESSAGE;
16
+ let code;
17
+ try {
18
+ const body = (await res.json());
19
+ if (typeof body?.error === "string" && body.error)
20
+ message = body.error;
21
+ if (typeof body?.code === "string")
22
+ code = body.code;
23
+ }
24
+ catch {
25
+ /* corps vide ou HTML */
26
+ }
27
+ return new KomaApiError(message, res.status, code);
28
+ }
@@ -0,0 +1,18 @@
1
+ export type FormatOptions = {
2
+ locale?: string;
3
+ timeZone?: string;
4
+ };
5
+ /** `1800` → « 18 € », `1650` → « 16,50 € ». Devise en minuscules côté API (`eur`). */
6
+ export declare function formatPrice(cents: number, currency?: string, locale?: string): string;
7
+ /** « 18h30 » en français, « 6:30 PM » ailleurs. */
8
+ export declare function formatTime(iso: string, opts?: FormatOptions): string;
9
+ /** « 1 h 30 », « 1 h », « 45 min » ; null sans fin ou si la durée est nulle. */
10
+ export declare function formatDuration(startIso: string, endIso: string | null): string | null;
11
+ /** « samedi 3 octobre » (année ajoutée si différente de l'année courante). */
12
+ export declare function formatDayHeading(iso: string, opts?: FormatOptions & {
13
+ now?: Date;
14
+ }): string;
15
+ /** « samedi 3 octobre 2026 » (toujours avec l'année : page cours, compte). */
16
+ export declare function formatFullDate(iso: string, opts?: FormatOptions): string;
17
+ /** Jour civil dans le fuseau (YYYY-MM-DD) — clé de groupement. */
18
+ export declare function dayKey(date: Date, timeZone?: string): string;
package/dist/format.js ADDED
@@ -0,0 +1,48 @@
1
+ // Formateurs d'affichage. Le fuseau vient toujours du studio (`Studio.timezone`), jamais en dur.
2
+ const DEFAULTS = { locale: "fr-FR", timeZone: "Europe/Paris" };
3
+ /** `1800` → « 18 € », `1650` → « 16,50 € ». Devise en minuscules côté API (`eur`). */
4
+ export function formatPrice(cents, currency = "eur", locale = DEFAULTS.locale) {
5
+ const whole = cents % 100 === 0;
6
+ return new Intl.NumberFormat(locale, {
7
+ style: "currency",
8
+ currency: currency.toUpperCase(),
9
+ minimumFractionDigits: whole ? 0 : 2,
10
+ maximumFractionDigits: whole ? 0 : 2,
11
+ }).format(cents / 100);
12
+ }
13
+ /** « 18h30 » en français, « 6:30 PM » ailleurs. */
14
+ export function formatTime(iso, opts = {}) {
15
+ const locale = opts.locale ?? DEFAULTS.locale;
16
+ const s = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", timeZone: opts.timeZone ?? DEFAULTS.timeZone }).format(new Date(iso));
17
+ return locale.startsWith("fr") ? s.replace(":", "h") : s;
18
+ }
19
+ /** « 1 h 30 », « 1 h », « 45 min » ; null sans fin ou si la durée est nulle. */
20
+ export function formatDuration(startIso, endIso) {
21
+ if (!endIso)
22
+ return null;
23
+ const mins = Math.round((new Date(endIso).getTime() - new Date(startIso).getTime()) / 60_000);
24
+ if (mins <= 0)
25
+ return null;
26
+ const h = Math.floor(mins / 60);
27
+ const m = mins % 60;
28
+ if (h === 0)
29
+ return `${m} min`;
30
+ return m > 0 ? `${h} h ${String(m).padStart(2, "0")}` : `${h} h`;
31
+ }
32
+ /** « samedi 3 octobre » (année ajoutée si différente de l'année courante). */
33
+ export function formatDayHeading(iso, opts = {}) {
34
+ const locale = opts.locale ?? DEFAULTS.locale;
35
+ const timeZone = opts.timeZone ?? DEFAULTS.timeZone;
36
+ const date = new Date(iso);
37
+ const year = (d) => new Intl.DateTimeFormat(locale, { year: "numeric", timeZone }).format(d);
38
+ const sameYear = year(date) === year(opts.now ?? new Date());
39
+ return new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", ...(sameYear ? {} : { year: "numeric" }), timeZone }).format(date);
40
+ }
41
+ /** « samedi 3 octobre 2026 » (toujours avec l'année : page cours, compte). */
42
+ export function formatFullDate(iso, opts = {}) {
43
+ return new Intl.DateTimeFormat(opts.locale ?? DEFAULTS.locale, { weekday: "long", day: "numeric", month: "long", year: "numeric", timeZone: opts.timeZone ?? DEFAULTS.timeZone }).format(new Date(iso));
44
+ }
45
+ /** Jour civil dans le fuseau (YYYY-MM-DD) — clé de groupement. */
46
+ export function dayKey(date, timeZone = DEFAULTS.timeZone) {
47
+ return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
48
+ }
@@ -0,0 +1,8 @@
1
+ export * from "./types";
2
+ export { KomaApiError, DEFAULT_ERROR_MESSAGE } from "./errors";
3
+ export { createKomaClient, type KomaClient, type KomaClientOptions } from "./client";
4
+ export * from "./format";
5
+ export * from "./agenda";
6
+ export * from "./storage";
7
+ export * from "./calendar";
8
+ export * from "./seo";
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./types";
2
+ export { KomaApiError, DEFAULT_ERROR_MESSAGE } from "./errors";
3
+ export { createKomaClient } from "./client";
4
+ export * from "./format";
5
+ export * from "./agenda";
6
+ export * from "./storage";
7
+ export * from "./calendar";
8
+ export * from "./seo";
package/dist/seo.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { AgendaCourse } from "./types";
2
+ export type JsonLdOptions = {
3
+ /** URL publique de la page d'un cours (ex. `slug => https://www.mon-studio.fr/cours/${slug}`). */
4
+ courseUrl: (slug: string) => string;
5
+ /** Organisateur / intervenant (le studio ou le professeur). */
6
+ organizer: {
7
+ name: string;
8
+ url?: string;
9
+ type?: "Person" | "Organization";
10
+ };
11
+ /** Image partagée (og:image) — recommandée par Google pour les événements. */
12
+ image?: string | string[];
13
+ /** Ville et pays du lieu (l'API ne renvoie que le nom et l'adresse). */
14
+ locality?: string;
15
+ country?: string;
16
+ currency?: string;
17
+ };
18
+ /** Données structurées schema.org `Event` d'un cours de l'agenda, à injecter dans un `<script type="application/ld+json">`. */
19
+ export declare function courseJsonLd(c: AgendaCourse, o: JsonLdOptions): Record<string, unknown>;
package/dist/seo.js ADDED
@@ -0,0 +1,35 @@
1
+ /** Données structurées schema.org `Event` d'un cours de l'agenda, à injecter dans un `<script type="application/ld+json">`. */
2
+ export function courseJsonLd(c, o) {
3
+ const who = { "@type": o.organizer.type ?? "Organization", name: o.organizer.name, ...(o.organizer.url ? { url: o.organizer.url } : {}) };
4
+ return {
5
+ "@context": "https://schema.org",
6
+ "@type": "Event",
7
+ name: c.title,
8
+ startDate: c.startsAt,
9
+ ...(c.endsAt ? { endDate: c.endsAt } : {}),
10
+ ...(c.description ? { description: c.description } : {}),
11
+ ...(o.image ? { image: Array.isArray(o.image) ? o.image : [o.image] } : {}),
12
+ location: {
13
+ "@type": "Place",
14
+ name: c.locationName,
15
+ address: {
16
+ "@type": "PostalAddress",
17
+ streetAddress: c.locationAddress,
18
+ ...(o.locality ? { addressLocality: o.locality } : {}),
19
+ ...(o.country ? { addressCountry: o.country } : {}),
20
+ },
21
+ },
22
+ organizer: who,
23
+ performer: who,
24
+ eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
25
+ eventStatus: "https://schema.org/EventScheduled",
26
+ offers: {
27
+ "@type": "Offer",
28
+ url: o.courseUrl(c.slug),
29
+ price: (c.priceCents / 100).toFixed(2),
30
+ priceCurrency: (o.currency ?? "eur").toUpperCase(),
31
+ availability: c.full ? "https://schema.org/SoldOut" : "https://schema.org/InStock",
32
+ ...(c.createdAt ? { validFrom: c.createdAt } : {}),
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,23 @@
1
+ import type { CheckoutIdentity } from "./types";
2
+ type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
3
+ /** Nom et email mémorisés pour pré-remplir les prochains achats (localStorage). */
4
+ export declare function loadSavedIdentity(store?: StorageLike | null): CheckoutIdentity;
5
+ export declare function saveIdentity(identity: CheckoutIdentity, store?: StorageLike | null): void;
6
+ /** Réservation mémorisée par cours : la page ré-affiche « C'est confirmé » sans session (le serveur fait foi une fois connecté). */
7
+ export declare function localReservationEmail(slug: string, store?: StorageLike | null): string | null;
8
+ export declare function saveLocalReservation(slug: string, email: string, store?: StorageLike | null): void;
9
+ export declare function clearLocalReservation(slug: string, store?: StorageLike | null): void;
10
+ /** Verrou post-paiement (sessionStorage) : la connexion reste sur l'email d'achat le temps de l'onglet. */
11
+ export declare function savePurchaseLock(email: string, store?: StorageLike | null): void;
12
+ export declare function purchaseLockEmail(store?: StorageLike | null): string | null;
13
+ export declare function clearPurchaseLock(store?: StorageLike | null): void;
14
+ /** Contexte du parcours essai (sessionStorage) : /essai → page cours en mode essai. */
15
+ export type TrialInfo = {
16
+ name: string;
17
+ email: string;
18
+ level: string;
19
+ };
20
+ export declare function saveTrialInfo(info: TrialInfo, store?: StorageLike | null): void;
21
+ export declare function loadTrialInfo(store?: StorageLike | null): TrialInfo | null;
22
+ export declare function clearTrialInfo(store?: StorageLike | null): void;
23
+ export {};
@@ -0,0 +1,70 @@
1
+ // Mémoire de l'appareil (localStorage / sessionStorage), tolérante à la navigation privée.
2
+ // Préfixe `koma.` ; un `Storage` peut être injecté (tests, SSR).
3
+ const PREFIX = "koma.";
4
+ const local = () => (typeof localStorage !== "undefined" ? localStorage : null);
5
+ const session = () => (typeof sessionStorage !== "undefined" ? sessionStorage : null);
6
+ function read(store, key) {
7
+ try {
8
+ const raw = store?.getItem(PREFIX + key);
9
+ return raw ? JSON.parse(raw) : null;
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ function write(store, key, value) {
16
+ try {
17
+ store?.setItem(PREFIX + key, JSON.stringify(value));
18
+ }
19
+ catch {
20
+ /* navigation privée : pas bloquant */
21
+ }
22
+ }
23
+ function remove(store, key) {
24
+ try {
25
+ store?.removeItem(PREFIX + key);
26
+ }
27
+ catch {
28
+ /* rien */
29
+ }
30
+ }
31
+ /** Nom et email mémorisés pour pré-remplir les prochains achats (localStorage). */
32
+ export function loadSavedIdentity(store = local()) {
33
+ const saved = read(store, "identity");
34
+ return { name: saved?.name ?? "", email: saved?.email ?? "", website: "" };
35
+ }
36
+ export function saveIdentity(identity, store = local()) {
37
+ write(store, "identity", { name: identity.name, email: identity.email });
38
+ }
39
+ /** Réservation mémorisée par cours : la page ré-affiche « C'est confirmé » sans session (le serveur fait foi une fois connecté). */
40
+ export function localReservationEmail(slug, store = local()) {
41
+ return read(store, `reserved.${slug}`)?.email ?? null;
42
+ }
43
+ export function saveLocalReservation(slug, email, store = local()) {
44
+ write(store, `reserved.${slug}`, { email: email.trim().toLowerCase(), at: new Date().toISOString() });
45
+ }
46
+ export function clearLocalReservation(slug, store = local()) {
47
+ remove(store, `reserved.${slug}`);
48
+ }
49
+ /** Verrou post-paiement (sessionStorage) : la connexion reste sur l'email d'achat le temps de l'onglet. */
50
+ export function savePurchaseLock(email, store = session()) {
51
+ write(store, "purchase-lock", email.trim().toLowerCase());
52
+ }
53
+ export function purchaseLockEmail(store = session()) {
54
+ return read(store, "purchase-lock") || null;
55
+ }
56
+ export function clearPurchaseLock(store = session()) {
57
+ remove(store, "purchase-lock");
58
+ }
59
+ export function saveTrialInfo(info, store = session()) {
60
+ write(store, "trial", info);
61
+ }
62
+ export function loadTrialInfo(store = session()) {
63
+ const saved = read(store, "trial");
64
+ if (!saved?.email || !saved.level)
65
+ return null;
66
+ return { name: saved.name ?? "", email: saved.email, level: saved.level };
67
+ }
68
+ export function clearTrialInfo(store = session()) {
69
+ remove(store, "trial");
70
+ }
@@ -0,0 +1,108 @@
1
+ export type StudioBranding = Record<string, unknown> | null;
2
+ export type Studio = {
3
+ slug: string;
4
+ name: string;
5
+ locale: string;
6
+ currency: string;
7
+ timezone: string;
8
+ trialEnabled: boolean;
9
+ trialPriceCents: number | null;
10
+ cancellationMinHours: number;
11
+ /** Prix d'un cours à l'unité (affichage Tarifs) ; null si le studio ne l'a pas renseigné. */
12
+ defaultPriceCents: number | null;
13
+ publicBaseUrl: string | null;
14
+ /** Clé publishable Stripe du studio : initialise Stripe.js côté site. */
15
+ stripePublishableKey: string | null;
16
+ branding: StudioBranding;
17
+ };
18
+ /** Cours de l'agenda public (`GET /courses`). */
19
+ export type AgendaCourse = {
20
+ slug: string;
21
+ title: string;
22
+ startsAt: string;
23
+ endsAt: string | null;
24
+ priceCents: number;
25
+ locationName: string;
26
+ locationAddress: string;
27
+ description: string | null;
28
+ createdAt: string;
29
+ spotsLeft: number;
30
+ full: boolean;
31
+ myRegistered: boolean;
32
+ };
33
+ export type MyReservation = {
34
+ createdAt: string;
35
+ cancellationRequestedAt: string | null;
36
+ };
37
+ export type MyCredits = {
38
+ total: number;
39
+ nextExpiresAt: string | null;
40
+ };
41
+ /** Détail d'un cours (`GET /courses/:slug`). `kind` = clé du type (`cours`, `stage`, …). */
42
+ export type PublicCourse = AgendaCourse & {
43
+ kind: string;
44
+ capacity: number;
45
+ listed: boolean;
46
+ cardAccepted: boolean;
47
+ cancellationMinHours: number;
48
+ myReservation: MyReservation | null;
49
+ myCredits: MyCredits | null;
50
+ };
51
+ export type CardProduct = {
52
+ id: string;
53
+ name: string;
54
+ credits: number;
55
+ priceCents: number;
56
+ validityMonths: number;
57
+ position: number;
58
+ };
59
+ export type ClientInfo = {
60
+ email: string;
61
+ firstName: string | null;
62
+ lastName: string | null;
63
+ };
64
+ export type AccountCard = {
65
+ id: number;
66
+ creditsLeft: number;
67
+ totalCredits: number;
68
+ expiresAt: string;
69
+ };
70
+ export type PaymentMethod = "onsite" | "card" | "credit";
71
+ export type AccountReservation = {
72
+ courseSlug: string;
73
+ courseKind: string;
74
+ courseTitle: string;
75
+ startsAt: string;
76
+ endsAt: string | null;
77
+ locationName: string;
78
+ paymentMethod: PaymentMethod;
79
+ cancellationRequestedAt: string | null;
80
+ };
81
+ export type AccountSummary = {
82
+ email: string;
83
+ cards: AccountCard[];
84
+ reservations: AccountReservation[];
85
+ };
86
+ /** Identité saisie au paiement : un seul champ « Prénom et nom » + email (décision du 26/09). */
87
+ export type CheckoutIdentity = {
88
+ name: string;
89
+ email: string;
90
+ website?: string;
91
+ };
92
+ export type PaymentIntentInfo = {
93
+ clientSecret: string;
94
+ amountCents: number;
95
+ };
96
+ export type TrialEligibility = {
97
+ eligible: boolean;
98
+ trialEnabled: boolean;
99
+ };
100
+ export type CreditPaymentResult = {
101
+ success: true;
102
+ creditsLeft: number;
103
+ nextExpiresAt: string | null;
104
+ };
105
+ export type CancellationResult = {
106
+ success: true;
107
+ cancellationRequestedAt: string;
108
+ };
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // Types des réponses du storefront Koma (`/api/v1`). Source de vérité : docs/api.md.
2
+ export {};
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@koma-app/core",
3
+ "version": "0.1.0",
4
+ "description": "Client typé, types et formateurs du storefront Koma — sans React.",
5
+ "license": "SEE LICENSE IN LICENSE.md",
6
+ "author": "Koma <contact@koma-app.fr>",
7
+ "homepage": "https://koma-app.fr",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/yannsheriff/studioflow.git",
11
+ "directory": "packages/core"
12
+ },
13
+ "keywords": [
14
+ "koma",
15
+ "studio",
16
+ "cours",
17
+ "réservation",
18
+ "api-client"
19
+ ],
20
+ "type": "module",
21
+ "main": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE.md"
33
+ ],
34
+ "sideEffects": false,
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "vitest run",
45
+ "prepack": "npm run build"
46
+ },
47
+ "devDependencies": {
48
+ "typescript": "^5.6.0",
49
+ "vitest": "^3.2.7"
50
+ }
51
+ }