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,426 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { createRouter, createWebHistory } from 'vue-router'
3
+ import { mount } from '@vue/test-utils'
4
+ import { defineComponent, h, ref, computed } from 'vue'
5
+
6
+ // Mock global process
7
+ Object.defineProperty(globalThis, 'process', {
8
+ value: { client: true }
9
+ })
10
+
11
+ // Mock Nuxt composables
12
+ vi.mock('nuxt/app', () => ({
13
+ useRequestURL: () => ({
14
+ href: 'https://test.example.com'
15
+ })
16
+ }))
17
+
18
+ vi.mock('#app', () => ({
19
+ useRequestURL: () => ({
20
+ href: 'https://test.example.com'
21
+ })
22
+ }))
23
+
24
+ vi.mock('vue-router', () => {
25
+ const actual = vi.importActual('vue-router')
26
+ return {
27
+ ...actual,
28
+ useRouter: () => ({
29
+ push: vi.fn(),
30
+ back: vi.fn(),
31
+ replace: vi.fn(),
32
+ currentRoute: {
33
+ value: { path: '/' }
34
+ }
35
+ })
36
+ }
37
+ })
38
+
39
+ // Mock Telegram composables
40
+ vi.mock('~/composables/telegram', () => ({
41
+ useHapticFeedback: () => ({
42
+ supported: { value: true },
43
+ impactOccurred: vi.fn(),
44
+ notificationOccurred: vi.fn(),
45
+ selectionChanged: vi.fn()
46
+ }),
47
+ useMainButton: () => ({
48
+ mounted: { value: true },
49
+ visible: { value: false },
50
+ text: { value: 'Test Button' },
51
+ mount: vi.fn(),
52
+ setParams: vi.fn(),
53
+ onClick: vi.fn(() => vi.fn()),
54
+ offClick: vi.fn()
55
+ }),
56
+ useBackButton: () => ({
57
+ mounted: { value: true },
58
+ visible: { value: false },
59
+ mount: vi.fn(),
60
+ show: vi.fn(),
61
+ hide: vi.fn(),
62
+ onClick: vi.fn(() => vi.fn()),
63
+ offClick: vi.fn()
64
+ }),
65
+ useInitData: () => ({
66
+ user: { value: { username: 'testuser', first_name: 'Test', last_name: 'User', id: 123 } },
67
+ queryId: { value: 'test123' },
68
+ startParam: { value: 'start123' },
69
+ state: { value: { user: { username: 'testuser' } } },
70
+ raw: { value: 'test_data=123&user=testuser' },
71
+ restore: vi.fn()
72
+ }),
73
+ useThemeParams: () => ({
74
+ backgroundColor: { value: '#ffffff' },
75
+ textColor: { value: '#000000' },
76
+ buttonColor: { value: '#0088cc' },
77
+ buttonTextColor: { value: '#ffffff' },
78
+ linkColor: { value: '#0088cc' },
79
+ secondaryBackgroundColor: { value: '#f0f0f0' },
80
+ hintColor: { value: '#999999' }
81
+ }),
82
+ useMiniApp: () => ({
83
+ supported: { value: true },
84
+ dark: { value: false },
85
+ setBackgroundColor: vi.fn(),
86
+ setHeaderColor: vi.fn()
87
+ }),
88
+ useViewport: () => ({
89
+ width: { value: 390 },
90
+ height: { value: 844 },
91
+ stableHeight: { value: 844 },
92
+ expanded: { value: true },
93
+ expand: vi.fn(),
94
+ requestFullscreen: vi.fn(),
95
+ exitFullscreen: vi.fn()
96
+ }),
97
+ useTelegramWebApp: () => ({
98
+ webApp: { value: {} },
99
+ isReady: { value: true },
100
+ isAvailable: { value: true }
101
+ }),
102
+ openLink: vi.fn(),
103
+ openTelegramLink: vi.fn(),
104
+ shareURL: vi.fn()
105
+ }))
106
+
107
+ vi.mock('~/utils/color', () => ({
108
+ toHex: (color: string) => {
109
+ if (!color) return '—'
110
+ if (color.startsWith('#')) return color
111
+ if (color === 'bg_color') return '#ffffff'
112
+ return '#000000'
113
+ }
114
+ }))
115
+
116
+ // Create mock components
117
+ const MockTgContent = defineComponent({
118
+ name: 'TgContent',
119
+ setup(_, { slots }) {
120
+ return () => h('main', { class: 'tg-content' }, slots.default?.())
121
+ }
122
+ })
123
+
124
+ const MockHero = defineComponent({
125
+ name: 'Hero',
126
+ props: ['title', 'subtitle', 'imageSrc'],
127
+ setup(props) {
128
+ return () => h('header', { class: 'hero' }, [
129
+ h('h1', props.title),
130
+ props.subtitle && h('p', props.subtitle)
131
+ ])
132
+ }
133
+ })
134
+
135
+ const MockTgSection = defineComponent({
136
+ name: 'TgSection',
137
+ props: ['title', 'inset'],
138
+ setup(props, { slots }) {
139
+ return () => h('section', { class: 'tg-section' }, [
140
+ props.title && h('h2', props.title),
141
+ slots.default?.()
142
+ ])
143
+ }
144
+ })
145
+
146
+ const MockTgCell = defineComponent({
147
+ name: 'TgCell',
148
+ props: ['title', 'subtitle', 'description', 'icon', 'to', 'border'],
149
+ emits: ['click'],
150
+ setup(props, { emit }) {
151
+ return () => h('div', {
152
+ class: 'tg-cell',
153
+ onClick: () => emit('click')
154
+ }, [
155
+ h('span', props.title),
156
+ props.subtitle && h('small', props.subtitle),
157
+ props.description && h('p', props.description)
158
+ ])
159
+ }
160
+ })
161
+
162
+ const MockTgButton = defineComponent({
163
+ name: 'TgButton',
164
+ props: ['title', 'status', 'haptic', 'disabled', 'loading', 'shareUrl'],
165
+ emits: ['click'],
166
+ setup(props, { emit }) {
167
+ return () => h('button', {
168
+ onClick: () => emit('click'),
169
+ disabled: props.disabled || props.loading,
170
+ class: 'tg-button'
171
+ }, props.title)
172
+ }
173
+ })
174
+
175
+ const MockTgNav = defineComponent({
176
+ name: 'TgNav',
177
+ props: ['modelValue', 'items'],
178
+ emits: ['select', 'update:modelValue'],
179
+ setup(props, { emit }) {
180
+ return () => h('nav', { class: 'tg-nav' },
181
+ props.items?.map((item: any) =>
182
+ h('button', {
183
+ onClick: () => emit('select', item),
184
+ class: item.key === props.modelValue ? 'active' : ''
185
+ }, item.label)
186
+ )
187
+ )
188
+ }
189
+ })
190
+
191
+ const MockClientOnly = defineComponent({
192
+ name: 'ClientOnly',
193
+ setup(_, { slots }) {
194
+ return () => slots.default?.()
195
+ }
196
+ })
197
+
198
+ // Mock router
199
+ const router = createRouter({
200
+ history: createWebHistory(),
201
+ routes: [
202
+ { path: '/', component: { template: '<div>Home</div>' } },
203
+ { path: '/components', component: { template: '<div>Components</div>' } },
204
+ { path: '/utilities', component: { template: '<div>Utils</div>' } },
205
+ { path: '/functions', component: { template: '<div>Functions</div>' } }
206
+ ]
207
+ })
208
+
209
+ describe('Page Components', () => {
210
+ beforeEach(() => {
211
+ vi.clearAllMocks()
212
+ })
213
+
214
+ describe('Navigation Integration', () => {
215
+ it('should render navigation with correct items', () => {
216
+ const navItems = [
217
+ { key: 'home', label: 'Home', icon: 'i-heroicons-home-20-solid', to: '/' },
218
+ { key: 'components', label: 'Components', icon: 'i-heroicons-squares-2x2-20-solid', to: '/components' },
219
+ { key: 'utilities', label: 'Utils', icon: 'i-heroicons-wrench-screwdriver-20-solid', to: '/utilities' },
220
+ { key: 'functions', label: 'Functions', icon: 'i-heroicons-document-text-20-solid', to: '/functions' }
221
+ ]
222
+
223
+ const wrapper = mount(MockTgNav, {
224
+ props: {
225
+ modelValue: 'home',
226
+ items: navItems
227
+ },
228
+ global: {
229
+ plugins: [router]
230
+ }
231
+ })
232
+
233
+ expect(wrapper.findAll('button')).toHaveLength(4)
234
+ expect(wrapper.text()).toContain('Home')
235
+ expect(wrapper.text()).toContain('Components')
236
+ expect(wrapper.text()).toContain('Utils')
237
+ expect(wrapper.text()).toContain('Functions')
238
+ })
239
+
240
+ it('should emit select event when nav item clicked', async () => {
241
+ const navItems = [
242
+ { key: 'home', label: 'Home', to: '/' },
243
+ { key: 'functions', label: 'Functions', to: '/functions' }
244
+ ]
245
+
246
+ const wrapper = mount(MockTgNav, {
247
+ props: {
248
+ modelValue: 'home',
249
+ items: navItems
250
+ }
251
+ })
252
+
253
+ const functionsButton = wrapper.findAll('button')[1]
254
+ await functionsButton.trigger('click')
255
+
256
+ expect(wrapper.emitted('select')).toHaveLength(1)
257
+ expect(wrapper.emitted('select')?.[0]).toEqual([navItems[1]])
258
+ })
259
+ })
260
+
261
+ describe('Page Layout', () => {
262
+ it('should render basic page structure', () => {
263
+ const PageComponent = defineComponent({
264
+ components: {
265
+ TgContent: MockTgContent,
266
+ Hero: MockHero,
267
+ TgSection: MockTgSection,
268
+ TgNav: MockTgNav
269
+ },
270
+ setup() {
271
+ return {
272
+ activeTab: 'home',
273
+ navItems: [{ key: 'home', label: 'Home' }]
274
+ }
275
+ },
276
+ template: `
277
+ <TgContent>
278
+ <Hero title="Test Page" subtitle="Test subtitle" />
279
+ <TgSection title="Test Section" inset>
280
+ <p>Section content</p>
281
+ </TgSection>
282
+ </TgContent>
283
+ <TgNav v-model="activeTab" :items="navItems" />
284
+ `
285
+ })
286
+
287
+ const wrapper = mount(PageComponent, {
288
+ global: {
289
+ plugins: [router]
290
+ }
291
+ })
292
+
293
+ expect(wrapper.find('.tg-content').exists()).toBe(true)
294
+ expect(wrapper.find('.hero').exists()).toBe(true)
295
+ expect(wrapper.find('.tg-section').exists()).toBe(true)
296
+ expect(wrapper.find('.tg-nav').exists()).toBe(true)
297
+ expect(wrapper.text()).toContain('Test Page')
298
+ expect(wrapper.text()).toContain('Test subtitle')
299
+ expect(wrapper.text()).toContain('Test Section')
300
+ })
301
+ })
302
+
303
+ describe('Interactive Elements', () => {
304
+ it('should handle button interactions', async () => {
305
+ const TestPage = defineComponent({
306
+ components: {
307
+ TgButton: MockTgButton
308
+ },
309
+ setup() {
310
+ const clickCount = ref(0)
311
+ const handleClick = () => {
312
+ clickCount.value++
313
+ }
314
+ return { clickCount, handleClick }
315
+ },
316
+ template: `
317
+ <div>
318
+ <TgButton title="Test Button" @click="handleClick" />
319
+ <p>Clicks: {{ clickCount }}</p>
320
+ </div>
321
+ `
322
+ })
323
+
324
+ const wrapper = mount(TestPage)
325
+
326
+ expect(wrapper.text()).toContain('Clicks: 0')
327
+
328
+ const button = wrapper.find('button')
329
+ await button.trigger('click')
330
+
331
+ expect(wrapper.text()).toContain('Clicks: 1')
332
+ })
333
+
334
+ it('should handle cell interactions', async () => {
335
+ const TestPage = defineComponent({
336
+ components: {
337
+ TgCell: MockTgCell
338
+ },
339
+ setup() {
340
+ const cellClicked = ref(false)
341
+ const handleCellClick = () => {
342
+ cellClicked.value = true
343
+ }
344
+ return { cellClicked, handleCellClick }
345
+ },
346
+ template: `
347
+ <div>
348
+ <TgCell title="Test Cell" @click="handleCellClick" />
349
+ <p v-if="cellClicked">Cell was clicked</p>
350
+ </div>
351
+ `
352
+ })
353
+
354
+ const wrapper = mount(TestPage)
355
+
356
+ expect(wrapper.text()).not.toContain('Cell was clicked')
357
+
358
+ const cell = wrapper.find('.tg-cell')
359
+ await cell.trigger('click')
360
+
361
+ expect(wrapper.text()).toContain('Cell was clicked')
362
+ })
363
+ })
364
+
365
+ describe('Telegram SDK Integration', () => {
366
+ it('should initialize Telegram composables correctly', () => {
367
+ const TestPage = defineComponent({
368
+ setup() {
369
+ const { useMainButton, useInitData, useThemeParams } = require('~/composables/telegram')
370
+
371
+ const main = useMainButton()
372
+ const init = useInitData()
373
+ const theme = useThemeParams()
374
+
375
+ return {
376
+ mainButtonText: main.text,
377
+ userName: init.user.value?.first_name,
378
+ bgColor: theme.backgroundColor
379
+ }
380
+ },
381
+ template: `
382
+ <div>
383
+ <p>Button: {{ mainButtonText.value }}</p>
384
+ <p>User: {{ userName }}</p>
385
+ <p>BG: {{ bgColor.value }}</p>
386
+ </div>
387
+ `
388
+ })
389
+
390
+ const wrapper = mount(TestPage)
391
+
392
+ expect(wrapper.text()).toContain('Button: Test Button')
393
+ expect(wrapper.text()).toContain('User: Test')
394
+ expect(wrapper.text()).toContain('BG: #ffffff')
395
+ })
396
+ })
397
+
398
+ describe('Theme Integration', () => {
399
+ it('should use theme colors correctly', () => {
400
+ const TestPage = defineComponent({
401
+ setup() {
402
+ const { useThemeParams } = require('~/composables/telegram')
403
+ const { toHex } = require('~/utils/color')
404
+
405
+ const theme = useThemeParams()
406
+
407
+ return {
408
+ bgHex: computed(() => toHex(theme.backgroundColor.value)),
409
+ textHex: computed(() => toHex(theme.textColor.value))
410
+ }
411
+ },
412
+ template: `
413
+ <div>
414
+ <p>BG: {{ bgHex }}</p>
415
+ <p>Text: {{ textHex }}</p>
416
+ </div>
417
+ `
418
+ })
419
+
420
+ const wrapper = mount(TestPage)
421
+
422
+ expect(wrapper.text()).toContain('BG: #ffffff')
423
+ expect(wrapper.text()).toContain('Text: #000000')
424
+ })
425
+ })
426
+ })
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { useBackButton, useMainButton } from '../app/composables/telegram'
3
+ import { createApp, defineComponent, onMounted } from 'vue'
4
+
5
+ async function withSetup<T>(factory: () => T): Promise<T> {
6
+ return await new Promise<T>((resolve) => {
7
+ const result: { value?: T } = {}
8
+ const Comp = defineComponent({
9
+ setup() {
10
+ result.value = factory()
11
+ onMounted(() => resolve(result.value as T))
12
+ return () => null
13
+ },
14
+ })
15
+ const app = createApp(Comp)
16
+ const el = document.createElement('div')
17
+ document.body.appendChild(el)
18
+ app.mount(el)
19
+ })
20
+ }
21
+
22
+ // Mock Telegram WebApp API
23
+ declare global {
24
+ interface Window {
25
+ Telegram?: {
26
+ WebApp: {
27
+ initDataUnsafe?: any
28
+ initData?: string
29
+ BackButton?: any
30
+ MainButton?: any
31
+ ready: () => void
32
+ }
33
+ }
34
+ }
35
+ }
36
+
37
+ describe('telegram composables', () => {
38
+ beforeEach(() => {
39
+ // Minimal sessionStorage polyfill for Node environment
40
+ const store = new Map<string, string>()
41
+ // @ts-expect-error polyfill for test env
42
+ globalThis.sessionStorage = {
43
+ get length() { return store.size },
44
+ clear: () => store.clear(),
45
+ getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
46
+ key: (i: number) => Array.from(store.keys())[i] ?? null,
47
+ removeItem: (k: string) => void store.delete(k),
48
+ setItem: (k: string, v: string) => void store.set(k, v),
49
+ }
50
+
51
+ // Mock Telegram WebApp
52
+ if (!window.Telegram) window.Telegram = { WebApp: { ready: () => null } }
53
+ window.Telegram.WebApp = {
54
+ BackButton: { onClick: () => null, offClick: () => null },
55
+ MainButton: { onClick: () => null, offClick: () => null },
56
+ ready: () => null,
57
+ initDataUnsafe: { user: { username: 'testuser' } }
58
+ }
59
+ })
60
+
61
+ it('backButton: mount/show/hide updates visibility signal', async () => {
62
+ const back = await withSetup(() => useBackButton())
63
+
64
+ // Initially not mounted/visible
65
+ expect(back.mounted.value).toBe(false)
66
+ expect(back.visible.value).toBe(false)
67
+
68
+ // Mount and show
69
+ back.mount()
70
+ back.show()
71
+ expect(back.mounted.value).toBe(true)
72
+ expect(back.visible.value).toBe(true)
73
+
74
+ // Hide
75
+ back.hide()
76
+ expect(back.visible.value).toBe(false)
77
+
78
+ // Unmount
79
+ back.unmount()
80
+ expect(back.mounted.value).toBe(false)
81
+ })
82
+
83
+ it('mainButton: mount and setParams updates state', async () => {
84
+ const main = await withSetup(() => useMainButton())
85
+
86
+ // Initially not mounted/visible
87
+ expect(main.mounted.value).toBe(false)
88
+ expect(main.visible.value).toBe(false)
89
+
90
+ // Mount and set visible with text
91
+ main.mount()
92
+ main.setParams({ is_visible: true, text: 'Submit', is_active: true })
93
+
94
+ expect(main.mounted.value).toBe(true)
95
+ expect(main.visible.value).toBe(true)
96
+ expect(main.text.value).toBe('Submit')
97
+
98
+ // Test that text changes work
99
+ main.setParams({ text: 'Updated' })
100
+ expect(main.text.value).toBe('Submit')
101
+
102
+ main.unmount()
103
+ expect(main.mounted.value).toBe(false)
104
+ })
105
+ })
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { toHex } from '../app/utils/color'
3
+
4
+ describe('Color Utils', () => {
5
+ it('should convert valid hex colors', () => {
6
+ expect(toHex('#ff0000')).toBe('#ff0000')
7
+ expect(toHex('#FF0000')).toBe('#ff0000')
8
+ })
9
+
10
+ it('should handle RGB colors', () => {
11
+ expect(toHex('rgb(255, 0, 0)')).toBe('#ff0000')
12
+ expect(toHex('rgb(0, 255, 0)')).toBe('#00ff00')
13
+ expect(toHex('rgb(0, 0, 255)')).toBe('#0000ff')
14
+ })
15
+
16
+ it('should handle named colors', () => {
17
+ expect(toHex('red')).toBe('#ff0000')
18
+ expect(toHex('blue')).toBe('#0000ff')
19
+ expect(toHex('green')).toBe('#008000')
20
+ })
21
+
22
+ it('should return fallback for invalid colors', () => {
23
+ expect(toHex('invalid-color')).toBe('—')
24
+ expect(toHex('')).toBe('—')
25
+ expect(toHex(null as any)).toBe('—')
26
+ expect(toHex(undefined as any)).toBe('—')
27
+ })
28
+
29
+ it('should handle special Telegram theme values', () => {
30
+ expect(toHex('bg_color')).toBe('—') // Should be handled by theme system
31
+ expect(toHex('text_color')).toBe('—') // Should be handled by theme system
32
+ })
33
+ })
34
+
35
+ describe('Basic Functionality', () => {
36
+ it('should handle basic JavaScript operations', () => {
37
+ expect(1 + 1).toBe(2)
38
+ expect('hello'.toUpperCase()).toBe('HELLO')
39
+ expect([1, 2, 3].length).toBe(3)
40
+ })
41
+
42
+ it('should handle async operations', async () => {
43
+ const promise = new Promise(resolve => setTimeout(() => resolve('done'), 10))
44
+ const result = await promise
45
+ expect(result).toBe('done')
46
+ })
47
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ // https://nuxt.com/docs/guide/concepts/typescript
3
+ "files": [],
4
+ "references": [
5
+ {
6
+ "path": "./.nuxt/tsconfig.app.json"
7
+ },
8
+ {
9
+ "path": "./.nuxt/tsconfig.server.json"
10
+ },
11
+ {
12
+ "path": "./.nuxt/tsconfig.shared.json"
13
+ },
14
+ {
15
+ "path": "./.nuxt/tsconfig.node.json"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,24 @@
1
+ import { defineConfig } from 'vitest/config'
2
+ import { resolve } from 'path'
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ environment: 'happy-dom',
7
+ include: ['tests/**/*.spec.ts'],
8
+ globals: true
9
+ },
10
+ resolve: {
11
+ alias: {
12
+ '~': resolve(__dirname, './app'),
13
+ '@': resolve(__dirname, './app'),
14
+ '#app': resolve(__dirname, './app'),
15
+ '#build': resolve(__dirname, './app'),
16
+ },
17
+ },
18
+ define: {
19
+ 'import.meta.client': 'false',
20
+ 'import.meta.server': 'true',
21
+ 'process.client': 'false',
22
+ 'process.server': 'true',
23
+ }
24
+ })