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
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "nuxt-telegram-mini-app",
3
+ "description": "Structure and configurations for building a Telegram Mini App using Nuxt and TailWind, hosted on Netlify.",
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "nuxt build",
8
+ "dev": "nuxt dev",
9
+ "generate": "nuxt generate",
10
+ "preview": "nuxt preview",
11
+ "postinstall": "nuxt prepare",
12
+ "test": "vitest run",
13
+ "test:watch": "vitest"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/patricktobias86/nuxt-telegram-mini-app.git"
18
+ },
19
+ "author": {
20
+ "name": "Patrick Tobias",
21
+ "url": "https://github.com/patricktobias86"
22
+ },
23
+ "dependencies": {
24
+ "@nuxt/eslint": "^1.9.0",
25
+ "@nuxt/fonts": "^0.11.4",
26
+ "@nuxt/icon": "^2.0.0",
27
+ "eslint": "^9.35.0",
28
+ "nuxt": "^4.1.1",
29
+ "vue": "^3.5.21",
30
+ "vue-router": "^4.5.1"
31
+ },
32
+ "devDependencies": {
33
+ "@nuxtjs/tailwindcss": "^6.14.0",
34
+ "@types/node": "^24.3.1",
35
+ "@vitest/coverage-v8": "^3.2.4",
36
+ "@vue/test-utils": "^2.4.6",
37
+ "happy-dom": "^18.0.1",
38
+ "jsdom": "^26.1.0",
39
+ "typescript": "^5.4.5",
40
+ "vitest": "^3.2.4"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "registry": "https://registry.npmjs.org"
45
+ }
46
+ }
@@ -0,0 +1,2 @@
1
+ /* /index.html 200
2
+
Binary file
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
3
+ <!-- Background rectangle -->
4
+ <rect width="16" height="16" fill="#f0f0f0"/>
5
+
6
+ <!-- Icon path -->
7
+ <path d="m 8 1 c -1.65625 0 -3 1.34375 -3 3 s 1.34375 3 3 3 s 3 -1.34375 3 -3 s -1.34375 -3 -3 -3 z m -1.5 7 c -2.492188 0 -4.5 2.007812 -4.5 4.5 v 0.5 c 0 1.109375 0.890625 2 2 2 h 8 c 1.109375 0 2 -0.890625 2 -2 v -0.5 c 0 -2.492188 -2.007812 -4.5 -4.5 -4.5 z m 0 0" fill="#2e3436"/>
8
+ </svg>
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
2
+ <!-- Soft green background -->
3
+ <rect width="200" height="200" fill="#e6f7ec" />
4
+
5
+ <!-- Original Nuxt logo with scaling and padding -->
6
+ <g transform="translate(25,25) scale(0.75)">
7
+ <path d="M108.8 126.6c-1.1 1.9-3 3-5.2 3H7.4c-2.2 0-4.1-1.1-5.2-3s-1.1-4.1 0-6l50.2-87c1.1-1.9 3-3 5.2-3s4.1 1.1 5.2 3l50.2 87c1.1 1.9 1.1 4.1 0 6z" fill="#00C58E"/>
8
+ <path d="M197.8 126.6c-1.1 1.9-3 3-5.2 3h-89.9c-2.2 0-4.1-1.1-5.2-3s-1.1-4.1 0-6l44.9-77.9c1.1-1.9 3-3 5.2-3s4.1 1.1 5.2 3l44.9 77.9c1.1 1.9 1.1 4.1 0 6z" fill="#108775"/>
9
+ <path d="M152.9 126.6c-1.1 1.9-3 3-5.2 3h-45c-2.2 0-4.1-1.1-5.2-3s-1.1-4.1 0-6l22.5-39c1.1-1.9 3-3 5.2-3s4.1 1.1 5.2 3l22.5 39c1.1 1.9 1.1 4.1 0 6z" fill="#2F495E"/>
10
+ </g>
11
+ </svg>
@@ -0,0 +1,2 @@
1
+ User-Agent: *
2
+ Disallow: /
@@ -0,0 +1,150 @@
1
+ import { createHmac } from 'node:crypto'
2
+ import { defineEventHandler, getMethod, readBody, setHeaders, createError } from 'h3'
3
+
4
+
5
+ interface TelegramInitData {
6
+ query_id?: string
7
+ user?: string
8
+ auth_date: string
9
+ hash: string
10
+ [key: string]: string | undefined
11
+ }
12
+
13
+ const BOT_TOKEN = process.env.BOT_TOKEN
14
+
15
+ function verifyTelegramWebAppData(telegramInitData: string): boolean {
16
+ try {
17
+ if (!BOT_TOKEN) {
18
+ console.error('BOT_TOKEN environment variable is not set')
19
+ return false
20
+ }
21
+
22
+ const urlParams = new URLSearchParams(telegramInitData)
23
+ const hash = urlParams.get('hash')
24
+
25
+ if (!hash) {
26
+ return false
27
+ }
28
+
29
+ urlParams.delete('hash')
30
+
31
+ // Create data-check-string
32
+ const dataCheckArr: string[] = []
33
+ for (const [key, value] of Array.from(urlParams.entries()).sort()) {
34
+ if (value) {
35
+ dataCheckArr.push(`${key}=${value}`)
36
+ }
37
+ }
38
+ const dataCheckString = dataCheckArr.join('\n')
39
+
40
+ // Create secret key from bot token
41
+ const secretKey = createHmac('sha256', 'WebAppData').update(BOT_TOKEN).digest()
42
+
43
+ // Create hash of data-check-string using secret key
44
+ const calculatedHash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
45
+
46
+ return calculatedHash === hash
47
+ } catch (error) {
48
+ console.error('Error verifying Telegram data:', error)
49
+ return false
50
+ }
51
+ }
52
+
53
+ function isDataFresh(authDate: string, maxAgeSeconds: number = 86400): boolean {
54
+ const authTimestamp = parseInt(authDate, 10)
55
+ const currentTimestamp = Math.floor(Date.now() / 1000)
56
+ return (currentTimestamp - authTimestamp) <= maxAgeSeconds
57
+ }
58
+
59
+ export default defineEventHandler(async (event) => {
60
+ // Set CORS headers
61
+ setHeaders(event, {
62
+ 'Access-Control-Allow-Origin': '*',
63
+ 'Access-Control-Allow-Headers': 'Content-Type',
64
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
65
+ 'Content-Type': 'application/json'
66
+ })
67
+
68
+ // Handle preflight requests
69
+ if (getMethod(event) === 'OPTIONS') {
70
+ return ''
71
+ }
72
+
73
+ if (getMethod(event) !== 'POST') {
74
+ throw createError({
75
+ statusCode: 405,
76
+ statusMessage: 'Method not allowed'
77
+ })
78
+ }
79
+
80
+ try {
81
+ const body = await readBody(event)
82
+ const { initData } = body
83
+
84
+ if (!initData) {
85
+ throw createError({
86
+ statusCode: 400,
87
+ statusMessage: 'Missing initData parameter',
88
+ data: { valid: false }
89
+ })
90
+ }
91
+
92
+ // Verify the data authenticity
93
+ const isValid = verifyTelegramWebAppData(initData)
94
+
95
+ if (!isValid) {
96
+ return {
97
+ valid: false,
98
+ error: 'Invalid signature'
99
+ }
100
+ }
101
+
102
+ // Parse the init data to get user info and check freshness
103
+ const urlParams = new URLSearchParams(initData)
104
+ const authDate = urlParams.get('auth_date')
105
+
106
+ if (!authDate) {
107
+ return {
108
+ valid: false,
109
+ error: 'Missing auth_date'
110
+ }
111
+ }
112
+
113
+ // Check if data is fresh (within 24 hours by default)
114
+ const isFresh = isDataFresh(authDate, 86400)
115
+
116
+ if (!isFresh) {
117
+ return {
118
+ valid: false,
119
+ error: 'Data is too old'
120
+ }
121
+ }
122
+
123
+ // Parse user data if available
124
+ let user = null
125
+ const userParam = urlParams.get('user')
126
+ if (userParam) {
127
+ try {
128
+ user = JSON.parse(decodeURIComponent(userParam))
129
+ } catch (e) {
130
+ console.warn('Failed to parse user data:', e)
131
+ }
132
+ }
133
+
134
+ return {
135
+ valid: true,
136
+ user,
137
+ auth_date: parseInt(authDate, 10),
138
+ query_id: urlParams.get('query_id'),
139
+ start_param: urlParams.get('start_param')
140
+ }
141
+
142
+ } catch (error) {
143
+ console.error('API error:', error)
144
+ throw createError({
145
+ statusCode: 500,
146
+ statusMessage: 'Internal server error',
147
+ data: { valid: false }
148
+ })
149
+ }
150
+ })
@@ -0,0 +1,39 @@
1
+ import type { Config } from 'tailwindcss'
2
+ import defaultTheme from 'tailwindcss/defaultTheme'
3
+
4
+ export default {
5
+ content: [
6
+ "~/components/**/*.{js,vue,ts}",
7
+ "~/layouts/**/*.vue",
8
+ "~/pages/**/*.vue",
9
+ "~/plugins/**/*.{js,ts}",
10
+ "~/app.vue",
11
+ "~/error.vue",
12
+ ],
13
+ theme: {
14
+ extend: {
15
+ colors: {
16
+ // Map Telegram theme variables to Tailwind colors
17
+ bg: 'var(--tg-theme-bg-color)',
18
+ secondaryBg: 'var(--tg-theme-secondary-bg-color)',
19
+ sectionBg: 'var(--tg-theme-section-bg-color)',
20
+ text: 'var(--tg-theme-text-color)',
21
+ hint: 'var(--tg-theme-hint-color)',
22
+ link: 'var(--tg-theme-link-color)',
23
+ primary: 'var(--tg-theme-button-color)',
24
+ primaryFg: 'var(--tg-theme-button-text-color)',
25
+ destructive: 'var(--tg-theme-destructive-text-color)',
26
+ accent: 'var(--tg-theme-accent-text-color)',
27
+ headerBg: 'var(--tg-theme-header-bg-color)',
28
+ sectionSeparator: 'var(--tg-theme-section-separator-color)',
29
+ },
30
+ borderColor: {
31
+ DEFAULT: 'var(--tg-theme-section-separator-color)'
32
+ },
33
+ fontFamily: {
34
+ sans: ['Roboto', ...defaultTheme.fontFamily.sans],
35
+ },
36
+ },
37
+ },
38
+ plugins: [],
39
+ } satisfies Config
@@ -0,0 +1,311 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { defineComponent, h } from 'vue'
4
+
5
+ // Mock Nuxt composables
6
+ vi.mock('#app', () => ({
7
+ useRequestURL: () => ({
8
+ href: 'https://test.example.com'
9
+ })
10
+ }))
11
+
12
+ vi.mock('vue-router', () => ({
13
+ useRouter: () => ({
14
+ push: vi.fn(),
15
+ back: vi.fn(),
16
+ replace: vi.fn(),
17
+ currentRoute: {
18
+ value: { path: '/' }
19
+ }
20
+ })
21
+ }))
22
+
23
+ // Mock Telegram composables
24
+ vi.mock('~/composables/telegram', () => ({
25
+ useHapticFeedback: () => ({
26
+ supported: { value: true },
27
+ impactOccurred: vi.fn(),
28
+ notificationOccurred: vi.fn(),
29
+ selectionChanged: vi.fn()
30
+ }),
31
+ useMainButton: () => ({
32
+ mounted: { value: true },
33
+ visible: { value: false },
34
+ text: { value: '' },
35
+ mount: vi.fn(),
36
+ setParams: vi.fn(),
37
+ onClick: vi.fn(() => vi.fn())
38
+ }),
39
+ useBackButton: () => ({
40
+ mounted: { value: true },
41
+ visible: { value: false },
42
+ mount: vi.fn(),
43
+ show: vi.fn(),
44
+ hide: vi.fn(),
45
+ onClick: vi.fn(() => vi.fn())
46
+ }),
47
+ useInitData: () => ({
48
+ user: { value: { username: 'testuser', first_name: 'Test' } },
49
+ queryId: { value: 'test123' },
50
+ startParam: { value: null },
51
+ state: { value: {} },
52
+ raw: { value: 'test_data=123' }
53
+ }),
54
+ useThemeParams: () => ({
55
+ backgroundColor: { value: '#ffffff' },
56
+ textColor: { value: '#000000' },
57
+ buttonColor: { value: '#0088cc' },
58
+ buttonTextColor: { value: '#ffffff' },
59
+ linkColor: { value: '#0088cc' },
60
+ secondaryBackgroundColor: { value: '#f0f0f0' }
61
+ }),
62
+ useMiniApp: () => ({
63
+ supported: { value: true },
64
+ dark: { value: false },
65
+ setBackgroundColor: vi.fn(),
66
+ setHeaderColor: vi.fn()
67
+ }),
68
+ useViewport: () => ({
69
+ width: { value: 390 },
70
+ height: { value: 844 },
71
+ stableHeight: { value: 844 },
72
+ expand: vi.fn()
73
+ }),
74
+ openLink: vi.fn(),
75
+ openTelegramLink: vi.fn(),
76
+ shareURL: vi.fn()
77
+ }))
78
+
79
+ vi.mock('~/utils/color', () => ({
80
+ toHex: (color: string) => color.startsWith('#') ? color : '#000000'
81
+ }))
82
+
83
+ // Test components
84
+ const TestTgButton = defineComponent({
85
+ name: 'TgButton',
86
+ props: {
87
+ title: String,
88
+ status: String,
89
+ haptic: String,
90
+ disabled: Boolean,
91
+ loading: Boolean
92
+ },
93
+ emits: ['click'],
94
+ setup(props, { emit }) {
95
+ return () => h('button', {
96
+ onClick: () => emit('click'),
97
+ disabled: props.disabled || props.loading,
98
+ class: `tg-button tg-button--${props.status || 'primary'}`
99
+ }, props.title)
100
+ }
101
+ })
102
+
103
+ const TestTgCell = defineComponent({
104
+ name: 'TgCell',
105
+ props: {
106
+ title: String,
107
+ subtitle: String,
108
+ description: String,
109
+ icon: String,
110
+ to: String,
111
+ border: { type: Boolean, default: true }
112
+ },
113
+ emits: ['click'],
114
+ setup(props, { emit }) {
115
+ return () => h('div', {
116
+ onClick: () => emit('click'),
117
+ class: 'tg-cell'
118
+ }, [
119
+ props.icon && h('div', { class: 'tg-cell__icon' }),
120
+ h('div', { class: 'tg-cell__content' }, [
121
+ h('div', { class: 'tg-cell__title' }, props.title),
122
+ props.subtitle && h('div', { class: 'tg-cell__subtitle' }, props.subtitle),
123
+ props.description && h('div', { class: 'tg-cell__description' }, props.description)
124
+ ])
125
+ ])
126
+ }
127
+ })
128
+
129
+ const TestTgSection = defineComponent({
130
+ name: 'TgSection',
131
+ props: {
132
+ title: String,
133
+ inset: Boolean
134
+ },
135
+ setup(props, { slots }) {
136
+ return () => h('section', {
137
+ class: ['tg-section', props.inset && 'tg-section--inset']
138
+ }, [
139
+ props.title && h('h2', { class: 'tg-section__title' }, props.title),
140
+ h('div', { class: 'tg-section__content' }, slots.default?.())
141
+ ])
142
+ }
143
+ })
144
+
145
+ const TestTgContent = defineComponent({
146
+ name: 'TgContent',
147
+ setup(_, { slots }) {
148
+ return () => h('main', { class: 'tg-content' }, slots.default?.())
149
+ }
150
+ })
151
+
152
+ const TestHero = defineComponent({
153
+ name: 'Hero',
154
+ props: {
155
+ title: String,
156
+ subtitle: String,
157
+ imageSrc: String
158
+ },
159
+ setup(props) {
160
+ return () => h('header', { class: 'hero' }, [
161
+ props.imageSrc && h('img', { src: props.imageSrc, alt: props.title }),
162
+ h('h1', { class: 'hero__title' }, props.title),
163
+ props.subtitle && h('p', { class: 'hero__subtitle' }, props.subtitle)
164
+ ])
165
+ }
166
+ })
167
+
168
+ describe('Telegram Components', () => {
169
+ beforeEach(() => {
170
+ vi.clearAllMocks()
171
+ })
172
+
173
+ describe('TgButton', () => {
174
+ it('renders with title', () => {
175
+ const wrapper = mount(TestTgButton, {
176
+ props: { title: 'Test Button' }
177
+ })
178
+ expect(wrapper.text()).toBe('Test Button')
179
+ })
180
+
181
+ it('applies correct status class', () => {
182
+ const wrapper = mount(TestTgButton, {
183
+ props: { title: 'Test', status: 'secondary' }
184
+ })
185
+ expect(wrapper.classes()).toContain('tg-button--secondary')
186
+ })
187
+
188
+ it('handles disabled state', () => {
189
+ const wrapper = mount(TestTgButton, {
190
+ props: { title: 'Test', disabled: true }
191
+ })
192
+ expect(wrapper.attributes('disabled')).toBeDefined()
193
+ })
194
+
195
+ it('handles loading state', () => {
196
+ const wrapper = mount(TestTgButton, {
197
+ props: { title: 'Test', loading: true }
198
+ })
199
+ expect(wrapper.attributes('disabled')).toBeDefined()
200
+ })
201
+
202
+ it('emits click event', async () => {
203
+ const wrapper = mount(TestTgButton, {
204
+ props: { title: 'Test' }
205
+ })
206
+ await wrapper.trigger('click')
207
+ expect(wrapper.emitted('click')).toHaveLength(1)
208
+ })
209
+ })
210
+
211
+ describe('TgCell', () => {
212
+ it('renders title', () => {
213
+ const wrapper = mount(TestTgCell, {
214
+ props: { title: 'Test Cell' }
215
+ })
216
+ expect(wrapper.find('.tg-cell__title').text()).toBe('Test Cell')
217
+ })
218
+
219
+ it('renders subtitle when provided', () => {
220
+ const wrapper = mount(TestTgCell, {
221
+ props: { title: 'Test', subtitle: 'Test Subtitle' }
222
+ })
223
+ expect(wrapper.find('.tg-cell__subtitle').text()).toBe('Test Subtitle')
224
+ })
225
+
226
+ it('renders description when provided', () => {
227
+ const wrapper = mount(TestTgCell, {
228
+ props: { title: 'Test', description: 'Test Description' }
229
+ })
230
+ expect(wrapper.find('.tg-cell__description').text()).toBe('Test Description')
231
+ })
232
+
233
+ it('renders icon when provided', () => {
234
+ const wrapper = mount(TestTgCell, {
235
+ props: { title: 'Test', icon: 'i-heroicons-star-20-solid' }
236
+ })
237
+ expect(wrapper.find('.tg-cell__icon').exists()).toBe(true)
238
+ })
239
+
240
+ it('emits click event', async () => {
241
+ const wrapper = mount(TestTgCell, {
242
+ props: { title: 'Test' }
243
+ })
244
+ await wrapper.trigger('click')
245
+ expect(wrapper.emitted('click')).toHaveLength(1)
246
+ })
247
+ })
248
+
249
+ describe('TgSection', () => {
250
+ it('renders title', () => {
251
+ const wrapper = mount(TestTgSection, {
252
+ props: { title: 'Test Section' }
253
+ })
254
+ expect(wrapper.find('.tg-section__title').text()).toBe('Test Section')
255
+ })
256
+
257
+ it('applies inset class when inset prop is true', () => {
258
+ const wrapper = mount(TestTgSection, {
259
+ props: { title: 'Test', inset: true }
260
+ })
261
+ expect(wrapper.classes()).toContain('tg-section--inset')
262
+ })
263
+
264
+ it('renders slot content', () => {
265
+ const wrapper = mount(TestTgSection, {
266
+ props: { title: 'Test' },
267
+ slots: {
268
+ default: '<p>Slot content</p>'
269
+ }
270
+ })
271
+ expect(wrapper.html()).toContain('Slot content')
272
+ })
273
+ })
274
+
275
+ describe('TgContent', () => {
276
+ it('renders as main element', () => {
277
+ const wrapper = mount(TestTgContent, {
278
+ slots: {
279
+ default: '<p>Content</p>'
280
+ }
281
+ })
282
+ expect(wrapper.element.tagName).toBe('MAIN')
283
+ expect(wrapper.classes()).toContain('tg-content')
284
+ })
285
+ })
286
+
287
+ describe('Hero', () => {
288
+ it('renders title', () => {
289
+ const wrapper = mount(TestHero, {
290
+ props: { title: 'Test Title' }
291
+ })
292
+ expect(wrapper.find('.hero__title').text()).toBe('Test Title')
293
+ })
294
+
295
+ it('renders subtitle when provided', () => {
296
+ const wrapper = mount(TestHero, {
297
+ props: { title: 'Test', subtitle: 'Test Subtitle' }
298
+ })
299
+ expect(wrapper.find('.hero__subtitle').text()).toBe('Test Subtitle')
300
+ })
301
+
302
+ it('renders image when imageSrc provided', () => {
303
+ const wrapper = mount(TestHero, {
304
+ props: { title: 'Test', imageSrc: '/test.jpg' }
305
+ })
306
+ const img = wrapper.find('img')
307
+ expect(img.attributes('src')).toBe('/test.jpg')
308
+ expect(img.attributes('alt')).toBe('Test')
309
+ })
310
+ })
311
+ })