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,50 @@
1
+ <template>
2
+ <section :class="sectionClass">
3
+ <header v-if="title" :class="inset ? 'px-4' : 'last:pb-0'">
4
+ <h2 class="px-3 text-sm uppercase mb-1.5" :style="{ color: 'var(--tg-theme-section-header-text-color)' }">{{ title }}</h2>
5
+ </header>
6
+ <div class="bg-bg" :class="bodyClass" :style="{ backgroundColor: 'var(--tg-theme-section-bg-color)' }">
7
+ <slot />
8
+ </div>
9
+ <footer v-if="$slots.append" class="pt-2 px-5 text-xs text-hint" :class="appendBorder ? 'border-t border-sectionSeparator' : ''">
10
+ <slot name="append" />
11
+ </footer>
12
+ </section>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ import { computed } from 'vue'
17
+
18
+ const props = withDefaults(defineProps<{
19
+ title?: string
20
+ /** Adds outer rounding/indents reminiscent of iOS */
21
+ inset?: boolean
22
+ /** Adds a thin top border above the append slot */
23
+ appendBorder?: boolean
24
+ /** Inner background tone */
25
+ tone?: 'default' | 'secondary'
26
+ /** Inner padding of the body (removed: default only) */
27
+ class?: string
28
+ }>(), {
29
+ title: undefined,
30
+ inset: false,
31
+ appendBorder: true,
32
+ tone: 'default',
33
+ class: '',
34
+ })
35
+
36
+ const sectionClass = computed(() => [props.inset ? 'px-3' : '', props.class].filter(Boolean).join(' '))
37
+
38
+ const roundedClass = computed(() => props.inset ? 'rounded-2xl overflow-hidden' : 'rounded')
39
+
40
+ const paddingClass = computed(() => '')
41
+
42
+ const toneClass = computed(() => props.tone === 'secondary' ? 'bg-secondaryBg' : 'bg-bg')
43
+
44
+ const borderClass = computed(() => '')
45
+
46
+ const bodyClass = computed(() => [roundedClass.value, borderClass.value, toneClass.value, paddingClass.value].filter(Boolean).join(' '))
47
+ </script>
48
+
49
+ <style scoped>
50
+ </style>
@@ -0,0 +1,342 @@
1
+ import { ref, computed, onMounted } from 'vue'
2
+ import type {
3
+ TelegramWebApp,
4
+ TelegramWebAppInitData
5
+ } from '~/types/telegram-webapp'
6
+
7
+ // Helper to get WebApp instance
8
+ function getWebApp(): TelegramWebApp | null {
9
+ if (typeof window === 'undefined') return null
10
+ return window.Telegram?.WebApp || null
11
+ }
12
+
13
+ // Shared WebApp instance
14
+ let globalWebApp: TelegramWebApp | null = null
15
+ let isInitialized = false
16
+
17
+ // Initialize WebApp once
18
+ function initWebApp() {
19
+ if (isInitialized) return
20
+ if (typeof window === 'undefined') return
21
+
22
+ globalWebApp = getWebApp()
23
+ if (globalWebApp) {
24
+ globalWebApp.ready()
25
+ isInitialized = true
26
+ }
27
+ }
28
+
29
+ // Main composable to access Telegram WebApp
30
+ export function useTelegramWebApp() {
31
+ const webApp = ref<TelegramWebApp | null>(null)
32
+ const isReady = ref(false)
33
+
34
+ onMounted(() => {
35
+ initWebApp()
36
+ webApp.value = globalWebApp
37
+ isReady.value = isInitialized
38
+ })
39
+
40
+ return {
41
+ webApp: computed(() => webApp.value),
42
+ isReady: computed(() => isReady.value),
43
+ isAvailable: computed(() => !!webApp.value)
44
+ }
45
+ }
46
+
47
+ // Back Button
48
+ export function useBackButton() {
49
+ const visible = ref(false)
50
+
51
+ onMounted(() => {
52
+ initWebApp()
53
+ if (globalWebApp?.BackButton) {
54
+ visible.value = globalWebApp.BackButton.isVisible
55
+ }
56
+ })
57
+
58
+ return {
59
+ supported: computed(() => !!globalWebApp?.BackButton),
60
+ mounted: computed(() => !!globalWebApp?.BackButton),
61
+ visible: computed(() => visible.value),
62
+ mount: () => true, // Always available with official SDK
63
+ unmount: () => true,
64
+ show: () => {
65
+ if (globalWebApp?.BackButton) {
66
+ globalWebApp.BackButton.show()
67
+ visible.value = true
68
+ }
69
+ },
70
+ hide: () => {
71
+ if (globalWebApp?.BackButton) {
72
+ globalWebApp.BackButton.hide()
73
+ visible.value = false
74
+ }
75
+ },
76
+ onClick: (fn: () => void) => {
77
+ if (globalWebApp?.BackButton) {
78
+ globalWebApp.BackButton.onClick(fn)
79
+ return fn
80
+ }
81
+ return () => {}
82
+ },
83
+ offClick: (fn: () => void) => {
84
+ if (globalWebApp?.BackButton) {
85
+ globalWebApp.BackButton.offClick(fn)
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ // Haptic Feedback
92
+ export function useHapticFeedback() {
93
+ onMounted(() => {
94
+ initWebApp()
95
+ })
96
+
97
+ return {
98
+ supported: computed(() => !!globalWebApp?.HapticFeedback),
99
+ impactOccurred: (style: 'light' | 'medium' | 'heavy' = 'medium') => {
100
+ if (globalWebApp?.HapticFeedback) {
101
+ globalWebApp.HapticFeedback.impactOccurred(style)
102
+ }
103
+ },
104
+ notificationOccurred: (type: 'error' | 'success' | 'warning') => {
105
+ if (globalWebApp?.HapticFeedback) {
106
+ globalWebApp.HapticFeedback.notificationOccurred(type)
107
+ }
108
+ },
109
+ selectionChanged: () => {
110
+ if (globalWebApp?.HapticFeedback) {
111
+ globalWebApp.HapticFeedback.selectionChanged()
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ // Init Data
118
+ export function useInitData() {
119
+ onMounted(() => {
120
+ initWebApp()
121
+ })
122
+
123
+ const state = computed<TelegramWebAppInitData | undefined>(() =>
124
+ globalWebApp?.initDataUnsafe || undefined
125
+ )
126
+
127
+ const raw = computed<string | undefined>(() =>
128
+ globalWebApp?.initData || undefined
129
+ )
130
+
131
+ return {
132
+ state,
133
+ raw,
134
+ user: computed(() => state.value?.user),
135
+ receiver: computed(() => state.value?.receiver),
136
+ chat: computed(() => state.value?.chat),
137
+ authDate: computed(() => state.value?.auth_date),
138
+ queryId: computed(() => state.value?.query_id),
139
+ startParam: computed(() => state.value?.start_param),
140
+ restore: () => {
141
+ // Official SDK handles this automatically
142
+ return true
143
+ }
144
+ }
145
+ }
146
+
147
+ // Main Button
148
+ export function useMainButton() {
149
+ onMounted(() => {
150
+ initWebApp()
151
+ })
152
+
153
+ return {
154
+ mounted: computed(() => !!globalWebApp?.MainButton),
155
+ enabled: computed(() => globalWebApp?.MainButton?.isActive || false),
156
+ loaderVisible: computed(() => globalWebApp?.MainButton?.isProgressVisible || false),
157
+ visible: computed(() => globalWebApp?.MainButton?.isVisible || false),
158
+ state: computed(() => ({
159
+ text: globalWebApp?.MainButton?.text || '',
160
+ color: globalWebApp?.MainButton?.color || '',
161
+ text_color: globalWebApp?.MainButton?.textColor || '',
162
+ is_active: globalWebApp?.MainButton?.isActive || false,
163
+ is_visible: globalWebApp?.MainButton?.isVisible || false
164
+ })),
165
+ text: computed(() => globalWebApp?.MainButton?.text || ''),
166
+ textColor: computed(() => globalWebApp?.MainButton?.textColor),
167
+ backgroundColor: computed(() => globalWebApp?.MainButton?.color),
168
+ mount: () => true, // Always available
169
+ unmount: () => true,
170
+ onClick: (fn: () => void) => {
171
+ if (globalWebApp?.MainButton) {
172
+ globalWebApp.MainButton.onClick(fn)
173
+ return fn
174
+ }
175
+ return () => {}
176
+ },
177
+ offClick: (fn: () => void) => {
178
+ if (globalWebApp?.MainButton) {
179
+ globalWebApp.MainButton.offClick(fn)
180
+ }
181
+ },
182
+ setParams: (updates: {
183
+ text?: string
184
+ color?: string
185
+ text_color?: string
186
+ is_active?: boolean
187
+ is_visible?: boolean
188
+ }) => {
189
+ if (globalWebApp?.MainButton) {
190
+ globalWebApp.MainButton.setParams(updates)
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ // Mini App / Theme management
197
+ export function useMiniApp() {
198
+ onMounted(() => {
199
+ initWebApp()
200
+ })
201
+
202
+ return {
203
+ supported: computed(() => !!globalWebApp),
204
+ mounted: computed(() => !!globalWebApp),
205
+ active: computed(() => true), // Always active when available
206
+ dark: computed(() => globalWebApp?.colorScheme === 'dark'),
207
+ state: computed(() => globalWebApp || null),
208
+ backgroundColor: computed(() => globalWebApp?.backgroundColor),
209
+ headerColor: computed(() => globalWebApp?.headerColor),
210
+ bottomBarColor: computed(() => globalWebApp?.bottomBarColor),
211
+ setBackgroundColor: (color: string) => {
212
+ if (globalWebApp) {
213
+ globalWebApp.setBackgroundColor(color)
214
+ }
215
+ },
216
+ setHeaderColor: (color: string) => {
217
+ if (globalWebApp) {
218
+ globalWebApp.setHeaderColor(color)
219
+ }
220
+ },
221
+ setBottomBarColor: (color: string) => {
222
+ if (globalWebApp) {
223
+ globalWebApp.setBottomBarColor(color)
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ // Theme Params
230
+ export function useThemeParams() {
231
+ onMounted(() => {
232
+ initWebApp()
233
+ })
234
+
235
+ const themeParams = computed(() => globalWebApp?.themeParams || {})
236
+
237
+ return {
238
+ mounted: computed(() => !!globalWebApp),
239
+ dark: computed(() => globalWebApp?.colorScheme === 'dark'),
240
+ state: computed(() => themeParams.value),
241
+ backgroundColor: computed(() => themeParams.value.bg_color),
242
+ textColor: computed(() => themeParams.value.text_color),
243
+ accentTextColor: computed(() => themeParams.value.accent_text_color),
244
+ hintColor: computed(() => themeParams.value.hint_color),
245
+ buttonColor: computed(() => themeParams.value.button_color),
246
+ buttonTextColor: computed(() => themeParams.value.button_text_color),
247
+ destructiveTextColor: computed(() => themeParams.value.destructive_text_color),
248
+ linkColor: computed(() => themeParams.value.link_color),
249
+ secondaryBackgroundColor: computed(() => themeParams.value.secondary_bg_color),
250
+ sectionBackgroundColor: computed(() => themeParams.value.section_bg_color),
251
+ headerBackgroundColor: computed(() => themeParams.value.header_bg_color),
252
+ subtitleTextColor: computed(() => themeParams.value.subtitle_text_color),
253
+ sectionHeaderTextColor: computed(() => themeParams.value.section_header_text_color),
254
+ sectionSeparatorColor: computed(() => themeParams.value.section_separator_color)
255
+ }
256
+ }
257
+
258
+ // Viewport
259
+ export function useViewport() {
260
+ onMounted(() => {
261
+ initWebApp()
262
+ })
263
+
264
+ return {
265
+ mounted: computed(() => !!globalWebApp),
266
+ width: computed(() => {
267
+ // Telegram WebApp doesn't expose viewport width directly, use window.innerWidth
268
+ return typeof window !== 'undefined' ? window.innerWidth : 0
269
+ }),
270
+ height: computed(() => globalWebApp?.viewportHeight || 0),
271
+ stableHeight: computed(() => globalWebApp?.viewportStableHeight || 0),
272
+ expanded: computed(() => globalWebApp?.isExpanded || false),
273
+ stable: computed(() => {
274
+ // Consider stable if viewport height equals stable height
275
+ const vh = globalWebApp?.viewportHeight || 0
276
+ const svh = globalWebApp?.viewportStableHeight || 0
277
+ return vh === svh
278
+ }),
279
+ insets: computed(() => ({
280
+ top: 0,
281
+ bottom: 0,
282
+ left: 0,
283
+ right: 0
284
+ })),
285
+ requestFullscreen: () => {
286
+ // Not available in Telegram WebApp
287
+ },
288
+ exitFullscreen: () => {
289
+ // Not available in Telegram WebApp
290
+ },
291
+ expand: () => {
292
+ if (globalWebApp) {
293
+ globalWebApp.expand()
294
+ }
295
+ }
296
+ }
297
+ }
298
+
299
+ // Links and sharing
300
+ export function openLink(url: string, options?: { try_instant_view?: boolean }) {
301
+ const webApp = getWebApp()
302
+ if (webApp) {
303
+ webApp.openLink(url, options)
304
+ } else {
305
+ // Fallback to regular window.open
306
+ window.open(url, '_blank')
307
+ }
308
+ }
309
+
310
+ export function openTelegramLink(url: string) {
311
+ const webApp = getWebApp()
312
+ if (webApp) {
313
+ webApp.openTelegramLink(url)
314
+ } else {
315
+ // Fallback to regular window.open
316
+ window.open(url, '_blank')
317
+ }
318
+ }
319
+
320
+ export function shareURL(url: string) {
321
+ const webApp = getWebApp()
322
+ if (webApp) {
323
+ // Use Web Share API if available, otherwise copy to clipboard
324
+ if (navigator.share) {
325
+ navigator.share({ url }).catch(() => {
326
+ // Fallback to clipboard if share fails
327
+ if (navigator.clipboard) {
328
+ navigator.clipboard.writeText(url)
329
+ }
330
+ })
331
+ } else if (navigator.clipboard) {
332
+ navigator.clipboard.writeText(url)
333
+ }
334
+ } else {
335
+ // Fallback to navigator.share or clipboard
336
+ if (navigator.share) {
337
+ navigator.share({ url })
338
+ } else if (navigator.clipboard) {
339
+ navigator.clipboard.writeText(url)
340
+ }
341
+ }
342
+ }
package/app/error.vue ADDED
@@ -0,0 +1,161 @@
1
+ <template>
2
+ <div class="min-h-screen tg-bg flex items-center justify-center p-4">
3
+ <div class="max-w-md w-full text-center space-y-6">
4
+ <div class="space-y-2">
5
+ <h1 class="text-2xl font-bold text-tg-text">
6
+ {{ error?.statusCode === 404 ? 'Page Not Found' : 'Something went wrong' }}
7
+ </h1>
8
+ <p class="text-tg-hint">
9
+ {{ error?.statusCode === 404
10
+ ? 'The page you\'re looking for doesn\'t exist.'
11
+ : 'We encountered an unexpected error. Please try again.'
12
+ }}
13
+ </p>
14
+ </div>
15
+
16
+ <div class="space-y-3">
17
+ <button
18
+ @click="handleError"
19
+ class="w-full bg-tg-button text-tg-button-text py-3 px-4 rounded-lg font-medium hover:opacity-90 transition-opacity"
20
+ >
21
+ {{ error?.statusCode === 404 ? 'Go Home' : 'Try Again' }}
22
+ </button>
23
+
24
+ <button
25
+ @click="clearAndReload"
26
+ class="w-full border border-tg-button text-tg-button py-3 px-4 rounded-lg font-medium hover:bg-tg-button hover:text-tg-button-text transition-colors"
27
+ >
28
+ Clear Cache & Reload
29
+ </button>
30
+ </div>
31
+
32
+ <details class="text-left text-sm text-tg-hint">
33
+ <summary class="cursor-pointer hover:text-tg-text">Technical Details</summary>
34
+ <div class="mt-2 p-3 bg-tg-secondary-bg rounded border text-xs font-mono">
35
+ <div><strong>Status:</strong> {{ error?.statusCode || 'Unknown' }}</div>
36
+ <div><strong>Message:</strong> {{ error?.statusMessage || 'No details available' }}</div>
37
+ <div><strong>URL:</strong> {{ error?.url || 'Unknown' }}</div>
38
+ <div v-if="telegramInfo"><strong>Telegram:</strong> {{ telegramInfo }}</div>
39
+ </div>
40
+ </details>
41
+ </div>
42
+ </div>
43
+ </template>
44
+
45
+ <script setup lang="ts">
46
+ import { computed } from 'vue'
47
+
48
+ // Define props for the error
49
+ const props = defineProps<{
50
+ error: {
51
+ statusCode?: number
52
+ statusMessage?: string
53
+ message?: string
54
+ url?: string
55
+ }
56
+ }>()
57
+
58
+ // Get Telegram context info for debugging
59
+ const telegramInfo = computed(() => {
60
+ if (typeof window === 'undefined') return null
61
+
62
+ const info: string[] = []
63
+
64
+ // Check if running in Telegram
65
+ const tg = (window as any)?.Telegram?.WebApp
66
+ if (tg) {
67
+ info.push('In Telegram WebApp')
68
+ info.push(`Platform: ${tg.platform || 'unknown'}`)
69
+ info.push(`Version: ${tg.version || 'unknown'}`)
70
+ }
71
+
72
+ // Check for hash parameters
73
+ const hash = window.location.hash
74
+ if (hash && hash.includes('tgWebApp')) {
75
+ info.push('Has TG hash params')
76
+ }
77
+
78
+ // Check for session storage flags
79
+ if (sessionStorage.getItem('__tg_hash_fixed')) {
80
+ info.push('Hash was fixed')
81
+ }
82
+
83
+ return info.length > 0 ? info.join(', ') : 'Not in Telegram'
84
+ })
85
+
86
+ function handleError() {
87
+ if (props.error?.statusCode === 404) {
88
+ // Navigate to home
89
+ navigateTo('/')
90
+ } else {
91
+ // Try to reload the current page
92
+ if (typeof window !== 'undefined') {
93
+ window.location.reload()
94
+ }
95
+ }
96
+ }
97
+
98
+ function clearAndReload() {
99
+ if (typeof window !== 'undefined') {
100
+ // Clear all storage
101
+ try {
102
+ sessionStorage.clear()
103
+ localStorage.clear()
104
+ } catch (e) {
105
+ console.warn('Failed to clear storage:', e)
106
+ }
107
+
108
+ // Clear hash fix flag specifically
109
+ try {
110
+ sessionStorage.removeItem('__tg_hash_fixed')
111
+ } catch (e) {
112
+ console.warn('Failed to clear hash fix flag:', e)
113
+ }
114
+
115
+ // Reload without hash if there are problematic parameters
116
+ const hash = window.location.hash
117
+ if (hash && hash.includes('tgWebApp')) {
118
+ // Try to reload without the problematic hash
119
+ window.location.href = window.location.pathname + window.location.search
120
+ } else {
121
+ window.location.reload()
122
+ }
123
+ }
124
+ }
125
+
126
+ // Set page title
127
+ useHead({
128
+ title: computed(() =>
129
+ props.error?.statusCode === 404
130
+ ? 'Page Not Found'
131
+ : 'Error - Telegram Mini App'
132
+ )
133
+ })
134
+ </script>
135
+
136
+ <style scoped>
137
+ /* Use Telegram theme colors */
138
+ .tg-bg {
139
+ background-color: var(--tg-theme-bg-color, #ffffff);
140
+ }
141
+
142
+ .tg-text {
143
+ color: var(--tg-theme-text-color, #000000);
144
+ }
145
+
146
+ .tg-hint {
147
+ color: var(--tg-theme-hint-color, #999999);
148
+ }
149
+
150
+ .tg-button {
151
+ background-color: var(--tg-theme-button-color, #2481cc);
152
+ }
153
+
154
+ .tg-button-text {
155
+ color: var(--tg-theme-button-text-color, #ffffff);
156
+ }
157
+
158
+ .tg-secondary-bg {
159
+ background-color: var(--tg-theme-secondary-bg-color, #f1f1f1);
160
+ }
161
+ </style>