@bytesbrains/weblocks 0.6.1 → 0.7.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/AGENT.md +5 -5
- package/CATALOG.md +66 -2
- package/CHANGELOG.md +68 -0
- package/README.md +54 -5
- package/catalog.json +412 -1
- package/lib/anchors.d.ts +11 -0
- package/lib/anchors.js +77 -0
- package/lib/blocks/booking.d.ts +2 -0
- package/lib/blocks/booking.js +117 -0
- package/lib/blocks/hours.d.ts +2 -0
- package/lib/blocks/hours.js +97 -0
- package/lib/blocks/menu.d.ts +2 -0
- package/lib/blocks/menu.js +93 -0
- package/lib/blocks/nav.js +6 -3
- package/lib/blocks/product.d.ts +2 -0
- package/lib/blocks/product.js +92 -0
- package/lib/blocks/reviews.d.ts +2 -0
- package/lib/blocks/reviews.js +96 -0
- package/lib/generate.d.ts +19 -2
- package/lib/generate.js +43 -3
- package/lib/index.d.ts +4 -2
- package/lib/index.js +3 -1
- package/lib/islands/hours.d.ts +1 -0
- package/lib/islands/hours.js +69 -0
- package/lib/registry.d.ts +6 -0
- package/lib/registry.js +9 -4
- package/lib/render.js +9 -3
- package/lib/schema.d.ts +6 -0
- package/lib/schema.js +15 -0
- package/lib/templates.d.ts +37 -0
- package/lib/templates.js +450 -0
- package/lib/verticals.d.ts +40 -0
- package/lib/verticals.js +117 -0
- package/package.json +2 -1
package/catalog.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"package": "@bytesbrains/weblocks",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"description": "Block catalog for @bytesbrains/weblocks. Send this to the model as its API reference; it composes a SiteManifest ({ meta, design, blocks[] }) using ONLY these block types. The engine then validates and renders it.",
|
|
6
6
|
"blockTypes": [
|
|
7
7
|
"app-shell",
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
"steps",
|
|
21
21
|
"stats",
|
|
22
22
|
"services-catalogue",
|
|
23
|
+
"menu",
|
|
24
|
+
"product",
|
|
23
25
|
"pricing",
|
|
24
26
|
"logos",
|
|
25
27
|
"team",
|
|
@@ -32,10 +34,12 @@
|
|
|
32
34
|
"tabs",
|
|
33
35
|
"accordion",
|
|
34
36
|
"testimonials",
|
|
37
|
+
"reviews",
|
|
35
38
|
"faq",
|
|
36
39
|
"blog-list",
|
|
37
40
|
"blog-post",
|
|
38
41
|
"feed",
|
|
42
|
+
"booking",
|
|
39
43
|
"contact-form",
|
|
40
44
|
"newsletter",
|
|
41
45
|
"search",
|
|
@@ -43,6 +47,7 @@
|
|
|
43
47
|
"cta",
|
|
44
48
|
"social-links",
|
|
45
49
|
"contact-details",
|
|
50
|
+
"hours",
|
|
46
51
|
"directions",
|
|
47
52
|
"legal",
|
|
48
53
|
"divider",
|
|
@@ -927,6 +932,174 @@
|
|
|
927
932
|
"additionalProperties": false
|
|
928
933
|
}
|
|
929
934
|
},
|
|
935
|
+
{
|
|
936
|
+
"type": "menu",
|
|
937
|
+
"description": "A food/drink menu: named sections, each with items that have an optional price, dietary/allergen tags (e.g. V, VG, GF), and a 0–3 spice level. Use for restaurants and cafés.",
|
|
938
|
+
"schema": {
|
|
939
|
+
"type": "object",
|
|
940
|
+
"properties": {
|
|
941
|
+
"title": {
|
|
942
|
+
"type": "string",
|
|
943
|
+
"maxLength": 120,
|
|
944
|
+
"default": "Menu"
|
|
945
|
+
},
|
|
946
|
+
"subtitle": {
|
|
947
|
+
"type": "string",
|
|
948
|
+
"maxLength": 240,
|
|
949
|
+
"default": ""
|
|
950
|
+
},
|
|
951
|
+
"sections": {
|
|
952
|
+
"type": "array",
|
|
953
|
+
"items": {
|
|
954
|
+
"type": "object",
|
|
955
|
+
"properties": {
|
|
956
|
+
"name": {
|
|
957
|
+
"type": "string",
|
|
958
|
+
"maxLength": 80,
|
|
959
|
+
"default": "Section"
|
|
960
|
+
},
|
|
961
|
+
"note": {
|
|
962
|
+
"type": "string",
|
|
963
|
+
"maxLength": 200,
|
|
964
|
+
"default": ""
|
|
965
|
+
},
|
|
966
|
+
"items": {
|
|
967
|
+
"type": "array",
|
|
968
|
+
"items": {
|
|
969
|
+
"type": "object",
|
|
970
|
+
"properties": {
|
|
971
|
+
"name": {
|
|
972
|
+
"type": "string",
|
|
973
|
+
"maxLength": 100,
|
|
974
|
+
"default": "Item"
|
|
975
|
+
},
|
|
976
|
+
"description": {
|
|
977
|
+
"type": "string",
|
|
978
|
+
"maxLength": 300,
|
|
979
|
+
"default": ""
|
|
980
|
+
},
|
|
981
|
+
"price": {
|
|
982
|
+
"type": "string",
|
|
983
|
+
"maxLength": 40,
|
|
984
|
+
"default": ""
|
|
985
|
+
},
|
|
986
|
+
"tags": {
|
|
987
|
+
"type": "array",
|
|
988
|
+
"items": {
|
|
989
|
+
"type": "string",
|
|
990
|
+
"maxLength": 12,
|
|
991
|
+
"default": ""
|
|
992
|
+
},
|
|
993
|
+
"maxItems": 8
|
|
994
|
+
},
|
|
995
|
+
"spice": {
|
|
996
|
+
"type": "integer",
|
|
997
|
+
"minimum": 0,
|
|
998
|
+
"maximum": 3,
|
|
999
|
+
"default": 0
|
|
1000
|
+
}
|
|
1001
|
+
},
|
|
1002
|
+
"required": [
|
|
1003
|
+
"name"
|
|
1004
|
+
],
|
|
1005
|
+
"additionalProperties": false
|
|
1006
|
+
},
|
|
1007
|
+
"maxItems": 40
|
|
1008
|
+
}
|
|
1009
|
+
},
|
|
1010
|
+
"required": [
|
|
1011
|
+
"name"
|
|
1012
|
+
],
|
|
1013
|
+
"additionalProperties": false
|
|
1014
|
+
},
|
|
1015
|
+
"maxItems": 20
|
|
1016
|
+
}
|
|
1017
|
+
},
|
|
1018
|
+
"additionalProperties": false
|
|
1019
|
+
}
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
"type": "product",
|
|
1023
|
+
"description": "A shoppable product grid: each item has an image, price (with optional \"was\" price and badge), and a buy/enquire link. Use for retail and e-commerce; the cart/checkout is the host's.",
|
|
1024
|
+
"schema": {
|
|
1025
|
+
"type": "object",
|
|
1026
|
+
"properties": {
|
|
1027
|
+
"title": {
|
|
1028
|
+
"type": "string",
|
|
1029
|
+
"maxLength": 120,
|
|
1030
|
+
"default": ""
|
|
1031
|
+
},
|
|
1032
|
+
"subtitle": {
|
|
1033
|
+
"type": "string",
|
|
1034
|
+
"maxLength": 240,
|
|
1035
|
+
"default": ""
|
|
1036
|
+
},
|
|
1037
|
+
"columns": {
|
|
1038
|
+
"type": "integer",
|
|
1039
|
+
"enum": [
|
|
1040
|
+
2,
|
|
1041
|
+
3,
|
|
1042
|
+
4
|
|
1043
|
+
],
|
|
1044
|
+
"default": 3
|
|
1045
|
+
},
|
|
1046
|
+
"items": {
|
|
1047
|
+
"type": "array",
|
|
1048
|
+
"items": {
|
|
1049
|
+
"type": "object",
|
|
1050
|
+
"properties": {
|
|
1051
|
+
"name": {
|
|
1052
|
+
"type": "string",
|
|
1053
|
+
"maxLength": 100,
|
|
1054
|
+
"default": "Product"
|
|
1055
|
+
},
|
|
1056
|
+
"description": {
|
|
1057
|
+
"type": "string",
|
|
1058
|
+
"maxLength": 240,
|
|
1059
|
+
"default": ""
|
|
1060
|
+
},
|
|
1061
|
+
"price": {
|
|
1062
|
+
"type": "string",
|
|
1063
|
+
"maxLength": 40,
|
|
1064
|
+
"default": ""
|
|
1065
|
+
},
|
|
1066
|
+
"was": {
|
|
1067
|
+
"type": "string",
|
|
1068
|
+
"maxLength": 40,
|
|
1069
|
+
"default": ""
|
|
1070
|
+
},
|
|
1071
|
+
"image": {
|
|
1072
|
+
"type": "string",
|
|
1073
|
+
"maxLength": 500,
|
|
1074
|
+
"default": ""
|
|
1075
|
+
},
|
|
1076
|
+
"badge": {
|
|
1077
|
+
"type": "string",
|
|
1078
|
+
"maxLength": 24,
|
|
1079
|
+
"default": ""
|
|
1080
|
+
},
|
|
1081
|
+
"ctaLabel": {
|
|
1082
|
+
"type": "string",
|
|
1083
|
+
"maxLength": 40,
|
|
1084
|
+
"default": "Buy"
|
|
1085
|
+
},
|
|
1086
|
+
"ctaHref": {
|
|
1087
|
+
"type": "string",
|
|
1088
|
+
"maxLength": 500,
|
|
1089
|
+
"default": "#"
|
|
1090
|
+
}
|
|
1091
|
+
},
|
|
1092
|
+
"required": [
|
|
1093
|
+
"name"
|
|
1094
|
+
],
|
|
1095
|
+
"additionalProperties": false
|
|
1096
|
+
},
|
|
1097
|
+
"maxItems": 48
|
|
1098
|
+
}
|
|
1099
|
+
},
|
|
1100
|
+
"additionalProperties": false
|
|
1101
|
+
}
|
|
1102
|
+
},
|
|
930
1103
|
{
|
|
931
1104
|
"type": "pricing",
|
|
932
1105
|
"description": "A row of pricing/tier cards, each with a name, price, feature list, and a call-to-action button; one plan can be highlighted.",
|
|
@@ -1539,6 +1712,87 @@
|
|
|
1539
1712
|
"additionalProperties": false
|
|
1540
1713
|
}
|
|
1541
1714
|
},
|
|
1715
|
+
{
|
|
1716
|
+
"type": "reviews",
|
|
1717
|
+
"description": "Star-rated customer reviews (1–5 stars) each with an optional author, date, and source (Google, Yelp, …), plus an optional aggregate score. Richer than plain testimonial quotes.",
|
|
1718
|
+
"schema": {
|
|
1719
|
+
"type": "object",
|
|
1720
|
+
"properties": {
|
|
1721
|
+
"title": {
|
|
1722
|
+
"type": "string",
|
|
1723
|
+
"maxLength": 120,
|
|
1724
|
+
"default": ""
|
|
1725
|
+
},
|
|
1726
|
+
"subtitle": {
|
|
1727
|
+
"type": "string",
|
|
1728
|
+
"maxLength": 240,
|
|
1729
|
+
"default": ""
|
|
1730
|
+
},
|
|
1731
|
+
"average": {
|
|
1732
|
+
"type": "string",
|
|
1733
|
+
"maxLength": 8,
|
|
1734
|
+
"default": ""
|
|
1735
|
+
},
|
|
1736
|
+
"count": {
|
|
1737
|
+
"type": "string",
|
|
1738
|
+
"maxLength": 24,
|
|
1739
|
+
"default": ""
|
|
1740
|
+
},
|
|
1741
|
+
"source": {
|
|
1742
|
+
"type": "string",
|
|
1743
|
+
"maxLength": 40,
|
|
1744
|
+
"default": ""
|
|
1745
|
+
},
|
|
1746
|
+
"items": {
|
|
1747
|
+
"type": "array",
|
|
1748
|
+
"items": {
|
|
1749
|
+
"type": "object",
|
|
1750
|
+
"properties": {
|
|
1751
|
+
"rating": {
|
|
1752
|
+
"type": "integer",
|
|
1753
|
+
"minimum": 1,
|
|
1754
|
+
"maximum": 5,
|
|
1755
|
+
"default": 5
|
|
1756
|
+
},
|
|
1757
|
+
"quote": {
|
|
1758
|
+
"type": "string",
|
|
1759
|
+
"maxLength": 500,
|
|
1760
|
+
"default": ""
|
|
1761
|
+
},
|
|
1762
|
+
"author": {
|
|
1763
|
+
"type": "string",
|
|
1764
|
+
"maxLength": 80,
|
|
1765
|
+
"default": ""
|
|
1766
|
+
},
|
|
1767
|
+
"date": {
|
|
1768
|
+
"type": "string",
|
|
1769
|
+
"maxLength": 40,
|
|
1770
|
+
"default": ""
|
|
1771
|
+
},
|
|
1772
|
+
"source": {
|
|
1773
|
+
"enum": [
|
|
1774
|
+
"google",
|
|
1775
|
+
"yelp",
|
|
1776
|
+
"facebook",
|
|
1777
|
+
"trustpilot",
|
|
1778
|
+
"tripadvisor",
|
|
1779
|
+
"app-store",
|
|
1780
|
+
"other"
|
|
1781
|
+
],
|
|
1782
|
+
"default": "other"
|
|
1783
|
+
}
|
|
1784
|
+
},
|
|
1785
|
+
"required": [
|
|
1786
|
+
"quote"
|
|
1787
|
+
],
|
|
1788
|
+
"additionalProperties": false
|
|
1789
|
+
},
|
|
1790
|
+
"maxItems": 24
|
|
1791
|
+
}
|
|
1792
|
+
},
|
|
1793
|
+
"additionalProperties": false
|
|
1794
|
+
}
|
|
1795
|
+
},
|
|
1542
1796
|
{
|
|
1543
1797
|
"type": "faq",
|
|
1544
1798
|
"description": "An accordion of question/answer pairs. Expands/collapses natively (no JS).",
|
|
@@ -1773,6 +2027,99 @@
|
|
|
1773
2027
|
"additionalProperties": false
|
|
1774
2028
|
}
|
|
1775
2029
|
},
|
|
2030
|
+
{
|
|
2031
|
+
"type": "booking",
|
|
2032
|
+
"description": "An appointment/reservation request form (service, date, time, contact) that posts to a host-provided runtime. For booking-driven businesses — salons, clinics, gyms, hotels, events. No raw HTML.",
|
|
2033
|
+
"schema": {
|
|
2034
|
+
"type": "object",
|
|
2035
|
+
"properties": {
|
|
2036
|
+
"title": {
|
|
2037
|
+
"type": "string",
|
|
2038
|
+
"maxLength": 120,
|
|
2039
|
+
"default": "Book an appointment"
|
|
2040
|
+
},
|
|
2041
|
+
"intro": {
|
|
2042
|
+
"type": "string",
|
|
2043
|
+
"maxLength": 280,
|
|
2044
|
+
"default": ""
|
|
2045
|
+
},
|
|
2046
|
+
"submitLabel": {
|
|
2047
|
+
"type": "string",
|
|
2048
|
+
"maxLength": 40,
|
|
2049
|
+
"default": "Request booking"
|
|
2050
|
+
},
|
|
2051
|
+
"successMessage": {
|
|
2052
|
+
"type": "string",
|
|
2053
|
+
"maxLength": 200,
|
|
2054
|
+
"default": "Thanks — we'll confirm your booking shortly."
|
|
2055
|
+
},
|
|
2056
|
+
"serviceLabel": {
|
|
2057
|
+
"type": "string",
|
|
2058
|
+
"maxLength": 60,
|
|
2059
|
+
"default": "Service"
|
|
2060
|
+
},
|
|
2061
|
+
"services": {
|
|
2062
|
+
"type": "array",
|
|
2063
|
+
"items": {
|
|
2064
|
+
"type": "object",
|
|
2065
|
+
"properties": {
|
|
2066
|
+
"name": {
|
|
2067
|
+
"type": "string",
|
|
2068
|
+
"maxLength": 80,
|
|
2069
|
+
"default": "Service"
|
|
2070
|
+
},
|
|
2071
|
+
"duration": {
|
|
2072
|
+
"type": "string",
|
|
2073
|
+
"maxLength": 40,
|
|
2074
|
+
"default": ""
|
|
2075
|
+
},
|
|
2076
|
+
"price": {
|
|
2077
|
+
"type": "string",
|
|
2078
|
+
"maxLength": 40,
|
|
2079
|
+
"default": ""
|
|
2080
|
+
}
|
|
2081
|
+
},
|
|
2082
|
+
"required": [
|
|
2083
|
+
"name"
|
|
2084
|
+
],
|
|
2085
|
+
"additionalProperties": false
|
|
2086
|
+
},
|
|
2087
|
+
"maxItems": 40
|
|
2088
|
+
},
|
|
2089
|
+
"askDate": {
|
|
2090
|
+
"type": "boolean",
|
|
2091
|
+
"default": true
|
|
2092
|
+
},
|
|
2093
|
+
"askTime": {
|
|
2094
|
+
"type": "boolean",
|
|
2095
|
+
"default": true
|
|
2096
|
+
},
|
|
2097
|
+
"askParty": {
|
|
2098
|
+
"type": "boolean",
|
|
2099
|
+
"default": false
|
|
2100
|
+
},
|
|
2101
|
+
"askPhone": {
|
|
2102
|
+
"type": "boolean",
|
|
2103
|
+
"default": true
|
|
2104
|
+
},
|
|
2105
|
+
"askNotes": {
|
|
2106
|
+
"type": "boolean",
|
|
2107
|
+
"default": true
|
|
2108
|
+
},
|
|
2109
|
+
"notesLabel": {
|
|
2110
|
+
"type": "string",
|
|
2111
|
+
"maxLength": 80,
|
|
2112
|
+
"default": "Anything we should know?"
|
|
2113
|
+
}
|
|
2114
|
+
},
|
|
2115
|
+
"additionalProperties": false
|
|
2116
|
+
},
|
|
2117
|
+
"runtime": {
|
|
2118
|
+
"capabilities": [
|
|
2119
|
+
"booking.request"
|
|
2120
|
+
]
|
|
2121
|
+
}
|
|
2122
|
+
},
|
|
1776
2123
|
{
|
|
1777
2124
|
"type": "contact-form",
|
|
1778
2125
|
"description": "A configurable contact form with typed fields (text, email, tel, textarea, select, checkbox) that posts to a host-provided runtime. No raw HTML.",
|
|
@@ -2174,6 +2521,70 @@
|
|
|
2174
2521
|
"additionalProperties": false
|
|
2175
2522
|
}
|
|
2176
2523
|
},
|
|
2524
|
+
{
|
|
2525
|
+
"type": "hours",
|
|
2526
|
+
"description": "Structured weekly opening hours (24h times per day, split shifts allowed) rendered as an accessible table with a live \"open now / closed\" badge. Use for shops, cafés, salons, and clinics.",
|
|
2527
|
+
"schema": {
|
|
2528
|
+
"type": "object",
|
|
2529
|
+
"properties": {
|
|
2530
|
+
"title": {
|
|
2531
|
+
"type": "string",
|
|
2532
|
+
"maxLength": 120,
|
|
2533
|
+
"default": "Opening hours"
|
|
2534
|
+
},
|
|
2535
|
+
"timezone": {
|
|
2536
|
+
"type": "string",
|
|
2537
|
+
"maxLength": 60,
|
|
2538
|
+
"default": ""
|
|
2539
|
+
},
|
|
2540
|
+
"note": {
|
|
2541
|
+
"type": "string",
|
|
2542
|
+
"maxLength": 200,
|
|
2543
|
+
"default": ""
|
|
2544
|
+
},
|
|
2545
|
+
"days": {
|
|
2546
|
+
"type": "array",
|
|
2547
|
+
"items": {
|
|
2548
|
+
"type": "object",
|
|
2549
|
+
"properties": {
|
|
2550
|
+
"day": {
|
|
2551
|
+
"enum": [
|
|
2552
|
+
"mon",
|
|
2553
|
+
"tue",
|
|
2554
|
+
"wed",
|
|
2555
|
+
"thu",
|
|
2556
|
+
"fri",
|
|
2557
|
+
"sat",
|
|
2558
|
+
"sun"
|
|
2559
|
+
],
|
|
2560
|
+
"default": "mon"
|
|
2561
|
+
},
|
|
2562
|
+
"open": {
|
|
2563
|
+
"type": "string",
|
|
2564
|
+
"maxLength": 5,
|
|
2565
|
+
"default": ""
|
|
2566
|
+
},
|
|
2567
|
+
"close": {
|
|
2568
|
+
"type": "string",
|
|
2569
|
+
"maxLength": 5,
|
|
2570
|
+
"default": ""
|
|
2571
|
+
},
|
|
2572
|
+
"closed": {
|
|
2573
|
+
"type": "boolean",
|
|
2574
|
+
"default": false
|
|
2575
|
+
}
|
|
2576
|
+
},
|
|
2577
|
+
"required": [
|
|
2578
|
+
"day"
|
|
2579
|
+
],
|
|
2580
|
+
"additionalProperties": false
|
|
2581
|
+
},
|
|
2582
|
+
"maxItems": 21
|
|
2583
|
+
}
|
|
2584
|
+
},
|
|
2585
|
+
"additionalProperties": false
|
|
2586
|
+
}
|
|
2587
|
+
},
|
|
2177
2588
|
{
|
|
2178
2589
|
"type": "directions",
|
|
2179
2590
|
"description": "A location card with deep links that open the visitor’s map app (Google/Apple Maps) for directions, from an address, GPS coordinates, or a pasted map link.",
|
package/lib/anchors.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Block } from './types.js';
|
|
2
|
+
export interface Anchors {
|
|
3
|
+
/** blockId → the anchor id emitted on its section (absent = skipped). */
|
|
4
|
+
idFor: Map<string, string>;
|
|
5
|
+
/** Resolve a nav/CTA link (by hash, label, or alias) to a working href. */
|
|
6
|
+
resolve(href: unknown, label?: string): string;
|
|
7
|
+
}
|
|
8
|
+
/** Assign a unique anchor id to each anchorable block and build a link resolver. */
|
|
9
|
+
export declare function buildAnchors(blocks: Block[]): Anchors;
|
|
10
|
+
/** Insert an anchor `id` into the first element of a block's rendered HTML. */
|
|
11
|
+
export declare function injectAnchorId(html: string, id: string): string;
|
package/lib/anchors.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-page anchors — makes nav links actually scroll (issue #26).
|
|
3
|
+
*
|
|
4
|
+
* `renderSite` emits sections without ids, so `#about` / `#menu` resolve to
|
|
5
|
+
* nothing. This module computes a stable, de-duped anchor id per content section
|
|
6
|
+
* (a canonical slug that can differ from the block's short CSS class, e.g.
|
|
7
|
+
* `contact-details` → `contact`, `services-catalogue` → `services`) and resolves
|
|
8
|
+
* a nav/CTA link — by its `#hash`, its label, or a common alias — to a real id.
|
|
9
|
+
* Chrome blocks (nav, app-shell, sidebar, announcement-bar) and pure rhythm
|
|
10
|
+
* blocks get no anchor. Unresolved links fall back to `#` (top), never a dead
|
|
11
|
+
* anchor. Render-side, so re-rendering an existing manifest just works.
|
|
12
|
+
*/
|
|
13
|
+
import { slugify } from './schema.js';
|
|
14
|
+
const SKIP = new Set(['nav', 'app-shell', 'sidebar', 'announcement-bar', 'divider', 'spacer']);
|
|
15
|
+
/** Canonical anchor slug per block type (default = the type name). */
|
|
16
|
+
const SECTION_SLUG = {
|
|
17
|
+
hero: 'home', 'hero-app': 'home', 'profile-header': 'home',
|
|
18
|
+
about: 'about',
|
|
19
|
+
'contact-details': 'contact', 'contact-form': 'contact',
|
|
20
|
+
'services-catalogue': 'services',
|
|
21
|
+
testimonials: 'reviews',
|
|
22
|
+
'blog-list': 'blog', 'blog-post': 'blog',
|
|
23
|
+
'video-gallery': 'videos',
|
|
24
|
+
};
|
|
25
|
+
/** Common nav-label slugs → the canonical section slug they mean. */
|
|
26
|
+
const ALIAS = {
|
|
27
|
+
menu: 'services', catalog: 'services', catalogue: 'services', products: 'services', shop: 'services',
|
|
28
|
+
story: 'about', 'our-story': 'about', 'about-us': 'about', bio: 'about', 'who-we-are': 'about',
|
|
29
|
+
'get-in-touch': 'contact', 'contact-us': 'contact', 'reach-us': 'contact', reach: 'contact', hire: 'contact', 'hire-me': 'contact',
|
|
30
|
+
testimonials: 'reviews', feedback: 'reviews',
|
|
31
|
+
work: 'gallery', portfolio: 'gallery', photos: 'gallery',
|
|
32
|
+
questions: 'faq', help: 'faq',
|
|
33
|
+
plans: 'pricing', prices: 'pricing',
|
|
34
|
+
};
|
|
35
|
+
function sectionSlug(type) {
|
|
36
|
+
if (SKIP.has(type))
|
|
37
|
+
return null;
|
|
38
|
+
return SECTION_SLUG[type] ?? type;
|
|
39
|
+
}
|
|
40
|
+
/** Assign a unique anchor id to each anchorable block and build a link resolver. */
|
|
41
|
+
export function buildAnchors(blocks) {
|
|
42
|
+
const idFor = new Map();
|
|
43
|
+
const ids = new Set(); // every emitted anchor id
|
|
44
|
+
const slugToId = new Map(); // canonical slug → first emitted id of that type
|
|
45
|
+
for (const b of blocks) {
|
|
46
|
+
const base = b && sectionSlug(b.type);
|
|
47
|
+
if (!base || !b.id)
|
|
48
|
+
continue;
|
|
49
|
+
let id = base;
|
|
50
|
+
for (let n = 2; ids.has(id); n++)
|
|
51
|
+
id = `${base}-${n}`; // de-dup repeats
|
|
52
|
+
ids.add(id);
|
|
53
|
+
idFor.set(b.id, id);
|
|
54
|
+
if (!slugToId.has(base))
|
|
55
|
+
slugToId.set(base, id);
|
|
56
|
+
}
|
|
57
|
+
// A slug → an existing anchor id: exact id (incl. de-duped like `features-2`),
|
|
58
|
+
// canonical slug, or a common alias. '' when nothing matches.
|
|
59
|
+
const lookup = (key) => (ids.has(key) ? key : slugToId.get(key) ?? slugToId.get(ALIAS[key] ?? '') ?? '');
|
|
60
|
+
const resolve = (href, label = '') => {
|
|
61
|
+
const raw = String(href ?? '').trim();
|
|
62
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(raw))
|
|
63
|
+
return raw; // scheme (http/mailto/tel/…) → external, untouched
|
|
64
|
+
if (raw && !raw.startsWith('#'))
|
|
65
|
+
return raw; // relative path/query → untouched
|
|
66
|
+
const key = slugify(raw.startsWith('#') && raw.length > 1 ? raw.slice(1) : label); // hash text, else label
|
|
67
|
+
if (!key || key === 'home' || key === 'top' || key === 'start')
|
|
68
|
+
return '#'; // Home/Top → page top
|
|
69
|
+
const id = lookup(key);
|
|
70
|
+
return id ? `#${id}` : '#'; // resolved anchor, else top (never a dead anchor)
|
|
71
|
+
};
|
|
72
|
+
return { idFor, resolve };
|
|
73
|
+
}
|
|
74
|
+
/** Insert an anchor `id` into the first element of a block's rendered HTML. */
|
|
75
|
+
export function injectAnchorId(html, id) {
|
|
76
|
+
return html.replace(/^(\s*<[a-zA-Z][\w-]*)(\s|>)/, (_m, open, next) => `${open} id="${id}"${next}`);
|
|
77
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `booking` — an appointment/reservation request form. Powered brick (§6): the
|
|
3
|
+
* submit target is resolved by the host via the `booking.request` capability.
|
|
4
|
+
* With no runtime wired it renders inert-but-valid (disabled submit + a note)
|
|
5
|
+
* while keeping the `data-wl-*` hooks so a host can enhance it client-side.
|
|
6
|
+
*
|
|
7
|
+
* This is the home for the booking-driven verticals (salon, clinic, gym, hotel,
|
|
8
|
+
* events) — `contact-form` is generic and `auth` is login; neither fits. The
|
|
9
|
+
* safety-critical parts (availability, double-booking, delivery, spam) live in
|
|
10
|
+
* the host's vetted runtime, never in anything the AI emits. Works without JS
|
|
11
|
+
* via a native form post; a host `booking` island can enhance it (live slots).
|
|
12
|
+
*/
|
|
13
|
+
import { escapeAttr, escapeHtml } from '../schema.js';
|
|
14
|
+
import { NOOP_RUNTIME, safeMethod } from '../runtime.js';
|
|
15
|
+
const CAPABILITY = 'booking.request';
|
|
16
|
+
const schema = {
|
|
17
|
+
title: { kind: 'string', default: 'Book an appointment', max: 120 },
|
|
18
|
+
intro: { kind: 'string', default: '', max: 280 },
|
|
19
|
+
submitLabel: { kind: 'string', default: 'Request booking', max: 40 },
|
|
20
|
+
successMessage: { kind: 'string', default: "Thanks — we'll confirm your booking shortly.", max: 200 },
|
|
21
|
+
serviceLabel: { kind: 'string', default: 'Service', max: 60 },
|
|
22
|
+
services: {
|
|
23
|
+
kind: 'array', max: 40,
|
|
24
|
+
of: {
|
|
25
|
+
kind: 'object',
|
|
26
|
+
fields: {
|
|
27
|
+
name: { kind: 'string', required: true, default: 'Service', max: 80 },
|
|
28
|
+
duration: { kind: 'string', default: '', max: 40 },
|
|
29
|
+
price: { kind: 'string', default: '', max: 40 },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
askDate: { kind: 'boolean', default: true },
|
|
34
|
+
askTime: { kind: 'boolean', default: true },
|
|
35
|
+
askParty: { kind: 'boolean', default: false },
|
|
36
|
+
askPhone: { kind: 'boolean', default: true },
|
|
37
|
+
askNotes: { kind: 'boolean', default: true },
|
|
38
|
+
notesLabel: { kind: 'string', default: 'Anything we should know?', max: 80 },
|
|
39
|
+
};
|
|
40
|
+
const css = `
|
|
41
|
+
.blk-booking{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
|
|
42
|
+
.blk-booking .wrap{max-width:620px;margin:0 auto}
|
|
43
|
+
.blk-booking h2{font-size:var(--fs-xl);margin:0 0 .2em;font-weight:800}
|
|
44
|
+
.blk-booking .intro{color:var(--muted);margin:0 0 var(--space)}
|
|
45
|
+
.blk-booking form{display:grid;gap:calc(var(--space)*.9)}
|
|
46
|
+
.blk-booking .grid2{display:grid;gap:calc(var(--space)*.9);grid-template-columns:repeat(auto-fit,minmax(180px,1fr))}
|
|
47
|
+
.blk-booking label{display:grid;gap:.35em;font-weight:600}
|
|
48
|
+
.blk-booking input,.blk-booking textarea,.blk-booking select{font:inherit;color:inherit;background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 18%,transparent);border-radius:var(--radius);padding:.6em .7em;width:100%}
|
|
49
|
+
.blk-booking textarea{min-height:6em;resize:vertical}
|
|
50
|
+
.blk-booking button{font:inherit;font-weight:700;cursor:pointer;border:0;border-radius:var(--radius);padding:.7em 1.2em;background:var(--primary);color:var(--on-primary);transition:opacity var(--motion)}
|
|
51
|
+
.blk-booking button[disabled]{opacity:.55;cursor:not-allowed}
|
|
52
|
+
.blk-booking .note{margin:.4em 0 0;font-size:var(--fs-base);color:var(--muted)}
|
|
53
|
+
`.trim();
|
|
54
|
+
function serviceLabel(s) {
|
|
55
|
+
const meta = [s.duration, s.price].filter(Boolean).join(' · ');
|
|
56
|
+
return meta ? `${s.name} (${meta})` : s.name;
|
|
57
|
+
}
|
|
58
|
+
function render(config, _tokens, ctx) {
|
|
59
|
+
const id = ctx?.id ?? 'booking';
|
|
60
|
+
const runtime = ctx?.runtime ?? NOOP_RUNTIME;
|
|
61
|
+
const title = config.title;
|
|
62
|
+
const intro = config.intro;
|
|
63
|
+
const submitLabel = config.submitLabel || 'Request booking';
|
|
64
|
+
const services = (config.services ?? []).filter((s) => s && s.name);
|
|
65
|
+
const action = runtime.resolve(CAPABILITY, id);
|
|
66
|
+
const base = `bk-${id}`;
|
|
67
|
+
const serviceField = services.length
|
|
68
|
+
? `<label for="${escapeAttr(base)}-service">${escapeHtml(config.serviceLabel)}
|
|
69
|
+
<select id="${escapeAttr(base)}-service" name="service" required>
|
|
70
|
+
${services.map((s) => `<option value="${escapeAttr(s.name)}">${escapeHtml(serviceLabel(s))}</option>`).join('\n ')}
|
|
71
|
+
</select></label>`
|
|
72
|
+
: '';
|
|
73
|
+
const dateTime = (config.askDate || config.askTime)
|
|
74
|
+
? `<div class="grid2">
|
|
75
|
+
${config.askDate ? `<label for="${escapeAttr(base)}-date">Date<input id="${escapeAttr(base)}-date" type="date" name="date" required></label>` : ''}
|
|
76
|
+
${config.askTime ? `<label for="${escapeAttr(base)}-time">Time<input id="${escapeAttr(base)}-time" type="time" name="time" required></label>` : ''}
|
|
77
|
+
</div>`
|
|
78
|
+
: '';
|
|
79
|
+
const party = config.askParty
|
|
80
|
+
? `<label for="${escapeAttr(base)}-party">Party size<input id="${escapeAttr(base)}-party" type="number" name="party" min="1" inputmode="numeric"></label>`
|
|
81
|
+
: '';
|
|
82
|
+
const contact = `<div class="grid2">
|
|
83
|
+
<label for="${escapeAttr(base)}-name">Your name<input id="${escapeAttr(base)}-name" type="text" name="name" required></label>
|
|
84
|
+
<label for="${escapeAttr(base)}-email">Email<input id="${escapeAttr(base)}-email" type="email" name="email" required></label>
|
|
85
|
+
</div>`;
|
|
86
|
+
const phone = config.askPhone
|
|
87
|
+
? `<label for="${escapeAttr(base)}-phone">Phone<input id="${escapeAttr(base)}-phone" type="tel" name="phone"></label>`
|
|
88
|
+
: '';
|
|
89
|
+
const notes = config.askNotes
|
|
90
|
+
? `<label for="${escapeAttr(base)}-notes">${escapeHtml(config.notesLabel)}<textarea id="${escapeAttr(base)}-notes" name="notes"></textarea></label>`
|
|
91
|
+
: '';
|
|
92
|
+
return `<section class="blk-booking" aria-label="${escapeAttr(title || 'Booking')}">
|
|
93
|
+
<div class="wrap">
|
|
94
|
+
${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
|
|
95
|
+
${intro ? `<p class="intro">${escapeHtml(intro)}</p>` : ''}
|
|
96
|
+
<form method="${safeMethod(action?.method)}" action="${action ? escapeAttr(action.url) : '#'}" data-wl-capability="${CAPABILITY}" data-wl-block="${escapeAttr(id)}"${action ? '' : ' data-wl-inert="true"'}>
|
|
97
|
+
${serviceField}
|
|
98
|
+
${dateTime}
|
|
99
|
+
${party}
|
|
100
|
+
${contact}
|
|
101
|
+
${phone}
|
|
102
|
+
${notes}
|
|
103
|
+
<button type="submit"${action ? '' : ' disabled'}>${escapeHtml(submitLabel)}</button>
|
|
104
|
+
${action ? '' : '<p class="note">This booking form needs a runtime to be wired before it can send.</p>'}
|
|
105
|
+
</form>
|
|
106
|
+
</div>
|
|
107
|
+
</section>`;
|
|
108
|
+
}
|
|
109
|
+
export const booking = {
|
|
110
|
+
type: 'booking',
|
|
111
|
+
description: 'An appointment/reservation request form (service, date, time, contact) that posts to a host-provided runtime. For booking-driven businesses — salons, clinics, gyms, hotels, events. No raw HTML.',
|
|
112
|
+
schema,
|
|
113
|
+
css,
|
|
114
|
+
render,
|
|
115
|
+
island: 'booking',
|
|
116
|
+
runtime: { capabilities: [CAPABILITY] },
|
|
117
|
+
};
|