@xosen/site-sdk 0.0.5 → 0.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xosen/site-sdk",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Shared Vue components and composables for Xosen site templates",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -9,6 +9,7 @@ export interface PreviewMessage {
9
9
  title?: string;
10
10
  blocks?: any[];
11
11
  meta?: Record<string, any>;
12
+ layout?: string;
12
13
  };
13
14
  }
14
15
 
@@ -54,10 +55,6 @@ export function useLivePreview() {
54
55
 
55
56
  onMounted(() => {
56
57
  window.addEventListener('message', handleMessage);
57
- // Notify parent that preview is ready
58
- if (isPreviewMode.value) {
59
- window.parent.postMessage({ type: 'xosen-preview-ready' }, '*');
60
- }
61
58
  });
62
59
 
63
60
  onUnmounted(() => {
@@ -9,6 +9,7 @@ export function useSiteConfig() {
9
9
  const siteData = (window as any).__SITE_DATA__ as SiteData | undefined;
10
10
 
11
11
  const config = computed<SiteConfig | null>(() => siteData?.config || null);
12
+ const components = computed<Record<string, any>>(() => (siteData as any)?.components || {});
12
13
 
13
14
  const navigation = computed(() => config.value?.navigation || []);
14
15
  const locales = computed(() => config.value?.locales || []);
@@ -26,6 +27,7 @@ export function useSiteConfig() {
26
27
 
27
28
  return {
28
29
  config,
30
+ components,
29
31
  navigation,
30
32
  locales,
31
33
  defaultLocale,
package/src/index.ts CHANGED
@@ -1,22 +1,3 @@
1
- // Layout
2
- export { default as XSiteLayout } from './components/layout/XSiteLayout.vue';
3
- export { default as XSiteNav } from './components/layout/XSiteNav.vue';
4
- export { default as XSiteFooter } from './components/layout/XSiteFooter.vue';
5
-
6
- // Blocks
7
- export { default as XBlockRenderer } from './components/blocks/XBlockRenderer.vue';
8
- export { default as XHeroBlock } from './components/blocks/XHeroBlock.vue';
9
- export { default as XHtmlBlock } from './components/blocks/XHtmlBlock.vue';
10
- export { default as XCardsBlock } from './components/blocks/XCardsBlock.vue';
11
- export { default as XImageTextBlock } from './components/blocks/XImageTextBlock.vue';
12
- export { default as XPricingBlock } from './components/blocks/XPricingBlock.vue';
13
- export { default as XContactsBlock } from './components/blocks/XContactsBlock.vue';
14
- export { default as XGalleryBlock } from './components/blocks/XGalleryBlock.vue';
15
- export { default as XMapBlock } from './components/blocks/XMapBlock.vue';
16
-
17
- // Common
18
- export { default as XLocaleSwitcher } from './components/common/XLocaleSwitcher.vue';
19
-
20
1
  // Composables
21
2
  export { useSiteData } from './composables/useSiteData.js';
22
3
  export { useSiteConfig } from './composables/useSiteConfig.js';
@@ -26,7 +26,7 @@ export interface SiteConfig {
26
26
  }
27
27
 
28
28
  export interface NavItem {
29
- text: string;
29
+ text: string | Record<string, string>;
30
30
  url: string;
31
31
  external?: boolean;
32
32
  children?: NavItem[];
@@ -17,7 +17,7 @@ function detectLocale(request: Request, config: WorkerConfig): string {
17
17
  return config.defaultLocale;
18
18
  }
19
19
 
20
- function handleContentApi(url: URL, env: WorkerEnv): Promise<Response> | null {
20
+ function handleContentApi(url: URL, env: WorkerEnv, defaultLocale: string): Promise<Response> | null {
21
21
  if (!url.pathname.startsWith('/api/content/')) return null;
22
22
 
23
23
  const key = decodeURIComponent(url.pathname.replace('/api/content/', ''));
@@ -26,7 +26,16 @@ function handleContentApi(url: URL, env: WorkerEnv): Promise<Response> | null {
26
26
  }
27
27
 
28
28
  return (async () => {
29
- const value = await env.SITE_CONTENT.get(key);
29
+ let value = await env.SITE_CONTENT.get(key);
30
+
31
+ // Fallback to default locale
32
+ if (!value) {
33
+ const lastColon = key.lastIndexOf(':');
34
+ if (lastColon > 0 && key.slice(lastColon + 1) !== defaultLocale) {
35
+ value = await env.SITE_CONTENT.get(key.slice(0, lastColon + 1) + defaultLocale);
36
+ }
37
+ }
38
+
30
39
  if (!value) {
31
40
  return Response.json({ error: 'Not found' }, { status: 404 });
32
41
  }
@@ -81,7 +90,7 @@ export function createSiteWorker(config: WorkerConfig): {
81
90
  const url = new URL(request.url);
82
91
 
83
92
  // API route: /api/content/:key
84
- const apiResponse = handleContentApi(url, env);
93
+ const apiResponse = handleContentApi(url, env, config.defaultLocale);
85
94
  if (apiResponse) return apiResponse;
86
95
 
87
96
  // Static assets
@@ -95,6 +104,8 @@ export function createSiteWorker(config: WorkerConfig): {
95
104
  const assetResponse = await env.ASSETS.fetch(new Request(indexUrl));
96
105
  let html = await assetResponse.text();
97
106
 
107
+ const fallbackLocale = config.defaultLocale;
108
+
98
109
  // Read 2 KV keys: config + content for locale
99
110
  const [configRaw, contentRaw] = await Promise.all([
100
111
  env.SITE_CONTENT.get('config'),
@@ -102,7 +113,13 @@ export function createSiteWorker(config: WorkerConfig): {
102
113
  ]);
103
114
 
104
115
  const siteConfig = configRaw ? JSON.parse(configRaw) : {};
105
- const content: Record<string, PageData> = contentRaw ? JSON.parse(contentRaw) : {};
116
+ let content: Record<string, PageData> = contentRaw ? JSON.parse(contentRaw) : {};
117
+
118
+ // Fallback to default locale if no content for requested locale
119
+ if (!contentRaw && locale !== fallbackLocale) {
120
+ const fallbackRaw = await env.SITE_CONTENT.get(`content:${fallbackLocale}`);
121
+ if (fallbackRaw) content = JSON.parse(fallbackRaw);
122
+ }
106
123
 
107
124
  const pageMatch = url.pathname.match(/^\/p\/(.+)$/);
108
125
 
@@ -110,7 +127,11 @@ export function createSiteWorker(config: WorkerConfig): {
110
127
  if (pageMatch) {
111
128
  const slug = pageMatch[1];
112
129
  if (!content[slug]) {
113
- const value = await env.SITE_CONTENT.get(`page:${slug}:${locale}`);
130
+ let value = await env.SITE_CONTENT.get(`page:${slug}:${locale}`);
131
+ // Fallback to default locale
132
+ if (!value && locale !== fallbackLocale) {
133
+ value = await env.SITE_CONTENT.get(`page:${slug}:${fallbackLocale}`);
134
+ }
114
135
  if (value) content[slug] = JSON.parse(value);
115
136
  }
116
137
  }
package/dist/index.css DELETED
@@ -1 +0,0 @@
1
- .x-locale-switcher[data-v-675f0bbd]{display:flex;gap:4px}.x-locale-switcher__btn[data-v-675f0bbd]{padding:4px 8px;border:1px solid transparent;border-radius:4px;background:transparent;cursor:pointer;font-size:.75rem;font-weight:600;opacity:.6;transition:all .2s}.x-locale-switcher__btn--active[data-v-675f0bbd]{opacity:1;border-color:currentColor}.x-locale-switcher__btn[data-v-675f0bbd]:hover{opacity:1}.x-nav[data-v-07c4b748]{position:fixed;top:0;left:0;right:0;z-index:100;transition:background .3s,box-shadow .3s}.x-nav--scrolled[data-v-07c4b748]{background:#fffffff2;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);box-shadow:0 2px 8px #00000014}.x-nav__container[data-v-07c4b748]{max-width:var(--x-max-width, 1200px);margin:0 auto;padding:0 24px;height:64px;display:flex;align-items:center;justify-content:space-between}.x-nav__logo[data-v-07c4b748]{text-decoration:none;display:flex;align-items:center}.x-nav__logo-img[data-v-07c4b748]{height:36px}.x-nav__logo-text[data-v-07c4b748]{font-size:1.25rem;font-weight:700;color:var(--x-color-text, #2c3e50)}.x-nav--scrolled .x-nav__logo-text[data-v-07c4b748]{color:var(--x-color-text, #2c3e50)}.x-nav:not(.x-nav--scrolled) .x-nav__logo-text[data-v-07c4b748]{color:#fff}.x-nav__links[data-v-07c4b748]{display:flex;align-items:center;gap:24px}.x-nav__link[data-v-07c4b748]{text-decoration:none;font-weight:500;font-size:.9rem;transition:color .2s}.x-nav--scrolled .x-nav__link[data-v-07c4b748]{color:var(--x-color-text, #2c3e50)}.x-nav:not(.x-nav--scrolled) .x-nav__link[data-v-07c4b748]{color:#ffffffe6}.x-nav__link[data-v-07c4b748]:hover{color:var(--x-color-primary, #1e73be)}.x-nav__hamburger[data-v-07c4b748]{display:none;flex-direction:column;gap:5px;background:none;border:none;cursor:pointer;padding:4px}.x-nav__hamburger span[data-v-07c4b748]{width:24px;height:2px;background:var(--x-color-text, #2c3e50);transition:all .2s}.x-nav:not(.x-nav--scrolled) .x-nav__hamburger span[data-v-07c4b748]{background:#fff}@media(max-width:768px){.x-nav__hamburger[data-v-07c4b748]{display:flex}.x-nav__links[data-v-07c4b748]{display:none;position:fixed;top:64px;right:0;width:280px;height:calc(100vh - 64px);background:var(--x-color-surface, #fff);flex-direction:column;padding:24px;box-shadow:-4px 0 16px #0000001a;align-items:flex-start}.x-nav__links--open[data-v-07c4b748]{display:flex}.x-nav__links .x-nav__link[data-v-07c4b748]{color:var(--x-color-text, #2c3e50)!important;padding:8px 0}}.x-footer[data-v-f9b38381]{background:var(--x-color-text, #2c3e50);color:#ffffffb3;padding:32px 24px;text-align:center}.x-footer__container[data-v-f9b38381]{max-width:var(--x-max-width, 1200px);margin:0 auto}.x-footer__links[data-v-f9b38381]{display:flex;gap:24px;justify-content:center;margin-bottom:16px;flex-wrap:wrap}.x-footer__link[data-v-f9b38381]{color:#ffffffb3;text-decoration:none;font-size:.9rem}.x-footer__link[data-v-f9b38381]:hover{color:#fff}.x-footer__copyright[data-v-f9b38381]{font-size:.85rem;margin:0;opacity:.6}.x-site-layout[data-v-41b0f2a9]{min-height:100vh;display:flex;flex-direction:column;font-family:var(--x-font-family, "Inter", sans-serif);color:var(--x-color-text, #2c3e50);background:var(--x-color-background, #ffffff)}.x-site-layout__main[data-v-41b0f2a9]{flex:1}.x-hero[data-v-d8cfe56f]{position:relative;display:flex;align-items:center;justify-content:center;min-height:400px;padding:120px 24px;background:linear-gradient(135deg,var(--x-color-primary, #1e73be) 0%,var(--x-color-secondary, #2c3e50) 100%);background-size:cover;background-position:center;color:#fff;text-align:center}.x-hero--fullscreen[data-v-d8cfe56f]{min-height:100vh}.x-hero__overlay[data-v-d8cfe56f]{position:absolute;inset:0;background:#00000080}.x-hero__content[data-v-d8cfe56f]{position:relative;max-width:var(--x-max-width, 1200px);z-index:1}.x-hero__title[data-v-d8cfe56f]{font-family:var(--x-font-heading, var(--x-font-family, inherit));font-size:clamp(2rem,5vw,3.5rem);font-weight:800;margin:0 0 16px}.x-hero__subtitle[data-v-d8cfe56f]{font-size:clamp(1rem,2vw,1.25rem);opacity:.9;max-width:600px;margin:0 auto 32px}.x-hero__buttons[data-v-d8cfe56f]{display:flex;gap:16px;justify-content:center;flex-wrap:wrap;margin-bottom:48px}.x-hero__stats[data-v-d8cfe56f]{display:flex;gap:48px;justify-content:center;flex-wrap:wrap}.x-hero__stat[data-v-d8cfe56f]{display:flex;flex-direction:column}.x-hero__stat-value[data-v-d8cfe56f]{font-size:2rem;font-weight:700}.x-hero__stat-label[data-v-d8cfe56f]{font-size:.875rem;opacity:.8}.x-btn[data-v-d8cfe56f]{display:inline-flex;align-items:center;padding:12px 32px;border-radius:var(--x-border-radius, 8px);font-weight:600;text-decoration:none;transition:all .2s;cursor:pointer;border:2px solid transparent}.x-btn--primary[data-v-d8cfe56f]{background:#fff;color:var(--x-color-primary, #1e73be)}.x-btn--outline[data-v-d8cfe56f]{border-color:#fff;color:#fff;background:transparent}.x-btn--white[data-v-d8cfe56f]{background:#ffffff26;color:#fff;border-color:#ffffff4d}.x-html-block[data-v-abfd69e8]{padding:48px 24px}.x-html-block__content[data-v-abfd69e8]{max-width:800px;margin:0 auto;font-family:var(--x-font-family, inherit);color:var(--x-color-text, #2c3e50);line-height:1.8}.x-html-block__content[data-v-abfd69e8] h2{font-size:1.75rem;font-weight:700;margin:32px 0 16px;color:var(--x-color-text, #2c3e50)}.x-html-block__content[data-v-abfd69e8] h3{font-size:1.25rem;font-weight:600;margin:24px 0 12px}.x-html-block__content[data-v-abfd69e8] p{margin:0 0 16px}.x-html-block__content[data-v-abfd69e8] ul,.x-html-block__content[data-v-abfd69e8] ol{padding-left:24px;margin:0 0 16px}.x-html-block__content[data-v-abfd69e8] img{max-width:100%;border-radius:var(--x-border-radius, 8px);margin:16px 0}.x-html-block__content[data-v-abfd69e8] blockquote{border-left:4px solid var(--x-color-primary, #1e73be);margin:16px 0;padding:12px 24px;background:var(--x-color-background-alt, #f7f9fc);border-radius:0 var(--x-border-radius, 8px) var(--x-border-radius, 8px) 0}.x-cards-block[data-v-a1505674]{padding:80px 24px}.x-cards-block__container[data-v-a1505674]{max-width:var(--x-max-width, 1200px);margin:0 auto}.x-cards-block__title[data-v-a1505674]{text-align:center;font-size:2rem;font-weight:700;color:var(--x-color-text, #2c3e50);margin:0 0 8px}.x-cards-block__subtitle[data-v-a1505674]{text-align:center;color:var(--x-color-text-light, #6b7c93);margin:0 0 48px}.x-cards-block__grid[data-v-a1505674]{display:grid;grid-template-columns:repeat(var(--columns, 4),1fr);gap:24px}@media(max-width:768px){.x-cards-block__grid[data-v-a1505674]{grid-template-columns:repeat(auto-fit,minmax(250px,1fr))}}.x-card[data-v-a1505674]{background:var(--x-color-surface, #fff);border:1px solid var(--x-color-border, #e2e8f0);border-radius:var(--x-border-radius, 12px);padding:32px 24px;text-align:center;text-decoration:none;color:inherit;transition:transform .2s,box-shadow .2s}.x-card[data-v-a1505674]:hover{transform:translateY(-4px);box-shadow:0 8px 24px #00000014}.x-card__icon[data-v-a1505674]{font-size:2.5rem;margin-bottom:16px;width:64px;height:64px;display:flex;align-items:center;justify-content:center;margin-left:auto;margin-right:auto;background:var(--x-color-background-alt, #f7f9fc);border-radius:50%}.x-card__image[data-v-a1505674]{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:calc(var(--x-border-radius, 12px) - 4px);margin-bottom:16px}.x-card__title[data-v-a1505674]{font-size:1.125rem;font-weight:600;color:var(--x-color-text, #2c3e50);margin:0 0 8px}.x-card__desc[data-v-a1505674]{font-size:.9rem;color:var(--x-color-text-light, #6b7c93);line-height:1.6;margin:0}.x-image-text[data-v-5ca1e218]{padding:80px 24px}.x-image-text__container[data-v-5ca1e218]{max-width:var(--x-max-width, 1200px);margin:0 auto;display:grid;grid-template-columns:1fr 1fr;gap:48px;align-items:center}.x-image-text--reverse .x-image-text__container[data-v-5ca1e218]{direction:rtl}.x-image-text--reverse .x-image-text__text[data-v-5ca1e218]{direction:ltr}.x-image-text__title[data-v-5ca1e218]{font-size:2rem;font-weight:700;color:var(--x-color-text, #2c3e50);margin:0 0 16px}.x-image-text__content[data-v-5ca1e218]{color:var(--x-color-text, #2c3e50);line-height:1.8}.x-image-text__checklist[data-v-5ca1e218]{list-style:none;padding:0;margin:0}.x-image-text__checklist li[data-v-5ca1e218]{display:flex;align-items:center;gap:12px;padding:8px 0;font-size:1rem;color:var(--x-color-text, #2c3e50)}.x-image-text__check-icon[data-v-5ca1e218]{color:var(--x-color-success, #00d084);font-weight:700}.x-image-text__image[data-v-5ca1e218]{width:100%;border-radius:var(--x-border-radius, 12px)}@media(max-width:768px){.x-image-text__container[data-v-5ca1e218]{grid-template-columns:1fr}.x-image-text--reverse .x-image-text__container[data-v-5ca1e218]{direction:ltr}}.x-pricing[data-v-bd1e5d79]{padding:80px 24px;background:var(--x-color-background-alt, #f7f9fc)}.x-pricing__container[data-v-bd1e5d79]{max-width:var(--x-max-width, 1200px);margin:0 auto}.x-pricing__title[data-v-bd1e5d79]{text-align:center;font-size:2rem;font-weight:700;color:var(--x-color-text, #2c3e50);margin:0 0 8px}.x-pricing__subtitle[data-v-bd1e5d79]{text-align:center;color:var(--x-color-text-light, #6b7c93);margin:0 0 32px}.x-pricing__tabs[data-v-bd1e5d79]{display:flex;justify-content:center;gap:8px;margin-bottom:32px}.x-pricing__tab[data-v-bd1e5d79]{padding:8px 24px;border:2px solid var(--x-color-border, #e2e8f0);border-radius:var(--x-border-radius, 8px);background:transparent;cursor:pointer;font-weight:500;transition:all .2s}.x-pricing__tab--active[data-v-bd1e5d79]{background:var(--x-color-primary, #1e73be);border-color:var(--x-color-primary, #1e73be);color:#fff}.x-pricing__grid[data-v-bd1e5d79]{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:24px;align-items:start}.x-pricing-card[data-v-bd1e5d79]{background:var(--x-color-surface, #fff);border:1px solid var(--x-color-border, #e2e8f0);border-radius:var(--x-border-radius, 12px);padding:32px;text-align:center;transition:transform .2s}.x-pricing-card--featured[data-v-bd1e5d79]{transform:scale(1.04);border-color:var(--x-color-primary, #1e73be);box-shadow:0 8px 32px #0000001a}.x-pricing-card__name[data-v-bd1e5d79]{font-size:1.25rem;font-weight:600;margin:0 0 8px}.x-pricing-card__desc[data-v-bd1e5d79]{color:var(--x-color-text-light, #6b7c93);font-size:.9rem;margin:0 0 16px}.x-pricing-card__price[data-v-bd1e5d79]{margin:16px 0}.x-pricing-card__amount[data-v-bd1e5d79]{font-size:2.5rem;font-weight:800;color:var(--x-color-primary, #1e73be)}.x-pricing-card__period[data-v-bd1e5d79]{color:var(--x-color-text-light, #6b7c93)}.x-pricing-card__features[data-v-bd1e5d79]{list-style:none;padding:0;margin:0 0 24px;text-align:left}.x-pricing-card__features li[data-v-bd1e5d79]{padding:6px 0;border-bottom:1px solid var(--x-color-border, #e2e8f0);font-size:.9rem}.x-pricing-card__features li[data-v-bd1e5d79]:before{content:"✓ ";color:var(--x-color-success, #00d084);font-weight:700}.x-pricing-card__action[data-v-bd1e5d79]{width:100%;justify-content:center}.x-btn[data-v-bd1e5d79]{display:inline-flex;align-items:center;padding:12px 32px;border-radius:var(--x-border-radius, 8px);font-weight:600;text-decoration:none;transition:all .2s;cursor:pointer}.x-btn--primary[data-v-bd1e5d79]{background:var(--x-color-primary, #1e73be);color:#fff}.x-contacts[data-v-e873ba26]{padding:80px 24px}.x-contacts__container[data-v-e873ba26]{max-width:var(--x-max-width, 1200px);margin:0 auto}.x-contacts__title[data-v-e873ba26]{text-align:center;font-size:2rem;font-weight:700;color:var(--x-color-text, #2c3e50);margin:0 0 48px}.x-contacts__grid[data-v-e873ba26]{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:24px}.x-contacts__card[data-v-e873ba26]{background:var(--x-color-surface, #fff);border:1px solid var(--x-color-border, #e2e8f0);border-radius:var(--x-border-radius, 12px);padding:24px}.x-contacts__card--main[data-v-e873ba26]{border-color:var(--x-color-primary, #1e73be);background:var(--x-color-primary-light, #e8f2fc)}.x-contacts__name[data-v-e873ba26]{font-size:1.125rem;font-weight:600;margin:0 0 12px;color:var(--x-color-text, #2c3e50)}.x-contacts__info[data-v-e873ba26]{margin:0 0 8px;font-size:.9rem;color:var(--x-color-text, #2c3e50)}.x-contacts__info a[data-v-e873ba26]{color:var(--x-color-primary, #1e73be);text-decoration:none}.x-contacts__hours[data-v-e873ba26]{margin:8px 0 0;font-size:.85rem;color:var(--x-color-text-light, #6b7c93)}.x-gallery-block[data-v-b99bb987]{padding:48px 24px}.x-gallery-block__title[data-v-b99bb987]{text-align:center;font-size:1.75rem;font-weight:700;margin-bottom:32px;color:var(--x-color-text, #2c3e50)}.x-gallery-block__grid[data-v-b99bb987]{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:16px;max-width:1200px;margin:0 auto}.x-gallery-block__item img[data-v-b99bb987]{width:100%;height:200px;object-fit:cover;border-radius:var(--x-border-radius, 8px);display:block}.x-gallery-block__item figcaption[data-v-b99bb987]{text-align:center;font-size:.875rem;color:var(--x-color-text-secondary, #6c757d);margin-top:8px}.x-map-block[data-v-583f4762]{padding:48px 24px}.x-map-block__title[data-v-583f4762]{text-align:center;font-size:1.75rem;font-weight:700;margin-bottom:32px;color:var(--x-color-text, #2c3e50)}.x-map-block__container[data-v-583f4762]{max-width:1200px;margin:0 auto;border-radius:var(--x-border-radius, 8px);overflow:hidden}
@@ -1,48 +0,0 @@
1
- <template>
2
- <div class="x-block-renderer">
3
- <template v-for="(block, i) in visibleBlocks" :key="i">
4
- <component
5
- :is="getBlockComponent(block.type)"
6
- v-if="getBlockComponent(block.type)"
7
- :data="block.data || block.props"
8
- v-bind="block.settings"
9
- :class="block.settings?.className"
10
- />
11
- </template>
12
- </div>
13
- </template>
14
-
15
- <script setup lang="ts">
16
- import { computed, type Component } from 'vue';
17
-
18
- import type { Block } from '../../types/blocks.js';
19
- import XHeroBlock from './XHeroBlock.vue';
20
- import XHtmlBlock from './XHtmlBlock.vue';
21
- import XCardsBlock from './XCardsBlock.vue';
22
- import XImageTextBlock from './XImageTextBlock.vue';
23
- import XPricingBlock from './XPricingBlock.vue';
24
- import XContactsBlock from './XContactsBlock.vue';
25
- import XGalleryBlock from './XGalleryBlock.vue';
26
- import XMapBlock from './XMapBlock.vue';
27
-
28
- const props = defineProps<{
29
- blocks: Block[];
30
- }>();
31
-
32
- const blockRegistry: Record<string, Component> = {
33
- hero: XHeroBlock,
34
- html: XHtmlBlock,
35
- cards: XCardsBlock,
36
- 'image-text': XImageTextBlock,
37
- pricing: XPricingBlock,
38
- contacts: XContactsBlock,
39
- gallery: XGalleryBlock,
40
- map: XMapBlock,
41
- };
42
-
43
- function getBlockComponent(type: string): Component | undefined {
44
- return blockRegistry[type];
45
- }
46
-
47
- const visibleBlocks = computed(() => props.blocks.filter((b) => b.settings?.visible !== false));
48
- </script>
@@ -1,167 +0,0 @@
1
- <template>
2
- <section class="x-cards-block" :class="`x-cards-block--${variant}`">
3
- <div class="x-cards-block__container">
4
- <h2 v-if="data.title" class="x-cards-block__title">{{ data.title }}</h2>
5
- <p v-if="data.subtitle" class="x-cards-block__subtitle">{{ data.subtitle }}</p>
6
-
7
- <div class="x-cards-block__grid" :style="gridStyle">
8
- <component
9
- :is="card.link ? 'a' : 'div'"
10
- v-for="(card, i) in data.cards"
11
- :key="i"
12
- class="x-card"
13
- :href="card.link"
14
- >
15
- <img
16
- v-if="card.image && variant === 'image-cards'"
17
- :src="card.image"
18
- :alt="card.title"
19
- class="x-card__image"
20
- />
21
- <div v-if="card.icon && variant !== 'image-cards'" class="x-card__icon" :style="iconStyle">
22
- {{ iconDisplay(card.icon) }}
23
- </div>
24
- <h3 class="x-card__title">{{ card.title }}</h3>
25
- <p class="x-card__desc">{{ card.desc }}</p>
26
- </component>
27
- </div>
28
- </div>
29
- </section>
30
- </template>
31
-
32
- <script setup lang="ts">
33
- import { computed } from 'vue';
34
-
35
- import type { CardsBlockData } from '../../types/blocks.js';
36
-
37
- const props = withDefaults(
38
- defineProps<{
39
- data: CardsBlockData;
40
- variant?: 'default' | 'icon-cards' | 'image-cards';
41
- columns?: 2 | 3 | 4;
42
- }>(),
43
- { variant: 'default', columns: undefined },
44
- );
45
-
46
- const gridStyle = computed(() => {
47
- const cols = props.columns || Math.min(props.data.cards.length, 4);
48
- return { '--columns': cols };
49
- });
50
-
51
- const iconStyle = { color: 'var(--x-color-primary, #1e73be)' };
52
-
53
- // Map mdi-* icon names to emoji, or pass through if already an emoji/character
54
- const iconMap: Record<string, string> = {
55
- 'mdi-lightbulb': '💡',
56
- 'mdi-code-braces': '⚙️',
57
- 'mdi-headset': '🎧',
58
- 'mdi-check-circle': '✅',
59
- 'mdi-shield': '🛡️',
60
- 'mdi-chart-line': '📈',
61
- 'mdi-rocket': '🚀',
62
- 'mdi-heart': '❤️',
63
- 'mdi-star': '⭐',
64
- 'mdi-cog': '⚙️',
65
- 'mdi-email': '📧',
66
- 'mdi-phone': '📞',
67
- 'mdi-map-marker': '📍',
68
- 'mdi-clock': '🕐',
69
- 'mdi-account': '👤',
70
- 'mdi-team': '👥',
71
- };
72
-
73
- function iconDisplay(icon: string): string {
74
- return iconMap[icon] || icon;
75
- }
76
- </script>
77
-
78
- <style scoped>
79
- .x-cards-block {
80
- padding: 80px 24px;
81
- }
82
-
83
- .x-cards-block__container {
84
- max-width: var(--x-max-width, 1200px);
85
- margin: 0 auto;
86
- }
87
-
88
- .x-cards-block__title {
89
- text-align: center;
90
- font-size: 2rem;
91
- font-weight: 700;
92
- color: var(--x-color-text, #2c3e50);
93
- margin: 0 0 8px;
94
- }
95
-
96
- .x-cards-block__subtitle {
97
- text-align: center;
98
- color: var(--x-color-text-light, #6b7c93);
99
- margin: 0 0 48px;
100
- }
101
-
102
- .x-cards-block__grid {
103
- display: grid;
104
- grid-template-columns: repeat(var(--columns, 4), 1fr);
105
- gap: 24px;
106
- }
107
-
108
- @media (max-width: 768px) {
109
- .x-cards-block__grid {
110
- grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
111
- }
112
- }
113
-
114
- .x-card {
115
- background: var(--x-color-surface, #fff);
116
- border: 1px solid var(--x-color-border, #e2e8f0);
117
- border-radius: var(--x-border-radius, 12px);
118
- padding: 32px 24px;
119
- text-align: center;
120
- text-decoration: none;
121
- color: inherit;
122
- transition:
123
- transform 0.2s,
124
- box-shadow 0.2s;
125
- }
126
-
127
- .x-card:hover {
128
- transform: translateY(-4px);
129
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
130
- }
131
-
132
- .x-card__icon {
133
- font-size: 2.5rem;
134
- margin-bottom: 16px;
135
- width: 64px;
136
- height: 64px;
137
- display: flex;
138
- align-items: center;
139
- justify-content: center;
140
- margin-left: auto;
141
- margin-right: auto;
142
- background: var(--x-color-background-alt, #f7f9fc);
143
- border-radius: 50%;
144
- }
145
-
146
- .x-card__image {
147
- width: 100%;
148
- aspect-ratio: 16/9;
149
- object-fit: cover;
150
- border-radius: calc(var(--x-border-radius, 12px) - 4px);
151
- margin-bottom: 16px;
152
- }
153
-
154
- .x-card__title {
155
- font-size: 1.125rem;
156
- font-weight: 600;
157
- color: var(--x-color-text, #2c3e50);
158
- margin: 0 0 8px;
159
- }
160
-
161
- .x-card__desc {
162
- font-size: 0.9rem;
163
- color: var(--x-color-text-light, #6b7c93);
164
- line-height: 1.6;
165
- margin: 0;
166
- }
167
- </style>
@@ -1,110 +0,0 @@
1
- <template>
2
- <section class="x-contacts">
3
- <div class="x-contacts__container">
4
- <h2 v-if="data?.title" class="x-contacts__title">{{ data.title }}</h2>
5
-
6
- <div v-if="offices" class="x-contacts__grid">
7
- <div
8
- v-for="(office, i) in offices"
9
- :key="i"
10
- class="x-contacts__card"
11
- :class="{ 'x-contacts__card--main': office.isMain }"
12
- >
13
- <h3 class="x-contacts__name">{{ office.name }}</h3>
14
- <p v-if="office.address" class="x-contacts__info">{{ office.address }}</p>
15
- <p v-if="office.phone" class="x-contacts__info">
16
- <a :href="`tel:${office.phone}`">{{ office.phone }}</a>
17
- </p>
18
- <p v-if="office.email" class="x-contacts__info">
19
- <a :href="`mailto:${office.email}`">{{ office.email }}</a>
20
- </p>
21
- <p v-if="office.hours" class="x-contacts__hours">{{ office.hours }}</p>
22
- </div>
23
- </div>
24
- </div>
25
- </section>
26
- </template>
27
-
28
- <script setup lang="ts">
29
- import { computed } from 'vue';
30
-
31
- import type { ContactsBlockData } from '../../types/blocks.js';
32
- import { useSiteData } from '../../composables/useSiteData.js';
33
-
34
- interface Office {
35
- name: string;
36
- address?: string;
37
- phone?: string;
38
- email?: string;
39
- hours?: string;
40
- isMain?: boolean;
41
- }
42
-
43
- defineProps<{
44
- data?: ContactsBlockData;
45
- }>();
46
-
47
- const kvOffices = useSiteData<Office[]>('contacts:offices', 'contacts:offices');
48
- const offices = computed(() => kvOffices.value || []);
49
- </script>
50
-
51
- <style scoped>
52
- .x-contacts {
53
- padding: 80px 24px;
54
- }
55
-
56
- .x-contacts__container {
57
- max-width: var(--x-max-width, 1200px);
58
- margin: 0 auto;
59
- }
60
-
61
- .x-contacts__title {
62
- text-align: center;
63
- font-size: 2rem;
64
- font-weight: 700;
65
- color: var(--x-color-text, #2c3e50);
66
- margin: 0 0 48px;
67
- }
68
-
69
- .x-contacts__grid {
70
- display: grid;
71
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
72
- gap: 24px;
73
- }
74
-
75
- .x-contacts__card {
76
- background: var(--x-color-surface, #fff);
77
- border: 1px solid var(--x-color-border, #e2e8f0);
78
- border-radius: var(--x-border-radius, 12px);
79
- padding: 24px;
80
- }
81
-
82
- .x-contacts__card--main {
83
- border-color: var(--x-color-primary, #1e73be);
84
- background: var(--x-color-primary-light, #e8f2fc);
85
- }
86
-
87
- .x-contacts__name {
88
- font-size: 1.125rem;
89
- font-weight: 600;
90
- margin: 0 0 12px;
91
- color: var(--x-color-text, #2c3e50);
92
- }
93
-
94
- .x-contacts__info {
95
- margin: 0 0 8px;
96
- font-size: 0.9rem;
97
- color: var(--x-color-text, #2c3e50);
98
- }
99
-
100
- .x-contacts__info a {
101
- color: var(--x-color-primary, #1e73be);
102
- text-decoration: none;
103
- }
104
-
105
- .x-contacts__hours {
106
- margin: 8px 0 0;
107
- font-size: 0.85rem;
108
- color: var(--x-color-text-light, #6b7c93);
109
- }
110
- </style>
@@ -1,56 +0,0 @@
1
- <template>
2
- <section class="x-gallery-block">
3
- <h2 v-if="data.title" class="x-gallery-block__title">{{ data.title }}</h2>
4
- <div class="x-gallery-block__grid">
5
- <figure v-for="(img, i) in data.images" :key="i" class="x-gallery-block__item">
6
- <img :src="img.src" :alt="img.alt || ''" loading="lazy" />
7
- <figcaption v-if="img.caption">{{ img.caption }}</figcaption>
8
- </figure>
9
- </div>
10
- </section>
11
- </template>
12
-
13
- <script setup lang="ts">
14
- import type { GalleryBlockData } from '../../types/blocks.js';
15
-
16
- defineProps<{
17
- data: GalleryBlockData;
18
- }>();
19
- </script>
20
-
21
- <style scoped>
22
- .x-gallery-block {
23
- padding: 48px 24px;
24
- }
25
-
26
- .x-gallery-block__title {
27
- text-align: center;
28
- font-size: 1.75rem;
29
- font-weight: 700;
30
- margin-bottom: 32px;
31
- color: var(--x-color-text, #2c3e50);
32
- }
33
-
34
- .x-gallery-block__grid {
35
- display: grid;
36
- grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
37
- gap: 16px;
38
- max-width: 1200px;
39
- margin: 0 auto;
40
- }
41
-
42
- .x-gallery-block__item img {
43
- width: 100%;
44
- height: 200px;
45
- object-fit: cover;
46
- border-radius: var(--x-border-radius, 8px);
47
- display: block;
48
- }
49
-
50
- .x-gallery-block__item figcaption {
51
- text-align: center;
52
- font-size: 0.875rem;
53
- color: var(--x-color-text-secondary, #6c757d);
54
- margin-top: 8px;
55
- }
56
- </style>