/** * Shortcode options, from one spec (Razorbooking_Shortcodes::spec(), served * at `shortcodes`): * * - ShortcodeBuilder (Embed dialog): the shortcode, and a control per option * that rewrites it. Only options that differ from their default are written. * - ShortcodeReference (Support): every shortcode, option, value and default. */ import { useEffect, useMemo, useState } from 'react'; import { CopyField, Select, Switch, TextField, cn } from '../ds'; import { teamFor, type EmbedCatalog, type EmbedShows } from '../design-system/embed/embed-snippet'; import { SHOWS_OPTIONS } from '../../common/embed'; import { api } from '../lib/api'; import { useAsync } from '../lib/useAsync'; export interface ShortcodeAttr { name: string; label: string; type: 'service' | 'variation' | 'quantity' | 'category' | 'member' | 'select' | 'switch' | 'number' | 'text'; /** * The value when left out. `null` means no value expresses the default * (an omitted title is the booking site's own), so the option is written * whenever it is set, empty included. */ default: string | null; /** What an empty field shows. */ placeholder?: string; /** The builder's initial value when it differs from `default` (so it is written out). */ start?: string; options?: { value: string; label: string; disabled?: boolean }[]; min?: number; max?: number; help?: string; required?: boolean; /** Shown only while another option holds one of these values. */ when?: Record< string, string[] >; } export interface ShortcodeSpec { tag: string; title: string; description: string; block: string; attrs: ShortcodeAttr[]; } let specCache: Promise< ShortcodeSpec[] > | null = null; /** Fetched once per page load. */ function useShortcodeSpec() { return useAsync< ShortcodeSpec[] >( () => { specCache = specCache ?? api.get< ShortcodeSpec[] >( 'shortcodes' ).catch( ( caught ) => { specCache = null; throw caught; } ); return specCache; } ); } /** The embed catalog, plus whether each service and person is active and bookable. */ type Catalog = Omit< EmbedCatalog, 'services' | 'team' > & { services: ( EmbedCatalog[ 'services' ][ number ] & { active?: boolean } )[]; team: ( EmbedCatalog[ 'team' ][ number ] & { active?: boolean; bookable?: boolean } )[]; }; /** Each option's current value: what was set, else where the builder starts, else the default. */ function resolve( spec: ShortcodeSpec, values: Record< string, string > ): Record< string, string > { return Object.fromEntries( spec.attrs.map( ( a ) => [ a.name, values[ a.name ] ?? a.start ?? a.default ?? '' ] ) ); } /** * `[tag a="1" b="yes"]`, writing only visible options that differ from their * default. An option with a `null` default is written whenever it was set, * so `title=""` means "no title". */ function buildShortcode( spec: ShortcodeSpec, values: Record< string, string > ): string { const current = resolve( spec, values ); const parts = spec.attrs .filter( ( attr ) => visible( attr, current ) ) .filter( ( attr ) => ( attr.default === null ? attr.name in values : clampToLimits( attr, current[ attr.name ] ) !== attr.default ) ) // A shortcode value cannot contain a straight double quote. .map( ( attr ) => `${ attr.name }="${ clampToLimits( attr, current[ attr.name ] ).replace( /"/g, '”' ) }"` ); return `[${ [ spec.tag, ...parts ].join( ' ' ) }]`; } /** A number option clamped to its min and max, as PHP reads it. */ function clampToLimits( attr: ShortcodeAttr, value: string ): string { if ( attr.type !== 'number' || value === '' || Number.isNaN( Number( value ) ) ) return value; return String( clamp( Math.floor( Number( value ) ), attr.min, attr.max ) ); } const clamp = ( n: number, min?: number, max?: number ) => Math.min( max ?? Infinity, Math.max( min ?? -Infinity, n ) ); function visible( attr: ShortcodeAttr, values: Record< string, string > ): boolean { if ( ! attr.when ) return true; return Object.entries( attr.when ).every( ( [ name, allowed ] ) => allowed.includes( values[ name ] ?? '' ) ); } /** The options "Shows" governs, in the order they are asked. */ const SCOPE = [ 'service', 'variation', 'quantity', 'category', 'staff' ]; /** Select has no empty value; this stands for "any" (anyone, any variation). */ const ANY = 'any'; let catalogCache: Promise< Catalog > | null = null; /** Fetched once per page load, and only for a shortcode with a service option. */ function useCatalog( needed: boolean ) { return useAsync< Catalog | null >( () => { if ( ! needed ) return Promise.resolve( null ); catalogCache = catalogCache ?? api.get< Catalog >( 'embed-catalog' ).catch( ( caught ) => { catalogCache = null; throw caught; } ); return catalogCache; }, [ needed ] ); } export interface ShortcodeBuilderProps { tag: string; /** Options the embed already decided — the service, category or person it is for. */ initial?: Record< string, string >; className?: string; } export function ShortcodeBuilder( { tag, initial = {}, className }: ShortcodeBuilderProps ) { const specs = useShortcodeSpec(); const spec = specs.data?.find( ( s ) => s.tag === tag ); const [ values, setValues ] = useState< Record< string, string > >( initial ); const scoped = !! spec?.attrs.some( ( a ) => a.name === 'service' ); const catalog = useCatalog( scoped ).data ?? null; const merged = useMemo( () => ( spec ? resolve( spec, values ) : {} ), [ spec, values ] ); const shows: EmbedShows = merged.service ? 'service' : merged.category ? 'category' : 'all'; const chosen = catalog?.services.find( ( s ) => String( s.id ) === merged.service ) ?? null; const scopeId = shows === 'service' ? merged.service : shows === 'category' ? merged.category : null; const team = useMemo( () => catalog ? ( teamFor( catalog, shows, scopeId ) as Catalog[ 'team' ] ).filter( ( p ) => ( p.active !== false && p.bookable !== false ) || String( p.id ) === merged.staff ) : [], [ catalog, shows, scopeId, merged.staff ] ); // Clear the team member when they no longer offer what it shows. useEffect( () => { if ( catalog && merged.staff && ! team.some( ( p ) => String( p.id ) === merged.staff ) ) { setValues( ( current ) => ( { ...current, staff: '' } ) ); } }, [ catalog, team, merged.staff ] ); // Until the spec loads (or if it fails), show the shortcode with the item's own options. if ( ! spec ) { const fallback = `[${ [ tag, ...Object.entries( initial ).map( ( [ k, v ] ) => `${ k }="${ v }"` ) ].join( ' ' ) }]`; return ; } /** `null` unsets an option: the shortcode leaves it out. */ const set = ( name: string, value: string | null ) => setValues( ( current ) => { if ( value !== null ) return { ...current, [ name ]: value }; const rest = { ...current }; delete rest[ name ]; return rest; } ); const setService = ( id: string ) => setValues( ( current ) => ( { ...current, service: id, variation: '', quantity: '' } ) ); const choose = ( next: EmbedShows ) => setValues( ( current ) => ( { ...current, service: next === 'service' ? current.service || initial.service || String( catalog?.services.find( ( s ) => s.active !== false )?.id ?? '' ) : '', variation: '', quantity: '', category: next === 'category' ? current.category || initial.category || String( catalog?.categories[ 0 ]?.id ?? '' ) : '', } ) ); // Variation and quantity apply only to a service that has them. const applies = ( attr: ShortcodeAttr ) => { switch ( attr.name ) { case 'service': return shows === 'service'; case 'variation': return shows === 'service' && !! chosen?.versions.length; case 'quantity': return shows === 'service' && !! chosen?.quantity; case 'category': return shows === 'category'; default: return true; } }; const scopeAttrs = scoped ? spec.attrs.filter( ( a ) => SCOPE.includes( a.name ) && applies( a ) ) : []; const rest = spec.attrs.filter( ( a ) => ! ( scoped && SCOPE.includes( a.name ) ) && visible( a, merged ) ); const control = ( attr: ShortcodeAttr ) => ( ( attr.name === 'service' ? setService( v ?? '' ) : set( attr.name, v ) ) } catalog={ catalog } chosen={ chosen } team={ team } /> ); return (
Options

The shortcode above updates as you change these.

{ scoped ? { attr.help ?

{ attr.help }

: null }
); case 'service': return ( ( { label: v.name, value: String( v.id ) } ) ) ] } onChange={ ( v ) => onChange( v === ANY ? '' : v ) } /> ); case 'quantity': { const q = chosen?.quantity; const least = String( q?.min ?? 1 ); return ( onChange( ! q || next === '' || Number( next ) === q.min ? '' : next ) } /> ); } case 'category': return ( ( { label: m.name, value: String( m.id ) } ) ) ] } onChange={ ( v ) => onChange( v === ANY ? '' : v ) } /> ); case 'number': return ( ); default: // A `null` default (a content title) has three states, as in the block: // left out (the booking site's own), a text, or none (`title=""`). if ( attr.default === null ) { const shown = ! ( isSet && value === '' ); return (
onChange( on ? null : '' ) } /> { shown ? ( onChange( e.target.value === '' ? null : e.target.value ) } /> ) : null }
); } return (
onChange( e.target.value ) } />
); } } interface ClampedNumberProps { label: string; value: string; min?: number; max?: number; /** What an emptied field goes back to when it loses focus. */ fallback: string; hint?: string; onCommit: ( value: string ) => void; } /** * A number field that commits only in-range values while typing (so "12" can * be typed with a minimum of 2) and clamps on blur. */ function ClampedNumber( { label, value, min, max, fallback, hint, onCommit }: ClampedNumberProps ) { const [ text, setText ] = useState( value ); useEffect( () => setText( value ), [ value ] ); return ( { const raw = e.target.value.replace( /[^\d]/g, '' ); setText( raw ); if ( raw !== '' && clamp( Number( raw ), min, max ) === Number( raw ) ) onCommit( String( Number( raw ) ) ); } } onBlur={ () => { const next = text === '' ? fallback : String( clamp( Number( text ), min, max ) ); setText( next ); onCommit( next ); } } /> ); } const VALUES: Partial< Record< ShortcodeAttr[ 'type' ], string > > = { service: 'a service ID', variation: 'a version’s ID', quantity: 'a number, within the service’s limits', category: 'a category ID', member: 'a team member ID', switch: 'yes or no', text: 'any text', }; function valuesOf( attr: ShortcodeAttr ): string { if ( attr.options ) return attr.options.map( ( o ) => o.value ).join( ', ' ); if ( attr.type === 'number' ) return attr.min != null && attr.max != null ? `${ attr.min }–${ attr.max }` : 'a number'; return VALUES[ attr.type ] ?? ''; } export function ShortcodeReference() { const specs = useShortcodeSpec(); if ( ! specs.data ) { return

{ specs.error ? 'Couldn’t load the list of shortcodes.' : 'Loading…' }

; } return (

Everything here can also be added with a shortcode, in widget areas, page builders, and theme templates. The easiest way to get one is to click Embed on the item (on Add to site, Services, Categories, or Team). It builds the shortcode as you choose options, IDs included. In the booking shortcode, use service (plus variation and quantity) or category to narrow what it shows, or leave both out for all services. Use staff to pick a team member.

{ specs.data.map( ( spec ) => (

{ spec.title }

{ spec.description }

{ spec.attrs.length ? (
{ spec.attrs.map( ( attr ) => ( ) ) }
Option What it does Values Default
{ attr.name } { attr.label } { attr.help ? { attr.help } : null } { valuesOf( attr ) } { attr.required ? 'required' : attr.default === null ? attr.placeholder ?? '—' : attr.default === '' ? '—' : attr.default }
) : null }
) ) }
); }