juo 0.3.0-alpha.1 → 0.3.0-alpha.3

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.
@@ -1,59 +1,55 @@
1
1
  @import "@juo/customer-ui/design-tokens.css";
2
2
  @import "@juo/customer-ui/theme-modes.css";
3
3
 
4
- .container {
5
- font-weight: 540;
4
+ /*
5
+ * Styling for the starter block (non-Tailwind projects).
6
+ *
7
+ * Everything below comes from the Customer UI design tokens imported above:
8
+ * the `--space-*` spacing scale, the `--font-size-*` type scale, the
9
+ * `--radius-*` radius scale, and the `--tint-*` (neutral) / `--accent-*`
10
+ * (brand) color palettes. Prefer these tokens over hardcoded values so the
11
+ * block follows the merchant's theme automatically.
12
+ */
13
+
14
+ .starter-block {
6
15
  display: flex;
7
- gap: 2rem;
8
- justify-content: space-between;
9
16
  align-items: center;
10
- padding: 2.5rem;
11
- border-radius: 0.5rem;
12
- border: 1px solid #9ca3af;
13
- background-color: #fafafa;
17
+ justify-content: space-between;
18
+ gap: var(--space-32);
19
+ padding: var(--space-32);
14
20
  }
15
21
 
16
- .content {
22
+ .starter-block__info {
17
23
  display: flex;
18
24
  align-items: center;
19
- gap: 0.875rem;
25
+ gap: var(--space-12);
20
26
  }
21
27
 
22
- .icon-container {
28
+ .starter-block__icons {
23
29
  display: flex;
24
30
  align-items: center;
25
- gap: 0.5rem;
31
+ gap: var(--space-8);
32
+ /* Defaults to a neutral color; overridden inline by the color control. */
33
+ color: var(--tint-900);
26
34
  }
27
35
 
28
- .framework-icon {
29
- width: 2.25rem;
30
- height: 2.25rem;
31
- fill: currentColor;
32
- color: black;
36
+ .starter-block__icon {
37
+ width: var(--space-32);
38
+ height: var(--space-32);
33
39
  }
34
40
 
35
- .typescript-icon {
36
- width: 2.5rem;
37
- height: 2.5rem;
38
- fill: currentColor;
39
- color: black;
40
- }
41
-
42
- .text-container {
41
+ .starter-block__text {
43
42
  display: flex;
44
43
  flex-direction: column;
45
- align-self: center;
46
44
  }
47
45
 
48
- .subtitle {
49
- font-size: 12px;
50
- line-height: 1rem;
51
- color: #696969;
46
+ .starter-block__subtitle {
47
+ font-size: var(--font-size-12);
48
+ color: var(--tint-500);
52
49
  }
53
50
 
54
- .title {
55
- font-size: 15px;
56
- line-height: 1;
57
- color: #111;
51
+ .starter-block__title {
52
+ font-size: var(--font-size-16);
53
+ font-weight: 500;
54
+ color: var(--tint-900);
58
55
  }
59
-
@@ -4,6 +4,15 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Juo Blocks Dev</title>
7
+ <link rel="preconnect" href="https://rsms.me/" />
8
+ <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
9
+ <style>
10
+ body {
11
+ font-family: InterVariable, Inter, sans-serif;
12
+ -webkit-font-smoothing: antialiased;
13
+ -moz-osx-font-smoothing: grayscale;
14
+ }
15
+ </style>
7
16
  <script type="importmap">
8
17
  {
9
18
  "imports": {
@@ -14,7 +23,7 @@
14
23
  </head>
15
24
  <body>
16
25
  <juo-context-root>
17
- <div id="app"></div>
26
+ <div id="app" style="padding: 20px; max-width: 1080px; margin: 0 auto;"></div>
18
27
  </juo-context-root>
19
28
  <script type="module" src="/vite/theme-shell.ts"></script>
20
29
  </body>
@@ -3,9 +3,28 @@ import type { Plugin } from "vite";
3
3
  const EDITOR_EMBED_URL =
4
4
  "https://cdn.juo.io/web/blocks/juo/editor/{{ editorVersion }}/juo-editor.embed.js";
5
5
 
6
+ const DEVTOOLS_EMBED_URL =
7
+ "https://cdn.juo.io/web/blocks/juo/devtools/{{ devtoolsVersion }}/blocks-devtools.embed.js";
8
+
6
9
  export default function juo(): Plugin {
7
10
  return {
8
11
  name: "juo-editor",
12
+
13
+ transformIndexHtml(_html, ctx) {
14
+ // Skip the editor page itself — devtools aren't needed there.
15
+ if (ctx.path === "/editor" || ctx.path.startsWith("/editor?")) return;
16
+ return {
17
+ html: _html,
18
+ tags: [
19
+ {
20
+ tag: "script",
21
+ attrs: { type: "module", src: DEVTOOLS_EMBED_URL },
22
+ injectTo: "head-prepend",
23
+ },
24
+ ],
25
+ };
26
+ },
27
+
9
28
  configureServer(server) {
10
29
  server.middlewares.use("/editor", (_req, res) => {
11
30
  res.writeHead(200, { "Content-Type": "text/html" });
@@ -15,8 +34,10 @@ export default function juo(): Plugin {
15
34
  <meta charset="UTF-8" />
16
35
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
17
36
  <title>Juo Blocks Editor</title>
37
+ <link rel="preconnect" href="https://rsms.me/" />
38
+ <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
18
39
  <style>
19
- html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; }
40
+ html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; font-family: InterVariable, Inter, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
20
41
  juo-editor { display: block; height: 100vh; }
21
42
  </style>
22
43
  </head>
@@ -0,0 +1,83 @@
1
+ // Dev-only mock login service. Ported from the reference theme's
2
+ // `lib/loginService.ts`. Core's `createLoginService` requires real deps
3
+ // (API adapter, router, shop, error formatter), so the dev preview hand-writes
4
+ // a mock that implements the `LoginServiceContext` shape directly.
5
+ import { ContextType, LoginServiceContext, signal } from "@juo/blocks";
6
+
7
+ export function createMockLoginService(): ContextType<typeof LoginServiceContext> {
8
+ const loggedIn = signal<boolean>(false);
9
+ const expectedCode = signal<string | null>(null);
10
+
11
+ const service: ContextType<typeof LoginServiceContext> = {
12
+ passwordless: {
13
+ email: {
14
+ async start(_) {
15
+ expectedCode.value = String(
16
+ Math.floor(100000 + Math.random() * 900000)
17
+ );
18
+ window.alert(`Use code ${expectedCode.value} to log in`);
19
+
20
+ return { _tag: "Success", data: undefined };
21
+ },
22
+
23
+ async verify(_, code) {
24
+ if (expectedCode.value != null && code === expectedCode.value) {
25
+ loggedIn.value = true;
26
+ return { _tag: "Success", data: undefined };
27
+ }
28
+
29
+ return {
30
+ _tag: "Failure",
31
+ error: {
32
+ title: "Invalid code",
33
+ message: "The provided code is invalid",
34
+ },
35
+ };
36
+ },
37
+ },
38
+ },
39
+
40
+ social: {
41
+ async start() {
42
+ return {
43
+ _tag: "Failure",
44
+ error: {
45
+ title: "Not implemented",
46
+ message: "This functionality is not available in test mode",
47
+ },
48
+ };
49
+ },
50
+
51
+ async verify() {
52
+ return {
53
+ _tag: "Failure",
54
+ error: {
55
+ title: "Not implemented",
56
+ message: "This functionality is not available in test mode",
57
+ },
58
+ };
59
+ },
60
+ },
61
+
62
+ async handleQueryParams() {
63
+ return { _tag: "Success", data: undefined };
64
+ },
65
+
66
+ async logout() {
67
+ loggedIn.value = false;
68
+ return { _tag: "Success", data: undefined };
69
+ },
70
+
71
+ isAuthenticated: loggedIn,
72
+
73
+ async getToken() {
74
+ return null;
75
+ },
76
+
77
+ async getAuthHeaders(): Promise<Record<string, string>> {
78
+ return {};
79
+ },
80
+ };
81
+
82
+ return service;
83
+ }
@@ -0,0 +1,159 @@
1
+ // Dev-only mock services. Provides every blocks service backed by its mock
2
+ // adapter so any block you author (or scaffold) has its service dependencies
3
+ // available in the dev preview. Lives in `vite/` and never enters the built
4
+ // blocks bundle (whose entry is `src/blocks/register.ts`), so there is no
5
+ // production cost — the Juo runtime provides the real services in production.
6
+ import {
7
+ provideContext,
8
+ effect,
9
+ untracked,
10
+ type ThemeState,
11
+ // product
12
+ ProductServiceContext,
13
+ createProductService,
14
+ createMockProductAdapter,
15
+ // customer
16
+ CustomerServiceContext,
17
+ createCustomerService,
18
+ createMockCustomerAdapter,
19
+ // orders
20
+ OrdersServiceContext,
21
+ createOrdersService,
22
+ createMockOrdersAdapter,
23
+ // subscription
24
+ SubscriptionServiceContext,
25
+ createSubscriptionService,
26
+ createMockSubscriptionAdapter,
27
+ // schedules
28
+ SchedulesServiceContext,
29
+ createSchedulesService,
30
+ createMockSchedulesAdapter,
31
+ // router
32
+ RouterServiceContext,
33
+ createMockRouterService,
34
+ // settings
35
+ SettingsServiceContext,
36
+ createSettingsService,
37
+ // shop
38
+ ShopServiceContext,
39
+ createShopService,
40
+ // login
41
+ LoginServiceContext,
42
+ // workflow
43
+ WorkflowServiceContext,
44
+ createWorkflowService,
45
+ createMockWorkflowApiAdapter,
46
+ } from "@juo/blocks";
47
+ import { createMockLoginService } from "./mock-login";
48
+ import { createWorkflowOverlay } from "./workflowOverlay";
49
+
50
+ /**
51
+ * Provide every available blocks service (backed by mock adapters) onto the
52
+ * dev preview's context root, and mount the workflow overlay.
53
+ */
54
+ export function provideMockServices(root: HTMLElement, themeState: ThemeState) {
55
+ // One product adapter instance, shared with the subscription mock so that
56
+ // items added to a subscription resolve a real title/price from the same
57
+ // product catalog the picker reads from.
58
+ const productAdapter = createMockProductAdapter();
59
+ provideContext(
60
+ root,
61
+ ProductServiceContext,
62
+ createProductService(productAdapter)
63
+ );
64
+
65
+ const customerService = createCustomerService({
66
+ adapter: createMockCustomerAdapter(),
67
+ getAccountOrdersUrl: () => "https://example.com/account",
68
+ });
69
+ // Eagerly populate `current` so name-aware blocks (e.g. Header) render in the
70
+ // dev preview. `createCustomerService` leaves `current` as `null` until
71
+ // `getCurrent()` is called — in the portal that population is done by
72
+ // `useCustomerService`'s watchEffect; here nothing else triggers it.
73
+ void customerService.getCurrent();
74
+ provideContext(root, CustomerServiceContext, customerService);
75
+
76
+ provideContext(
77
+ root,
78
+ OrdersServiceContext,
79
+ createOrdersService(createMockOrdersAdapter())
80
+ );
81
+
82
+ // One subscription adapter instance, shared with the schedules mock so that
83
+ // item add/remove/update mutations are reflected in the projected schedule.
84
+ const subscriptionAdapter = createMockSubscriptionAdapter({ productAdapter });
85
+ const subscriptionService = createSubscriptionService(subscriptionAdapter);
86
+ provideContext(root, SubscriptionServiceContext, subscriptionService);
87
+
88
+ const schedulesService = createSchedulesService(
89
+ createMockSchedulesAdapter(subscriptionAdapter),
90
+ {
91
+ // Share the billing-attempt signal so schedule-aware blocks stay in sync.
92
+ hasPendingBilling: subscriptionService.hasPendingBilling,
93
+ }
94
+ );
95
+ provideContext(root, SchedulesServiceContext, schedulesService);
96
+
97
+ provideContext(root, RouterServiceContext, createMockRouterService());
98
+
99
+ provideContext(root, SettingsServiceContext, createSettingsService());
100
+
101
+ provideContext(
102
+ root,
103
+ ShopServiceContext,
104
+ createShopService({ getContactEmail: () => "support@example.com" })
105
+ );
106
+
107
+ provideContext(root, LoginServiceContext, createMockLoginService());
108
+
109
+ // Note: OverlayServiceContext is provided by the theme shell's setup
110
+ // functions — editor mode must pass the same overlay service instance into
111
+ // `setupEditorMode`, so its lifecycle is owned there, not here.
112
+
113
+ const workflowService = createWorkflowService({
114
+ api: createMockWorkflowApiAdapter(),
115
+ themeState,
116
+ });
117
+ provideContext(root, WorkflowServiceContext, workflowService);
118
+
119
+ // Mount the workflow overlay UI so flows render in the dev preview.
120
+ createWorkflowOverlay(root, workflowService, themeState);
121
+
122
+ // Seed a focused subscription so subscription-scoped blocks render. There is
123
+ // no subscription id in the dev-preview URL, so use the mock adapter's default
124
+ // (mirrors the editor seed in customer-portal's useSubscriptionService).
125
+ void (async () => {
126
+ const idResult = await subscriptionService.getDefaultSubscriptionId();
127
+ const id = idResult._tag === "Success" ? idResult.data : null;
128
+ if (!id) return;
129
+ const subResult = await subscriptionService.getById(id);
130
+ if (subResult._tag === "Success") {
131
+ subscriptionService.current.value = subResult.data;
132
+ }
133
+ })();
134
+
135
+ // Cascade: load the upcoming order whenever the focused subscription changes
136
+ // (mirrors customer-portal's useSchedulesService watcher).
137
+ effect(() => {
138
+ const id = subscriptionService.current.value?.id ?? null;
139
+ untracked(() => {
140
+ if (!id) {
141
+ schedulesService.current.value = null;
142
+ schedulesService.currentLoading.value = false;
143
+ return;
144
+ }
145
+ void schedulesService.reloadCurrent(id);
146
+ });
147
+ });
148
+
149
+ // Re-project the schedule after any item add/remove/update. The focused
150
+ // subscription id is unchanged by an item mutation, so the id-based watcher
151
+ // above does not fire — `itemsChangedAt` bumps on every successful change.
152
+ effect(() => {
153
+ subscriptionService.itemsChangedAt.value; // subscribe
154
+ untracked(() => {
155
+ const id = subscriptionService.current.value?.id ?? null;
156
+ if (id) void schedulesService.reloadCurrent(id);
157
+ });
158
+ });
159
+ }
@@ -2,7 +2,6 @@
2
2
  // source tree contains only their blocks. Vite serves this file directly (see
3
3
  // the <script> + import map injected in index.html); it never enters the built
4
4
  // blocks bundle (whose entry is `src/blocks/register.ts`).
5
- import "@juo/blocks/web-components/editor";
6
5
  {% if tailwind %}import "../src/tailwind.css";
7
6
  {% else %}import "../src/styles.css";
8
7
  {% endif %}
@@ -19,15 +18,38 @@ import {
19
18
  ThemeStateContext,
20
19
  TranslationContext,
21
20
  createTranslationService,
21
+ createOverlayService,
22
+ OverlayServiceContext,
22
23
  untracked,
23
24
  } from "@juo/blocks";
24
25
  import { registerBlocks as registerProjectBlocks } from "../src/main";
26
+ import { provideMockServices } from "./mock-services";
25
27
  // Native blocks shipped by @juo/blocks-extensions, loaded from CDN. Its bare
26
28
  // "@juo/blocks" import is bridged to the local instance via the index.html
27
29
  // import map (see vite/blocks-shim.ts).
28
30
  // @ts-expect-error Remote, untyped module
29
31
  import { blocks as nativeBlocks } from "https://cdn.juo.io/web/blocks/juo/{{ extensionsVersion }}/extensions.js";
30
32
 
33
+ function hexToOklch(hex: string): { l: number; h: number; c: number } {
34
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
35
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
36
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
37
+ const linearize = (v: number) =>
38
+ v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
39
+ const lr = linearize(r);
40
+ const lg = linearize(g);
41
+ const lb = linearize(b);
42
+ const lm = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
43
+ const mm = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
44
+ const sm = Math.cbrt(0.0883024619 * lr + 0.2220049178 * lg + 0.6896926208 * lb);
45
+ const L = 0.2104542553 * lm + 0.793617785 * mm - 0.0040720468 * sm;
46
+ const a = 1.9779984951 * lm - 2.428592205 * mm + 0.4505937099 * sm;
47
+ const bOk = 0.0259040371 * lm + 0.7827717662 * mm - 0.808675766 * sm;
48
+ const c = Math.sqrt(a * a + bOk * bOk);
49
+ const h = Math.atan2(bOk, a) * (180 / Math.PI);
50
+ return { l: L * 100, h: h < 0 ? h + 360 : h, c };
51
+ }
52
+
31
53
  const homePageBlock = defineBlock("HomePage", {
32
54
  displayName: "Home page",
33
55
  group: "page",
@@ -51,7 +73,15 @@ const homePageBlock = defineBlock("HomePage", {
51
73
  initialValue: () => ({
52
74
  props: {},
53
75
  slots: {
54
- main: [createBlockInstance("Starter Block")],
76
+ main: [
77
+ createBlockInstance("Banner"),
78
+ createBlockInstance("Header"),
79
+ createBlockInstance("RenewalSummary"),
80
+ createBlockInstance("UpcomingOrderItems"),
81
+ createBlockInstance("Starter Block"),
82
+ createBlockInstance("CustomerSubscriptions"),
83
+ createBlockInstance("OrderList"),
84
+ ],
55
85
  },
56
86
  }),
57
87
  renderer() {
@@ -89,7 +119,9 @@ async function setupEditorMode(root: HTMLElement) {
89
119
  routerService,
90
120
  });
91
121
  provideContext(root, ThemeStateContext, themeState);
92
- await editorModule.setupEditorMode({ routerService, themeState });
122
+ const overlayService = createOverlayService(themeState);
123
+ provideContext(root, OverlayServiceContext, overlayService);
124
+ await editorModule.setupEditorMode({ routerService, themeState, overlayService });
93
125
  }
94
126
 
95
127
  function setupViewMode(root: HTMLElement) {
@@ -99,11 +131,18 @@ function setupViewMode(root: HTMLElement) {
99
131
  }),
100
132
  });
101
133
  provideContext(root, ThemeStateContext, themeState);
134
+ provideContext(root, OverlayServiceContext, createOverlayService(themeState));
102
135
  }
103
136
 
104
137
  async function initializeTheme() {
105
138
  const root = document.querySelector("juo-context-root") as HTMLElement;
106
139
 
140
+ if (isEditorPreview()) {
141
+ await import("@juo/blocks/web-components/editor");
142
+ } else {
143
+ await import("@juo/blocks/web-components/runtime");
144
+ }
145
+
107
146
  registerBlocks();
108
147
 
109
148
  if (isEditorPreview()) {
@@ -114,6 +153,89 @@ async function initializeTheme() {
114
153
 
115
154
  const themeState = injectContext(root, ThemeStateContext);
116
155
 
156
+ effect(() => {
157
+ const styles = themeState.globalStyles.value;
158
+ const el = root;
159
+
160
+ el.setAttribute("data-volume", styles.volume);
161
+ el.setAttribute("data-style", styles.style);
162
+ el.setAttribute("data-corners", styles.corners);
163
+ el.setAttribute("data-button-corners", styles.buttonCorners);
164
+ el.setAttribute("data-thumbnail-corners", styles.thumbnailCorners);
165
+
166
+ const bg = styles.colors.background;
167
+ if (bg) {
168
+ el.style.setProperty("background-color", bg);
169
+ } else {
170
+ el.style.removeProperty("background-color");
171
+ }
172
+
173
+ const colorizableKeys = ["tint", "accent", "secondary", "callout"] as const;
174
+
175
+ if (styles.theme === "custom") {
176
+ for (const key of colorizableKeys) {
177
+ el.setAttribute(`data-${key}`, "custom");
178
+ const hex = styles.colors[key];
179
+ if (hex) {
180
+ const { l, h, c } = hexToOklch(hex);
181
+ el.style.setProperty(`--${key}-l`, `${l}%`);
182
+ el.style.setProperty(`--${key}-hue`, String(h));
183
+ el.style.setProperty(`--${key}-chroma`, String(c / 0.045));
184
+ }
185
+ }
186
+ } else {
187
+ el.setAttribute("data-tint", styles.themeType ?? "neutral");
188
+ el.removeAttribute("data-callout");
189
+
190
+ const customColorKeys = ["accent", "secondary"] as const;
191
+ for (const key of customColorKeys) {
192
+ const hex = styles.colors[key];
193
+ if (hex) {
194
+ el.setAttribute(`data-${key}`, "custom");
195
+ const { l, h, c } = hexToOklch(hex);
196
+ el.style.setProperty(`--${key}-l`, `${l}%`);
197
+ el.style.setProperty(`--${key}-hue`, String(h));
198
+ el.style.setProperty(`--${key}-chroma`, String(c / 0.045));
199
+ } else {
200
+ el.removeAttribute(`data-${key}`);
201
+ el.style.removeProperty(`--${key}-l`);
202
+ el.style.removeProperty(`--${key}-hue`);
203
+ el.style.removeProperty(`--${key}-chroma`);
204
+ }
205
+ }
206
+
207
+ el.style.removeProperty("--tint-l");
208
+ el.style.removeProperty("--tint-hue");
209
+ el.style.removeProperty("--tint-chroma");
210
+ el.style.removeProperty("--callout-l");
211
+ el.style.removeProperty("--callout-hue");
212
+ el.style.removeProperty("--callout-chroma");
213
+ }
214
+
215
+ const semanticColorMap = [
216
+ { themeKey: "success", cssPrefix: "success" },
217
+ { themeKey: "error", cssPrefix: "error" },
218
+ { themeKey: "information", cssPrefix: "info" },
219
+ { themeKey: "warning", cssPrefix: "warning" },
220
+ ] as const;
221
+
222
+ for (const { themeKey, cssPrefix } of semanticColorMap) {
223
+ const hex = styles.colors[themeKey];
224
+ if (hex) {
225
+ el.setAttribute(`data-${cssPrefix}`, "custom");
226
+ const { l, h, c } = hexToOklch(hex);
227
+ el.style.setProperty(`--${cssPrefix}-l`, `${l}%`);
228
+ el.style.setProperty(`--${cssPrefix}-hue`, String(h));
229
+ el.style.setProperty(`--${cssPrefix}-chroma`, String(c / 0.045));
230
+ } else {
231
+ el.removeAttribute(`data-${cssPrefix}`);
232
+ el.style.removeProperty(`--${cssPrefix}-l`);
233
+ el.style.removeProperty(`--${cssPrefix}-hue`);
234
+ el.style.removeProperty(`--${cssPrefix}-chroma`);
235
+ }
236
+ }
237
+ });
238
+
117
239
  await themeState.locales.setLocale("en");
118
240
  provideContext(
119
241
  root,
@@ -125,6 +247,10 @@ async function initializeTheme() {
125
247
  }),
126
248
  );
127
249
 
250
+ // Provide every blocks service (backed by mock adapters) so authored blocks
251
+ // have their service dependencies available in the dev preview.
252
+ provideMockServices(root, themeState);
253
+
128
254
  void themeState.resolve("page", "/");
129
255
 
130
256
  effect(() => {