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/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,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>
|