@code-collective/booking-widget 1.0.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/README.md +266 -0
- package/dist/booking-widget.css +2 -0
- package/dist/booking-widget.js +4359 -0
- package/dist/booking-widget.umd.cjs +2 -0
- package/package.json +48 -0
- package/src/lib/AvailabilityCalendar.svelte +222 -0
- package/src/lib/CartBar.svelte +32 -0
- package/src/lib/CartBarView.svelte +60 -0
- package/src/lib/CartOverview.svelte +20 -0
- package/src/lib/CartOverviewButton.svelte +120 -0
- package/src/lib/Checkout.svelte +55 -0
- package/src/lib/CheckoutModal.svelte +510 -0
- package/src/lib/ConsentSection.svelte +45 -0
- package/src/lib/ContactForm.svelte +67 -0
- package/src/lib/CountdownTimer.svelte +30 -0
- package/src/lib/EditBookingView.svelte +73 -0
- package/src/lib/OptionCard.svelte +41 -0
- package/src/lib/PaymentPage.svelte +155 -0
- package/src/lib/PickupPointPicker.svelte +76 -0
- package/src/lib/ResultView.svelte +84 -0
- package/src/lib/SelectableCard.svelte +71 -0
- package/src/lib/TicketConfigurator.svelte +78 -0
- package/src/lib/TimeSlotPicker.svelte +45 -0
- package/src/lib/UnitCounter.svelte +91 -0
- package/src/lib/WizardPage.svelte +561 -0
- package/src/lib/api.ts +204 -0
- package/src/lib/app.css +246 -0
- package/src/lib/cart-manager.ts +76 -0
- package/src/lib/client-types.ts +52 -0
- package/src/lib/config.ts +92 -0
- package/src/lib/currency.ts +18 -0
- package/src/lib/elements/bw-cart.svelte +44 -0
- package/src/lib/elements/bw-checkout.svelte +99 -0
- package/src/lib/elements/bw-configurator.svelte +66 -0
- package/src/lib/elements/env.ts +11 -0
- package/src/lib/elements/register.ts +149 -0
- package/src/lib/elements/shared.ts +14 -0
- package/src/lib/elements/theme.css +193 -0
- package/src/lib/fake-api.ts +282 -0
- package/src/lib/generated-types.ts +828 -0
- package/src/lib/index.ts +189 -0
- package/src/lib/messages.ts +3 -0
- package/src/lib/mock-data.ts +241 -0
- package/src/lib/session-manager.ts +75 -0
- package/src/lib/utils.ts +25 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<svelte:options customElement={{ tag: 'bw-cart', shadow: 'none' }} />
|
|
2
|
+
|
|
3
|
+
<script lang="ts">
|
|
4
|
+
import CartOverview from '../CartOverview.svelte';
|
|
5
|
+
import type { CartOverviewDisplay } from '../config';
|
|
6
|
+
import { getSharedServices } from './shared';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
display = 'bar' as CartOverviewDisplay,
|
|
10
|
+
} = $props();
|
|
11
|
+
|
|
12
|
+
const { api, cartManager, ready: readyPromise } = getSharedServices();
|
|
13
|
+
|
|
14
|
+
let ready = $state(false);
|
|
15
|
+
readyPromise.then(() => { ready = true; });
|
|
16
|
+
|
|
17
|
+
function dispatch(name: string, detail: unknown) {
|
|
18
|
+
const host = (document.querySelector('bw-cart') as HTMLElement) ?? document.body;
|
|
19
|
+
host.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function handleMessage(e: MessageEvent) {
|
|
23
|
+
let d: Record<string, unknown>;
|
|
24
|
+
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
25
|
+
catch { return; }
|
|
26
|
+
if (!d?.type) return;
|
|
27
|
+
|
|
28
|
+
if (d.type === 'modal:open') {
|
|
29
|
+
dispatch('bw:checkout', {});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
$effect(() => {
|
|
34
|
+
window.addEventListener('message', handleMessage);
|
|
35
|
+
return () => window.removeEventListener('message', handleMessage);
|
|
36
|
+
});
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
{#if ready}
|
|
40
|
+
<CartOverview {api} {cartManager} {display} />
|
|
41
|
+
{:else}
|
|
42
|
+
<div class="loading-center"><div class="spinner"></div></div>
|
|
43
|
+
{/if}
|
|
44
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<svelte:options customElement={{ tag: 'bw-checkout', shadow: 'none' }} />
|
|
2
|
+
|
|
3
|
+
<script lang="ts">
|
|
4
|
+
import Checkout from '../Checkout.svelte';
|
|
5
|
+
import { DEFAULT_WIZARD_PAGES } from '../config';
|
|
6
|
+
import { getSharedServices } from './shared';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
'wizard-pages': wizardPagesJson = '',
|
|
10
|
+
'is-modal': isModalAttr = undefined,
|
|
11
|
+
} = $props();
|
|
12
|
+
|
|
13
|
+
let isModal = $derived(isModalAttr !== undefined);
|
|
14
|
+
let showModal = $state(false);
|
|
15
|
+
|
|
16
|
+
const { api, ready: readyPromise } = getSharedServices();
|
|
17
|
+
|
|
18
|
+
let ready = $state(false);
|
|
19
|
+
readyPromise.then(() => { ready = true; });
|
|
20
|
+
|
|
21
|
+
let wizardPages = $derived(wizardPagesJson ? JSON.parse(wizardPagesJson) : DEFAULT_WIZARD_PAGES);
|
|
22
|
+
|
|
23
|
+
function dispatch(name: string, detail: unknown) {
|
|
24
|
+
const host = (document.querySelector('bw-checkout') as HTMLElement) ?? document.body;
|
|
25
|
+
host.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Public API: open/close the modal from outside
|
|
29
|
+
export function open() { showModal = true; }
|
|
30
|
+
export function close() { showModal = false; dispatch('bw:close', {}); }
|
|
31
|
+
|
|
32
|
+
function handleMessage(e: MessageEvent) {
|
|
33
|
+
let d: Record<string, unknown>;
|
|
34
|
+
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
35
|
+
catch { return; }
|
|
36
|
+
if (!d?.type) return;
|
|
37
|
+
|
|
38
|
+
if (d.type === 'modal:close') {
|
|
39
|
+
if (isModal) showModal = false;
|
|
40
|
+
dispatch('bw:close', {});
|
|
41
|
+
}
|
|
42
|
+
if (d.type === 'order:complete') {
|
|
43
|
+
if (isModal) showModal = false;
|
|
44
|
+
dispatch('bw:order-confirmed', {
|
|
45
|
+
cartToken: d.cartToken ?? '',
|
|
46
|
+
value: d.value ?? 0,
|
|
47
|
+
currency: d.currency ?? 'ZAR',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
$effect(() => {
|
|
53
|
+
window.addEventListener('message', handleMessage);
|
|
54
|
+
return () => window.removeEventListener('message', handleMessage);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Listen for a custom 'bw:open' event so register.ts can trigger it
|
|
58
|
+
$effect(() => {
|
|
59
|
+
const host = document.querySelector('bw-checkout');
|
|
60
|
+
if (!host) return;
|
|
61
|
+
function onOpen() { showModal = true; }
|
|
62
|
+
host.addEventListener('bw:open', onOpen);
|
|
63
|
+
return () => host.removeEventListener('bw:open', onOpen);
|
|
64
|
+
});
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
{#if isModal}
|
|
68
|
+
{#if showModal && ready}
|
|
69
|
+
<div class="bw-modal-overlay" onclick={() => close()}>
|
|
70
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
71
|
+
<div class="bw-modal-inner" onclick={(e) => e.stopPropagation()}>
|
|
72
|
+
<Checkout {api} {wizardPages} />
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
{/if}
|
|
76
|
+
{:else if ready}
|
|
77
|
+
<Checkout {api} {wizardPages} />
|
|
78
|
+
{:else}
|
|
79
|
+
<div class="loading-center"><div class="spinner"></div></div>
|
|
80
|
+
{/if}
|
|
81
|
+
|
|
82
|
+
<style>
|
|
83
|
+
.bw-modal-overlay {
|
|
84
|
+
position: fixed;
|
|
85
|
+
inset: 0;
|
|
86
|
+
z-index: 10000;
|
|
87
|
+
background: rgba(0, 0, 0, 0.5);
|
|
88
|
+
}
|
|
89
|
+
.bw-modal-inner {
|
|
90
|
+
width: 100%;
|
|
91
|
+
max-width: 900px;
|
|
92
|
+
height: 95vh;
|
|
93
|
+
margin: 2.5vh auto;
|
|
94
|
+
border-radius: 12px;
|
|
95
|
+
background: #fff;
|
|
96
|
+
overflow: hidden;
|
|
97
|
+
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3);
|
|
98
|
+
}
|
|
99
|
+
</style>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<svelte:options customElement={{ tag: 'bw-configurator', shadow: 'none' }} />
|
|
2
|
+
|
|
3
|
+
<script lang="ts">
|
|
4
|
+
import TicketConfigurator from '../TicketConfigurator.svelte';
|
|
5
|
+
import { DEFAULT_WIZARD_PAGES } from '../config';
|
|
6
|
+
import { postMessage } from '../messages';
|
|
7
|
+
import { getSharedServices } from './shared';
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
'product-id': productId = '',
|
|
11
|
+
'wizard-pages': wizardPagesJson = '',
|
|
12
|
+
'auto-select-single-time-slot': autoSelectSingleTimeSlot = undefined,
|
|
13
|
+
cancelable = undefined,
|
|
14
|
+
} = $props();
|
|
15
|
+
|
|
16
|
+
const { api, cartManager, ready: readyPromise } = getSharedServices();
|
|
17
|
+
|
|
18
|
+
let ready = $state(false);
|
|
19
|
+
readyPromise.then(() => { ready = true; });
|
|
20
|
+
|
|
21
|
+
let wizardPages = $derived(wizardPagesJson ? JSON.parse(wizardPagesJson) : DEFAULT_WIZARD_PAGES);
|
|
22
|
+
|
|
23
|
+
function dispatch(name: string, detail: unknown) {
|
|
24
|
+
const host = (document.querySelector('bw-configurator') as HTMLElement) ?? document.body;
|
|
25
|
+
host.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Forward postMessage events as CustomEvents
|
|
29
|
+
function handleMessage(e: MessageEvent) {
|
|
30
|
+
let d: Record<string, unknown>;
|
|
31
|
+
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
32
|
+
catch { return; }
|
|
33
|
+
if (!d?.type) return;
|
|
34
|
+
|
|
35
|
+
if (d.type === 'cart:change') {
|
|
36
|
+
dispatch('bw:cart-change', {
|
|
37
|
+
itemCount: d.itemCount ?? 0,
|
|
38
|
+
cartItemId: d.cartItemId ?? '',
|
|
39
|
+
totalFormatted: d.totalFormatted ?? '',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
$effect(() => {
|
|
45
|
+
window.addEventListener('message', handleMessage);
|
|
46
|
+
return () => window.removeEventListener('message', handleMessage);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const onCancel = cancelable !== undefined
|
|
50
|
+
? () => { postMessage({ type: 'modal:close' }); dispatch('bw:cancel', {}); }
|
|
51
|
+
: undefined;
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
{#if ready}
|
|
55
|
+
<TicketConfigurator
|
|
56
|
+
{api}
|
|
57
|
+
{cartManager}
|
|
58
|
+
{productId}
|
|
59
|
+
{wizardPages}
|
|
60
|
+
autoSelectSingleTimeSlot={autoSelectSingleTimeSlot !== undefined}
|
|
61
|
+
{onCancel}
|
|
62
|
+
/>
|
|
63
|
+
{:else}
|
|
64
|
+
<div class="loading-center"><div class="spinner"></div></div>
|
|
65
|
+
{/if}
|
|
66
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const API_URLS: Record<string, string> = {
|
|
2
|
+
prod: 'https://checkout.moriitech.com',
|
|
3
|
+
qa: 'https://qa.tranzact.co.za/morii-checkout/api',
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
const w = window as any;
|
|
7
|
+
const env: string = w.BW_CHECKOUT_ENV ?? 'prod';
|
|
8
|
+
const explicitUrl: string | undefined = w.BW_CHECKOUT_API_URL;
|
|
9
|
+
|
|
10
|
+
export const useFakeApi = env === 'fake';
|
|
11
|
+
export const defaultApiBaseUrl = explicitUrl ?? API_URLS[env] ?? API_URLS.prod;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import '../app.css';
|
|
2
|
+
import type { BookingApi } from '../api';
|
|
3
|
+
import { ApiClient } from '../api';
|
|
4
|
+
import { FakeApiClient } from '../fake-api';
|
|
5
|
+
import { SessionManager } from '../session-manager';
|
|
6
|
+
import { CartManager } from '../cart-manager';
|
|
7
|
+
import { defaultApiBaseUrl, useFakeApi } from './env';
|
|
8
|
+
|
|
9
|
+
// Bootstrap shared services BEFORE element imports trigger connectedCallback.
|
|
10
|
+
// Elements read from window.__bwServices instead of importing shared.ts,
|
|
11
|
+
// because the IIFE bundler inlines imports per-element — a module-level
|
|
12
|
+
// singleton would be duplicated. window is the only truly shared scope.
|
|
13
|
+
const checkoutKey = document.querySelector('[checkout-key]')?.getAttribute('checkout-key') ?? '';
|
|
14
|
+
const api: BookingApi = useFakeApi ? new FakeApiClient() : new ApiClient(defaultApiBaseUrl);
|
|
15
|
+
const sessionManager = new SessionManager(api);
|
|
16
|
+
const cartManager = new CartManager(api);
|
|
17
|
+
sessionManager.startBackgroundRefresh();
|
|
18
|
+
const ready = sessionManager.ensureSession(checkoutKey).then(() => {});
|
|
19
|
+
(window as any).__bwServices = { api, cartManager, ready };
|
|
20
|
+
|
|
21
|
+
import './bw-configurator.svelte';
|
|
22
|
+
import './bw-cart.svelte';
|
|
23
|
+
import './bw-checkout.svelte';
|
|
24
|
+
|
|
25
|
+
interface BwOptions {
|
|
26
|
+
shouldBottomCloseOnModal: boolean;
|
|
27
|
+
autoSelectSingleTimeSlot: boolean;
|
|
28
|
+
wizardPages: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const defaultOptions: BwOptions = {
|
|
32
|
+
shouldBottomCloseOnModal: true,
|
|
33
|
+
autoSelectSingleTimeSlot: false,
|
|
34
|
+
wizardPages: '',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const options: BwOptions = {
|
|
38
|
+
...defaultOptions,
|
|
39
|
+
...((window as any)['bwOptions'] ?? {}),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function forwardToWindow(e: Event) {
|
|
43
|
+
if (e instanceof CustomEvent) {
|
|
44
|
+
window.dispatchEvent(new CustomEvent(e.type, { detail: e.detail }));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function autoWire() {
|
|
49
|
+
const configurators = document.querySelectorAll('bw-configurator');
|
|
50
|
+
const carts = document.querySelectorAll('bw-cart');
|
|
51
|
+
const checkouts = document.querySelectorAll('bw-checkout');
|
|
52
|
+
|
|
53
|
+
const allElements = [...configurators, ...carts, ...checkouts];
|
|
54
|
+
|
|
55
|
+
// Apply options as element attributes
|
|
56
|
+
if (options.wizardPages) {
|
|
57
|
+
allElements.forEach((el) => {
|
|
58
|
+
if (!el.hasAttribute('wizard-pages')) {
|
|
59
|
+
el.setAttribute('wizard-pages', options.wizardPages);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
configurators.forEach((el) => {
|
|
65
|
+
if (options.autoSelectSingleTimeSlot && !el.hasAttribute('auto-select-single-time-slot')) {
|
|
66
|
+
el.setAttribute('auto-select-single-time-slot', '');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Auto-add is-modal to any checkout that doesn't already have it
|
|
71
|
+
checkouts.forEach((el) => {
|
|
72
|
+
if (!el.hasAttribute('is-modal') && !el.hasAttribute('is-inline')) {
|
|
73
|
+
el.setAttribute('is-modal', '');
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function openCheckout() {
|
|
78
|
+
checkouts.forEach((el) => {
|
|
79
|
+
el.dispatchEvent(new CustomEvent('bw:open'));
|
|
80
|
+
});
|
|
81
|
+
window.dispatchEvent(new CustomEvent('bw:modal-open'));
|
|
82
|
+
|
|
83
|
+
if (options.shouldBottomCloseOnModal) {
|
|
84
|
+
hideCartBars();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function showCarts() {
|
|
89
|
+
carts.forEach((el) => {
|
|
90
|
+
const parent = el.parentElement;
|
|
91
|
+
if (parent) parent.classList.remove('hidden');
|
|
92
|
+
(el as HTMLElement).style.display = '';
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hideCartBars() {
|
|
97
|
+
carts.forEach((el) => {
|
|
98
|
+
if (el.getAttribute('display') === 'bar') {
|
|
99
|
+
const parent = el.parentElement;
|
|
100
|
+
if (parent) parent.classList.add('hidden');
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function showCartBars() {
|
|
106
|
+
carts.forEach((el) => {
|
|
107
|
+
if (el.getAttribute('display') === 'bar') {
|
|
108
|
+
const parent = el.parentElement;
|
|
109
|
+
if (parent) parent.classList.remove('hidden');
|
|
110
|
+
(el as HTMLElement).style.display = '';
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Forward bw:* events to window and wire up default behaviour
|
|
116
|
+
configurators.forEach((el) => {
|
|
117
|
+
el.addEventListener('bw:cancel', forwardToWindow);
|
|
118
|
+
el.addEventListener('bw:cart-change', (e) => {
|
|
119
|
+
forwardToWindow(e);
|
|
120
|
+
showCarts();
|
|
121
|
+
if (!el.hasAttribute('no-auto-checkout')) openCheckout();
|
|
122
|
+
|
|
123
|
+
const handler = el.getAttribute('on-cart-change');
|
|
124
|
+
if (handler && typeof (window as any)[handler] === 'function') {
|
|
125
|
+
(window as any)[handler]();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
carts.forEach((el) => {
|
|
130
|
+
el.addEventListener('bw:checkout', (e) => {
|
|
131
|
+
forwardToWindow(e);
|
|
132
|
+
openCheckout();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
checkouts.forEach((el) => {
|
|
136
|
+
el.addEventListener('bw:close', (e) => {
|
|
137
|
+
forwardToWindow(e);
|
|
138
|
+
window.dispatchEvent(new CustomEvent('bw:modal-close'));
|
|
139
|
+
if (options.shouldBottomCloseOnModal) showCartBars();
|
|
140
|
+
});
|
|
141
|
+
el.addEventListener('bw:order-confirmed', forwardToWindow);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (document.readyState === 'loading') {
|
|
146
|
+
document.addEventListener('DOMContentLoaded', autoWire);
|
|
147
|
+
} else {
|
|
148
|
+
autoWire();
|
|
149
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { BookingApi } from '../api';
|
|
2
|
+
import type { CartManager } from '../cart-manager';
|
|
3
|
+
|
|
4
|
+
interface SharedServices {
|
|
5
|
+
api: BookingApi;
|
|
6
|
+
cartManager: CartManager;
|
|
7
|
+
ready: Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const w = window as any;
|
|
11
|
+
|
|
12
|
+
export function getSharedServices(): SharedServices {
|
|
13
|
+
return w.__bwServices;
|
|
14
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/* Themeable CSS variables — host page can override --bw-* on :root or on the element.
|
|
2
|
+
Fallbacks cascade: --bw-color-primary → --header-background → #E30613.
|
|
3
|
+
This file mirrors app.css for shadow-DOM use if elements ever switch to shadow: 'open'. */
|
|
4
|
+
:host {
|
|
5
|
+
--bw-color-primary: var(--header-background, #E30613);
|
|
6
|
+
--bw-color-primary-dark: var(--footer-background, #C00510);
|
|
7
|
+
--bw-color-primary-light: rgba(227, 6, 19, 0.08);
|
|
8
|
+
--bw-color-text: #212121;
|
|
9
|
+
--bw-color-text-secondary: #757575;
|
|
10
|
+
--bw-color-border: #E0E0E0;
|
|
11
|
+
--bw-color-bg: #FFFFFF;
|
|
12
|
+
--bw-color-surface: #F5F5F5;
|
|
13
|
+
--bw-color-success: #4CAF50;
|
|
14
|
+
--bw-color-error: var(--header-background, #E30613);
|
|
15
|
+
--bw-font-family: var(--default-font-family, 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
|
|
16
|
+
--bw-radius-sm: 4px;
|
|
17
|
+
--bw-radius-md: var(--radius, 8px);
|
|
18
|
+
--bw-radius-lg: 12px;
|
|
19
|
+
--bw-radius-pill: 9999px;
|
|
20
|
+
--bw-transition: 0.15s ease;
|
|
21
|
+
--bw-shadow-1: 0 1px 3px rgba(0,0,0,0.10), 0 1px 2px rgba(0,0,0,0.06);
|
|
22
|
+
--bw-shadow-2: 0 4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.04);
|
|
23
|
+
--bw-shadow-3: 0 10px 24px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.04);
|
|
24
|
+
|
|
25
|
+
display: block;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Reset inside shadow root */
|
|
29
|
+
*, *::before, *::after {
|
|
30
|
+
margin: 0;
|
|
31
|
+
padding: 0;
|
|
32
|
+
box-sizing: border-box;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
:host {
|
|
36
|
+
font-family: var(--bw-font-family);
|
|
37
|
+
font-size: 14px;
|
|
38
|
+
line-height: 1.5;
|
|
39
|
+
color: var(--bw-color-text);
|
|
40
|
+
-webkit-font-smoothing: antialiased;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
button { cursor: pointer; font-family: inherit; }
|
|
44
|
+
input, select, textarea { font-family: inherit; }
|
|
45
|
+
|
|
46
|
+
/* Spinner */
|
|
47
|
+
@keyframes bw-spin { to { transform: rotate(360deg); } }
|
|
48
|
+
|
|
49
|
+
.spinner {
|
|
50
|
+
width: 36px;
|
|
51
|
+
height: 36px;
|
|
52
|
+
border: 3px solid var(--bw-color-border);
|
|
53
|
+
border-top-color: var(--bw-color-primary);
|
|
54
|
+
border-radius: 50%;
|
|
55
|
+
animation: bw-spin 0.7s linear infinite;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.loading-center {
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
justify-content: center;
|
|
62
|
+
flex: 1;
|
|
63
|
+
min-height: 120px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* Page header bar */
|
|
67
|
+
.page-header {
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: 8px;
|
|
71
|
+
padding: 12px 16px;
|
|
72
|
+
border-bottom: 1px solid var(--bw-color-border);
|
|
73
|
+
background: var(--bw-color-bg);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.page-header h2 {
|
|
77
|
+
font-size: 18px;
|
|
78
|
+
font-weight: 700;
|
|
79
|
+
letter-spacing: -0.01em;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.page-header .spacer { flex: 1; }
|
|
83
|
+
|
|
84
|
+
/* Icon button */
|
|
85
|
+
.icon-btn {
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
justify-content: center;
|
|
89
|
+
width: 36px;
|
|
90
|
+
height: 36px;
|
|
91
|
+
background: none;
|
|
92
|
+
border: none;
|
|
93
|
+
border-radius: var(--bw-radius-pill);
|
|
94
|
+
font-size: 20px;
|
|
95
|
+
color: var(--bw-color-text);
|
|
96
|
+
transition: background var(--bw-transition);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.icon-btn:hover {
|
|
100
|
+
background: var(--bw-color-surface);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* Cards */
|
|
104
|
+
.card {
|
|
105
|
+
background: var(--bw-color-bg);
|
|
106
|
+
border: 1px solid var(--bw-color-border);
|
|
107
|
+
border-radius: var(--bw-radius-lg);
|
|
108
|
+
padding: 16px;
|
|
109
|
+
transition: border-color var(--bw-transition);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.card-selectable {
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
text-align: left;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.card-selectable:hover { border-color: var(--bw-color-text); }
|
|
118
|
+
.card-selected { border-color: var(--bw-color-text); border-width: 2px; }
|
|
119
|
+
|
|
120
|
+
/* Action bar */
|
|
121
|
+
.action-bar {
|
|
122
|
+
display: flex;
|
|
123
|
+
gap: 10px;
|
|
124
|
+
flex-shrink: 0;
|
|
125
|
+
padding: 12px 16px;
|
|
126
|
+
border-top: 1px solid var(--bw-color-border);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.action-bar .btn-secondary {
|
|
130
|
+
height: 48px;
|
|
131
|
+
padding: 0 20px;
|
|
132
|
+
border: 1px solid var(--bw-color-border);
|
|
133
|
+
background: var(--bw-color-bg);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.action-bar .btn-primary {
|
|
137
|
+
flex: 1;
|
|
138
|
+
height: 48px;
|
|
139
|
+
font-size: 16px;
|
|
140
|
+
font-weight: 700;
|
|
141
|
+
position: relative;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.action-bar .btn-primary .arrow {
|
|
145
|
+
position: absolute;
|
|
146
|
+
right: 16px;
|
|
147
|
+
font-size: 20px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* Buttons */
|
|
151
|
+
.btn {
|
|
152
|
+
display: inline-flex;
|
|
153
|
+
align-items: center;
|
|
154
|
+
justify-content: center;
|
|
155
|
+
gap: 8px;
|
|
156
|
+
font-size: 14px;
|
|
157
|
+
font-weight: 600;
|
|
158
|
+
border: none;
|
|
159
|
+
border-radius: var(--bw-radius-md);
|
|
160
|
+
padding: 10px 20px;
|
|
161
|
+
transition: background var(--bw-transition), box-shadow var(--bw-transition), opacity var(--bw-transition);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.btn:disabled { opacity: 0.45; cursor: default; pointer-events: none; }
|
|
165
|
+
.btn-primary { background: var(--bw-color-primary); color: white; }
|
|
166
|
+
.btn-primary:hover:not(:disabled) { background: var(--bw-color-primary-dark); box-shadow: var(--bw-shadow-1); }
|
|
167
|
+
.btn-secondary { background: var(--bw-color-bg); color: var(--bw-color-text); }
|
|
168
|
+
.btn-secondary:hover:not(:disabled) { background: var(--bw-color-border); }
|
|
169
|
+
.btn-outline { background: transparent; border: 1px solid var(--bw-color-primary); color: var(--bw-color-primary); }
|
|
170
|
+
.btn-outline:hover:not(:disabled) { background: var(--bw-color-primary-light); }
|
|
171
|
+
|
|
172
|
+
/* Form inputs */
|
|
173
|
+
.input {
|
|
174
|
+
width: 100%;
|
|
175
|
+
padding: 10px 12px;
|
|
176
|
+
border: 1px solid var(--bw-color-border);
|
|
177
|
+
border-radius: var(--bw-radius-md);
|
|
178
|
+
font-size: 14px;
|
|
179
|
+
outline: none;
|
|
180
|
+
transition: border-color var(--bw-transition), box-shadow var(--bw-transition);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.input:focus { border-color: var(--bw-color-primary); box-shadow: 0 0 0 3px var(--bw-color-primary-light); }
|
|
184
|
+
.input-error { border-color: var(--bw-color-error); background: #FEF2F2; }
|
|
185
|
+
.input-error:focus { box-shadow: 0 0 0 3px rgba(227, 6, 19, 0.12); }
|
|
186
|
+
|
|
187
|
+
/* Section titles */
|
|
188
|
+
.section-title {
|
|
189
|
+
font-size: 14px;
|
|
190
|
+
font-weight: 400;
|
|
191
|
+
color: var(--bw-color-text-secondary);
|
|
192
|
+
margin-bottom: 12px;
|
|
193
|
+
}
|