{
	"$schema": "https://playground.wordpress.net/blueprint-schema.json",
	"meta": {
		"title": "Abientot demo: Brasswick Barbers",
		"author": "AbsoluDev",
		"description": "A fictional business with a booking website, services and two months of bookings."
	},
	"landingPage": "/",
	"preferredVersions": {
		"php": "8.3",
		"wp": "latest"
	},
	"steps": [
		{
			"step": "login",
			"username": "admin"
		},
		{
			"step": "mkdir",
			"path": "/wordpress/wp-content/mu-plugins"
		},
		{
			"step": "writeFile",
			"path": "/wordpress/wp-content/mu-plugins/abientot-demo.php",
			"data": "<?php\n/**\n * Plugin Name: Abientot demo sandbox\n * Description: For this throwaway Playground only. Keeps mail inside the browser tab and lets saves run on the SQLite preview database.\n */\n\n// Playground's SQLite layer answers GET_LOCK with a dummy value, rejects SET TRANSACTION and has no InnoDB, so Abientot refuses saves.\n// One visitor, one private database: nothing can compete here. Real sites use MySQL and never load this file.\nadd_filter(\n\t'query',\n\tstatic function ( $query ) {\n\t\t$query = preg_replace( '/TIMESTAMPDIFF\\(\\s*MINUTE\\s*,\\s*([\\w.`]+)\\s*,\\s*([\\w.`]+)\\s*\\)/i', '((UNIX_TIMESTAMP($2)-UNIX_TIMESTAMP($1))/60)', (string) $query );\n\t\treturn preg_match( '/^\\s*SELECT\\s+ENGINE\\s+FROM\\s+information_schema\\./i', (string) $query )\n\t\t\t? \"SELECT 'InnoDB' AS ENGINE\"\n\t\t\t: ( preg_match( '/^\\s*(SELECT\\s+(GET_LOCK|RELEASE_LOCK)\\s*\\(|SET\\s+TRANSACTION\\b)/i', (string) $query ) ? \"SELECT '1'\" : $query );\n\t}\n);\n\n// Reports and history use functions SQLite does not have.\nglobal $wpdb;\nif ( isset( $wpdb->dbh ) && method_exists( $wpdb->dbh, 'get_connection' ) ) {\n\t$wpdb->dbh->get_connection()->get_pdo()->sqliteCreateFunction(\n\t\t'lpad',\n\t\tstatic function ( $value, $length, $pad ) {\n\t\t\treturn str_pad( (string) $value, (int) $length, (string) $pad, STR_PAD_LEFT );\n\t\t},\n\t\t3\n\t);\n\t$wpdb->dbh->get_connection()->get_pdo()->sqliteCreateFunction(\n\t\t'json_unquote',\n\t\tstatic function ( $value ) {\n\t\t\treturn null === $value ? null : trim( (string) $value, '\"' );\n\t\t},\n\t\t1\n\t);\n}\n\n// Addresses typed in the demo must never receive anything.\nadd_filter( 'pre_wp_mail', '__return_true' );\n"
		},
		{
			"step": "writeFile",
			"path": "/wordpress/wp-content/mu-plugins/abientot-demo-site.php",
			"data": "<?php\n/**\n * Plugin Name: Abientot demo website\n * Description: The one-page website of this fictional business. Exists only in this throwaway Playground.\n */\n\n// Demo images live on abientot.app, so they are never copied into the Playground.\nadd_filter(\n\t'image_downsize',\n\tstatic function ( $out, $id, $size ) {\n\t\t$url = get_post_meta( (int) $id, '_abientot_demo_image', true );\n\t\treturn $url ? array( $url, 320, 320, false ) : $out;\n\t},\n\t10,\n\t3\n);\nadd_filter(\n\t'wp_get_attachment_url',\n\tstatic function ( $url, $id ) {\n\t\treturn get_post_meta( (int) $id, '_abientot_demo_image', true ) ?: $url;\n\t},\n\t10,\n\t2\n);\n\nadd_filter( 'show_admin_bar', static fn( $show ) => is_admin() ? $show : false );\n\nadd_action(\n\t'template_redirect',\n\tstatic function () {\n\t\t$demo = get_option( 'abientot_demo' );\n\t\tif ( ! is_array( $demo ) || ! is_front_page() ) {\n\t\t\treturn;\n\t\t}\n\t\t$form  = do_shortcode( '[abientot_booking_form]' );\n\t\t$theme = $demo['theme'];\n\t\t// Only these source-owned stacks may be emitted as CSS; HTML entities break font names in <style>.\n\t\t$fonts = array(\n\t\t\t'system'   => 'system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif',\n\t\t\t'classic'  => 'Georgia, \"Times New Roman\", serif',\n\t\t\t'oldstyle' => '\"Iowan Old Style\", \"Palatino Linotype\", Palatino, Georgia, serif',\n\t\t\t'clean'    => '\"Helvetica Neue\", Arial, sans-serif',\n\t\t);\n\t\t$heading_font = $fonts[ $theme['heading'] ] ?? $fonts['system'];\n\t\t$body_font    = $fonts['system'];\n\t\t$image = static fn( string $name ): string => esc_url( $demo['images'] . $name . '.webp' . $demo['image_version'] );\n\t\t$days  = array( 1 => 'Monday', 2 => 'Tuesday', 3 => 'Wednesday', 4 => 'Thursday', 5 => 'Friday', 6 => 'Saturday', 0 => 'Sunday' );\n\t\t?>\n<!doctype html>\n<html <?php language_attributes(); ?>>\n<head>\n<meta charset=\"<?php bloginfo( 'charset' ); ?>\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<title><?php echo esc_html( $demo['business'] . ' – ' . $demo['tagline'] ); ?></title>\n\t\t<?php wp_head(); ?>\n<style>\n:root{--bg:<?php echo esc_html( $theme['bg'] ); ?>;--surface:<?php echo esc_html( $theme['surface'] ); ?>;--text:<?php echo esc_html( $theme['text'] ); ?>;--muted:<?php echo esc_html( $theme['muted'] ); ?>;--accent:<?php echo esc_html( $theme['accent'] ); ?>;--on-accent:<?php echo esc_html( $theme['on_accent'] ); ?>;--line:<?php echo esc_html( $theme['line'] ); ?>;--radius:<?php echo esc_html( $theme['radius'] ); ?>;--heading:<?php echo $heading_font; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Fixed allowlist above. ?>;--body:<?php echo $body_font; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Fixed allowlist above. ?>}\n*{box-sizing:border-box}\nhtml{scroll-behavior:smooth}\nbody{margin:0;background:var(--bg);color:var(--text);font:400 17px/1.6 var(--body)}\nimg{display:block;max-width:100%}\na{color:inherit}\n.d-wrap{width:min(1120px,100%);margin:0 auto;padding:0 24px}\n.d-header{position:sticky;top:0;z-index:20}\n.d-strip{background:var(--surface);color:var(--text);font-size:13px;line-height:1.35;border-bottom:1px solid var(--line)}\n.d-strip .d-wrap{display:flex;justify-content:space-between;gap:16px;align-items:center;padding-top:9px;padding-bottom:9px}\n.d-admin{display:inline-flex;align-items:center;justify-content:center;flex:none;background:var(--text);color:var(--bg);border:2px solid var(--accent);border-radius:var(--radius);padding:10px 16px;font-weight:700;white-space:nowrap;text-decoration:none}\n.d-admin:hover{filter:brightness(1.12)}\n.d-nav{background:var(--bg);border-bottom:1px solid var(--line)}\n.d-nav .d-wrap{display:flex;justify-content:space-between;align-items:center;min-height:68px;gap:16px}\n.d-brand{min-width:0;overflow-wrap:anywhere;font:700 21px/1.15 var(--heading);text-decoration:none;letter-spacing:.01em}\n.d-button{display:inline-block;background:var(--accent);color:var(--on-accent);padding:13px 24px;border-radius:var(--radius);font-weight:600;text-decoration:none;white-space:nowrap;transition:transform .15s ease,filter .15s ease}\n.d-button:hover{filter:brightness(1.08);transform:translateY(-1px)}\n.d-button:focus-visible,.d-admin:focus-visible,.d-brand:focus-visible{outline:3px solid var(--text);outline-offset:4px}\n.d-nav .d-button{padding:9px 18px;font-size:15px}\n.d-hero{position:relative;min-height:min(78vh,640px);display:flex;align-items:center;background:var(--surface) url(\"<?php echo $image( 'hero' ); // phpcs:ignore ?>\") right center/cover no-repeat}\n.d-hero:before{content:\"\";position:absolute;inset:0;background:<?php echo esc_html( $theme['hero_overlay'] ); ?>}\n.d-hero .d-wrap{position:relative;padding-top:64px;padding-bottom:64px}\n.d-copy{max-width:540px}\n.d-kicker{margin:0 0 14px;color:var(--accent);font-size:14px;font-weight:700;letter-spacing:.09em;text-transform:uppercase}\nh1,h2,h3{font-family:var(--heading);line-height:1.12;margin:0}\nh1{font-size:clamp(38px,6vw,62px);font-weight:700;letter-spacing:-.015em}\nh2{font-size:clamp(28px,4vw,40px);font-weight:700;letter-spacing:-.01em}\n.d-intro{margin:20px 0 30px;font-size:19px;color:var(--muted)}\n.d-points{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(3,1fr)}\n.d-points-wrap{background:var(--surface)}\n.d-points li{padding:22px 16px;text-align:center;font-weight:600;font-size:15px}\n.d-points li+li{border-left:1px solid var(--line)}\n.d-book{padding:72px 0}\n.d-book,#top{scroll-margin-top:140px}\n.d-book .d-wrap{display:flex;flex-direction:column;align-items:center}\n.d-book .d-wrap>*{width:100%;max-width:860px;margin-left:auto;margin-right:auto}\n.d-about{background:var(--surface);padding:72px 0}\n.d-about .d-wrap{display:grid;grid-template-columns:1.1fr 1fr;gap:56px;align-items:center}\n.d-about-image{width:100%;aspect-ratio:3/2;object-fit:cover;border-radius:var(--radius)}\n.d-about p{color:var(--muted)}\n.d-person{display:flex;gap:18px;align-items:center;margin-top:28px;padding-top:26px;border-top:1px solid var(--line)}\n.d-person img{width:84px;height:84px;object-fit:cover;border-radius:50%;flex:none}\n.d-person strong{display:block;font-size:17px}\n.d-person span{display:block;color:var(--muted);font-size:14.5px}\n.d-person p{margin:8px 0 0;font-size:15px}\n.d-info{padding:72px 0}\n.d-info .d-wrap{display:grid;grid-template-columns:1fr 1fr;gap:56px}\n.d-hours{list-style:none;margin:22px 0 0;padding:0}\n.d-hours li{display:flex;justify-content:space-between;gap:20px;padding:9px 0;border-bottom:1px solid var(--line);font-size:16px}\n.d-hours span:last-child{color:var(--muted);text-align:right}\n.d-contact{margin:22px 0 0;color:var(--muted)}\n.d-foot{border-top:1px solid var(--line);padding:26px 0;color:var(--muted);font-size:14px;text-align:center}\n/* Desktop Playground overlays its expanded toolbar; mobile reserves space outside this document. */\n@media (min-width:761px){body{padding-bottom:160px}}\n@media (max-width:760px){\nbody{font-size:16px}\n.d-wrap{padding:0 16px}\n.d-strip .d-wrap{gap:12px;font-size:12px}\n.d-admin{padding:9px 10px;font-size:13px}\n.d-nav .d-wrap{gap:12px;padding-top:12px;padding-bottom:12px}\n.d-brand{font-size:18px}\n.d-nav .d-button{flex:none;padding:9px 12px;font-size:13px}\n.d-book,#top{scroll-margin-top:180px}\n.d-hero{min-height:0;display:block;padding-top:66vw;background-color:var(--bg);background-size:100% auto;background-position:center top}\n.d-hero:before{display:none}\n.d-hero .d-wrap{padding-top:28px;padding-bottom:36px}\n.d-points{grid-template-columns:1fr}\n.d-points li{padding:12px 8px}\n.d-points li+li{border-left:0;border-top:1px solid var(--line)}\n.d-book,.d-about,.d-info{padding:44px 0}\n.d-about .d-wrap,.d-info .d-wrap{grid-template-columns:1fr;gap:28px}\n}\n@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}.d-button{transition:none}}\n</style>\n</head>\n<body class=\"abientot-demo\">\n\t\t<?php wp_body_open(); ?>\n<header class=\"d-header\">\n<div class=\"d-strip\"><div class=\"d-wrap\"><span>Abientot demo · Fictional business.<br>Use made-up details.</span><a class=\"d-admin\" href=\"<?php echo esc_url( admin_url( 'admin.php?page=abientot' ) ); ?>\">Open the admin →</a></div></div>\n<nav class=\"d-nav\" aria-label=\"Demo website\"><div class=\"d-wrap\"><a class=\"d-brand\" href=\"#top\"><?php echo esc_html( $demo['business'] ); ?></a><a class=\"d-button\" href=\"#book\"><?php echo esc_html( $demo['cta'] ); ?></a></div></nav>\n</header>\n<main id=\"top\">\n<section class=\"d-hero\"><div class=\"d-wrap\"><div class=\"d-copy\"><p class=\"d-kicker\"><?php echo esc_html( $demo['kicker'] ); ?></p><h1><?php echo esc_html( $demo['heading'] ); ?></h1><p class=\"d-intro\"><?php echo esc_html( $demo['intro'] ); ?></p><a class=\"d-button\" href=\"#book\"><?php echo esc_html( $demo['cta'] ); ?></a></div></div></section>\n<div class=\"d-points-wrap\"><ul class=\"d-wrap d-points\">\n\t\t<?php foreach ( $demo['points'] as $point ) : ?>\n<li><?php echo esc_html( $point ); ?></li>\n\t\t<?php endforeach; ?>\n</ul></div>\n<section class=\"d-book\" id=\"book\"><div class=\"d-wrap\"><?php echo $form; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Rendered by the plugin. ?></div></section>\n<section class=\"d-about\"><div class=\"d-wrap\"><img class=\"d-about-image\" src=\"<?php echo $image( 'about' ); // phpcs:ignore ?>\" alt=\"\" loading=\"lazy\"><div><h2><?php echo esc_html( $demo['about_title'] ); ?></h2><p><?php echo esc_html( $demo['about'] ); ?></p><div class=\"d-person\"><img src=\"<?php echo $image( 'portrait' ); // phpcs:ignore ?>\" alt=\"\" loading=\"lazy\"><div><strong><?php echo esc_html( $demo['staff']['name'] ); ?></strong><span><?php echo esc_html( $demo['staff']['role'] ); ?></span><p><?php echo esc_html( $demo['staff']['bio'] ); ?></p></div></div></div></div></section>\n<section class=\"d-info\"><div class=\"d-wrap\"><div><h2>Opening hours</h2><ul class=\"d-hours\">\n\t\t<?php\n\t\tforeach ( $days as $number => $label ) {\n\t\t\t$periods = (array) ( $demo['hours'][ (string) $number ] ?? array() );\n\t\t\t$text    = $periods ? implode( ', ', array_map( static fn( $period ) => $period[0] . '–' . $period[1], $periods ) ) : 'Closed';\n\t\t\techo '<li><span>' . esc_html( $label ) . '</span><span>' . esc_html( $text ) . '</span></li>';\n\t\t}\n\t\t?>\n</ul></div><div><h2>Find us</h2><p class=\"d-contact\">1 Demo Street, Sampletown<br>+44 20 7946 0999<br>hello@studio.example</p><p style=\"margin-top:26px\"><a class=\"d-button\" href=\"#book\"><?php echo esc_html( $demo['cta'] ); ?></a></p></div></div></section>\n</main>\n<footer class=\"d-foot\"><div class=\"d-wrap\">Demo website built with Abientot, a booking plugin for WordPress.</div></footer>\n\t\t<?php wp_footer(); ?>\n</body>\n</html>\n\t\t<?php\n\t\texit;\n\t}\n);\n"
		},
		{
			"step": "installPlugin",
			"pluginData": {
				"resource": "wordpress.org/plugins",
				"slug": "abientot"
			},
			"options": {
				"activate": true
			}
		},
		{
			"step": "writeFile",
			"path": "/wordpress/wp-content/abientot-demo.json",
			"data": "{\"slug\": \"barber\", \"business\": \"Brasswick Barbers\", \"tagline\": \"Traditional cuts, modern fades\", \"kicker\": \"Barbershop · Open 7 days\", \"heading\": \"Sharp cuts, unhurried service\", \"intro\": \"Choose your barber's chair online in under a minute. Walk in, sit down, walk out looking your best.\", \"cta\": \"Book your chair\", \"look\": \"midnight\", \"staff\": {\"name\": \"Mateo Brandvold\", \"role\": \"Owner and master barber\", \"bio\": \"Fifteen years behind the chair, from classic scissor work to skin fades. Every appointment starts with a proper chat about what suits you.\"}, \"about_title\": \"A barbershop that respects your time\", \"about\": \"No queue and no guesswork. Book the time that suits you and your chair is ready when you arrive.\", \"points\": [\"Book in under a minute\", \"Reminders before your visit\", \"Change or cancel by email\"], \"theme\": {\"bg\": \"#14171f\", \"surface\": \"#1d2230\", \"text\": \"#f2efe8\", \"muted\": \"#b7b0a2\", \"accent\": \"#e3b458\", \"on_accent\": \"#14171f\", \"line\": \"#2c3240\", \"heading\": \"classic\", \"body\": \"system\", \"radius\": \"6px\", \"hero_overlay\": \"linear-gradient(90deg, rgba(10,11,16,.92) 0%, rgba(10,11,16,.72) 42%, rgba(10,11,16,.05) 74%)\"}, \"hours\": {\"0\": [[\"10:00\", \"16:00\"]], \"1\": [[\"09:00\", \"18:00\"]], \"2\": [[\"09:00\", \"18:00\"]], \"3\": [[\"09:00\", \"18:00\"]], \"4\": [[\"09:00\", \"20:00\"]], \"5\": [[\"09:00\", \"20:00\"]], \"6\": [[\"09:00\", \"17:00\"]]}, \"grid\": 15, \"density\": {\"past\": 0.62, \"future\": 0.34}, \"customers\": 90, \"seed\": 1101, \"weekly\": [], \"categories\": [{\"key\": \"haircuts\", \"name\": \"Haircuts\", \"description\": \"Scissor work, fades and everyday cuts.\", \"image\": \"category-haircuts\"}, {\"key\": \"beard\", \"name\": \"Beard and shave\", \"description\": \"Shape, trim and the classic hot towel.\", \"image\": \"category-beard\"}, {\"key\": \"care\", \"name\": \"Hair care\", \"description\": \"Finish and treat.\", \"image\": \"category-care\"}], \"services\": [{\"name\": \"Classic cut\", \"cat\": \"haircuts\", \"min\": 30, \"price\": 2600, \"weight\": 10, \"image\": \"service-classic-cut\", \"desc\": \"Scissors and clippers, finished with a neck shave.\", \"color\": \"#9f7535\"}, {\"name\": \"Skin fade\", \"cat\": \"haircuts\", \"min\": 45, \"price\": 3200, \"weight\": 9, \"image\": \"service-skin-fade\", \"desc\": \"A clean, sharp fade blended to the skin.\", \"color\": \"#3d7185\"}, {\"name\": \"Cut and style\", \"cat\": \"haircuts\", \"min\": 45, \"price\": 3400, \"weight\": 5, \"image\": \"service-cut-style\", \"desc\": \"A longer cut, shaped and styled to last.\", \"color\": \"#54806a\"}, {\"name\": \"Cut and beard\", \"cat\": \"beard\", \"min\": 60, \"price\": 4400, \"weight\": 7, \"image\": \"service-cut-beard\", \"desc\": \"The full service, top to bottom.\", \"color\": \"#a45d45\"}, {\"name\": \"Beard sculpt\", \"cat\": \"beard\", \"min\": 30, \"price\": 2000, \"weight\": 5, \"image\": \"service-beard-sculpt\", \"desc\": \"Shape, line-up and beard oil.\", \"color\": \"#9a8946\"}, {\"name\": \"Hot towel shave\", \"cat\": \"beard\", \"min\": 40, \"price\": 3000, \"weight\": 3, \"image\": \"service-hot-towel-shave\", \"desc\": \"Straight razor, warm towels, slow pace.\", \"color\": \"#557f91\"}, {\"name\": \"Scalp treatment\", \"cat\": \"care\", \"min\": 30, \"price\": 2400, \"weight\": 1, \"image\": \"service-scalp-treatment\", \"desc\": \"A soothing massage and deep cleanse.\", \"color\": \"#6b7750\"}, {\"name\": \"Wash and finish\", \"cat\": \"care\", \"min\": 20, \"price\": 1500, \"weight\": 2, \"image\": \"service-wash-finish\", \"desc\": \"A quick wash, blow-dry and style.\", \"color\": \"#8c6654\"}], \"notes\": [\"Same as last time, please\", \"Keep the length on top\", \"I may be five minutes late\", \"Please use the side door entrance\", \"First visit\"], \"images\": \"https://abientot.app/assets/demos/barber/\", \"image_version\": \"?v=20261031\", \"names\": {\"first\": [\"Anouk\", \"Bastian\", \"Cosima\", \"Dario\", \"Elif\", \"Fintan\", \"Greta\", \"Hamid\", \"Ilse\", \"Joaquim\", \"Kaia\", \"Lorcan\", \"Mirela\", \"Nikhil\", \"Oona\", \"Pavel\", \"Quentin\", \"Rosalind\", \"Sanjana\", \"Tobias\", \"Ulrika\", \"Vikram\", \"Wanda\", \"Xavier\", \"Yara\", \"Zoltan\", \"Aiko\", \"Bram\", \"Carmela\", \"Desmond\", \"Esi\", \"Florin\", \"Giulia\", \"Henrik\", \"Imani\", \"Jonas\", \"Keiko\", \"Leandro\", \"Maren\", \"Nadim\"], \"last\": [\"Ambleford\", \"Brackenholt\", \"Corrowind\", \"Dunmarrow\", \"Elverstone\", \"Fallowmere\", \"Glenthwick\", \"Hollowmere\", \"Ivorstead\", \"Junipell\", \"Kestrelby\", \"Larkwhistle\", \"Merrowdale\", \"Nettlewick\", \"Orrinwood\", \"Pennimore\", \"Quillbrook\", \"Rushenby\", \"Silverlock\", \"Thistlewick\", \"Underhay\", \"Vantermoor\", \"Willowmarsh\", \"Yarrowfield\", \"Zephrane\", \"Ashendell\", \"Birchcombe\", \"Cindervale\", \"Dovemere\", \"Emberlyn\"]}}"
		},
		{
			"step": "runPHP",
			"code": "<?php\n// Builds one fictional business from /wordpress/wp-content/abientot-demo.json. Bookings are placed relative to today.\nrequire_once '/wordpress/wp-load.php';\n\n// Activation and the demo shim have completed before this blueprint step. Use the real initializer.\nif ( ! class_exists( \\Abientot\\Import\\Migrator::class ) || ! \\Abientot\\Import\\Migrator::ensure() ) {\n\tthrow new RuntimeException( 'Abientot demo setup failed: import storage could not be initialized and verified.' );\n}\n\nuse Abientot\\Admin\\FormLooks;\nuse Abientot\\Repositories\\ServicesRepository;\n\nglobal $wpdb;\n$file   = WP_CONTENT_DIR . '/abientot-demo.json';\n$data   = json_decode( (string) file_get_contents( $file ), true );\n$names  = $data['names'];\n$prefix = $wpdb->prefix . 'abientot_';\n$stamp  = gmdate( 'Y-m-d H:i:s' );\n$zone   = new DateTimeZone( 'Europe/London' );\n$utc    = new DateTimeZone( 'UTC' );\n$now    = new DateTimeImmutable( 'now', $zone );\n$today  = $now->setTime( 0, 0 );\nunlink( $file );\n\nwp_set_current_user( 1 );\nif ( function_exists( 'abientot_fs' ) ) {\n\tabientot_fs()->skip_connection();\n\t// Consume only this demo's first-activation redirect after choosing anonymous mode.\n\tdelete_transient( 'fs_plugin_abientot_activated' );\n}\n// Demo visitors never see the real review request.\nupdate_user_meta( 1, \\Abientot\\Admin\\ReviewRequest::meta_key(), 'demo' );\nmt_srand( (int) $data['seed'] );\n\n$image = static function ( string $name ) use ( $data ): int {\n\t$id = wp_insert_post(\n\t\tarray(\n\t\t\t'post_title'     => $data['business'] . ' ' . $name,\n\t\t\t'post_type'      => 'attachment',\n\t\t\t'post_status'    => 'inherit',\n\t\t\t'post_mime_type' => 'image/webp',\n\t\t)\n\t);\n\tupdate_post_meta( $id, '_abientot_demo_image', $data['images'] . $name . '.webp' . $data['image_version'] );\n\treturn (int) $id;\n};\n\n// Business, hours and form.\nupdate_option( 'blogname', $data['business'] );\nupdate_option( 'blogdescription', $data['tagline'] );\nupdate_option( 'timezone_string', 'Europe/London' );\n$settings = array_merge(\n\t(array) get_option( 'abientot_settings', array() ),\n\tFormLooks::all()[ $data['look'] ]['settings'],\n\tarray(\n\t\t'form_look'                  => $data['look'],\n\t\t'business_name'              => $data['business'],\n\t\t'business_timezone'          => 'Europe/London',\n\t\t'currency'                   => 'GBP',\n\t\t'default_booking_status'     => 'confirmed',\n\t\t'booking_min_notice_hours'   => 2,\n\t\t'booking_window_days'        => 60,\n\t\t'booking_slot_interval_minutes' => (int) $data['grid'],\n\t\t'time_display_format'        => 'time-24',\n\t\t'admin_notification_email'   => 'owner@studio.example',\n\t\t'email_from_address'         => 'hello@studio.example',\n\t\t'form_title'                 => $data['cta'],\n\t\t'form_intro'                 => 'This is a demo, so please use made-up details. Nothing is really booked and no email is sent.',\n\t\t'form_group_services'        => true,\n\t\t'form_category_behavior'     => 'expanded',\n\t\t'form_show_service_images'   => true,\n\t\t'form_show_category_images'  => true,\n\t\t'form_image_size'            => 76,\n\t\t'form_image_radius'          => 10,\n\t\t'form_max_width'             => 860,\n\t)\n);\nupdate_option( 'abientot_settings', $settings );\n\n$staff_id    = (int) $wpdb->get_var( \"SELECT id FROM {$prefix}staff ORDER BY id LIMIT 1\" );\n$location_id = (int) $wpdb->get_var( \"SELECT id FROM {$prefix}locations ORDER BY id LIMIT 1\" );\n$wpdb->update(\n\t$prefix . 'staff',\n\tarray(\n\t\t'display_name' => $data['staff']['name'],\n\t\t'bio'          => $data['staff']['bio'],\n\t\t'photo_id'     => $image( 'portrait' ),\n\t\t'email'        => 'owner@studio.example',\n\t\t'timezone'     => 'Europe/London',\n\t),\n\tarray( 'id' => $staff_id )\n);\n$wpdb->update(\n\t$prefix . 'locations',\n\tarray(\n\t\t'name'      => $data['business'],\n\t\t'address_1' => '1 Demo Street',\n\t\t'city'      => 'Sampletown',\n\t\t'country'   => 'GB',\n\t\t'phone'     => '+442079460999',\n\t\t'email'     => 'hello@studio.example',\n\t\t'timezone'  => 'Europe/London',\n\t),\n\tarray( 'id' => $location_id )\n);\n\n$schedule = array();\n$wpdb->delete( $prefix . 'availability_rules', array( 'staff_id' => $staff_id ) );\nfor ( $day = 0; $day < 7; $day++ ) {\n\t$periods = array();\n\tforeach ( (array) ( $data['hours'][ (string) $day ] ?? array() ) as $period ) {\n\t\t$periods[] = array(\n\t\t\t'start_time' => $period[0] . ':00',\n\t\t\t'end_time'   => $period[1] . ':00',\n\t\t);\n\t\t$wpdb->insert(\n\t\t\t$prefix . 'availability_rules',\n\t\t\tarray(\n\t\t\t\t'staff_id'    => $staff_id,\n\t\t\t\t'location_id' => $location_id,\n\t\t\t\t'weekday'     => $day,\n\t\t\t\t'start_time'  => $period[0] . ':00',\n\t\t\t\t'end_time'    => $period[1] . ':00',\n\t\t\t\t'rule_type'   => 'working',\n\t\t\t\t'is_active'   => 1,\n\t\t\t\t'created_at'  => $stamp,\n\t\t\t\t'updated_at'  => $stamp,\n\t\t\t)\n\t\t);\n\t}\n\t$schedule[ $day ] = array( 'periods' => $periods );\n}\nupdate_option( 'abientot_location_weekly_schedule', $schedule, false );\n\n// Categories and services.\n$categories = array();\nforeach ( $data['categories'] as $order => $category ) {\n\t$wpdb->insert(\n\t\t$prefix . 'service_categories',\n\t\tarray(\n\t\t\t'name'        => $category['name'],\n\t\t\t'slug'        => $category['key'],\n\t\t\t'description' => $category['description'],\n\t\t\t'image_id'    => $image( $category['image'] ),\n\t\t\t'sort_order'  => $order,\n\t\t\t'is_active'   => 1,\n\t\t\t'created_at'  => $stamp,\n\t\t\t'updated_at'  => $stamp,\n\t\t)\n\t);\n\t$categories[ $category['key'] ] = (int) $wpdb->insert_id;\n}\n$repository = new ServicesRepository();\n$services   = array();\nforeach ( $data['services'] as $order => $service ) {\n\t$id = $repository->insert(\n\t\tarray(\n\t\t\t'category_id'           => $categories[ $service['cat'] ],\n\t\t\t'name'                  => $service['name'],\n\t\t\t'slug'                  => sanitize_title( $service['name'] ),\n\t\t\t'description'           => $service['desc'],\n\t\t\t'image_id'              => $image( $service['image'] ),\n\t\t\t'duration_minutes'      => $service['min'],\n\t\t\t'buffer_before_minutes' => 0,\n\t\t\t'buffer_after_minutes'  => (int) ( $service['buffer'] ?? 0 ),\n\t\t\t'price_cents'           => $service['price'],\n\t\t\t'currency'              => 'GBP',\n\t\t\t'color'                 => sanitize_hex_color( (string) ( $service['color'] ?? '' ) ) ?: sanitize_hex_color( (string) $data['theme']['accent'] ),\n\t\t\t'status'                => 'active',\n\t\t\t'visibility'            => 'public',\n\t\t\t'sort_order'            => $order,\n\t\t\t'created_at'            => $stamp,\n\t\t\t'updated_at'            => $stamp,\n\t\t)\n\t);\n\t$repository->link_to_staff( $id, $staff_id );\n\t$services[] = $service + array( 'id' => $id );\n}\n\n// Customers appear on first use. Fictional names, reserved email domain, reserved phone range.\n$statuses = array();\nforeach ( $wpdb->get_results( \"SELECT id, slug FROM {$prefix}booking_statuses\", ARRAY_A ) as $row ) {\n\t$statuses[ $row['slug'] ] = (int) $row['id'];\n}\n$pairs = array();\nforeach ( $names['first'] as $first ) {\n\tforeach ( $names['last'] as $last ) {\n\t\t$pairs[] = array( $first, $last );\n\t}\n}\nfor ( $i = count( $pairs ) - 1; $i > 0; $i-- ) {\n\t$j           = mt_rand( 0, $i );\n\t$held        = $pairs[ $i ];\n\t$pairs[ $i ] = $pairs[ $j ];\n\t$pairs[ $j ] = $held;\n}\n$customers = array();\n$customer  = static function ( int $index ) use ( &$customers, $pairs, $prefix, $stamp, $wpdb ): int {\n\tif ( ! isset( $customers[ $index ] ) ) {\n\t\t$wpdb->insert(\n\t\t\t$prefix . 'customers',\n\t\t\tarray(\n\t\t\t\t'first_name'   => $pairs[ $index ][0],\n\t\t\t\t'last_name'    => $pairs[ $index ][1],\n\t\t\t\t'display_name' => $pairs[ $index ][0] . ' ' . $pairs[ $index ][1],\n\t\t\t\t'email'        => sprintf( 'client.%04d@studio.example', $index + 1 ),\n\t\t\t\t'phone'        => sprintf( '+44207946%04d', $index ),\n\t\t\t\t'created_at'   => $stamp,\n\t\t\t\t'updated_at'   => $stamp,\n\t\t\t)\n\t\t);\n\t\t$customers[ $index ] = (int) $wpdb->insert_id;\n\t}\n\treturn $customers[ $index ];\n};\n$regular = static function () use ( $data ): int {\n\t$roll = mt_rand() / mt_getrandmax();\n\treturn (int) floor( $roll * $roll * (int) $data['customers'] );\n};\n$weighted = static function () use ( $services ): array {\n\t$roll = mt_rand( 1, array_sum( array_column( $services, 'weight' ) ) );\n\tforeach ( $services as $service ) {\n\t\t$roll -= $service['weight'];\n\t\tif ( $roll <= 0 ) {\n\t\t\treturn $service;\n\t\t}\n\t}\n\treturn $services[0];\n};\n$book = static function ( array $service, DateTimeImmutable $start, int $who ) use ( $data, $statuses, $customer, $now, $today, $utc, $zone, $staff_id, $location_id, $prefix, $wpdb ): void {\n\t$end  = $start->modify( '+' . $service['min'] . ' minutes' );\n\t$roll = mt_rand( 1, 100 );\n\tif ( $end <= $now ) {\n\t\t$status = $roll <= 86 ? 'completed' : ( $roll <= 95 ? 'cancelled' : 'no_show' );\n\t} else {\n\t\t$status = $roll <= 86 ? 'confirmed' : ( $roll <= 95 ? 'pending' : 'cancelled' );\n\t}\n\t$paid    = 'completed' === $status && mt_rand( 1, 100 ) <= 94;\n\t$created = $start->modify( '-' . mt_rand( 1, 21 ) . ' days -' . mt_rand( 0, 600 ) . ' minutes' );\n\t$created = $created > $now ? $now : $created;\n\t$note    = 1 === mt_rand( 1, 9 ) ? $data['notes'][ mt_rand( 0, count( $data['notes'] ) - 1 ) ] : null;\n\t$wpdb->insert(\n\t\t$prefix . 'appointments',\n\t\tarray(\n\t\t\t'customer_id'      => $customer( $who ),\n\t\t\t'service_id'       => $service['id'],\n\t\t\t'staff_id'         => $staff_id,\n\t\t\t'location_id'      => $location_id,\n\t\t\t'status_id'        => $statuses[ $status ],\n\t\t\t'start_utc'        => $start->setTimezone( $utc )->format( 'Y-m-d H:i:s' ),\n\t\t\t'end_utc'          => $end->setTimezone( $utc )->format( 'Y-m-d H:i:s' ),\n\t\t\t'timezone'         => $zone->getName(),\n\t\t\t'duration_minutes' => $service['min'],\n\t\t\t'price_cents'      => $service['price'],\n\t\t\t'currency'         => 'GBP',\n\t\t\t'payment_status'   => $paid ? 'paid' : 'unpaid',\n\t\t\t'source'           => mt_rand( 1, 10 ) <= 7 ? 'frontend' : 'admin',\n\t\t\t'customer_note'    => $note,\n\t\t\t'cancelled_at'     => 'cancelled' === $status ? $created->setTimezone( $utc )->format( 'Y-m-d H:i:s' ) : null,\n\t\t\t'created_at'       => $created->setTimezone( $utc )->format( 'Y-m-d H:i:s' ),\n\t\t\t'updated_at'       => $created->setTimezone( $utc )->format( 'Y-m-d H:i:s' ),\n\t\t)\n\t);\n};\n\n// One month back, one month ahead. The future thins out with distance, and each day is filled without overlaps.\n$first   = $today->modify( '-1 month' );\n$last    = $today->modify( '+1 month' );\n$horizon = max( 1, (int) $today->diff( $last )->days );\n$fallback = (array) ( $data['hours']['2'] ?? array() );\n$wpdb->query( 'START TRANSACTION' );\nfor ( $day = $first; $day <= $last; $day = $day->modify( '+1 day' ) ) {\n\t$weekday = (int) $day->format( 'w' );\n\t$periods = (array) ( $data['hours'][ (string) $weekday ] ?? array() );\n\t// Today is never empty, so the admin always opens on a real working day.\n\tif ( ! $periods && $day == $today ) {\n\t\t$periods = $fallback;\n\t}\n\tif ( ! $periods ) {\n\t\tcontinue;\n\t}\n\t$ahead  = (int) $today->diff( $day )->format( '%r%a' );\n\t$chance = $ahead <= 0\n\t\t? (float) $data['density']['past'] * ( 0.82 + mt_rand( 0, 36 ) / 100 )\n\t\t: (float) $data['density']['future'] * pow( 1 - $ahead / ( $horizon + 3 ), 1.6 );\n\t$chance = 0 === $ahead ? max( $chance, 0.8 ) : $chance;\n\t$taken  = array();\n\tforeach ( $data['weekly'] as $fixed ) {\n\t\tif ( (int) $fixed[0] === $weekday ) {\n\t\t\t$start   = $day->modify( $fixed[1] );\n\t\t\t$service = $services[ (int) $fixed[2] ];\n\t\t\tif ( $ahead <= 0 || $ahead <= 21 ) {\n\t\t\t\t$book( $service, $start, ( ( $weekday * 3 + (int) substr( $fixed[1], 0, 2 ) ) % 9 ) );\n\t\t\t\t$taken[] = array( $start, $start->modify( '+' . ( $service['min'] + (int) ( $service['buffer'] ?? 0 ) ) . ' minutes' ) );\n\t\t\t}\n\t\t}\n\t}\n\tforeach ( $periods as $period ) {\n\t\t$cursor = $day->modify( $period[0] );\n\t\t$close  = $day->modify( $period[1] );\n\t\twhile ( $cursor < $close ) {\n\t\t\tif ( mt_rand() / mt_getrandmax() >= $chance ) {\n\t\t\t\t$cursor = $cursor->modify( '+' . (int) $data['grid'] . ' minutes' );\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\t$service = $weighted();\n\t\t\t$end     = $cursor->modify( '+' . ( $service['min'] + (int) ( $service['buffer'] ?? 0 ) ) . ' minutes' );\n\t\t\t$clash   = $end > $close;\n\t\t\tforeach ( $taken as $block ) {\n\t\t\t\t$clash = $clash || ( $cursor < $block[1] && $end > $block[0] );\n\t\t\t}\n\t\t\tif ( $clash ) {\n\t\t\t\t$cursor = $cursor->modify( '+' . (int) $data['grid'] . ' minutes' );\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\t$book( $service, $cursor, $regular() );\n\t\t\t$taken[] = array( $cursor, $end );\n\t\t\t$minutes = (int) ceil( ( $end->getTimestamp() - $cursor->getTimestamp() ) / 60 / (int) $data['grid'] ) * (int) $data['grid'];\n\t\t\t$cursor  = $cursor->modify( '+' . $minutes . ' minutes' );\n\t\t}\n\t}\n}\n$wpdb->query( 'COMMIT' );\n\n// The page and the demo's own display data.\n$page = wp_insert_post(\n\tarray(\n\t\t'post_title'   => 'Book',\n\t\t'post_name'    => 'book',\n\t\t'post_status'  => 'publish',\n\t\t'post_type'    => 'page',\n\t\t'post_content' => '[abientot_booking_form]',\n\t)\n);\nupdate_option( 'show_on_front', 'page' );\nupdate_option( 'page_on_front', $page );\nunset( $data['names'], $data['notes'], $data['weekly'], $data['density'] );\nupdate_option( 'abientot_demo', $data, false );\n"
		}
	]
}
