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/app/app.vue ADDED
@@ -0,0 +1,87 @@
1
+ <template>
2
+ <div class="min-h-screen tg-bg">
3
+ <NuxtRouteAnnouncer />
4
+ <ErrorBoundary
5
+ fallback-message="The main application encountered an error. This might be related to Telegram integration."
6
+ :show-details="isDev"
7
+ >
8
+ <NuxtPage />
9
+ </ErrorBoundary>
10
+
11
+ <TgNav :safe-area="true" :items="navItems" />
12
+ </div>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ import { computed } from 'vue'
17
+ import type { TgNavItem } from '~/components/tg/Nav.vue'
18
+
19
+ // Check if we're in development mode for showing error details
20
+ const isDev = computed(() => {
21
+ return import.meta.dev || (typeof window !== 'undefined' && window.location.hostname === 'localhost')
22
+ })
23
+
24
+ // Navigation items for the bottom nav
25
+ const navItems = computed<TgNavItem[]>(() => [
26
+ {
27
+ key: 'home',
28
+ label: 'Home',
29
+ icon: 'i-heroicons-home-20-solid',
30
+ to: '/'
31
+ },
32
+ {
33
+ key: 'functions',
34
+ label: 'Functions',
35
+ icon: 'i-heroicons-cog-6-tooth-20-solid',
36
+ to: '/functions'
37
+ },
38
+ {
39
+ key: 'components',
40
+ label: 'Components',
41
+ icon: 'i-heroicons-squares-2x2-20-solid',
42
+ to: '/components'
43
+ },
44
+ {
45
+ key: 'utilities',
46
+ label: 'Utilities',
47
+ icon: 'i-heroicons-wrench-screwdriver-20-solid',
48
+ to: '/utilities'
49
+ }
50
+ ])
51
+
52
+ // Global error handler for unhandled promise rejections
53
+ if (typeof window !== 'undefined') {
54
+ window.addEventListener('unhandledrejection', (event) => {
55
+ console.error('[Global] Unhandled promise rejection:', event.reason)
56
+
57
+ // Check if it's a Telegram-related error
58
+ const reason = String(event.reason?.message || event.reason || '')
59
+ if (reason.includes('tgWebApp') || reason.includes('Telegram') || reason.includes('hash')) {
60
+ console.warn('[Global] Telegram-related error detected, attempting recovery')
61
+
62
+ // Try to clear problematic hash and reload
63
+ try {
64
+ if (sessionStorage.getItem('__tg_hash_fixed')) {
65
+ sessionStorage.removeItem('__tg_hash_fixed')
66
+ setTimeout(() => {
67
+ window.location.reload()
68
+ }, 1000)
69
+ }
70
+ } catch (e) {
71
+ console.error('[Global] Recovery attempt failed:', e)
72
+ }
73
+ }
74
+ })
75
+
76
+ // Global error handler for JavaScript errors
77
+ window.addEventListener('error', (event) => {
78
+ console.error('[Global] JavaScript error:', event.error)
79
+
80
+ // Check if it's a Telegram-related error
81
+ const message = String(event.error?.message || event.message || '')
82
+ if (message.includes('tgWebApp') || message.includes('Telegram') || message.includes('hash')) {
83
+ console.warn('[Global] Telegram-related JavaScript error detected')
84
+ }
85
+ })
86
+ }
87
+ </script>
@@ -0,0 +1,53 @@
1
+ :root {
2
+ --tg-color-scheme: dark;
3
+ --tg-theme-bg-color: #212121;
4
+ --tg-theme-button-color: #0F8AFF;
5
+ --tg-theme-button-text-color: #ffffff;
6
+ --tg-theme-hint-color: #5D5F62;
7
+ --tg-theme-link-color: #0F8AFF;
8
+ --tg-theme-secondary-bg-color: #181818;
9
+ --tg-theme-text-color: #ffffff;
10
+
11
+ --tg-theme-section-bg-color: var(--tg-theme-bg-color);
12
+ --tg-theme-section-separator-color: var(--tg-theme-secondary-bg-color);
13
+ --tg-theme-button-color-light: #4fb3ff;
14
+ --tg-theme-header-bg-color: #FFFFFF;
15
+ --tg-theme-accent-text-color: #536A89;
16
+ --tg-theme-section-header-text-color: var(--tg-theme-hint-color);
17
+ --tg-theme-subtitle-text-color: #999999;
18
+ --tg-theme-destructive-text-color: #D14E4E;
19
+ --tg-viewport-height: 100vh;
20
+ --tg-viewport-stable-height: 100vh;
21
+
22
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
23
+ -webkit-tap-highlight-color: transparent;
24
+ --safe-area-inset-bottom: calc(100vh - var(--tg-viewport-stable-height, 100vh));
25
+ }
26
+
27
+ body {
28
+ color: var(--tg-theme-text-color);
29
+ background-color: var(--tg-theme-secondary-bg-color);
30
+ font-family: Roboto, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
31
+ font-size: 1rem;
32
+ line-height: 1.5rem;
33
+ padding-top: 1rem;
34
+ }
35
+
36
+ /* Reduce accidental zoom/pinch interactions on mobile */
37
+ html, body {
38
+ touch-action: manipulation;
39
+ -ms-touch-action: manipulation;
40
+ overscroll-behavior: contain;
41
+ }
42
+
43
+ .tg-bg { background-color: var(--tg-theme-secondary-bg-color); }
44
+ .tg-secondary-bg { background-color: var(--tg-theme-secondary-bg-color); }
45
+ .tg-section-bg { background-color: var(--tg-theme-section-bg-color); }
46
+ .tg-link, a { color: var(--tg-theme-link-color); }
47
+ .tg-primary, .tg-button { background-color: var(--tg-theme-button-color); color: var(--tg-theme-button-text-color); }
48
+ .tg-button-danger { background-color: var(--tg-theme-destructive-text-color); color: var(--tg-theme-button-text-color); }
49
+ .tg-hint { color: var(--tg-theme-hint-color); }
50
+ .tg-accent { color: var(--tg-theme-accent-text-color); }
51
+ .tg-danger { color: var(--tg-theme-destructive-text-color); }
52
+ .text-tg { color: var(--tg-theme-text-color); }
53
+ .tg-button-text { color: var(--tg-theme-button-text-color); }
@@ -0,0 +1,3 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
@@ -0,0 +1,81 @@
1
+ <template>
2
+ <div v-if="hasError" class="error-boundary p-4 bg-red-50 border border-red-200 rounded-lg">
3
+ <div class="flex items-start space-x-3">
4
+ <div class="flex-shrink-0">
5
+ <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
6
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
7
+ </svg>
8
+ </div>
9
+ <div class="flex-1">
10
+ <h3 class="text-sm font-medium text-red-800">
11
+ Component Error
12
+ </h3>
13
+ <div class="mt-2 text-sm text-red-700">
14
+ <p>{{ fallbackMessage || 'Something went wrong with this component.' }}</p>
15
+ </div>
16
+ <div class="mt-3">
17
+ <button
18
+ @click="retry"
19
+ class="bg-red-100 hover:bg-red-200 text-red-800 px-3 py-1 rounded text-sm font-medium transition-colors"
20
+ >
21
+ Try Again
22
+ </button>
23
+ </div>
24
+ <details v-if="showDetails" class="mt-3">
25
+ <summary class="cursor-pointer text-sm text-red-600 hover:text-red-800">
26
+ Technical Details
27
+ </summary>
28
+ <pre class="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto">{{ errorDetails }}</pre>
29
+ </details>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ <slot v-else />
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ import { ref, onErrorCaptured, nextTick } from 'vue'
38
+
39
+ interface Props {
40
+ fallbackMessage?: string
41
+ showDetails?: boolean
42
+ }
43
+
44
+ const props = withDefaults(defineProps<Props>(), {
45
+ fallbackMessage: '',
46
+ showDetails: false
47
+ })
48
+
49
+ const hasError = ref(false)
50
+ const errorDetails = ref('')
51
+ const retryKey = ref(0)
52
+
53
+ onErrorCaptured((error, instance, info) => {
54
+ console.error('[ErrorBoundary] Caught error:', error)
55
+ console.error('[ErrorBoundary] Component info:', info)
56
+
57
+ hasError.value = true
58
+ errorDetails.value = `Error: ${error.message}\nInfo: ${info}\nStack: ${error.stack}`
59
+
60
+ // Prevent the error from propagating further
61
+ return false
62
+ })
63
+
64
+ const retry = async () => {
65
+ hasError.value = false
66
+ errorDetails.value = ''
67
+ retryKey.value++
68
+
69
+ // Wait for next tick to ensure component re-renders
70
+ await nextTick()
71
+ }
72
+
73
+ // Provide retry key to force re-render of child components
74
+ provide('errorBoundaryRetryKey', retryKey)
75
+ </script>
76
+
77
+ <style scoped>
78
+ .error-boundary {
79
+ margin: 1rem 0;
80
+ }
81
+ </style>
@@ -0,0 +1,61 @@
1
+ <template>
2
+ <section class="w-full">
3
+ <div :class="containerClass">
4
+ <div v-if="hasImage" class="flex justify-center">
5
+ <img
6
+ :src="imageSrc!"
7
+ :alt="altText"
8
+ class="h-28 w-28 rounded-full object-cover ring-1 ring-sectionSeparator"
9
+ decoding="async"
10
+ :loading="loadingAttr"
11
+ />
12
+ </div>
13
+
14
+ <div :class="textWrapClass">
15
+ <h1 class="text-2xl font-semibold text-text">{{ title }}</h1>
16
+ <p v-if="subtitle" class="text-sm text-hint">{{ subtitle }}</p>
17
+ </div>
18
+ </div>
19
+ </section>
20
+ </template>
21
+
22
+ <script setup lang="ts">
23
+ import { computed } from 'vue'
24
+ type Align = 'left' | 'center' | 'right'
25
+
26
+ const props = withDefaults(defineProps<{
27
+ title: string
28
+ subtitle?: string
29
+ imageSrc?: string
30
+ alt?: string
31
+ alignment?: Align // only used when image is hidden
32
+ eagerImage?: boolean
33
+ }>(), {
34
+ alignment: 'center',
35
+ eagerImage: false,
36
+ })
37
+
38
+ const hasImage = computed(() => !!props.imageSrc)
39
+ const altText = computed(() => props.alt ?? props.title)
40
+ const loadingAttr = computed(() => (props.eagerImage ? 'eager' : 'lazy'))
41
+
42
+ const containerClass = computed(() => {
43
+ if (hasImage.value) return 'flex flex-col items-center text-center gap-3 pt-6 pb-2'
44
+ return 'flex flex-col gap-1 pt-2 pb-2'
45
+ })
46
+
47
+ const textWrapClass = computed(() => {
48
+ if (hasImage.value) return 'text-center space-y-1'
49
+ switch (props.alignment) {
50
+ case 'left':
51
+ return 'text-left'
52
+ case 'right':
53
+ return 'text-right'
54
+ default:
55
+ return 'text-center'
56
+ }
57
+ })
58
+ </script>
59
+
60
+ <style scoped>
61
+ </style>
@@ -0,0 +1,128 @@
1
+ <template>
2
+ <component :is="tag" v-bind="bind" :class="classes" :disabled="disabled || loading" @click="onClick">
3
+ <Icon v-if="loading" name="i-heroicons-arrow-path-20-solid" class="h-5 w-5 animate-spin" />
4
+ <Icon v-if="icon && iconPosition === 'left' && !loading" :name="icon" class="h-5 w-5" />
5
+ <span>{{ title }}</span>
6
+ <Icon v-if="icon && iconPosition === 'right' && !loading" :name="icon" class="h-5 w-5" />
7
+ </component>
8
+ </template>
9
+
10
+ <script setup lang="ts">
11
+ import { computed } from 'vue'
12
+ import { shareURL } from '~/composables/telegram'
13
+ import { useHapticFeedback } from '~/composables/telegram'
14
+
15
+ const props = withDefaults(defineProps<{
16
+ title: string
17
+ /**
18
+ * Visual style of the button
19
+ * - 'primary' (default)
20
+ * - 'secondary' (subtle background)
21
+ * - 'outline' (bordered)
22
+ * - 'danger' | 'destructive' (red)
23
+ */
24
+ status?: 'primary' | 'secondary' | 'outline' | 'danger' | 'destructive'
25
+ icon?: string
26
+ /** Where to render the icon */
27
+ iconPosition?: 'left' | 'right'
28
+ to?: string
29
+ href?: string
30
+ /** When set, clicking triggers share; falls back to link if provided. */
31
+ shareUrl?: string
32
+ /** Full-width button */
33
+ block?: boolean
34
+ /** Deprecated in favor of `size`, kept for back-compat */
35
+ small?: boolean
36
+ /** Control size */
37
+ size?: 'sm' | 'md' | 'lg'
38
+ /** Loading state disables interactions */
39
+ loading?: boolean
40
+ /** Elevation shadow */
41
+ elevated?: boolean
42
+ /** Uppercase label */
43
+ uppercase?: boolean
44
+ disabled?: boolean
45
+ class?: string
46
+ /**
47
+ * Optional haptic feedback on click.
48
+ * - 'selection' (default when true)
49
+ * - 'impact-light' | 'impact-medium' | 'impact-heavy'
50
+ * - 'notification-success' | 'notification-warning' | 'notification-error'
51
+ */
52
+ haptic?: boolean | 'selection' | 'impact-light' | 'impact-medium' | 'impact-heavy' | 'notification-success' | 'notification-warning' | 'notification-error'
53
+ }>(), {
54
+ status: 'primary',
55
+ iconPosition: 'left',
56
+ block: true,
57
+ small: false,
58
+ size: undefined,
59
+ loading: false,
60
+ elevated: false,
61
+ uppercase: false,
62
+ disabled: false,
63
+ class: '',
64
+ haptic: false,
65
+ })
66
+
67
+ const tag = computed(() => props.to ? 'NuxtLink' : (props.href ? 'a' : 'button'))
68
+ const bind = computed(() => ({ to: props.to, href: props.href, type: 'button' }))
69
+
70
+ const sizeClass = computed(() => {
71
+ const size = props.size || (props.small ? 'sm' : 'md')
72
+ if (size === 'sm') return 'text-sm h-9 px-3'
73
+ if (size === 'lg') return 'h-12 px-5 text-base'
74
+ return 'h-11 px-4'
75
+ })
76
+
77
+ const variantClass = computed(() => {
78
+ const v = props.status
79
+ if (v === 'outline') return 'border border-sectionSeparator text-text bg-secondaryBg'
80
+ if (v === 'secondary') return 'bg-secondaryBg text-text'
81
+ if (v === 'danger' || v === 'destructive') return 'bg-[var(--tg-theme-destructive-text-color)] text-primaryFg'
82
+ return 'bg-primary text-primaryFg'
83
+ })
84
+
85
+ const classes = computed(() => [
86
+ 'inline-flex items-center justify-center gap-2 font-medium rounded-md',
87
+ sizeClass.value,
88
+ props.block ? 'w-full' : 'w-auto',
89
+ variantClass.value,
90
+ (props.disabled || props.loading) ? 'opacity-50 pointer-events-none' : 'hover:opacity-90',
91
+ props.elevated ? 'shadow-sm' : '',
92
+ props.uppercase ? 'uppercase' : '',
93
+ // visual feedback
94
+ 'transition-transform transition-opacity duration-150 active:scale-[.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40',
95
+ props.class,
96
+ ].filter(Boolean).join(' '))
97
+
98
+ const haptic = useHapticFeedback()
99
+
100
+ function triggerHaptic() {
101
+ if (!props.haptic) return
102
+ const kind = props.haptic === true ? 'selection' : props.haptic
103
+ try {
104
+ if (kind === 'selection') haptic.selectionChanged()
105
+ else if (kind.startsWith('impact-')) {
106
+ const style = kind.split('-')[1] as 'light' | 'medium' | 'heavy'
107
+ haptic.impactOccurred(style)
108
+ }
109
+ else if (kind.startsWith('notification-')) {
110
+ const n = kind.split('-')[1] as 'success' | 'warning' | 'error'
111
+ haptic.notificationOccurred(n)
112
+ }
113
+ } catch {}
114
+ }
115
+
116
+ function onClick(e: Event) {
117
+ if (props.disabled || props.loading) return
118
+ // Haptic first for immediate feedback
119
+ triggerHaptic()
120
+ if (props.shareUrl) {
121
+ e.preventDefault()
122
+ shareURL(props.shareUrl)
123
+ }
124
+ }
125
+ </script>
126
+
127
+ <style scoped>
128
+ </style>
@@ -0,0 +1,91 @@
1
+ <template>
2
+ <component :is="wrapperTag" v-bind="wrapperBind" :class="rootClass">
3
+ <div v-if="icon" class="shrink-0 h-6 w-6 flex items-center justify-center">
4
+ <Icon :name="icon" class="h-5 w-5" :style="iconStyle" />
5
+ </div>
6
+ <div class="min-w-0 min-h-6 flex-1">
7
+ <div class="text-sm font-medium" :style="titleStyle">
8
+ <slot name="title">{{ title }}</slot>
9
+ </div>
10
+ <div v-if="subtitle || $slots.subtitle" class="text-xs text-hint">
11
+ <slot name="subtitle">{{ subtitle }}</slot>
12
+ </div>
13
+ <div v-if="description || $slots.description" class="text-xs text-hint" :style="descStyle">
14
+ <slot name="description">{{ description }}</slot>
15
+ </div>
16
+ </div>
17
+ <Icon v-if="showChevron" name="i-heroicons-chevron-right-20-solid" class="h-5 w-5 text-hint" />
18
+ </component>
19
+ </template>
20
+
21
+ <script setup lang="ts">
22
+ import { computed } from 'vue'
23
+
24
+ const props = withDefaults(defineProps<{
25
+ title?: string
26
+ description?: string
27
+ subtitle?: string
28
+ icon?: string
29
+ /** E.g. '#3ea6ff' or 'var(--tg-theme-link-color)' */
30
+ color?: string
31
+ iconColor?: string
32
+ /** Line clamp for description/title (0 = none) */
33
+ lineClamp?: number
34
+ /** Show divider under the cell */
35
+ border?: boolean
36
+ /** Route location; renders a NuxtLink when set */
37
+ to?: string
38
+ /** External link; renders an anchor when set */
39
+ href?: string
40
+ /** Background tone */
41
+ tone?: 'default' | 'secondary'
42
+ /** Force interactive hover state even without link */
43
+ clickable?: boolean
44
+ /** Show chevron; defaults to auto when link */
45
+ chevron?: boolean
46
+ class?: string
47
+ }>(), {
48
+ title: '',
49
+ description: undefined,
50
+ subtitle: undefined,
51
+ icon: undefined,
52
+ color: undefined,
53
+ iconColor: undefined,
54
+ lineClamp: 0,
55
+ border: true,
56
+ to: undefined,
57
+ href: undefined,
58
+ tone: 'default',
59
+ clickable: false,
60
+ chevron: undefined,
61
+ class: '',
62
+ })
63
+
64
+ const isLink = computed(() => !!(props.to || props.href))
65
+
66
+ const wrapperTag = computed(() => props.to ? 'NuxtLink' : (props.href ? 'a' : 'div'))
67
+ const wrapperBind = computed(() => ({ to: props.to, href: props.href }))
68
+
69
+ const clampStyle = computed(() => props.lineClamp > 0
70
+ ? { display: '-webkit-box', WebkitLineClamp: String(props.lineClamp), WebkitBoxOrient: 'vertical', overflow: 'hidden' }
71
+ : {})
72
+
73
+ const titleStyle = computed(() => ({ ...(props.color ? { color: props.color } : {}), ...clampStyle.value }))
74
+ const descStyle = computed(() => clampStyle.value)
75
+ const iconStyle = computed(() => props.iconColor ? { color: props.iconColor } : {})
76
+
77
+ const bgClass = computed(() => props.tone === 'secondary' ? 'bg-secondaryBg' : 'bg-bg')
78
+
79
+ const showChevron = computed(() => props.chevron ?? isLink.value)
80
+
81
+ const rootClass = computed(() => [
82
+ 'w-full flex items-center gap-3 px-4 py-3',
83
+ bgClass.value,
84
+ props.border ? 'border-b border-sectionSeparator last:border-b-0' : '',
85
+ (isLink.value || props.clickable) ? 'hover:bg-secondaryBg transition-colors' : '',
86
+ props.class,
87
+ ].filter(Boolean).join(' '))
88
+ </script>
89
+
90
+ <style scoped>
91
+ </style>
@@ -0,0 +1,42 @@
1
+ <template>
2
+ <component :is="asTag" :class="rootClasses">
3
+ <slot />
4
+ </component>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ import { computed, ref, onMounted } from 'vue'
9
+
10
+ const props = withDefaults(defineProps<{
11
+ as?: string
12
+ /** Tailwind max-width class, e.g. 'max-w-2xl' (optional override) */
13
+ maxWidthClass?: string
14
+ /** Extra classes to merge */
15
+ class?: string
16
+ }>(), {
17
+ as: 'main',
18
+ maxWidthClass: undefined,
19
+ class: '',
20
+ })
21
+
22
+ const asTag = computed(() => props.as)
23
+
24
+ const maxWidthClass = computed(() => props.maxWidthClass || 'max-w-2xl')
25
+
26
+ const hasNav = ref(false)
27
+ onMounted(() => {
28
+ try {
29
+ hasNav.value = !!document.querySelector('[data-tg-nav]')
30
+ } catch {}
31
+ })
32
+
33
+ const rootClasses = computed(() => [
34
+ 'mx-auto text-text space-y-6',
35
+ maxWidthClass.value,
36
+ hasNav.value ? 'pb-[calc(56px+var(--safe-area-inset-bottom))]' : '',
37
+ props.class,
38
+ ].filter(Boolean).join(' '))
39
+ </script>
40
+
41
+ <style scoped>
42
+ </style>
@@ -0,0 +1,107 @@
1
+ <template>
2
+ <nav
3
+ class="fixed bottom-0 inset-x-0"
4
+ data-tg-nav
5
+ :class="[rootClass]"
6
+ role="tablist"
7
+ >
8
+ <ul class="mx-auto p-2 grid max-w-2xl" :style="gridStyle">
9
+ <li v-for="item in items" :key="item.key">
10
+ <NuxtLink
11
+ v-if="item.to"
12
+ :to="item.to"
13
+ :class="[itemClass, { 'text-text': currentActiveKey === item.key }]"
14
+ :aria-current="currentActiveKey === item.key ? 'page' : undefined"
15
+ >
16
+ <Icon v-if="item.icon" :name="item.icon" class="h-5 w-5" :class="currentActiveKey === item.key ? 'text-text' : ''" />
17
+ <span :class="currentActiveKey === item.key ? 'text-text' : ''">{{ item.label }}</span>
18
+ </NuxtLink>
19
+ <button
20
+ v-else
21
+ type="button"
22
+ :class="[itemClass, { 'text-text': currentActiveKey === item.key }]"
23
+ :aria-current="currentActiveKey === item.key ? 'page' : undefined"
24
+ @click="onSelect(item)"
25
+ >
26
+ <Icon v-if="item.icon" :name="item.icon" class="h-5 w-5" :class="currentActiveKey === item.key ? 'text-text' : ''" />
27
+ <span :class="currentActiveKey === item.key ? 'text-text' : ''">{{ item.label }}</span>
28
+ </button>
29
+ </li>
30
+ </ul>
31
+ <div v-if="safeArea" class="pb-[var(--safe-area-inset-bottom)]" />
32
+ </nav>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import { computed } from 'vue'
37
+ import { useRoute } from 'vue-router'
38
+
39
+ export interface TgNavItem {
40
+ key: string
41
+ label: string
42
+ icon?: string
43
+ to?: string
44
+ }
45
+
46
+ const props = withDefaults(defineProps<{
47
+ items: TgNavItem[]
48
+ modelValue?: string
49
+ /** Optional class for the root nav element */
50
+ rootClass?: string
51
+ /** Background tone */
52
+ tone?: 'default' | 'secondary'
53
+ /** Show top border */
54
+ border?: boolean
55
+ /** Height of each item */
56
+ height?: '12' | '14'
57
+ /** Respect bottom safe area */
58
+ safeArea?: boolean
59
+ }>(), {
60
+ items: () => [],
61
+ modelValue: undefined,
62
+ rootClass: '',
63
+ tone: 'default',
64
+ border: true,
65
+ height: '14',
66
+ safeArea: true,
67
+ })
68
+
69
+ const emit = defineEmits<{
70
+ (e: 'update:modelValue', value: string): void
71
+ (e: 'select', item: TgNavItem): void
72
+ }>()
73
+
74
+ const route = useRoute()
75
+
76
+ // Determine active key based on current route
77
+ const currentActiveKey = computed(() => {
78
+ // First try to match by route path
79
+ const matchedItem = props.items.find(item => item.to === route.path)
80
+ if (matchedItem) {
81
+ return matchedItem.key
82
+ }
83
+
84
+ // Fallback to modelValue prop
85
+ return props.modelValue || ''
86
+ })
87
+
88
+ const gridStyle = computed(() => ({ gridTemplateColumns: `repeat(${Math.min(props.items.length || 1, 4)}, minmax(0, 1fr))` }))
89
+
90
+ const itemClass = computed(() => [
91
+ `h-${props.height} w-full flex flex-col items-center justify-center gap-1 text-xs text-hint hover:text-text transition-colors`
92
+ ].join(' '))
93
+
94
+ const rootClass = computed(() => [
95
+ props.border ? 'border-t border-sectionSeparator' : '',
96
+ props.tone === 'secondary' ? 'bg-secondaryBg' : 'bg-bg',
97
+ 'backdrop-blur supports-[backdrop-filter]:bg-bg50',
98
+ ].filter(Boolean).join(' '))
99
+
100
+ function onSelect(item: TgNavItem) {
101
+ emit('update:modelValue', item.key)
102
+ emit('select', item)
103
+ }
104
+ </script>
105
+
106
+ <style scoped>
107
+ </style>