nuxt-telegram-mini-app 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.
Files changed (40) hide show
  1. package/.env.example +2 -0
  2. package/.vscode/settings.json +55 -0
  3. package/.vscode/tailwind.json +55 -0
  4. package/CONTRIBUTING.md +406 -0
  5. package/LICENSE +21 -0
  6. package/README.md +640 -0
  7. package/app/app.vue +87 -0
  8. package/app/assets/css/main.css +53 -0
  9. package/app/assets/css/tailwind.css +3 -0
  10. package/app/components/ErrorBoundary.vue +81 -0
  11. package/app/components/Hero.vue +61 -0
  12. package/app/components/tg/Button.vue +128 -0
  13. package/app/components/tg/Cell.vue +91 -0
  14. package/app/components/tg/Content.vue +42 -0
  15. package/app/components/tg/Nav.vue +107 -0
  16. package/app/components/tg/Section.vue +50 -0
  17. package/app/composables/telegram.ts +342 -0
  18. package/app/error.vue +161 -0
  19. package/app/pages/components.vue +279 -0
  20. package/app/pages/functions.vue +107 -0
  21. package/app/pages/index.vue +211 -0
  22. package/app/pages/utilities.vue +402 -0
  23. package/app/types/telegram-webapp.ts +160 -0
  24. package/app/utils/color.ts +37 -0
  25. package/eslint.config.mjs +6 -0
  26. package/nuxt.config.ts +55 -0
  27. package/package.json +46 -0
  28. package/public/_redirects +2 -0
  29. package/public/favicon.ico +0 -0
  30. package/public/img/hero-user.svg +8 -0
  31. package/public/img/nuxt-logo.svg +11 -0
  32. package/public/robots.txt +2 -0
  33. package/server/api/verify-telegram-data.post.ts +150 -0
  34. package/tailwind.config.ts +39 -0
  35. package/tests/components.spec.ts +311 -0
  36. package/tests/pages.spec.ts +426 -0
  37. package/tests/telegram.spec.ts +105 -0
  38. package/tests/utils.spec.ts +47 -0
  39. package/tsconfig.json +18 -0
  40. package/vitest.config.ts +24 -0
@@ -0,0 +1,279 @@
1
+ <template>
2
+ <TgContent>
3
+ <Hero
4
+ title="Components Demo"
5
+ subtitle="Interactive showcase of all Telegram Mini App components"
6
+ />
7
+
8
+ <TgSection title="Buttons" inset>
9
+ <div class="p-4 space-y-3">
10
+ <div class="space-y-2">
11
+ <h3 class="text-sm font-medium text-subtitle">Primary Buttons</h3>
12
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
13
+ <TgButton title="Default Primary" status="primary" haptic />
14
+ <TgButton title="Disabled Primary" status="primary" :disabled="true" />
15
+ </div>
16
+ </div>
17
+
18
+ <div class="space-y-2">
19
+ <h3 class="text-sm font-medium text-subtitle">Secondary Buttons</h3>
20
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
21
+ <TgButton title="Secondary" status="secondary" haptic="impact-light" />
22
+ <TgButton title="Outline" status="outline" haptic="selection" />
23
+ </div>
24
+ </div>
25
+
26
+ <div class="space-y-2">
27
+ <h3 class="text-sm font-medium text-subtitle">Special States</h3>
28
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
29
+ <TgButton title="Loading..." status="primary" :loading="loadingButton" @click="toggleLoading" />
30
+ <TgButton title="Destructive" status="destructive" haptic="notification-warning" />
31
+ </div>
32
+ </div>
33
+ </div>
34
+ </TgSection>
35
+
36
+ <TgSection title="Cells & Lists" inset>
37
+ <TgCell
38
+ title="Basic Cell"
39
+ subtitle="Simple cell with subtitle"
40
+ icon="i-heroicons-star-20-solid"
41
+ />
42
+ <TgCell
43
+ title="Cell with Description"
44
+ :description="cellDescription"
45
+ icon="i-heroicons-information-circle-20-solid"
46
+ />
47
+ <TgCell
48
+ title="Interactive Cell"
49
+ subtitle="Demo cell (no navigation)"
50
+ icon="i-heroicons-arrow-right-circle-20-solid"
51
+ @click="onCellClick"
52
+ />
53
+ <TgCell
54
+ title="Cell with Action"
55
+ subtitle="Custom click handler"
56
+ icon="i-heroicons-bolt-20-solid"
57
+ @click="onCellClick"
58
+ :border="false"
59
+ />
60
+ <TgCell
61
+ title="Router Link Cell"
62
+ subtitle="Navigates using NuxtLink"
63
+ icon="i-heroicons-chevron-right-20-solid"
64
+ to="/"
65
+ />
66
+ <TgCell
67
+ title="External Link Cell"
68
+ subtitle="Opens external URL"
69
+ icon="i-heroicons-arrow-top-right-on-square-20-solid"
70
+ href="https://nuxt.com"
71
+ />
72
+ </TgSection>
73
+
74
+ <TgSection title="Color Showcase" inset>
75
+ <div class="p-4 space-y-4">
76
+ <div class="space-y-2">
77
+ <h3 class="text-sm font-medium text-subtitle">Theme Colors</h3>
78
+ <div class="grid grid-cols-2 gap-3">
79
+ <div v-for="color in themeColors" :key="color.name" class="flex items-center gap-2">
80
+ <div
81
+ class="w-4 h-4 rounded border border-sectionSeparator"
82
+ :style="{ backgroundColor: color.value }"
83
+ />
84
+ <span class="text-xs">{{ color.name }}</span>
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ <div class="space-y-2">
90
+ <h3 class="text-sm font-medium text-subtitle">Dynamic Colors</h3>
91
+ <div class="flex flex-wrap gap-2">
92
+ <button
93
+ v-for="colorOption in backgroundColors"
94
+ :key="colorOption.name"
95
+ class="px-3 py-1 text-xs rounded border border-sectionSeparator"
96
+ :style="{ backgroundColor: colorOption.value, color: colorOption.textColor }"
97
+ @click="changeBackgroundColor(colorOption.value)"
98
+ >
99
+ {{ colorOption.name }}
100
+ </button>
101
+ </div>
102
+ </div>
103
+ </div>
104
+ </TgSection>
105
+
106
+ <TgSection title="Interactive Elements" inset>
107
+ <div class="p-4 space-y-4">
108
+ <div class="space-y-2">
109
+ <h3 class="text-sm font-medium text-subtitle">Form Elements</h3>
110
+ <div class="space-y-3">
111
+ <input
112
+ v-model="textInput"
113
+ type="text"
114
+ placeholder="Enter text here..."
115
+ class="w-full px-3 py-2 bg-secondaryBg border border-sectionSeparator rounded-lg text-text placeholder-hint"
116
+ />
117
+ <textarea
118
+ v-model="textareaInput"
119
+ rows="3"
120
+ placeholder="Multi-line input..."
121
+ class="w-full px-3 py-2 bg-secondaryBg border border-sectionSeparator rounded-lg text-text placeholder-hint resize-none"
122
+ />
123
+ </div>
124
+ </div>
125
+
126
+ <div class="space-y-2">
127
+ <h3 class="text-sm font-medium text-subtitle">Counters & Progress</h3>
128
+ <div class="space-y-3">
129
+ <div class="flex items-center justify-between">
130
+ <span class="text-sm">Counter: {{ counter }}</span>
131
+ <div class="flex gap-2">
132
+ <button
133
+ @click="counter--"
134
+ class="w-8 h-8 bg-button-color text-button-text rounded-full flex items-center justify-center text-sm font-medium"
135
+ >
136
+ -
137
+ </button>
138
+ <button
139
+ @click="counter++"
140
+ class="w-8 h-8 bg-button-color text-button-text rounded-full flex items-center justify-center text-sm font-medium"
141
+ >
142
+ +
143
+ </button>
144
+ </div>
145
+ </div>
146
+ <div class="space-y-1">
147
+ <div class="flex justify-between text-sm">
148
+ <span>Progress</span>
149
+ <span>{{ progress }}%</span>
150
+ </div>
151
+ <div class="w-full h-2 bg-secondaryBg rounded-full overflow-hidden">
152
+ <div
153
+ class="h-full bg-link transition-all duration-300"
154
+ :style="{ width: `${progress}%` }"
155
+ />
156
+ </div>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </TgSection>
162
+
163
+ <TgSection title="Haptic Feedback Demo" inset>
164
+ <div class="p-4 grid grid-cols-2 sm:grid-cols-3 gap-2">
165
+ <TgButton
166
+ title="Light Impact"
167
+ haptic="impact-light"
168
+ status="outline"
169
+ @click="() => {}"
170
+ />
171
+ <TgButton
172
+ title="Medium Impact"
173
+ haptic="impact-medium"
174
+ status="outline"
175
+ @click="() => {}"
176
+ />
177
+ <TgButton
178
+ title="Heavy Impact"
179
+ haptic="impact-heavy"
180
+ status="outline"
181
+ @click="() => {}"
182
+ />
183
+ <TgButton
184
+ title="Success"
185
+ haptic="notification-success"
186
+ status="outline"
187
+ @click="() => {}"
188
+ />
189
+ <TgButton
190
+ title="Warning"
191
+ haptic="notification-warning"
192
+ status="outline"
193
+ @click="() => {}"
194
+ />
195
+ <TgButton
196
+ title="Error"
197
+ haptic="notification-error"
198
+ status="outline"
199
+ @click="() => {}"
200
+ />
201
+ </div>
202
+ </TgSection>
203
+
204
+ <div class="h-2" />
205
+ </TgContent>
206
+
207
+ </template>
208
+
209
+ <script setup lang="ts">
210
+ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
211
+ import { useHapticFeedback, useMiniApp, useThemeParams, useMainButton } from '~/composables/telegram'
212
+
213
+ const haptic = useHapticFeedback()
214
+ const mini = useMiniApp()
215
+ const theme = useThemeParams()
216
+ const main = useMainButton()
217
+
218
+ // Interactive state
219
+ const loadingButton = ref(false)
220
+ const counter = ref(0)
221
+ const textInput = ref('')
222
+ const textareaInput = ref('')
223
+
224
+ const progress = computed(() => Math.min(100, Math.max(0, counter.value * 10)))
225
+
226
+ const cellDescription = computed(() => {
227
+ if (textInput.value) return `You typed: "${textInput.value}"`
228
+ return 'Type something in the input above'
229
+ })
230
+
231
+ function toggleLoading() {
232
+ loadingButton.value = true
233
+ setTimeout(() => {
234
+ loadingButton.value = false
235
+ haptic.notificationOccurred('success')
236
+ }, 2000)
237
+ }
238
+
239
+ function onCellClick() {
240
+ haptic.impactOccurred('medium')
241
+ counter.value += 5
242
+ }
243
+
244
+ // Theme colors demo
245
+ const themeColors = computed(() => [
246
+ { name: 'Background', value: theme.backgroundColor.value || 'var(--tg-theme-bg-color)' },
247
+ { name: 'Text', value: theme.textColor.value || 'var(--tg-theme-text-color)' },
248
+ { name: 'Button', value: theme.buttonColor.value || 'var(--tg-theme-button-color)' },
249
+ { name: 'Button Text', value: theme.buttonTextColor.value || 'var(--tg-theme-button-text-color)' },
250
+ { name: 'Link', value: theme.linkColor.value || 'var(--tg-theme-link-color)' },
251
+ { name: 'Secondary BG', value: theme.secondaryBackgroundColor.value || 'var(--tg-theme-secondary-bg-color)' },
252
+ ])
253
+
254
+ const backgroundColors = [
255
+ { name: 'Default', value: 'var(--tg-theme-bg-color)', textColor: 'var(--tg-theme-text-color)' },
256
+ { name: 'Dark', value: '#1a1a1a', textColor: '#ffffff' },
257
+ { name: 'Blue', value: '#0088cc', textColor: '#ffffff' },
258
+ { name: 'Green', value: '#00c896', textColor: '#ffffff' },
259
+ { name: 'Purple', value: '#8b5cf6', textColor: '#ffffff' },
260
+ ]
261
+
262
+ function changeBackgroundColor(color: string) {
263
+ mini.setBackgroundColor(color)
264
+ haptic.selectionChanged()
265
+ }
266
+
267
+ // Hide Main Button since we're using TgNav for navigation
268
+ onMounted(() => {
269
+ main.mount()
270
+
271
+ setTimeout(() => {
272
+ try {
273
+ main.setParams({ is_visible: false })
274
+ } catch (e) {
275
+ console.warn('Failed to hide main button:', e)
276
+ }
277
+ }, 500)
278
+ })
279
+ </script>
@@ -0,0 +1,107 @@
1
+ <template>
2
+ <TgContent>
3
+ <Hero
4
+ title="Functions"
5
+ image-src="/img/hero-user.svg"
6
+ />
7
+
8
+ <TgSection title="Navigation" inset>
9
+ <TgCell
10
+ title="Use Back Button"
11
+ subtitle="Telegram back button is shown on this page"
12
+ icon="i-heroicons-arrow-left-circle-20-solid"
13
+ />
14
+ <div class="p-4">
15
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
16
+ <TgButton title="Go Back (fallback)" haptic @click="goBack" />
17
+ <TgButton title="Go to Components" status="outline" to="/components" haptic="selection" />
18
+ </div>
19
+ </div>
20
+ </TgSection>
21
+
22
+ <TgSection title="Main Button" inset>
23
+ <TgCell title="Main Button is hidden" subtitle="Using bottom navigation instead" />
24
+ <div class="p-4">
25
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
26
+ <TgButton title="Demo Button" haptic @click="() => {}" />
27
+ <TgButton title="Toggle Main Button" status="outline" haptic="selection" @click="toggleMain" />
28
+ </div>
29
+ </div>
30
+ </TgSection>
31
+
32
+ <TgSection title="Links & Sharing" inset>
33
+ <div class="p-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
34
+ <TgButton title="Open Nuxt" href="https://nuxt.com" haptic />
35
+ <TgButton title="Open Telegram" haptic @click="() => openTelegramLink('https://t.me/nuxt_tg_demo_bot')" />
36
+ <TgButton title="Share This Page" :share-url="shareUrl" haptic="notification-success" />
37
+ </div>
38
+ </TgSection>
39
+
40
+ <div class="h-2" />
41
+ </TgContent>
42
+
43
+ </template>
44
+
45
+ <script setup lang="ts">
46
+ import { onBeforeUnmount, onMounted, computed } from 'vue'
47
+ import { useRouter } from 'vue-router'
48
+ import { useBackButton, useMainButton, openTelegramLink } from '~/composables/telegram'
49
+
50
+ const back = useBackButton()
51
+ const main = useMainButton()
52
+ const router = useRouter()
53
+
54
+ function goBack() {
55
+ if (!import.meta.client) return
56
+ // Prefer router navigation to play nicely with Nuxt links/TgNav
57
+ if (window.history.length > 1) {
58
+ try { router.back() } catch { /* fallback below */ }
59
+ } else {
60
+ try { router.push('/') } catch { /* no-op */ }
61
+ }
62
+ }
63
+
64
+ function toggleMain() {
65
+ const visible = !main.visible.value
66
+ main.setParams({ is_visible: visible })
67
+ }
68
+
69
+ // SSR-safe current URL for sharing examples
70
+ const shareUrl = computed(() => import.meta.client ? window.location.href : 'https://example.com')
71
+
72
+ onMounted(() => {
73
+ // Back Button wiring with guarded polling to avoid early store reads
74
+ back.mount()
75
+ const pollBack = setInterval(() => {
76
+ try {
77
+ // If store not ready yet, any error is swallowed
78
+ if (back.mounted.value) {
79
+ try { back.show() } catch {}
80
+ clearInterval(pollBack)
81
+ }
82
+ } catch {}
83
+ }, 50)
84
+ // Register back handler and properly unregister on unmount
85
+ const backHandler = () => { goBack() }
86
+ back.onClick(backHandler)
87
+
88
+ // Hide Main Button since we're using TgNav for navigation
89
+ main.mount()
90
+ const pollMain = setInterval(() => {
91
+ try {
92
+ if (main.mounted.value) {
93
+ main.setParams({ is_visible: false })
94
+ clearInterval(pollMain)
95
+ }
96
+ } catch {}
97
+ }, 50)
98
+
99
+ onBeforeUnmount(() => {
100
+ try { back.offClick(backHandler) } catch {}
101
+ try { back.hide() } catch {}
102
+ })
103
+ })
104
+ </script>
105
+
106
+ <style scoped>
107
+ </style>
@@ -0,0 +1,211 @@
1
+ <template>
2
+ <TgContent>
3
+ <Hero
4
+ title="Telegram Mini App Demo"
5
+ subtitle="Showcasing components and SDK integrations"
6
+ image-src="/img/nuxt-logo.svg"
7
+ />
8
+
9
+ <TgSection title="Navigation" inset>
10
+ <TgCell
11
+ title="Navigation Demo"
12
+ subtitle="Use the bottom navigation bar to navigate"
13
+ icon="i-heroicons-arrow-right-circle-20-solid"
14
+ />
15
+ <TgButton title="Demo Button" status="primary" haptic @click="() => haptic.impactOccurred('medium')" />
16
+ <TgButton title="Toggle Main Button" status="primary" haptic="selection" @click="toggleMain" />
17
+ <TgCell
18
+ title="Example Cell without icon"
19
+ />
20
+ <template #append>
21
+ Use the navigation bar at the bottom to switch between pages.
22
+ </template>
23
+ </TgSection>
24
+
25
+ <TgSection title="Haptic Feedback" inset>
26
+ <TgCell title="Supported" :description="hapticSupported" icon="i-heroicons-bolt-20-solid" :border="false" />
27
+ <div class="p-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
28
+ <TgButton title="Impact" :disabled="!hapticSupportedBool" @click="haptic.impactOccurred('medium')" />
29
+ <TgButton title="Notification" :disabled="!hapticSupportedBool" @click="haptic.notificationOccurred('success')" />
30
+ <TgButton title="Selection" :disabled="!hapticSupportedBool" @click="haptic.selectionChanged()" />
31
+ <!-- Example using TgButton's built-in haptic prop -->
32
+ <TgButton title="Built-in Haptic (medium)" haptic="impact-medium" />
33
+ </div>
34
+ </TgSection>
35
+
36
+ <TgSection title="Init Data" inset>
37
+ <TgCell title="User" :description="initUser" icon="i-heroicons-user-20-solid" />
38
+ <TgCell title="Query ID" :description="initQueryId" />
39
+ <TgCell title="Start Param" :description="initStartParam" :border="false" />
40
+ </TgSection>
41
+
42
+ <TgSection title="Mini App" inset>
43
+ <TgCell title="Supported" :description="miniSupported" icon="i-heroicons-check-badge-20-solid" />
44
+ <TgCell title="Dark" :description="miniDark" />
45
+ <div class="p-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
46
+ <TgButton title="Header = bg" :disabled="!miniSupportedBool" haptic @click="mini.setHeaderColor('bg_color')" />
47
+ <TgButton title="BG = bg" :disabled="!miniSupportedBool" haptic @click="mini.setBackgroundColor('bg_color')" />
48
+ </div>
49
+ </TgSection>
50
+
51
+ <ClientOnly>
52
+ <TgSection title="Theme Params" inset>
53
+ <div class="p-4 grid grid-cols-1 gap-3 text-sm">
54
+ <div class="flex items-center gap-2">
55
+ <span class="inline-block w-4 h-4 rounded" :style="{ background: themeBgHex }" />
56
+ <span>BG: {{ themeBgHex }}</span>
57
+ </div>
58
+ <div class="flex items-center gap-2">
59
+ <span class="inline-block w-4 h-4 rounded" :style="{ background: themeTextHex }" />
60
+ <span>Text: {{ themeTextHex }}</span>
61
+ </div>
62
+ </div>
63
+ </TgSection>
64
+ </ClientOnly>
65
+
66
+ <ClientOnly>
67
+ <TgSection title="Viewport" inset>
68
+ <TgCell title="Width" :description="viewportWidth" />
69
+ <TgCell title="Height" :description="viewportHeight" />
70
+ <TgCell title="Stable Height" :description="viewportStableHeight" />
71
+ <TgCell title="Expanded" :description="String(viewport.expanded.value ?? false)" :border="false" />
72
+ <div class="p-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
73
+ <TgButton title="Expand" haptic @click="viewport.expand" />
74
+ <TgButton title="Fullscreen" haptic="impact-light" @click="onRequestFullscreen" />
75
+ <TgButton title="Exit FS" haptic="impact-light" @click="onExitFullscreen" />
76
+ </div>
77
+ </TgSection>
78
+ </ClientOnly>
79
+
80
+ <TgSection title="Links" inset>
81
+ <div class="p-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
82
+ <TgButton title="Open Link" haptic @click="openLink('https://t.me/n')" />
83
+ <TgButton title="Open Telegram" haptic @click="openTelegramLink('https://t.me/nuxt_tg_demo_bot')" />
84
+ <TgButton title="Share URL" haptic="notification-success" :share-url="shareUrl" />
85
+ </div>
86
+ </TgSection>
87
+
88
+ <div class="h-2" />
89
+ </TgContent>
90
+ </template>
91
+
92
+ <script setup lang="ts">
93
+ import { onMounted, computed } from 'vue'
94
+ import { toHex } from '~/utils/color'
95
+ import { useRequestURL } from '#app'
96
+ import {
97
+ useHapticFeedback,
98
+ useInitData,
99
+ useMainButton,
100
+ useMiniApp,
101
+ useThemeParams,
102
+ useViewport,
103
+ openLink,
104
+ openTelegramLink,
105
+ } from '~/composables/telegram'
106
+
107
+ const main = useMainButton()
108
+ const haptic = useHapticFeedback()
109
+ const init = useInitData()
110
+ const mini = useMiniApp()
111
+ const theme = useThemeParams()
112
+ const viewport = useViewport()
113
+
114
+ // SSR-safe current URL for sharing
115
+ const reqURL = useRequestURL()
116
+ const shareUrl = computed(() => import.meta.client ? window.location.href : reqURL.href)
117
+
118
+ function toggleMain() {
119
+ const visible = !main.visible.value
120
+ main.setParams({ is_visible: visible })
121
+ }
122
+
123
+ // Guarded access helpers to avoid early SDK store reads
124
+ const safe = function <T>(fn: () => T, fallback: T): T {
125
+ try { return fn() } catch { return fallback }
126
+ }
127
+
128
+ // Haptic
129
+ const hapticSupported = computed(() => String(safe(() => haptic.supported.value, false)))
130
+ const hapticSupportedBool = computed(() => safe(() => !!haptic.supported.value, false))
131
+
132
+ // Mini App
133
+ const miniSupported = computed(() => String(safe(() => mini.supported.value, false)))
134
+ const miniSupportedBool = computed(() => safe(() => !!mini.supported.value, false))
135
+ const miniDark = computed(() => String(safe(() => mini.dark.value, false)))
136
+
137
+ // Init Data
138
+ const initUser = computed(() => safe(() => init.user.value?.username || init.user.value?.first_name || '—', '—'))
139
+ const initQueryId = computed(() => safe(() => init.queryId.value || '—', '—'))
140
+ const initStartParam = computed(() => safe(() => init.startParam.value || '—', '—'))
141
+
142
+ onMounted(async () => {
143
+ // Hide Main Button since we're using TgNav for navigation
144
+ main.mount()
145
+
146
+ setTimeout(() => {
147
+ try {
148
+ main.setParams({ is_visible: false })
149
+ } catch (e) {
150
+ console.warn('Failed to hide main button:', e)
151
+ }
152
+ }, 500)
153
+
154
+ // Try restore init data if not present yet (fallback for some environments)
155
+ try {
156
+ if (!init.state.value) init.restore()
157
+ } catch {
158
+ // Ignore restore errors - this is a fallback mechanism
159
+ }
160
+ })
161
+
162
+ // Fallbacks for Theme Params and Viewport displays
163
+ const themeBgHex = computed(() => {
164
+ const hex = toHex(theme.backgroundColor.value)
165
+ if (hex !== '—') return hex
166
+ if (import.meta.client) {
167
+ const v = getComputedStyle(document.documentElement).getPropertyValue('--tg-theme-bg-color')?.trim()
168
+ return v || '—'
169
+ }
170
+ return '—'
171
+ })
172
+
173
+ const themeTextHex = computed(() => {
174
+ const hex = toHex(theme.textColor.value)
175
+ if (hex !== '—') return hex
176
+ if (import.meta.client) {
177
+ const v = getComputedStyle(document.documentElement).getPropertyValue('--tg-theme-text-color')?.trim()
178
+ return v || '—'
179
+ }
180
+ return '—'
181
+ })
182
+
183
+ const viewportWidth = computed(() => String(safe(() => viewport.width.value, undefined) ?? (import.meta.client ? window.innerWidth : '—')))
184
+ const viewportHeight = computed(() => String(safe(() => viewport.height.value, undefined) ?? (import.meta.client ? window.innerHeight : '—')))
185
+ const viewportStableHeight = computed(() => String(safe(() => viewport.stableHeight.value, undefined) ?? (import.meta.client ? window.innerHeight : '—')))
186
+
187
+ function onRequestFullscreen() {
188
+ try {
189
+ viewport.requestFullscreen()
190
+ } catch {
191
+ // Ignore viewport fullscreen errors - fallback to browser API
192
+ }
193
+ if (import.meta.client && document.fullscreenEnabled && !document.fullscreenElement) {
194
+ document.documentElement.requestFullscreen?.().catch(() => {})
195
+ }
196
+ }
197
+
198
+ function onExitFullscreen() {
199
+ try {
200
+ viewport.exitFullscreen()
201
+ } catch {
202
+ // Ignore viewport fullscreen errors - fallback to browser API
203
+ }
204
+ if (import.meta.client && document.fullscreenElement) {
205
+ document.exitFullscreen?.().catch(() => {})
206
+ }
207
+ }
208
+ </script>
209
+
210
+ <style scoped>
211
+ </style>