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.
- package/.env.example +2 -0
- package/.vscode/settings.json +55 -0
- package/.vscode/tailwind.json +55 -0
- package/CONTRIBUTING.md +406 -0
- package/LICENSE +21 -0
- package/README.md +640 -0
- package/app/app.vue +87 -0
- package/app/assets/css/main.css +53 -0
- package/app/assets/css/tailwind.css +3 -0
- package/app/components/ErrorBoundary.vue +81 -0
- package/app/components/Hero.vue +61 -0
- package/app/components/tg/Button.vue +128 -0
- package/app/components/tg/Cell.vue +91 -0
- package/app/components/tg/Content.vue +42 -0
- package/app/components/tg/Nav.vue +107 -0
- package/app/components/tg/Section.vue +50 -0
- package/app/composables/telegram.ts +342 -0
- package/app/error.vue +161 -0
- package/app/pages/components.vue +279 -0
- package/app/pages/functions.vue +107 -0
- package/app/pages/index.vue +211 -0
- package/app/pages/utilities.vue +402 -0
- package/app/types/telegram-webapp.ts +160 -0
- package/app/utils/color.ts +37 -0
- package/eslint.config.mjs +6 -0
- package/nuxt.config.ts +55 -0
- package/package.json +46 -0
- package/public/_redirects +2 -0
- package/public/favicon.ico +0 -0
- package/public/img/hero-user.svg +8 -0
- package/public/img/nuxt-logo.svg +11 -0
- package/public/robots.txt +2 -0
- package/server/api/verify-telegram-data.post.ts +150 -0
- package/tailwind.config.ts +39 -0
- package/tests/components.spec.ts +311 -0
- package/tests/pages.spec.ts +426 -0
- package/tests/telegram.spec.ts +105 -0
- package/tests/utils.spec.ts +47 -0
- package/tsconfig.json +18 -0
- 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
|
+
}
|
|
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,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
|
+
})
|