@xosen/site-sdk 0.0.1

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.
@@ -0,0 +1,261 @@
1
+ <template>
2
+ <section class="x-pricing">
3
+ <div class="x-pricing__container">
4
+ <h2 v-if="title" class="x-pricing__title">{{ title }}</h2>
5
+ <p v-if="subtitle" class="x-pricing__subtitle">{{ subtitle }}</p>
6
+
7
+ <!-- Tabs for tariff groups -->
8
+ <div v-if="groups.length > 1" class="x-pricing__tabs">
9
+ <button
10
+ v-for="group in groups"
11
+ :key="group"
12
+ class="x-pricing__tab"
13
+ :class="{ 'x-pricing__tab--active': activeGroup === group }"
14
+ @click="activeGroup = group"
15
+ >
16
+ {{ group }}
17
+ </button>
18
+ </div>
19
+
20
+ <div class="x-pricing__grid">
21
+ <div
22
+ v-for="(plan, i) in activePlans"
23
+ :key="plan.id || i"
24
+ class="x-pricing-card"
25
+ :class="{ 'x-pricing-card--featured': i === featuredIndex }"
26
+ >
27
+ <h3 class="x-pricing-card__name">{{ plan.name }}</h3>
28
+ <p v-if="plan.description" class="x-pricing-card__desc">{{ plan.description }}</p>
29
+ <div class="x-pricing-card__price">
30
+ <span class="x-pricing-card__amount">{{ plan.price }}</span>
31
+ <span v-if="plan.period" class="x-pricing-card__period">/{{ plan.period }}</span>
32
+ </div>
33
+ <ul v-if="plan.features?.length" class="x-pricing-card__features">
34
+ <li v-for="(feature, fi) in plan.features" :key="fi">{{ feature }}</li>
35
+ </ul>
36
+ <a v-if="plan.actionUrl" :href="plan.actionUrl" class="x-btn x-btn--primary x-pricing-card__action">
37
+ {{ plan.actionText || 'Choose' }}
38
+ </a>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </section>
43
+ </template>
44
+
45
+ <script setup lang="ts">
46
+ import { ref, computed, onMounted, watch } from 'vue';
47
+
48
+ import type { PricingBlockData } from '../../types/blocks.js';
49
+ import { useSiteData } from '../../composables/useSiteData.js';
50
+ import { useSiteApi } from '../../composables/useSiteApi.js';
51
+
52
+ interface Plan {
53
+ id?: string;
54
+ name: string;
55
+ description?: string;
56
+ price: string | number;
57
+ period?: string;
58
+ features?: string[];
59
+ actionUrl?: string;
60
+ actionText?: string;
61
+ tariffGroupName?: string;
62
+ }
63
+
64
+ const props = withDefaults(
65
+ defineProps<{
66
+ data?: PricingBlockData;
67
+ source?: 'api' | 'kv';
68
+ featuredIndex?: number;
69
+ }>(),
70
+ { source: 'kv', featuredIndex: 1 },
71
+ );
72
+
73
+ const title = computed(() => props.data?.title);
74
+ const subtitle = computed(() => props.data?.subtitle);
75
+
76
+ const plans = ref<Plan[]>([]);
77
+ const activeGroup = ref('');
78
+
79
+ // Load from KV (preloaded)
80
+ const kvTariffs = useSiteData<Plan[]>('tariffs', 'tariffs');
81
+
82
+ // Load from API if source is 'api'
83
+ const api = useSiteApi();
84
+
85
+ onMounted(async () => {
86
+ if (props.source === 'api') {
87
+ try {
88
+ plans.value = await api.getTariffs();
89
+ } catch {
90
+ // Fallback to KV
91
+ }
92
+ }
93
+ });
94
+
95
+ watch(
96
+ kvTariffs,
97
+ (val) => {
98
+ if (val && props.source === 'kv') {
99
+ plans.value = val;
100
+ }
101
+ },
102
+ { immediate: true },
103
+ );
104
+
105
+ const groups = computed(() => {
106
+ const names = new Set(plans.value.map((p) => p.tariffGroupName).filter(Boolean));
107
+ return Array.from(names) as string[];
108
+ });
109
+
110
+ watch(
111
+ groups,
112
+ (g) => {
113
+ if (g.length > 0 && !activeGroup.value) {
114
+ activeGroup.value = g[0];
115
+ }
116
+ },
117
+ { immediate: true },
118
+ );
119
+
120
+ const activePlans = computed(() => {
121
+ if (!activeGroup.value || groups.value.length <= 1) return plans.value;
122
+ return plans.value.filter((p) => p.tariffGroupName === activeGroup.value);
123
+ });
124
+ </script>
125
+
126
+ <style scoped>
127
+ .x-pricing {
128
+ padding: 80px 24px;
129
+ background: var(--x-color-background-alt, #f7f9fc);
130
+ }
131
+
132
+ .x-pricing__container {
133
+ max-width: var(--x-max-width, 1200px);
134
+ margin: 0 auto;
135
+ }
136
+
137
+ .x-pricing__title {
138
+ text-align: center;
139
+ font-size: 2rem;
140
+ font-weight: 700;
141
+ color: var(--x-color-text, #2c3e50);
142
+ margin: 0 0 8px;
143
+ }
144
+
145
+ .x-pricing__subtitle {
146
+ text-align: center;
147
+ color: var(--x-color-text-light, #6b7c93);
148
+ margin: 0 0 32px;
149
+ }
150
+
151
+ .x-pricing__tabs {
152
+ display: flex;
153
+ justify-content: center;
154
+ gap: 8px;
155
+ margin-bottom: 32px;
156
+ }
157
+
158
+ .x-pricing__tab {
159
+ padding: 8px 24px;
160
+ border: 2px solid var(--x-color-border, #e2e8f0);
161
+ border-radius: var(--x-border-radius, 8px);
162
+ background: transparent;
163
+ cursor: pointer;
164
+ font-weight: 500;
165
+ transition: all 0.2s;
166
+ }
167
+
168
+ .x-pricing__tab--active {
169
+ background: var(--x-color-primary, #1e73be);
170
+ border-color: var(--x-color-primary, #1e73be);
171
+ color: white;
172
+ }
173
+
174
+ .x-pricing__grid {
175
+ display: grid;
176
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
177
+ gap: 24px;
178
+ align-items: start;
179
+ }
180
+
181
+ .x-pricing-card {
182
+ background: var(--x-color-surface, #fff);
183
+ border: 1px solid var(--x-color-border, #e2e8f0);
184
+ border-radius: var(--x-border-radius, 12px);
185
+ padding: 32px;
186
+ text-align: center;
187
+ transition: transform 0.2s;
188
+ }
189
+
190
+ .x-pricing-card--featured {
191
+ transform: scale(1.04);
192
+ border-color: var(--x-color-primary, #1e73be);
193
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
194
+ }
195
+
196
+ .x-pricing-card__name {
197
+ font-size: 1.25rem;
198
+ font-weight: 600;
199
+ margin: 0 0 8px;
200
+ }
201
+
202
+ .x-pricing-card__desc {
203
+ color: var(--x-color-text-light, #6b7c93);
204
+ font-size: 0.9rem;
205
+ margin: 0 0 16px;
206
+ }
207
+
208
+ .x-pricing-card__price {
209
+ margin: 16px 0;
210
+ }
211
+
212
+ .x-pricing-card__amount {
213
+ font-size: 2.5rem;
214
+ font-weight: 800;
215
+ color: var(--x-color-primary, #1e73be);
216
+ }
217
+
218
+ .x-pricing-card__period {
219
+ color: var(--x-color-text-light, #6b7c93);
220
+ }
221
+
222
+ .x-pricing-card__features {
223
+ list-style: none;
224
+ padding: 0;
225
+ margin: 0 0 24px;
226
+ text-align: left;
227
+ }
228
+
229
+ .x-pricing-card__features li {
230
+ padding: 6px 0;
231
+ border-bottom: 1px solid var(--x-color-border, #e2e8f0);
232
+ font-size: 0.9rem;
233
+ }
234
+
235
+ .x-pricing-card__features li::before {
236
+ content: '✓ ';
237
+ color: var(--x-color-success, #00d084);
238
+ font-weight: bold;
239
+ }
240
+
241
+ .x-pricing-card__action {
242
+ width: 100%;
243
+ justify-content: center;
244
+ }
245
+
246
+ .x-btn {
247
+ display: inline-flex;
248
+ align-items: center;
249
+ padding: 12px 32px;
250
+ border-radius: var(--x-border-radius, 8px);
251
+ font-weight: 600;
252
+ text-decoration: none;
253
+ transition: all 0.2s;
254
+ cursor: pointer;
255
+ }
256
+
257
+ .x-btn--primary {
258
+ background: var(--x-color-primary, #1e73be);
259
+ color: white;
260
+ }
261
+ </style>
@@ -0,0 +1,56 @@
1
+ <template>
2
+ <div class="x-locale-switcher">
3
+ <button
4
+ v-for="loc in locales"
5
+ :key="loc"
6
+ class="x-locale-switcher__btn"
7
+ :class="{ 'x-locale-switcher__btn--active': locale === loc }"
8
+ @click="switchLocale(loc)"
9
+ >
10
+ {{ loc.toUpperCase() }}
11
+ </button>
12
+ </div>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ import { useI18n } from 'vue-i18n';
17
+
18
+ defineProps<{
19
+ locales: string[];
20
+ }>();
21
+
22
+ const { locale } = useI18n();
23
+
24
+ function switchLocale(loc: string) {
25
+ locale.value = loc;
26
+ localStorage.setItem('site-locale', loc);
27
+ }
28
+ </script>
29
+
30
+ <style scoped>
31
+ .x-locale-switcher {
32
+ display: flex;
33
+ gap: 4px;
34
+ }
35
+
36
+ .x-locale-switcher__btn {
37
+ padding: 4px 8px;
38
+ border: 1px solid transparent;
39
+ border-radius: 4px;
40
+ background: transparent;
41
+ cursor: pointer;
42
+ font-size: 0.75rem;
43
+ font-weight: 600;
44
+ opacity: 0.6;
45
+ transition: all 0.2s;
46
+ }
47
+
48
+ .x-locale-switcher__btn--active {
49
+ opacity: 1;
50
+ border-color: currentColor;
51
+ }
52
+
53
+ .x-locale-switcher__btn:hover {
54
+ opacity: 1;
55
+ }
56
+ </style>
@@ -0,0 +1,64 @@
1
+ <template>
2
+ <footer class="x-footer">
3
+ <div class="x-footer__container">
4
+ <div v-if="footerConfig.links?.length" class="x-footer__links">
5
+ <a v-for="link in footerConfig.links" :key="link.url" :href="link.url" class="x-footer__link">
6
+ {{ link.text }}
7
+ </a>
8
+ </div>
9
+ <p v-if="copyright" class="x-footer__copyright">{{ copyright }}</p>
10
+ </div>
11
+ </footer>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ import { computed } from 'vue';
16
+
17
+ import { useSiteConfig } from '../../composables/useSiteConfig.js';
18
+
19
+ const { footer: footerConfig, branding } = useSiteConfig();
20
+
21
+ const copyright = computed(() => {
22
+ if (footerConfig.value.copyright) return footerConfig.value.copyright;
23
+ const year = new Date().getFullYear();
24
+ return `© ${year} ${branding.value.siteName || ''}`;
25
+ });
26
+ </script>
27
+
28
+ <style scoped>
29
+ .x-footer {
30
+ background: var(--x-color-text, #2c3e50);
31
+ color: rgba(255, 255, 255, 0.7);
32
+ padding: 32px 24px;
33
+ text-align: center;
34
+ }
35
+
36
+ .x-footer__container {
37
+ max-width: var(--x-max-width, 1200px);
38
+ margin: 0 auto;
39
+ }
40
+
41
+ .x-footer__links {
42
+ display: flex;
43
+ gap: 24px;
44
+ justify-content: center;
45
+ margin-bottom: 16px;
46
+ flex-wrap: wrap;
47
+ }
48
+
49
+ .x-footer__link {
50
+ color: rgba(255, 255, 255, 0.7);
51
+ text-decoration: none;
52
+ font-size: 0.9rem;
53
+ }
54
+
55
+ .x-footer__link:hover {
56
+ color: white;
57
+ }
58
+
59
+ .x-footer__copyright {
60
+ font-size: 0.85rem;
61
+ margin: 0;
62
+ opacity: 0.6;
63
+ }
64
+ </style>
@@ -0,0 +1,35 @@
1
+ <template>
2
+ <div class="x-site-layout">
3
+ <XSiteNav />
4
+ <main class="x-site-layout__main">
5
+ <slot />
6
+ </main>
7
+ <XSiteFooter />
8
+ </div>
9
+ </template>
10
+
11
+ <script setup lang="ts">
12
+ import { onMounted } from 'vue';
13
+
14
+ import { useSkin } from '../../composables/useSkin.js';
15
+ import XSiteNav from './XSiteNav.vue';
16
+ import XSiteFooter from './XSiteFooter.vue';
17
+
18
+ // Apply skin CSS variables on mount
19
+ useSkin();
20
+ </script>
21
+
22
+ <style scoped>
23
+ .x-site-layout {
24
+ min-height: 100vh;
25
+ display: flex;
26
+ flex-direction: column;
27
+ font-family: var(--x-font-family, 'Inter', sans-serif);
28
+ color: var(--x-color-text, #2c3e50);
29
+ background: var(--x-color-background, #ffffff);
30
+ }
31
+
32
+ .x-site-layout__main {
33
+ flex: 1;
34
+ }
35
+ </style>
@@ -0,0 +1,178 @@
1
+ <template>
2
+ <nav class="x-nav" :class="{ 'x-nav--scrolled': scrolled }">
3
+ <div class="x-nav__container">
4
+ <a href="/" class="x-nav__logo">
5
+ <img v-if="branding.logo" :src="branding.logo" :alt="branding.siteName || ''" class="x-nav__logo-img" />
6
+ <span v-else class="x-nav__logo-text">{{ branding.siteName || '' }}</span>
7
+ </a>
8
+
9
+ <div class="x-nav__links" :class="{ 'x-nav__links--open': menuOpen }">
10
+ <a
11
+ v-for="item in navigation"
12
+ :key="item.url"
13
+ :href="item.url"
14
+ class="x-nav__link"
15
+ :target="item.external ? '_blank' : undefined"
16
+ @click="menuOpen = false"
17
+ >
18
+ {{ item.text }}
19
+ </a>
20
+
21
+ <XLocaleSwitcher v-if="locales.length > 1" :locales="locales" />
22
+ </div>
23
+
24
+ <button class="x-nav__hamburger" @click="menuOpen = !menuOpen">
25
+ <span />
26
+ <span />
27
+ <span />
28
+ </button>
29
+ </div>
30
+ </nav>
31
+ </template>
32
+
33
+ <script setup lang="ts">
34
+ import { ref, onMounted, onUnmounted } from 'vue';
35
+
36
+ import { useSiteConfig } from '../../composables/useSiteConfig.js';
37
+ import XLocaleSwitcher from '../common/XLocaleSwitcher.vue';
38
+
39
+ const { navigation, branding, locales } = useSiteConfig();
40
+
41
+ const scrolled = ref(false);
42
+ const menuOpen = ref(false);
43
+
44
+ function onScroll() {
45
+ scrolled.value = window.scrollY > 20;
46
+ }
47
+
48
+ onMounted(() => window.addEventListener('scroll', onScroll));
49
+ onUnmounted(() => window.removeEventListener('scroll', onScroll));
50
+ </script>
51
+
52
+ <style scoped>
53
+ .x-nav {
54
+ position: fixed;
55
+ top: 0;
56
+ left: 0;
57
+ right: 0;
58
+ z-index: 100;
59
+ transition:
60
+ background 0.3s,
61
+ box-shadow 0.3s;
62
+ }
63
+
64
+ .x-nav--scrolled {
65
+ background: rgba(255, 255, 255, 0.95);
66
+ backdrop-filter: blur(10px);
67
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
68
+ }
69
+
70
+ .x-nav__container {
71
+ max-width: var(--x-max-width, 1200px);
72
+ margin: 0 auto;
73
+ padding: 0 24px;
74
+ height: 64px;
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: space-between;
78
+ }
79
+
80
+ .x-nav__logo {
81
+ text-decoration: none;
82
+ display: flex;
83
+ align-items: center;
84
+ }
85
+
86
+ .x-nav__logo-img {
87
+ height: 36px;
88
+ }
89
+
90
+ .x-nav__logo-text {
91
+ font-size: 1.25rem;
92
+ font-weight: 700;
93
+ color: var(--x-color-text, #2c3e50);
94
+ }
95
+
96
+ .x-nav--scrolled .x-nav__logo-text {
97
+ color: var(--x-color-text, #2c3e50);
98
+ }
99
+
100
+ .x-nav:not(.x-nav--scrolled) .x-nav__logo-text {
101
+ color: white;
102
+ }
103
+
104
+ .x-nav__links {
105
+ display: flex;
106
+ align-items: center;
107
+ gap: 24px;
108
+ }
109
+
110
+ .x-nav__link {
111
+ text-decoration: none;
112
+ font-weight: 500;
113
+ font-size: 0.9rem;
114
+ transition: color 0.2s;
115
+ }
116
+
117
+ .x-nav--scrolled .x-nav__link {
118
+ color: var(--x-color-text, #2c3e50);
119
+ }
120
+
121
+ .x-nav:not(.x-nav--scrolled) .x-nav__link {
122
+ color: rgba(255, 255, 255, 0.9);
123
+ }
124
+
125
+ .x-nav__link:hover {
126
+ color: var(--x-color-primary, #1e73be);
127
+ }
128
+
129
+ .x-nav__hamburger {
130
+ display: none;
131
+ flex-direction: column;
132
+ gap: 5px;
133
+ background: none;
134
+ border: none;
135
+ cursor: pointer;
136
+ padding: 4px;
137
+ }
138
+
139
+ .x-nav__hamburger span {
140
+ width: 24px;
141
+ height: 2px;
142
+ background: var(--x-color-text, #2c3e50);
143
+ transition: all 0.2s;
144
+ }
145
+
146
+ .x-nav:not(.x-nav--scrolled) .x-nav__hamburger span {
147
+ background: white;
148
+ }
149
+
150
+ @media (max-width: 768px) {
151
+ .x-nav__hamburger {
152
+ display: flex;
153
+ }
154
+
155
+ .x-nav__links {
156
+ display: none;
157
+ position: fixed;
158
+ top: 64px;
159
+ right: 0;
160
+ width: 280px;
161
+ height: calc(100vh - 64px);
162
+ background: var(--x-color-surface, #fff);
163
+ flex-direction: column;
164
+ padding: 24px;
165
+ box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
166
+ align-items: flex-start;
167
+ }
168
+
169
+ .x-nav__links--open {
170
+ display: flex;
171
+ }
172
+
173
+ .x-nav__links .x-nav__link {
174
+ color: var(--x-color-text, #2c3e50) !important;
175
+ padding: 8px 0;
176
+ }
177
+ }
178
+ </style>
@@ -0,0 +1,32 @@
1
+ import type { SiteData } from '../types/config.js';
2
+
3
+ /**
4
+ * Fetch data from the Xosen org API (for live data like tariffs, products).
5
+ * Uses the API base URL from site config.
6
+ */
7
+ export function useSiteApi() {
8
+ const siteData = (window as any).__SITE_DATA__ as SiteData | undefined;
9
+ const apiBase = siteData?.apiBase || '';
10
+
11
+ async function get<T>(path: string): Promise<T> {
12
+ const response = await fetch(`${apiBase}${path}`);
13
+ if (!response.ok) {
14
+ throw new Error(`API error: ${response.status}`);
15
+ }
16
+ return response.json();
17
+ }
18
+
19
+ async function getTariffs() {
20
+ return get<any[]>('/v1/billing/tariffs');
21
+ }
22
+
23
+ async function getProducts() {
24
+ return get<any[]>('/v1/billing/services');
25
+ }
26
+
27
+ return {
28
+ get,
29
+ getTariffs,
30
+ getProducts,
31
+ };
32
+ }
@@ -0,0 +1,37 @@
1
+ import { computed } from 'vue';
2
+
3
+ import type { SiteConfig, SiteData } from '../types/config.js';
4
+
5
+ /**
6
+ * Access the site configuration injected by the CF Worker.
7
+ */
8
+ export function useSiteConfig() {
9
+ const siteData = (window as any).__SITE_DATA__ as SiteData | undefined;
10
+
11
+ const config = computed<SiteConfig | null>(() => siteData?.config || null);
12
+
13
+ const navigation = computed(() => config.value?.navigation || []);
14
+ const locales = computed(() => config.value?.locales || []);
15
+ const defaultLocale = computed(() => config.value?.defaultLocale || 'en');
16
+ const branding = computed(() => config.value?.branding || {});
17
+ const footer = computed(() => config.value?.footer || {});
18
+ const features = computed(() => config.value?.features || {});
19
+
20
+ function hasFeature(key: string): boolean {
21
+ const val = features.value[key];
22
+ if (typeof val === 'boolean') return val;
23
+ if (typeof val === 'object') return true;
24
+ return false;
25
+ }
26
+
27
+ return {
28
+ config,
29
+ navigation,
30
+ locales,
31
+ defaultLocale,
32
+ branding,
33
+ footer,
34
+ features,
35
+ hasFeature,
36
+ };
37
+ }
@@ -0,0 +1,51 @@
1
+ import { ref, onMounted, watch, type Ref } from 'vue';
2
+ import { useI18n } from 'vue-i18n';
3
+
4
+ import type { SiteData } from '../types/config.js';
5
+
6
+ /**
7
+ * Read preloaded site data from window.__SITE_DATA__ or fetch from API.
8
+ *
9
+ * @param key - Key in __SITE_DATA__ object (e.g. 'tariffs', 'contacts:offices')
10
+ * @param kvKey - KV key suffix for API fallback (e.g. 'tariffs', 'contacts:offices')
11
+ */
12
+ export function useSiteData<T>(key: string, kvKey: string): Ref<T | null> {
13
+ const data = ref<T | null>(null) as Ref<T | null>;
14
+ const { locale } = useI18n();
15
+
16
+ const siteData = (window as any).__SITE_DATA__ as SiteData | undefined;
17
+ const initialLocale = siteData?.locale;
18
+ const tenantPrefix = siteData?.config?.tenantPrefix || 'tenant';
19
+
20
+ function loadFromPreloaded(): boolean {
21
+ if (siteData && siteData[key] && locale.value === initialLocale) {
22
+ data.value = siteData[key] as T;
23
+ return true;
24
+ }
25
+ return false;
26
+ }
27
+
28
+ async function fetchFromApi() {
29
+ try {
30
+ const fullKey = `${tenantPrefix}:${kvKey}:${locale.value}`;
31
+ const response = await fetch(`/api/content/${fullKey}`);
32
+ if (response.ok) {
33
+ data.value = await response.json();
34
+ }
35
+ } catch {
36
+ // Silently fail — data stays null
37
+ }
38
+ }
39
+
40
+ onMounted(() => {
41
+ if (!loadFromPreloaded()) {
42
+ fetchFromApi();
43
+ }
44
+ });
45
+
46
+ watch(locale, () => {
47
+ fetchFromApi();
48
+ });
49
+
50
+ return data;
51
+ }