juo 0.3.0-alpha.1 → 0.3.0-alpha.2

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-36);
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-36);
38
+ height: var(--space-36);
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,140 @@
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
+ provideContext(
56
+ root,
57
+ ProductServiceContext,
58
+ createProductService(createMockProductAdapter())
59
+ );
60
+
61
+ const customerService = createCustomerService({
62
+ adapter: createMockCustomerAdapter(),
63
+ getAccountOrdersUrl: () => "https://example.com/account",
64
+ });
65
+ // Eagerly populate `current` so name-aware blocks (e.g. Header) render in the
66
+ // dev preview. `createCustomerService` leaves `current` as `null` until
67
+ // `getCurrent()` is called — in the portal that population is done by
68
+ // `useCustomerService`'s watchEffect; here nothing else triggers it.
69
+ void customerService.getCurrent();
70
+ provideContext(root, CustomerServiceContext, customerService);
71
+
72
+ provideContext(
73
+ root,
74
+ OrdersServiceContext,
75
+ createOrdersService(createMockOrdersAdapter())
76
+ );
77
+
78
+ const subscriptionService = createSubscriptionService(
79
+ createMockSubscriptionAdapter()
80
+ );
81
+ provideContext(root, SubscriptionServiceContext, subscriptionService);
82
+
83
+ const schedulesService = createSchedulesService(createMockSchedulesAdapter(), {
84
+ // Share the billing-attempt signal so schedule-aware blocks stay in sync.
85
+ hasPendingBilling: subscriptionService.hasPendingBilling,
86
+ });
87
+ provideContext(root, SchedulesServiceContext, schedulesService);
88
+
89
+ provideContext(root, RouterServiceContext, createMockRouterService());
90
+
91
+ provideContext(root, SettingsServiceContext, createSettingsService());
92
+
93
+ provideContext(
94
+ root,
95
+ ShopServiceContext,
96
+ createShopService({ getContactEmail: () => "support@example.com" })
97
+ );
98
+
99
+ provideContext(root, LoginServiceContext, createMockLoginService());
100
+
101
+ // Note: OverlayServiceContext is provided by the theme shell's setup
102
+ // functions — editor mode must pass the same overlay service instance into
103
+ // `setupEditorMode`, so its lifecycle is owned there, not here.
104
+
105
+ const workflowService = createWorkflowService({
106
+ api: createMockWorkflowApiAdapter(),
107
+ themeState,
108
+ });
109
+ provideContext(root, WorkflowServiceContext, workflowService);
110
+
111
+ // Mount the workflow overlay UI so flows render in the dev preview.
112
+ createWorkflowOverlay(root, workflowService, themeState);
113
+
114
+ // Seed a focused subscription so subscription-scoped blocks render. There is
115
+ // no subscription id in the dev-preview URL, so use the mock adapter's default
116
+ // (mirrors the editor seed in customer-portal's useSubscriptionService).
117
+ void (async () => {
118
+ const idResult = await subscriptionService.getDefaultSubscriptionId();
119
+ const id = idResult._tag === "Success" ? idResult.data : null;
120
+ if (!id) return;
121
+ const subResult = await subscriptionService.getById(id);
122
+ if (subResult._tag === "Success") {
123
+ subscriptionService.current.value = subResult.data;
124
+ }
125
+ })();
126
+
127
+ // Cascade: load the upcoming order whenever the focused subscription changes
128
+ // (mirrors customer-portal's useSchedulesService watcher).
129
+ effect(() => {
130
+ const id = subscriptionService.current.value?.id ?? null;
131
+ untracked(() => {
132
+ if (!id) {
133
+ schedulesService.current.value = null;
134
+ schedulesService.currentLoading.value = false;
135
+ return;
136
+ }
137
+ void schedulesService.reloadCurrent(id);
138
+ });
139
+ });
140
+ }
@@ -19,15 +19,38 @@ import {
19
19
  ThemeStateContext,
20
20
  TranslationContext,
21
21
  createTranslationService,
22
+ createOverlayService,
23
+ OverlayServiceContext,
22
24
  untracked,
23
25
  } from "@juo/blocks";
24
26
  import { registerBlocks as registerProjectBlocks } from "../src/main";
27
+ import { provideMockServices } from "./mock-services";
25
28
  // Native blocks shipped by @juo/blocks-extensions, loaded from CDN. Its bare
26
29
  // "@juo/blocks" import is bridged to the local instance via the index.html
27
30
  // import map (see vite/blocks-shim.ts).
28
31
  // @ts-expect-error Remote, untyped module
29
32
  import { blocks as nativeBlocks } from "https://cdn.juo.io/web/blocks/juo/{{ extensionsVersion }}/extensions.js";
30
33
 
34
+ function hexToOklch(hex: string): { l: number; h: number; c: number } {
35
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
36
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
37
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
38
+ const linearize = (v: number) =>
39
+ v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
40
+ const lr = linearize(r);
41
+ const lg = linearize(g);
42
+ const lb = linearize(b);
43
+ const lm = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
44
+ const mm = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
45
+ const sm = Math.cbrt(0.0883024619 * lr + 0.2220049178 * lg + 0.6896926208 * lb);
46
+ const L = 0.2104542553 * lm + 0.793617785 * mm - 0.0040720468 * sm;
47
+ const a = 1.9779984951 * lm - 2.428592205 * mm + 0.4505937099 * sm;
48
+ const bOk = 0.0259040371 * lm + 0.7827717662 * mm - 0.808675766 * sm;
49
+ const c = Math.sqrt(a * a + bOk * bOk);
50
+ const h = Math.atan2(bOk, a) * (180 / Math.PI);
51
+ return { l: L * 100, h: h < 0 ? h + 360 : h, c };
52
+ }
53
+
31
54
  const homePageBlock = defineBlock("HomePage", {
32
55
  displayName: "Home page",
33
56
  group: "page",
@@ -51,7 +74,15 @@ const homePageBlock = defineBlock("HomePage", {
51
74
  initialValue: () => ({
52
75
  props: {},
53
76
  slots: {
54
- main: [createBlockInstance("Starter Block")],
77
+ main: [
78
+ createBlockInstance("Banner"),
79
+ createBlockInstance("Header"),
80
+ createBlockInstance("RenewalSummary"),
81
+ createBlockInstance("UpcomingOrderItems"),
82
+ createBlockInstance("Starter Block"),
83
+ createBlockInstance("CustomerSubscriptions"),
84
+ createBlockInstance("OrderList"),
85
+ ],
55
86
  },
56
87
  }),
57
88
  renderer() {
@@ -89,7 +120,9 @@ async function setupEditorMode(root: HTMLElement) {
89
120
  routerService,
90
121
  });
91
122
  provideContext(root, ThemeStateContext, themeState);
92
- await editorModule.setupEditorMode({ routerService, themeState });
123
+ const overlayService = createOverlayService(themeState);
124
+ provideContext(root, OverlayServiceContext, overlayService);
125
+ await editorModule.setupEditorMode({ routerService, themeState, overlayService });
93
126
  }
94
127
 
95
128
  function setupViewMode(root: HTMLElement) {
@@ -99,6 +132,7 @@ function setupViewMode(root: HTMLElement) {
99
132
  }),
100
133
  });
101
134
  provideContext(root, ThemeStateContext, themeState);
135
+ provideContext(root, OverlayServiceContext, createOverlayService(themeState));
102
136
  }
103
137
 
104
138
  async function initializeTheme() {
@@ -114,6 +148,89 @@ async function initializeTheme() {
114
148
 
115
149
  const themeState = injectContext(root, ThemeStateContext);
116
150
 
151
+ effect(() => {
152
+ const styles = themeState.globalStyles.value;
153
+ const el = root;
154
+
155
+ el.setAttribute("data-volume", styles.volume);
156
+ el.setAttribute("data-style", styles.style);
157
+ el.setAttribute("data-corners", styles.corners);
158
+ el.setAttribute("data-button-corners", styles.buttonCorners);
159
+ el.setAttribute("data-thumbnail-corners", styles.thumbnailCorners);
160
+
161
+ const bg = styles.colors.background;
162
+ if (bg) {
163
+ el.style.setProperty("background-color", bg);
164
+ } else {
165
+ el.style.removeProperty("background-color");
166
+ }
167
+
168
+ const colorizableKeys = ["tint", "accent", "secondary", "callout"] as const;
169
+
170
+ if (styles.theme === "custom") {
171
+ for (const key of colorizableKeys) {
172
+ el.setAttribute(`data-${key}`, "custom");
173
+ const hex = styles.colors[key];
174
+ if (hex) {
175
+ const { l, h, c } = hexToOklch(hex);
176
+ el.style.setProperty(`--${key}-l`, `${l}%`);
177
+ el.style.setProperty(`--${key}-hue`, String(h));
178
+ el.style.setProperty(`--${key}-chroma`, String(c / 0.045));
179
+ }
180
+ }
181
+ } else {
182
+ el.setAttribute("data-tint", styles.themeType ?? "neutral");
183
+ el.removeAttribute("data-callout");
184
+
185
+ const customColorKeys = ["accent", "secondary"] as const;
186
+ for (const key of customColorKeys) {
187
+ const hex = styles.colors[key];
188
+ if (hex) {
189
+ el.setAttribute(`data-${key}`, "custom");
190
+ const { l, h, c } = hexToOklch(hex);
191
+ el.style.setProperty(`--${key}-l`, `${l}%`);
192
+ el.style.setProperty(`--${key}-hue`, String(h));
193
+ el.style.setProperty(`--${key}-chroma`, String(c / 0.045));
194
+ } else {
195
+ el.removeAttribute(`data-${key}`);
196
+ el.style.removeProperty(`--${key}-l`);
197
+ el.style.removeProperty(`--${key}-hue`);
198
+ el.style.removeProperty(`--${key}-chroma`);
199
+ }
200
+ }
201
+
202
+ el.style.removeProperty("--tint-l");
203
+ el.style.removeProperty("--tint-hue");
204
+ el.style.removeProperty("--tint-chroma");
205
+ el.style.removeProperty("--callout-l");
206
+ el.style.removeProperty("--callout-hue");
207
+ el.style.removeProperty("--callout-chroma");
208
+ }
209
+
210
+ const semanticColorMap = [
211
+ { themeKey: "success", cssPrefix: "success" },
212
+ { themeKey: "error", cssPrefix: "error" },
213
+ { themeKey: "information", cssPrefix: "info" },
214
+ { themeKey: "warning", cssPrefix: "warning" },
215
+ ] as const;
216
+
217
+ for (const { themeKey, cssPrefix } of semanticColorMap) {
218
+ const hex = styles.colors[themeKey];
219
+ if (hex) {
220
+ el.setAttribute(`data-${cssPrefix}`, "custom");
221
+ const { l, h, c } = hexToOklch(hex);
222
+ el.style.setProperty(`--${cssPrefix}-l`, `${l}%`);
223
+ el.style.setProperty(`--${cssPrefix}-hue`, String(h));
224
+ el.style.setProperty(`--${cssPrefix}-chroma`, String(c / 0.045));
225
+ } else {
226
+ el.removeAttribute(`data-${cssPrefix}`);
227
+ el.style.removeProperty(`--${cssPrefix}-l`);
228
+ el.style.removeProperty(`--${cssPrefix}-hue`);
229
+ el.style.removeProperty(`--${cssPrefix}-chroma`);
230
+ }
231
+ }
232
+ });
233
+
117
234
  await themeState.locales.setLocale("en");
118
235
  provideContext(
119
236
  root,
@@ -125,6 +242,10 @@ async function initializeTheme() {
125
242
  }),
126
243
  );
127
244
 
245
+ // Provide every blocks service (backed by mock adapters) so authored blocks
246
+ // have their service dependencies available in the dev preview.
247
+ provideMockServices(root, themeState);
248
+
128
249
  void themeState.resolve("page", "/");
129
250
 
130
251
  effect(() => {