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,402 @@
1
+ <template>
2
+ <TgContent>
3
+ <Hero
4
+ title="Utilities Demo"
5
+ />
6
+
7
+ <TgSection title="Link Utilities" inset>
8
+ <div class="p-4 space-y-3">
9
+ <div class="space-y-2">
10
+ <h3 class="text-sm font-medium text-subtitle">External Links</h3>
11
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
12
+ <TgButton
13
+ title="Open GitHub"
14
+ status="outline"
15
+ haptic
16
+ @click="() => openLink('https://github.com/patricktobias86/nuxt-telegram-mini-app')"
17
+ />
18
+ <TgButton
19
+ title="Open Nuxt Docs"
20
+ status="outline"
21
+ haptic
22
+ @click="() => openLink('https://nuxt.com')"
23
+ />
24
+ </div>
25
+ </div>
26
+
27
+ <div class="space-y-2">
28
+ <h3 class="text-sm font-medium text-subtitle">Telegram Links</h3>
29
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
30
+ <TgButton
31
+ title="Telegram Channel"
32
+ status="outline"
33
+ haptic
34
+ @click="() => openTelegramLink('https://t.me/telegram')"
35
+ />
36
+ <TgButton
37
+ title="Bot Platform"
38
+ status="outline"
39
+ haptic
40
+ @click="() => openTelegramLink('https://t.me/botfather')"
41
+ />
42
+ </div>
43
+ </div>
44
+
45
+ <div class="space-y-2">
46
+ <h3 class="text-sm font-medium text-subtitle">Sharing</h3>
47
+ <div class="grid grid-cols-1 gap-2">
48
+ <TgButton
49
+ title="Share This App"
50
+ status="primary"
51
+ haptic="notification-success"
52
+ @click="() => shareURL(shareUrl)"
53
+ />
54
+ </div>
55
+ </div>
56
+ </div>
57
+ </TgSection>
58
+
59
+ <TgSection title="Data Validation" inset>
60
+ <div class="p-4 space-y-4">
61
+ <div class="space-y-2">
62
+ <h3 class="text-sm font-medium text-subtitle">Init Data Status</h3>
63
+ <div class="space-y-2">
64
+ <div class="flex items-center justify-between p-3 bg-secondary-bg rounded-lg">
65
+ <span class="text-sm">Data Available</span>
66
+ <div class="flex items-center gap-2">
67
+ <div :class="[
68
+ 'w-2 h-2 rounded-full',
69
+ initDataAvailable ? 'bg-green-500' : 'bg-red-500'
70
+ ]" />
71
+ <span class="text-xs text-hint">{{ initDataAvailable ? 'Yes' : 'No' }}</span>
72
+ </div>
73
+ </div>
74
+
75
+ <div class="flex items-center justify-between p-3 bg-secondary-bg rounded-lg">
76
+ <span class="text-sm">User Identified</span>
77
+ <div class="flex items-center gap-2">
78
+ <div :class="[
79
+ 'w-2 h-2 rounded-full',
80
+ userIdentified ? 'bg-green-500' : 'bg-red-500'
81
+ ]" />
82
+ <span class="text-xs text-hint">{{ userIdentified ? 'Yes' : 'No' }}</span>
83
+ </div>
84
+ </div>
85
+
86
+ <div class="flex items-center justify-between p-3 bg-secondary-bg rounded-lg">
87
+ <span class="text-sm">Raw Data Length</span>
88
+ <span class="text-xs text-hint">{{ rawDataLength }} chars</span>
89
+ </div>
90
+ </div>
91
+ </div>
92
+
93
+ <div class="space-y-2">
94
+ <h3 class="text-sm font-medium text-subtitle">User Information</h3>
95
+ <div class="space-y-2 text-sm">
96
+ <div v-if="userData.username" class="flex justify-between p-2 bg-secondary-bg rounded">
97
+ <span class="text-hint">Username:</span>
98
+ <span>@{{ userData.username }}</span>
99
+ </div>
100
+ <div v-if="userData.firstName" class="flex justify-between p-2 bg-secondary-bg rounded">
101
+ <span class="text-hint">First Name:</span>
102
+ <span>{{ userData.firstName }}</span>
103
+ </div>
104
+ <div v-if="userData.lastName" class="flex justify-between p-2 bg-secondary-bg rounded">
105
+ <span class="text-hint">Last Name:</span>
106
+ <span>{{ userData.lastName }}</span>
107
+ </div>
108
+ <div v-if="userData.languageCode" class="flex justify-between p-2 bg-secondary-bg rounded">
109
+ <span class="text-hint">Language:</span>
110
+ <span>{{ userData.languageCode }}</span>
111
+ </div>
112
+ <div v-if="!userData.username && !userData.firstName" class="p-2 bg-secondary-bg rounded text-center text-hint">
113
+ No user data available (not in Telegram context)
114
+ </div>
115
+ </div>
116
+ </div>
117
+ </div>
118
+ </TgSection>
119
+
120
+ <TgSection title="Color Utilities" inset>
121
+ <div class="p-4 space-y-4">
122
+ <div class="space-y-2">
123
+ <h3 class="text-sm font-medium text-subtitle">Color Converter</h3>
124
+ <div class="space-y-3">
125
+ <input
126
+ v-model="testColor"
127
+ type="text"
128
+ placeholder="Enter a color (hex, rgb, hsl, or name)"
129
+ class="w-full px-3 py-2 bg-secondary-bg border border-section-separator rounded-lg text-text placeholder-hint"
130
+ />
131
+ <div class="flex items-center justify-between p-3 bg-secondary-bg rounded-lg">
132
+ <div class="flex items-center gap-2">
133
+ <div
134
+ class="w-6 h-6 rounded border border-section-separator"
135
+ :style="{ backgroundColor: convertedColor }"
136
+ />
137
+ <span class="text-sm">Converted:</span>
138
+ </div>
139
+ <span class="text-xs font-mono">{{ convertedColor }}</span>
140
+ </div>
141
+ </div>
142
+ </div>
143
+
144
+ <div class="space-y-2">
145
+ <h3 class="text-sm font-medium text-subtitle">Theme Color Analysis</h3>
146
+ <div class="grid grid-cols-1 gap-2 text-xs">
147
+ <div v-for="color in colorAnalysis" :key="color.name"
148
+ class="flex items-center justify-between p-2 bg-secondary-bg rounded">
149
+ <div class="flex items-center gap-2">
150
+ <div
151
+ class="w-4 h-4 rounded border border-section-separator"
152
+ :style="{ backgroundColor: color.hex }"
153
+ />
154
+ <span>{{ color.name }}</span>
155
+ </div>
156
+ <span class="font-mono">{{ color.hex }}</span>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </TgSection>
162
+
163
+ <TgSection title="Performance Monitoring" inset>
164
+ <div class="p-4 space-y-4">
165
+ <div class="space-y-2">
166
+ <h3 class="text-sm font-medium text-subtitle">App Performance</h3>
167
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
168
+ <div class="p-3 bg-secondary-bg rounded-lg">
169
+ <div class="text-xs text-hint mb-1">Viewport Size</div>
170
+ <div class="text-sm font-medium">{{ viewportInfo }}</div>
171
+ </div>
172
+ <div class="p-3 bg-secondary-bg rounded-lg">
173
+ <div class="text-xs text-hint mb-1">Device Pixel Ratio</div>
174
+ <div class="text-sm font-medium">{{ devicePixelRatio }}</div>
175
+ </div>
176
+ <div class="p-3 bg-secondary-bg rounded-lg">
177
+ <div class="text-xs text-hint mb-1">Connection</div>
178
+ <div class="text-sm font-medium">{{ connectionType }}</div>
179
+ </div>
180
+ <div class="p-3 bg-secondary-bg rounded-lg">
181
+ <div class="text-xs text-hint mb-1">Memory Usage</div>
182
+ <div class="text-sm font-medium">{{ memoryInfo }}</div>
183
+ </div>
184
+ </div>
185
+ </div>
186
+
187
+ <div class="space-y-2">
188
+ <h3 class="text-sm font-medium text-subtitle">Render Performance</h3>
189
+ <div class="space-y-2">
190
+ <div class="flex justify-between items-center">
191
+ <span class="text-sm">Render Count</span>
192
+ <span class="text-xs text-hint">{{ renderCount }}</span>
193
+ </div>
194
+ <TgButton
195
+ title="Force Re-render"
196
+ status="outline"
197
+ haptic="impact-light"
198
+ @click="forceRerender"
199
+ />
200
+ </div>
201
+ </div>
202
+ </div>
203
+ </TgSection>
204
+
205
+ <TgSection title="Local Storage Demo" inset>
206
+ <div class="p-4 space-y-4">
207
+ <div class="space-y-2">
208
+ <h3 class="text-sm font-medium text-subtitle">Storage Operations</h3>
209
+ <div class="space-y-3">
210
+ <div class="flex gap-2">
211
+ <input
212
+ v-model="storageKey"
213
+ type="text"
214
+ placeholder="Storage key"
215
+ class="flex-1 px-3 py-2 bg-secondary-bg border border-section-separator rounded-lg text-text placeholder-hint"
216
+ />
217
+ <input
218
+ v-model="storageValue"
219
+ type="text"
220
+ placeholder="Storage value"
221
+ class="flex-1 px-3 py-2 bg-secondary-bg border border-section-separator rounded-lg text-text placeholder-hint"
222
+ />
223
+ </div>
224
+ <div class="grid grid-cols-2 gap-2">
225
+ <TgButton
226
+ title="Save"
227
+ status="primary"
228
+ haptic
229
+ :disabled="!storageKey || !storageValue"
230
+ @click="saveToStorage"
231
+ />
232
+ <TgButton
233
+ title="Load"
234
+ status="outline"
235
+ haptic
236
+ :disabled="!storageKey"
237
+ @click="loadFromStorage"
238
+ />
239
+ </div>
240
+ </div>
241
+ </div>
242
+
243
+ <div class="space-y-2">
244
+ <h3 class="text-sm font-medium text-subtitle">Stored Data</h3>
245
+ <div class="p-3 bg-secondary-bg rounded-lg">
246
+ <pre class="text-xs text-hint whitespace-pre-wrap">{{ storedDataDisplay }}</pre>
247
+ </div>
248
+ <TgButton
249
+ title="Clear All Storage"
250
+ status="destructive"
251
+ haptic="notification-warning"
252
+ @click="clearStorage"
253
+ />
254
+ </div>
255
+ </div>
256
+ </TgSection>
257
+
258
+ <div class="h-2"></div>
259
+ </TgContent>
260
+
261
+ </template>
262
+
263
+ <script setup lang="ts">
264
+ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
265
+ import { useRequestURL, navigateTo } from '#app'
266
+ import {
267
+ useInitData,
268
+ useThemeParams,
269
+ useViewport,
270
+ useMainButton,
271
+ openLink,
272
+ openTelegramLink,
273
+ shareURL
274
+ } from '~/composables/telegram'
275
+ import { toHex } from '~/utils/color'
276
+
277
+ const init = useInitData()
278
+ const theme = useThemeParams()
279
+ const viewport = useViewport()
280
+ const main = useMainButton()
281
+
282
+ // Sharing
283
+ const reqURL = useRequestURL()
284
+ const shareUrl = computed(() => import.meta.client ? window.location.href : reqURL.href)
285
+
286
+ // Data validation
287
+ const initDataAvailable = computed(() => !!init.state.value)
288
+ const userIdentified = computed(() => !!init.user.value?.id)
289
+ const rawDataLength = computed(() => init.raw.value?.length || 0)
290
+
291
+ const userData = computed(() => ({
292
+ username: init.user.value?.username || '',
293
+ firstName: init.user.value?.first_name || '',
294
+ lastName: init.user.value?.last_name || '',
295
+ languageCode: init.user.value?.language_code || ''
296
+ }))
297
+
298
+ // Color utilities
299
+ const testColor = ref('#0088cc')
300
+ const convertedColor = computed(() => toHex(testColor.value) || testColor.value)
301
+
302
+ const colorAnalysis = computed(() => [
303
+ { name: 'Background', hex: toHex(theme.backgroundColor.value) || '—' },
304
+ { name: 'Text', hex: toHex(theme.textColor.value) || '—' },
305
+ { name: 'Button', hex: toHex(theme.buttonColor.value) || '—' },
306
+ { name: 'Link', hex: toHex(theme.linkColor.value) || '—' },
307
+ { name: 'Hint', hex: toHex(theme.hintColor.value) || '—' },
308
+ { name: 'Secondary BG', hex: toHex(theme.secondaryBackgroundColor.value) || '—' }
309
+ ])
310
+
311
+ // Performance monitoring
312
+ const renderCount = ref(0)
313
+ const viewportInfo = computed(() => `${viewport.width.value}×${viewport.height.value}`)
314
+ const devicePixelRatio = computed(() => import.meta.client ? window.devicePixelRatio : 1)
315
+ const connectionType = computed(() => {
316
+ if (!import.meta.client) return 'Unknown'
317
+ // @ts-ignore
318
+ const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection
319
+ return connection?.effectiveType || 'Unknown'
320
+ })
321
+ const memoryInfo = computed(() => {
322
+ if (!import.meta.client) return 'Unknown'
323
+ // @ts-ignore
324
+ const memory = performance.memory
325
+ if (memory) {
326
+ return `${Math.round(memory.usedJSHeapSize / 1024 / 1024)}MB`
327
+ }
328
+ return 'Unknown'
329
+ })
330
+
331
+ function forceRerender() {
332
+ renderCount.value++
333
+ }
334
+
335
+ // Storage demo
336
+ const storageKey = ref('demo-key')
337
+ const storageValue = ref('Hello World')
338
+ const storedData = ref<Record<string, string>>({})
339
+
340
+ const storedDataDisplay = computed(() => {
341
+ const data = Object.keys(storedData.value).length ? storedData.value : { 'No data': 'stored yet' }
342
+ return JSON.stringify(data, null, 2)
343
+ })
344
+
345
+ function saveToStorage() {
346
+ if (!import.meta.client || !storageKey.value || !storageValue.value) return
347
+ try {
348
+ localStorage.setItem(storageKey.value, storageValue.value)
349
+ loadAllStoredData()
350
+ } catch (error) {
351
+ console.error('Storage error:', error)
352
+ }
353
+ }
354
+
355
+ function loadFromStorage() {
356
+ if (!import.meta.client || !storageKey.value) return
357
+ try {
358
+ const value = localStorage.getItem(storageKey.value)
359
+ if (value !== null) {
360
+ storageValue.value = value
361
+ }
362
+ } catch (error) {
363
+ console.error('Storage error:', error)
364
+ }
365
+ }
366
+
367
+ function loadAllStoredData() {
368
+ if (!import.meta.client) return
369
+ const data: Record<string, string> = {}
370
+ for (let i = 0; i < localStorage.length; i++) {
371
+ const key = localStorage.key(i)
372
+ if (key) {
373
+ data[key] = localStorage.getItem(key) || ''
374
+ }
375
+ }
376
+ storedData.value = data
377
+ }
378
+
379
+ function clearStorage() {
380
+ if (!import.meta.client) return
381
+ localStorage.clear()
382
+ storedData.value = {}
383
+ storageKey.value = 'demo-key'
384
+ storageValue.value = 'Hello World'
385
+ }
386
+
387
+ // Hide Main Button since we're using TgNav for navigation
388
+ onMounted(() => {
389
+ renderCount.value++
390
+ loadAllStoredData()
391
+
392
+ main.mount()
393
+
394
+ setTimeout(() => {
395
+ try {
396
+ main.setParams({ is_visible: false })
397
+ } catch (e) {
398
+ console.warn('Failed to hide main button:', e)
399
+ }
400
+ }, 500)
401
+ })
402
+ </script>
@@ -0,0 +1,160 @@
1
+ // TypeScript definitions for Telegram Web App
2
+ // Based on https://core.telegram.org/bots/webapps
3
+
4
+ export interface TelegramWebAppUser {
5
+ id: number
6
+ is_bot?: boolean
7
+ first_name: string
8
+ last_name?: string
9
+ username?: string
10
+ language_code?: string
11
+ is_premium?: boolean
12
+ added_to_attachment_menu?: boolean
13
+ allows_write_to_pm?: boolean
14
+ photo_url?: string
15
+ }
16
+
17
+ export interface TelegramWebAppChat {
18
+ id: number
19
+ type: 'group' | 'supergroup' | 'channel'
20
+ title: string
21
+ username?: string
22
+ photo_url?: string
23
+ }
24
+
25
+ export interface TelegramWebAppInitData {
26
+ query_id?: string
27
+ user?: TelegramWebAppUser
28
+ receiver?: TelegramWebAppUser
29
+ chat?: TelegramWebAppChat
30
+ chat_type?: 'sender' | 'private' | 'group' | 'supergroup' | 'channel'
31
+ chat_instance?: string
32
+ start_param?: string
33
+ can_send_after?: number
34
+ auth_date: number
35
+ hash: string
36
+ }
37
+
38
+ export interface TelegramWebAppThemeParams {
39
+ bg_color?: string
40
+ text_color?: string
41
+ hint_color?: string
42
+ link_color?: string
43
+ button_color?: string
44
+ button_text_color?: string
45
+ secondary_bg_color?: string
46
+ header_bg_color?: string
47
+ accent_text_color?: string
48
+ section_bg_color?: string
49
+ section_header_text_color?: string
50
+ subtitle_text_color?: string
51
+ destructive_text_color?: string
52
+ section_separator_color?: string
53
+ bottom_bar_bg_color?: string
54
+ }
55
+
56
+ export interface TelegramWebAppBackButton {
57
+ isVisible: boolean
58
+ onClick(callback: () => void): void
59
+ offClick(callback: () => void): void
60
+ show(): void
61
+ hide(): void
62
+ }
63
+
64
+ export interface TelegramWebAppMainButton {
65
+ text: string
66
+ color: string
67
+ textColor: string
68
+ isVisible: boolean
69
+ isActive: boolean
70
+ readonly isProgressVisible: boolean
71
+ setText(text: string): void
72
+ onClick(callback: () => void): void
73
+ offClick(callback: () => void): void
74
+ show(): void
75
+ hide(): void
76
+ enable(): void
77
+ disable(): void
78
+ showProgress(leaveActive?: boolean): void
79
+ hideProgress(): void
80
+ setParams(params: {
81
+ text?: string
82
+ color?: string
83
+ text_color?: string
84
+ is_active?: boolean
85
+ is_visible?: boolean
86
+ }): void
87
+ }
88
+
89
+ export interface TelegramWebAppHapticFeedback {
90
+ impactOccurred(style: 'light' | 'medium' | 'heavy'): void
91
+ notificationOccurred(type: 'error' | 'success' | 'warning'): void
92
+ selectionChanged(): void
93
+ }
94
+
95
+ export interface TelegramWebApp {
96
+ initData: string
97
+ initDataUnsafe: TelegramWebAppInitData
98
+ version: string
99
+ platform: string
100
+ colorScheme: 'light' | 'dark'
101
+ themeParams: TelegramWebAppThemeParams
102
+ isExpanded: boolean
103
+ viewportHeight: number
104
+ viewportStableHeight: number
105
+ headerColor: string
106
+ backgroundColor: string
107
+ bottomBarColor: string
108
+ isClosingConfirmationEnabled: boolean
109
+ isVerticalSwipesEnabled: boolean
110
+ BackButton: TelegramWebAppBackButton
111
+ MainButton: TelegramWebAppMainButton
112
+ HapticFeedback: TelegramWebAppHapticFeedback
113
+
114
+ isVersionAtLeast(version: string): boolean
115
+ setHeaderColor(color: string): void
116
+ setBackgroundColor(color: string): void
117
+ setBottomBarColor(color: string): void
118
+ enableClosingConfirmation(): void
119
+ disableClosingConfirmation(): void
120
+ enableVerticalSwipes(): void
121
+ disableVerticalSwipes(): void
122
+ onEvent(eventType: string, eventHandler: () => void): void
123
+ offEvent(eventType: string, eventHandler: () => void): void
124
+ sendData(data: string): void
125
+ switchInlineQuery(query: string, choose_chat_types?: string[]): void
126
+ openLink(url: string, options?: { try_instant_view?: boolean }): void
127
+ openTelegramLink(url: string): void
128
+ openInvoice(url: string, callback?: (status: string) => void): void
129
+ showPopup(params: {
130
+ title?: string
131
+ message: string
132
+ buttons?: Array<{
133
+ id?: string
134
+ type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive'
135
+ text: string
136
+ }>
137
+ }, callback?: (buttonId: string) => void): void
138
+ showAlert(message: string, callback?: () => void): void
139
+ showConfirm(message: string, callback?: (confirmed: boolean) => void): void
140
+ showScanQrPopup(params: {
141
+ text?: string
142
+ }, callback?: (text: string) => boolean): void
143
+ closeScanQrPopup(): void
144
+ readTextFromClipboard(callback?: (text: string) => void): void
145
+ requestWriteAccess(callback?: (granted: boolean) => void): void
146
+ requestContact(callback?: (granted: boolean) => void): void
147
+ ready(): void
148
+ expand(): void
149
+ close(): void
150
+ }
151
+
152
+ declare global {
153
+ interface Window {
154
+ Telegram?: {
155
+ WebApp: TelegramWebApp
156
+ }
157
+ }
158
+ }
159
+
160
+ export {}
@@ -0,0 +1,37 @@
1
+ export type RGB = { r?: number; g?: number; b?: number }
2
+
3
+ export function toHex(value?: RGB | `#${string}` | string | null): string {
4
+ if (!value) return '—'
5
+
6
+ if (typeof value === 'string') {
7
+ const v = value.trim()
8
+ return v.startsWith('#') ? v : '—'
9
+ }
10
+
11
+ const { r, g, b } = value
12
+ if (typeof r !== 'number' || typeof g !== 'number' || typeof b !== 'number') return '—'
13
+ const to = (n: number) => n.toString(16).padStart(2, '0')
14
+ return `#${to(r)}${to(g)}${to(b)}`
15
+ }
16
+
17
+ export function toRgb(value?: RGB | `#${string}` | string | null): { r: number; g: number; b: number } | null {
18
+ if (!value) return null
19
+ if (typeof value === 'string') {
20
+ const v = value.trim()
21
+ if (!v.startsWith('#')) return null
22
+ const hex = v.replace('#', '')
23
+ const clean = hex.length === 3
24
+ ? hex.split('').map(c => c + c).join('')
25
+ : hex
26
+ if (clean.length !== 6) return null
27
+ const r = parseInt(clean.slice(0, 2), 16)
28
+ const g = parseInt(clean.slice(2, 4), 16)
29
+ const b = parseInt(clean.slice(4, 6), 16)
30
+ if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null
31
+ return { r, g, b }
32
+ }
33
+ const { r, g, b } = value
34
+ if (typeof r !== 'number' || typeof g !== 'number' || typeof b !== 'number') return null
35
+ return { r, g, b }
36
+ }
37
+
@@ -0,0 +1,6 @@
1
+ // @ts-check
2
+ import withNuxt from './.nuxt/eslint.config.mjs'
3
+
4
+ export default withNuxt(
5
+ // Your custom configs here
6
+ )
package/nuxt.config.ts ADDED
@@ -0,0 +1,55 @@
1
+ // https://nuxt.com/docs/api/configuration/nuxt-config
2
+ export default defineNuxtConfig({
3
+ extends: [],
4
+ ssr: false,
5
+ compatibilityDate: '2025-09-06',
6
+ srcDir: 'app',
7
+ devtools: {
8
+ enabled: true,
9
+
10
+ timeline: {
11
+ enabled: true,
12
+ },
13
+ },
14
+ runtimeConfig: {
15
+ public: {
16
+ // ENV is read at build-time; exposed to client as appEnv
17
+ appEnv: process.env.ENV || ''
18
+ }
19
+ },
20
+ app: {
21
+ head: {
22
+ meta: [
23
+ {
24
+ name: 'viewport',
25
+ content: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover',
26
+ },
27
+ ],
28
+ script: [
29
+ {
30
+ src: 'https://telegram.org/js/telegram-web-app.js?58'
31
+ }
32
+ ]
33
+ },
34
+ },
35
+ pages: true,
36
+ router: {
37
+ options: {
38
+ // Use history mode so Telegram's #tgWebAppData is preserved as a URL hash
39
+ hashMode: false
40
+ }
41
+ },
42
+ nitro: {
43
+ prerender: {
44
+ crawlLinks: true,
45
+ routes: ['/'],
46
+ failOnError: false,
47
+ },
48
+ },
49
+ // Additional SPA configuration
50
+ experimental: {
51
+ payloadExtraction: false
52
+ },
53
+ css: ['~/assets/css/tailwind.css', '~/assets/css/main.css'],
54
+ modules: ['@nuxt/icon', '@nuxtjs/tailwindcss', '@nuxt/eslint', '@nuxt/fonts'],
55
+ })