Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
ANALYZE=false # true | false
# Set the Node.js environment (production: for live deployment, development: for local development)
NODE_ENV=production

NODE_ENV=production # production | development
# Enable or disable bundle analysis (true/false)
ANALYZE=false

# Open bundle analyzer automatically after build (true/false)
OPEN_ANALYZER=false

# Set the mode for bundle analyzer output (static: HTML file, json: JSON file)
ANALYZER_MODE=static
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@

# testing
/coverage
/analyze

# next.js
/.next/
Expand Down
7 changes: 4 additions & 3 deletions next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import bundleAnalyzer from '@next/bundle-analyzer'
import { env } from './src/env.mjs'

const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: false,
analyzerMode: 'static',
enabled: env.ANALYZE,
openAnalyzer: env.OPEN_ANALYZER,
analyzerMode: env.ANALYZER_MODE,
})

/**
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,14 +26,16 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/compat": "^1.2.1",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.13.0",
"@next/bundle-analyzer": "^14.2.15",
"@next/eslint-plugin-next": "^14.2.15",
"@t3-oss/env-nextjs": "^0.11.1",
"@types/node": "^22.7.6",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
Expand Down
36 changes: 36 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,10 +37,10 @@ const RootLayout: React.FC<PropsWithChildren> = ({ children }) => {
className={cn(
GeistSans.variable,
GeistMono.variable,
'top-[-25px] antialiased',
'bg-black text-white antialiased',
)}
>
<body className='bg-black text-white'>{children}</body>
<body>{children}</body>
</html>
)
}
Expand Down
26 changes: 26 additions & 0 deletions src/app/tma__/layout.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
import type React from 'react'
import type { PropsWithChildren } from 'react'
import type { Metadata, Viewport } from 'next'

import { Fragment } from 'react'
import { TelegramMiniAppRegister } from '~/src/features'

export const metadata: Metadata = {
//
}

export const viewport: Viewport = {
colorScheme: 'dark',
userScalable: false,
}

const TelegramWebAppsLayout: React.FC<PropsWithChildren> = ({ children }) => {
return (
<Fragment>
<TelegramMiniAppRegister>{children}</TelegramMiniAppRegister>
</Fragment>
)
}
TelegramWebAppsLayout.displayName = 'Root layout for telegram web app'

export default TelegramWebAppsLayout
25 changes: 25 additions & 0 deletions src/app/tma__/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import type React from 'react'

import { cn } from '#utils'

const Homepage: React.FC = () => {
return (
<section className='grid min-h-dvh place-content-center'>
<span
className={cn(
'bg-clip-text text-2xl font-semibold text-transparent',
'bg-gradient-to-r from-white via-slate-500 to-white',
'animate-[shimmer-bg_2s_infinite] from-40% to-60%',
)}
style={{
backgroundSize: '300% 100%',
backgroundPositionX: '100%',
}}
>
Goodbye World!
</span>
</section>
)
}

export default Homepage
13 changes: 13 additions & 0 deletions src/components/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
/**
* This file exports all components from the #components directory.
* It serves as a central point for importing components throughout the application.
* When adding new components, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { Button } from '#components/Button'
* import { Input } from '#components/Input'
*
* You can now use:
* import { Button, Input } from '#components'
*/
49 changes: 49 additions & 0 deletions src/env.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
/*
* Server-side Environment variables, not available on the client.
* Will throw if you access these variables on the client.
*/
server: {
NODE_ENV: z.enum(['development', 'production']),

ANALYZE: z.string().transform(v => v === 'true'),
OPEN_ANALYZER: z.string().transform(v => v === 'true'),
ANALYZER_MODE: z.enum(['static', 'json']),
},

/*
* Environment variables available on the client (and server).
*/
client: {
NEXT_PUBLIC_NODE_ENV: z.enum(['development', 'production']),
},

/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,

ANALYZE: process.env.ANALYZE,
OPEN_ANALYZER: process.env.OPEN_ANALYZER,
ANALYZER_MODE: process.env.ANALYZER_MODE,
},

/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* This is especially useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
})

export default env
Empty file.
Empty file.
Empty file.
66 changes: 66 additions & 0 deletions src/features/TelegramWebApp/register.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
'use client'

import type React from 'react'
import type { PropsWithChildren } from 'react'

import env from '#env'
import { Fragment } from 'react'
import { init } from '@/init'
import {
initDataUser,
useLaunchParams,
useSignal,
} from '@telegram-apps/sdk-react'
import { useClientOnce, useDidMount, useTelegramMock } from '#hooks'
import { cn } from '#utils'

const TelegramMiniApp: React.FC<PropsWithChildren> = ({ children }) => {
const isDev = env.NEXT_PUBLIC_NODE_ENV === 'development'

if (isDev) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useTelegramMock()
}

const lp = useLaunchParams()
const debug = isDev || lp.startParam === 'debug'

useClientOnce(() => {
init(debug)
})

const client = useSignal(initDataUser)

return (
<Fragment>
<section className='fixed left-0 top-0 bg-black/75 backdrop-blur-sm'>
<pre className='font-mono text-white'>
{JSON.stringify(client, null, 2)}
</pre>
</section>

{children}
</Fragment>
)
}

const TelegramMiniAppLoader: React.FC = () => {
return (
<div
className={cn(
'size-20 animate-spin rounded-full',
'border-4 border-white border-t-transparent',
)}
/>
)
}

export const TelegramMiniAppRegister: React.FC<PropsWithChildren> = props => {
const isMounted = useDidMount()

if (!isMounted) {
return <TelegramMiniAppLoader />
}

return <TelegramMiniApp {...props} />
}
15 changes: 15 additions & 0 deletions src/features/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
/**
* This file exports all features from the #features directory.
* It serves as a central point for importing features throughout the application.
* When adding new features, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { TelegramMiniAppRegister } from '#features/Button'
* import { SomeFeatureRegister } from '#features/Input'
*
* You can now use:
* import { TelegramMiniAppRegister, SomeFeatureRegister } from '#features'
*/

export { TelegramMiniAppRegister } from '#features/TelegramWebApp/register'
17 changes: 17 additions & 0 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
/**
* This file exports all functions from the #hooks directory.
* It serves as a central point for importing hook functions throughout the application.
* When adding new hooks, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { useDidMount } from '#hooks/useDidMount'
* import { useSomeFn } from '#hooks/useSomeFn'
*
* You can now use:
* import { useDidMount, useSomeFn } from '#hooks'
*/

export { useTelegramMock } from '#hooks/useTelegramMock'
export { useClientOnce } from '#hooks/useClientOnce'
export { useDidMount } from '#hooks/useDidMount'
34 changes: 34 additions & 0 deletions src/hooks/useClientOnce.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
import { useRef } from 'react'

type UseClientOnceFn = () => void

/**
* A custom React hook that ensures a function is called only once on the client side.
*
* @param {UseClientOnceFn} fn - The function to be executed once on the client side.
*
* @example ```ts
* useClientOnce(() => {
* console.log('This will be logged only once on the client side');
* });
* ```
* @see https://github.com/Telegram-Mini-Apps/telegram-apps/blob/master/playgrounds/next/src/hooks/useClientOnce.ts
*/
export function useClientOnce(fn: UseClientOnceFn): void {
const hasRun = useRef(false)

/**
* Confirms that the code is running in a browser environment (`typeof window !== 'undefined`),
* which prevents execution on the server side.
*
* By checking outside React lifecycle hooks (like `useEffect`), the hook minimizes unnecessary renders
* and allows `fn` to execute immediately on component initialization, but only once in the client environment.
* The `hasRun` ref ensures `fn` is only called once per component lifecycle.
*/
if (typeof window !== 'undefined' && !hasRun.current) {
hasRun.current = true
fn()
}

return
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
ANALYZE=false # true | false
# Set the Node.js environment (production: for live deployment, development: for local development)
NODE_ENV=production

NODE_ENV=production # production | development
# Enable or disable bundle analysis (true/false)
ANALYZE=false

# Open bundle analyzer automatically after build (true/false)
OPEN_ANALYZER=false

# Set the mode for bundle analyzer output (static: HTML file, json: JSON file)
ANALYZER_MODE=static
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@

# testing
/coverage
/analyze

# next.js
/.next/
Expand Down
7 changes: 4 additions & 3 deletions next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import bundleAnalyzer from '@next/bundle-analyzer'
import { env } from './src/env.mjs'

const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: false,
analyzerMode: 'static',
enabled: env.ANALYZE,
openAnalyzer: env.OPEN_ANALYZER,
analyzerMode: env.ANALYZER_MODE,
})

/**
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,14 +26,16 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/compat": "^1.2.1",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.13.0",
"@next/bundle-analyzer": "^14.2.15",
"@next/eslint-plugin-next": "^14.2.15",
"@t3-oss/env-nextjs": "^0.11.1",
"@types/node": "^22.7.6",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
Expand Down
36 changes: 36 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,10 +37,10 @@ const RootLayout: React.FC<PropsWithChildren> = ({ children }) => {
className={cn(
GeistSans.variable,
GeistMono.variable,
'top-[-25px] antialiased',
'bg-black text-white antialiased',
)}
>
<body className='bg-black text-white'>{children}</body>
<body>{children}</body>
</html>
)
}
Expand Down
26 changes: 26 additions & 0 deletions src/app/tma__/layout.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
import type React from 'react'
import type { PropsWithChildren } from 'react'
import type { Metadata, Viewport } from 'next'

import { Fragment } from 'react'
import { TelegramMiniAppRegister } from '~/src/features'

export const metadata: Metadata = {
//
}

export const viewport: Viewport = {
colorScheme: 'dark',
userScalable: false,
}

const TelegramWebAppsLayout: React.FC<PropsWithChildren> = ({ children }) => {
return (
<Fragment>
<TelegramMiniAppRegister>{children}</TelegramMiniAppRegister>
</Fragment>
)
}
TelegramWebAppsLayout.displayName = 'Root layout for telegram web app'

export default TelegramWebAppsLayout
25 changes: 25 additions & 0 deletions src/app/tma__/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import type React from 'react'

import { cn } from '#utils'

const Homepage: React.FC = () => {
return (
<section className='grid min-h-dvh place-content-center'>
<span
className={cn(
'bg-clip-text text-2xl font-semibold text-transparent',
'bg-gradient-to-r from-white via-slate-500 to-white',
'animate-[shimmer-bg_2s_infinite] from-40% to-60%',
)}
style={{
backgroundSize: '300% 100%',
backgroundPositionX: '100%',
}}
>
Goodbye World!
</span>
</section>
)
}

export default Homepage
13 changes: 13 additions & 0 deletions src/components/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
/**
* This file exports all components from the #components directory.
* It serves as a central point for importing components throughout the application.
* When adding new components, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { Button } from '#components/Button'
* import { Input } from '#components/Input'
*
* You can now use:
* import { Button, Input } from '#components'
*/
49 changes: 49 additions & 0 deletions src/env.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
/*
* Server-side Environment variables, not available on the client.
* Will throw if you access these variables on the client.
*/
server: {
NODE_ENV: z.enum(['development', 'production']),

ANALYZE: z.string().transform(v => v === 'true'),
OPEN_ANALYZER: z.string().transform(v => v === 'true'),
ANALYZER_MODE: z.enum(['static', 'json']),
},

/*
* Environment variables available on the client (and server).
*/
client: {
NEXT_PUBLIC_NODE_ENV: z.enum(['development', 'production']),
},

/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,

ANALYZE: process.env.ANALYZE,
OPEN_ANALYZER: process.env.OPEN_ANALYZER,
ANALYZER_MODE: process.env.ANALYZER_MODE,
},

/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* This is especially useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
})

export default env
Empty file.
Empty file.
Empty file.
66 changes: 66 additions & 0 deletions src/features/TelegramWebApp/register.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
'use client'

import type React from 'react'
import type { PropsWithChildren } from 'react'

import env from '#env'
import { Fragment } from 'react'
import { init } from '@/init'
import {
initDataUser,
useLaunchParams,
useSignal,
} from '@telegram-apps/sdk-react'
import { useClientOnce, useDidMount, useTelegramMock } from '#hooks'
import { cn } from '#utils'

const TelegramMiniApp: React.FC<PropsWithChildren> = ({ children }) => {
const isDev = env.NEXT_PUBLIC_NODE_ENV === 'development'

if (isDev) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useTelegramMock()
}

const lp = useLaunchParams()
const debug = isDev || lp.startParam === 'debug'

useClientOnce(() => {
init(debug)
})

const client = useSignal(initDataUser)

return (
<Fragment>
<section className='fixed left-0 top-0 bg-black/75 backdrop-blur-sm'>
<pre className='font-mono text-white'>
{JSON.stringify(client, null, 2)}
</pre>
</section>

{children}
</Fragment>
)
}

const TelegramMiniAppLoader: React.FC = () => {
return (
<div
className={cn(
'size-20 animate-spin rounded-full',
'border-4 border-white border-t-transparent',
)}
/>
)
}

export const TelegramMiniAppRegister: React.FC<PropsWithChildren> = props => {
const isMounted = useDidMount()

if (!isMounted) {
return <TelegramMiniAppLoader />
}

return <TelegramMiniApp {...props} />
}
15 changes: 15 additions & 0 deletions src/features/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
/**
* This file exports all features from the #features directory.
* It serves as a central point for importing features throughout the application.
* When adding new features, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { TelegramMiniAppRegister } from '#features/Button'
* import { SomeFeatureRegister } from '#features/Input'
*
* You can now use:
* import { TelegramMiniAppRegister, SomeFeatureRegister } from '#features'
*/

export { TelegramMiniAppRegister } from '#features/TelegramWebApp/register'
17 changes: 17 additions & 0 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
/**
* This file exports all functions from the #hooks directory.
* It serves as a central point for importing hook functions throughout the application.
* When adding new hooks, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { useDidMount } from '#hooks/useDidMount'
* import { useSomeFn } from '#hooks/useSomeFn'
*
* You can now use:
* import { useDidMount, useSomeFn } from '#hooks'
*/

export { useTelegramMock } from '#hooks/useTelegramMock'
export { useClientOnce } from '#hooks/useClientOnce'
export { useDidMount } from '#hooks/useDidMount'
34 changes: 34 additions & 0 deletions src/hooks/useClientOnce.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
import { useRef } from 'react'

type UseClientOnceFn = () => void

/**
* A custom React hook that ensures a function is called only once on the client side.
*
* @param {UseClientOnceFn} fn - The function to be executed once on the client side.
*
* @example ```ts
* useClientOnce(() => {
* console.log('This will be logged only once on the client side');
* });
* ```
* @see https://github.com/Telegram-Mini-Apps/telegram-apps/blob/master/playgrounds/next/src/hooks/useClientOnce.ts
*/
export function useClientOnce(fn: UseClientOnceFn): void {
const hasRun = useRef(false)

/**
* Confirms that the code is running in a browser environment (`typeof window !== 'undefined`),
* which prevents execution on the server side.
*
* By checking outside React lifecycle hooks (like `useEffect`), the hook minimizes unnecessary renders
* and allows `fn` to execute immediately on component initialization, but only once in the client environment.
* The `hasRun` ref ensures `fn` is only called once per component lifecycle.
*/
if (typeof window !== 'undefined' && !hasRun.current) {
hasRun.current = true
fn()
}

return
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
ANALYZE=false # true | false
# Set the Node.js environment (production: for live deployment, development: for local development)
NODE_ENV=production

NODE_ENV=production # production | development
# Enable or disable bundle analysis (true/false)
ANALYZE=false

# Open bundle analyzer automatically after build (true/false)
OPEN_ANALYZER=false

# Set the mode for bundle analyzer output (static: HTML file, json: JSON file)
ANALYZER_MODE=static
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@

# testing
/coverage
/analyze

# next.js
/.next/
Expand Down
7 changes: 4 additions & 3 deletions next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import bundleAnalyzer from '@next/bundle-analyzer'
import { env } from './src/env.mjs'

const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: false,
analyzerMode: 'static',
enabled: env.ANALYZE,
openAnalyzer: env.OPEN_ANALYZER,
analyzerMode: env.ANALYZER_MODE,
})

/**
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,14 +26,16 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/compat": "^1.2.1",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.13.0",
"@next/bundle-analyzer": "^14.2.15",
"@next/eslint-plugin-next": "^14.2.15",
"@t3-oss/env-nextjs": "^0.11.1",
"@types/node": "^22.7.6",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
Expand Down
36 changes: 36 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,10 +37,10 @@ const RootLayout: React.FC<PropsWithChildren> = ({ children }) => {
className={cn(
GeistSans.variable,
GeistMono.variable,
'top-[-25px] antialiased',
'bg-black text-white antialiased',
)}
>
<body className='bg-black text-white'>{children}</body>
<body>{children}</body>
</html>
)
}
Expand Down
26 changes: 26 additions & 0 deletions src/app/tma__/layout.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
import type React from 'react'
import type { PropsWithChildren } from 'react'
import type { Metadata, Viewport } from 'next'

import { Fragment } from 'react'
import { TelegramMiniAppRegister } from '~/src/features'

export const metadata: Metadata = {
//
}

export const viewport: Viewport = {
colorScheme: 'dark',
userScalable: false,
}

const TelegramWebAppsLayout: React.FC<PropsWithChildren> = ({ children }) => {
return (
<Fragment>
<TelegramMiniAppRegister>{children}</TelegramMiniAppRegister>
</Fragment>
)
}
TelegramWebAppsLayout.displayName = 'Root layout for telegram web app'

export default TelegramWebAppsLayout
25 changes: 25 additions & 0 deletions src/app/tma__/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import type React from 'react'

import { cn } from '#utils'

const Homepage: React.FC = () => {
return (
<section className='grid min-h-dvh place-content-center'>
<span
className={cn(
'bg-clip-text text-2xl font-semibold text-transparent',
'bg-gradient-to-r from-white via-slate-500 to-white',
'animate-[shimmer-bg_2s_infinite] from-40% to-60%',
)}
style={{
backgroundSize: '300% 100%',
backgroundPositionX: '100%',
}}
>
Goodbye World!
</span>
</section>
)
}

export default Homepage
13 changes: 13 additions & 0 deletions src/components/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
/**
* This file exports all components from the #components directory.
* It serves as a central point for importing components throughout the application.
* When adding new components, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { Button } from '#components/Button'
* import { Input } from '#components/Input'
*
* You can now use:
* import { Button, Input } from '#components'
*/
49 changes: 49 additions & 0 deletions src/env.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
/*
* Server-side Environment variables, not available on the client.
* Will throw if you access these variables on the client.
*/
server: {
NODE_ENV: z.enum(['development', 'production']),

ANALYZE: z.string().transform(v => v === 'true'),
OPEN_ANALYZER: z.string().transform(v => v === 'true'),
ANALYZER_MODE: z.enum(['static', 'json']),
},

/*
* Environment variables available on the client (and server).
*/
client: {
NEXT_PUBLIC_NODE_ENV: z.enum(['development', 'production']),
},

/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,

ANALYZE: process.env.ANALYZE,
OPEN_ANALYZER: process.env.OPEN_ANALYZER,
ANALYZER_MODE: process.env.ANALYZER_MODE,
},

/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* This is especially useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
})

export default env
Empty file.
Empty file.
Empty file.
66 changes: 66 additions & 0 deletions src/features/TelegramWebApp/register.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
'use client'

import type React from 'react'
import type { PropsWithChildren } from 'react'

import env from '#env'
import { Fragment } from 'react'
import { init } from '@/init'
import {
initDataUser,
useLaunchParams,
useSignal,
} from '@telegram-apps/sdk-react'
import { useClientOnce, useDidMount, useTelegramMock } from '#hooks'
import { cn } from '#utils'

const TelegramMiniApp: React.FC<PropsWithChildren> = ({ children }) => {
const isDev = env.NEXT_PUBLIC_NODE_ENV === 'development'

if (isDev) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useTelegramMock()
}

const lp = useLaunchParams()
const debug = isDev || lp.startParam === 'debug'

useClientOnce(() => {
init(debug)
})

const client = useSignal(initDataUser)

return (
<Fragment>
<section className='fixed left-0 top-0 bg-black/75 backdrop-blur-sm'>
<pre className='font-mono text-white'>
{JSON.stringify(client, null, 2)}
</pre>
</section>

{children}
</Fragment>
)
}

const TelegramMiniAppLoader: React.FC = () => {
return (
<div
className={cn(
'size-20 animate-spin rounded-full',
'border-4 border-white border-t-transparent',
)}
/>
)
}

export const TelegramMiniAppRegister: React.FC<PropsWithChildren> = props => {
const isMounted = useDidMount()

if (!isMounted) {
return <TelegramMiniAppLoader />
}

return <TelegramMiniApp {...props} />
}
15 changes: 15 additions & 0 deletions src/features/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
/**
* This file exports all features from the #features directory.
* It serves as a central point for importing features throughout the application.
* When adding new features, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { TelegramMiniAppRegister } from '#features/Button'
* import { SomeFeatureRegister } from '#features/Input'
*
* You can now use:
* import { TelegramMiniAppRegister, SomeFeatureRegister } from '#features'
*/

export { TelegramMiniAppRegister } from '#features/TelegramWebApp/register'
17 changes: 17 additions & 0 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
/**
* This file exports all functions from the #hooks directory.
* It serves as a central point for importing hook functions throughout the application.
* When adding new hooks, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { useDidMount } from '#hooks/useDidMount'
* import { useSomeFn } from '#hooks/useSomeFn'
*
* You can now use:
* import { useDidMount, useSomeFn } from '#hooks'
*/

export { useTelegramMock } from '#hooks/useTelegramMock'
export { useClientOnce } from '#hooks/useClientOnce'
export { useDidMount } from '#hooks/useDidMount'
34 changes: 34 additions & 0 deletions src/hooks/useClientOnce.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
import { useRef } from 'react'

type UseClientOnceFn = () => void

/**
* A custom React hook that ensures a function is called only once on the client side.
*
* @param {UseClientOnceFn} fn - The function to be executed once on the client side.
*
* @example ```ts
* useClientOnce(() => {
* console.log('This will be logged only once on the client side');
* });
* ```
* @see https://github.com/Telegram-Mini-Apps/telegram-apps/blob/master/playgrounds/next/src/hooks/useClientOnce.ts
*/
export function useClientOnce(fn: UseClientOnceFn): void {
const hasRun = useRef(false)

/**
* Confirms that the code is running in a browser environment (`typeof window !== 'undefined`),
* which prevents execution on the server side.
*
* By checking outside React lifecycle hooks (like `useEffect`), the hook minimizes unnecessary renders
* and allows `fn` to execute immediately on component initialization, but only once in the client environment.
* The `hasRun` ref ensures `fn` is only called once per component lifecycle.
*/
if (typeof window !== 'undefined' && !hasRun.current) {
hasRun.current = true
fn()
}

return
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
ANALYZE=false # true | false
# Set the Node.js environment (production: for live deployment, development: for local development)
NODE_ENV=production

NODE_ENV=production # production | development
# Enable or disable bundle analysis (true/false)
ANALYZE=false

# Open bundle analyzer automatically after build (true/false)
OPEN_ANALYZER=false

# Set the mode for bundle analyzer output (static: HTML file, json: JSON file)
ANALYZER_MODE=static
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@

# testing
/coverage
/analyze

# next.js
/.next/
Expand Down
7 changes: 4 additions & 3 deletions next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import bundleAnalyzer from '@next/bundle-analyzer'
import { env } from './src/env.mjs'

const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: false,
analyzerMode: 'static',
enabled: env.ANALYZE,
openAnalyzer: env.OPEN_ANALYZER,
analyzerMode: env.ANALYZER_MODE,
})

/**
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,14 +26,16 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/compat": "^1.2.1",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.13.0",
"@next/bundle-analyzer": "^14.2.15",
"@next/eslint-plugin-next": "^14.2.15",
"@t3-oss/env-nextjs": "^0.11.1",
"@types/node": "^22.7.6",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
Expand Down
36 changes: 36 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,10 +37,10 @@ const RootLayout: React.FC<PropsWithChildren> = ({ children }) => {
className={cn(
GeistSans.variable,
GeistMono.variable,
'top-[-25px] antialiased',
'bg-black text-white antialiased',
)}
>
<body className='bg-black text-white'>{children}</body>
<body>{children}</body>
</html>
)
}
Expand Down
26 changes: 26 additions & 0 deletions src/app/tma__/layout.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
import type React from 'react'
import type { PropsWithChildren } from 'react'
import type { Metadata, Viewport } from 'next'

import { Fragment } from 'react'
import { TelegramMiniAppRegister } from '~/src/features'

export const metadata: Metadata = {
//
}

export const viewport: Viewport = {
colorScheme: 'dark',
userScalable: false,
}

const TelegramWebAppsLayout: React.FC<PropsWithChildren> = ({ children }) => {
return (
<Fragment>
<TelegramMiniAppRegister>{children}</TelegramMiniAppRegister>
</Fragment>
)
}
TelegramWebAppsLayout.displayName = 'Root layout for telegram web app'

export default TelegramWebAppsLayout
25 changes: 25 additions & 0 deletions src/app/tma__/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import type React from 'react'

import { cn } from '#utils'

const Homepage: React.FC = () => {
return (
<section className='grid min-h-dvh place-content-center'>
<span
className={cn(
'bg-clip-text text-2xl font-semibold text-transparent',
'bg-gradient-to-r from-white via-slate-500 to-white',
'animate-[shimmer-bg_2s_infinite] from-40% to-60%',
)}
style={{
backgroundSize: '300% 100%',
backgroundPositionX: '100%',
}}
>
Goodbye World!
</span>
</section>
)
}

export default Homepage
13 changes: 13 additions & 0 deletions src/components/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
/**
* This file exports all components from the #components directory.
* It serves as a central point for importing components throughout the application.
* When adding new components, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { Button } from '#components/Button'
* import { Input } from '#components/Input'
*
* You can now use:
* import { Button, Input } from '#components'
*/
49 changes: 49 additions & 0 deletions src/env.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
/*
* Server-side Environment variables, not available on the client.
* Will throw if you access these variables on the client.
*/
server: {
NODE_ENV: z.enum(['development', 'production']),

ANALYZE: z.string().transform(v => v === 'true'),
OPEN_ANALYZER: z.string().transform(v => v === 'true'),
ANALYZER_MODE: z.enum(['static', 'json']),
},

/*
* Environment variables available on the client (and server).
*/
client: {
NEXT_PUBLIC_NODE_ENV: z.enum(['development', 'production']),
},

/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,

ANALYZE: process.env.ANALYZE,
OPEN_ANALYZER: process.env.OPEN_ANALYZER,
ANALYZER_MODE: process.env.ANALYZER_MODE,
},

/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* This is especially useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
})

export default env
Empty file.
Empty file.
Empty file.
66 changes: 66 additions & 0 deletions src/features/TelegramWebApp/register.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
'use client'

import type React from 'react'
import type { PropsWithChildren } from 'react'

import env from '#env'
import { Fragment } from 'react'
import { init } from '@/init'
import {
initDataUser,
useLaunchParams,
useSignal,
} from '@telegram-apps/sdk-react'
import { useClientOnce, useDidMount, useTelegramMock } from '#hooks'
import { cn } from '#utils'

const TelegramMiniApp: React.FC<PropsWithChildren> = ({ children }) => {
const isDev = env.NEXT_PUBLIC_NODE_ENV === 'development'

if (isDev) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useTelegramMock()
}

const lp = useLaunchParams()
const debug = isDev || lp.startParam === 'debug'

useClientOnce(() => {
init(debug)
})

const client = useSignal(initDataUser)

return (
<Fragment>
<section className='fixed left-0 top-0 bg-black/75 backdrop-blur-sm'>
<pre className='font-mono text-white'>
{JSON.stringify(client, null, 2)}
</pre>
</section>

{children}
</Fragment>
)
}

const TelegramMiniAppLoader: React.FC = () => {
return (
<div
className={cn(
'size-20 animate-spin rounded-full',
'border-4 border-white border-t-transparent',
)}
/>
)
}

export const TelegramMiniAppRegister: React.FC<PropsWithChildren> = props => {
const isMounted = useDidMount()

if (!isMounted) {
return <TelegramMiniAppLoader />
}

return <TelegramMiniApp {...props} />
}
15 changes: 15 additions & 0 deletions src/features/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
/**
* This file exports all features from the #features directory.
* It serves as a central point for importing features throughout the application.
* When adding new features, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { TelegramMiniAppRegister } from '#features/Button'
* import { SomeFeatureRegister } from '#features/Input'
*
* You can now use:
* import { TelegramMiniAppRegister, SomeFeatureRegister } from '#features'
*/

export { TelegramMiniAppRegister } from '#features/TelegramWebApp/register'
17 changes: 17 additions & 0 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
/**
* This file exports all functions from the #hooks directory.
* It serves as a central point for importing hook functions throughout the application.
* When adding new hooks, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { useDidMount } from '#hooks/useDidMount'
* import { useSomeFn } from '#hooks/useSomeFn'
*
* You can now use:
* import { useDidMount, useSomeFn } from '#hooks'
*/

export { useTelegramMock } from '#hooks/useTelegramMock'
export { useClientOnce } from '#hooks/useClientOnce'
export { useDidMount } from '#hooks/useDidMount'
34 changes: 34 additions & 0 deletions src/hooks/useClientOnce.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
import { useRef } from 'react'

type UseClientOnceFn = () => void

/**
* A custom React hook that ensures a function is called only once on the client side.
*
* @param {UseClientOnceFn} fn - The function to be executed once on the client side.
*
* @example ```ts
* useClientOnce(() => {
* console.log('This will be logged only once on the client side');
* });
* ```
* @see https://github.com/Telegram-Mini-Apps/telegram-apps/blob/master/playgrounds/next/src/hooks/useClientOnce.ts
*/
export function useClientOnce(fn: UseClientOnceFn): void {
const hasRun = useRef(false)

/**
* Confirms that the code is running in a browser environment (`typeof window !== 'undefined`),
* which prevents execution on the server side.
*
* By checking outside React lifecycle hooks (like `useEffect`), the hook minimizes unnecessary renders
* and allows `fn` to execute immediately on component initialization, but only once in the client environment.
* The `hasRun` ref ensures `fn` is only called once per component lifecycle.
*/
if (typeof window !== 'undefined' && !hasRun.current) {
hasRun.current = true
fn()
}

return
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
ANALYZE=false # true | false
# Set the Node.js environment (production: for live deployment, development: for local development)
NODE_ENV=production

NODE_ENV=production # production | development
# Enable or disable bundle analysis (true/false)
ANALYZE=false

# Open bundle analyzer automatically after build (true/false)
OPEN_ANALYZER=false

# Set the mode for bundle analyzer output (static: HTML file, json: JSON file)
ANALYZER_MODE=static
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@

# testing
/coverage
/analyze

# next.js
/.next/
Expand Down
7 changes: 4 additions & 3 deletions next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import bundleAnalyzer from '@next/bundle-analyzer'
import { env } from './src/env.mjs'

const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: false,
analyzerMode: 'static',
enabled: env.ANALYZE,
openAnalyzer: env.OPEN_ANALYZER,
analyzerMode: env.ANALYZER_MODE,
})

/**
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,14 +26,16 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/compat": "^1.2.1",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.13.0",
"@next/bundle-analyzer": "^14.2.15",
"@next/eslint-plugin-next": "^14.2.15",
"@t3-oss/env-nextjs": "^0.11.1",
"@types/node": "^22.7.6",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
Expand Down
36 changes: 36 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,10 +37,10 @@ const RootLayout: React.FC<PropsWithChildren> = ({ children }) => {
className={cn(
GeistSans.variable,
GeistMono.variable,
'top-[-25px] antialiased',
'bg-black text-white antialiased',
)}
>
<body className='bg-black text-white'>{children}</body>
<body>{children}</body>
</html>
)
}
Expand Down
26 changes: 26 additions & 0 deletions src/app/tma__/layout.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
import type React from 'react'
import type { PropsWithChildren } from 'react'
import type { Metadata, Viewport } from 'next'

import { Fragment } from 'react'
import { TelegramMiniAppRegister } from '~/src/features'

export const metadata: Metadata = {
//
}

export const viewport: Viewport = {
colorScheme: 'dark',
userScalable: false,
}

const TelegramWebAppsLayout: React.FC<PropsWithChildren> = ({ children }) => {
return (
<Fragment>
<TelegramMiniAppRegister>{children}</TelegramMiniAppRegister>
</Fragment>
)
}
TelegramWebAppsLayout.displayName = 'Root layout for telegram web app'

export default TelegramWebAppsLayout
25 changes: 25 additions & 0 deletions src/app/tma__/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import type React from 'react'

import { cn } from '#utils'

const Homepage: React.FC = () => {
return (
<section className='grid min-h-dvh place-content-center'>
<span
className={cn(
'bg-clip-text text-2xl font-semibold text-transparent',
'bg-gradient-to-r from-white via-slate-500 to-white',
'animate-[shimmer-bg_2s_infinite] from-40% to-60%',
)}
style={{
backgroundSize: '300% 100%',
backgroundPositionX: '100%',
}}
>
Goodbye World!
</span>
</section>
)
}

export default Homepage
13 changes: 13 additions & 0 deletions src/components/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
/**
* This file exports all components from the #components directory.
* It serves as a central point for importing components throughout the application.
* When adding new components, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { Button } from '#components/Button'
* import { Input } from '#components/Input'
*
* You can now use:
* import { Button, Input } from '#components'
*/
49 changes: 49 additions & 0 deletions src/env.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
/*
* Server-side Environment variables, not available on the client.
* Will throw if you access these variables on the client.
*/
server: {
NODE_ENV: z.enum(['development', 'production']),

ANALYZE: z.string().transform(v => v === 'true'),
OPEN_ANALYZER: z.string().transform(v => v === 'true'),
ANALYZER_MODE: z.enum(['static', 'json']),
},

/*
* Environment variables available on the client (and server).
*/
client: {
NEXT_PUBLIC_NODE_ENV: z.enum(['development', 'production']),
},

/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,

ANALYZE: process.env.ANALYZE,
OPEN_ANALYZER: process.env.OPEN_ANALYZER,
ANALYZER_MODE: process.env.ANALYZER_MODE,
},

/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* This is especially useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
})

export default env
Empty file.
Empty file.
Empty file.
66 changes: 66 additions & 0 deletions src/features/TelegramWebApp/register.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
'use client'

import type React from 'react'
import type { PropsWithChildren } from 'react'

import env from '#env'
import { Fragment } from 'react'
import { init } from '@/init'
import {
initDataUser,
useLaunchParams,
useSignal,
} from '@telegram-apps/sdk-react'
import { useClientOnce, useDidMount, useTelegramMock } from '#hooks'
import { cn } from '#utils'

const TelegramMiniApp: React.FC<PropsWithChildren> = ({ children }) => {
const isDev = env.NEXT_PUBLIC_NODE_ENV === 'development'

if (isDev) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useTelegramMock()
}

const lp = useLaunchParams()
const debug = isDev || lp.startParam === 'debug'

useClientOnce(() => {
init(debug)
})

const client = useSignal(initDataUser)

return (
<Fragment>
<section className='fixed left-0 top-0 bg-black/75 backdrop-blur-sm'>
<pre className='font-mono text-white'>
{JSON.stringify(client, null, 2)}
</pre>
</section>

{children}
</Fragment>
)
}

const TelegramMiniAppLoader: React.FC = () => {
return (
<div
className={cn(
'size-20 animate-spin rounded-full',
'border-4 border-white border-t-transparent',
)}
/>
)
}

export const TelegramMiniAppRegister: React.FC<PropsWithChildren> = props => {
const isMounted = useDidMount()

if (!isMounted) {
return <TelegramMiniAppLoader />
}

return <TelegramMiniApp {...props} />
}
15 changes: 15 additions & 0 deletions src/features/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
/**
* This file exports all features from the #features directory.
* It serves as a central point for importing features throughout the application.
* When adding new features, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { TelegramMiniAppRegister } from '#features/Button'
* import { SomeFeatureRegister } from '#features/Input'
*
* You can now use:
* import { TelegramMiniAppRegister, SomeFeatureRegister } from '#features'
*/

export { TelegramMiniAppRegister } from '#features/TelegramWebApp/register'
17 changes: 17 additions & 0 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
/**
* This file exports all functions from the #hooks directory.
* It serves as a central point for importing hook functions throughout the application.
* When adding new hooks, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { useDidMount } from '#hooks/useDidMount'
* import { useSomeFn } from '#hooks/useSomeFn'
*
* You can now use:
* import { useDidMount, useSomeFn } from '#hooks'
*/

export { useTelegramMock } from '#hooks/useTelegramMock'
export { useClientOnce } from '#hooks/useClientOnce'
export { useDidMount } from '#hooks/useDidMount'
34 changes: 34 additions & 0 deletions src/hooks/useClientOnce.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
import { useRef } from 'react'

type UseClientOnceFn = () => void

/**
* A custom React hook that ensures a function is called only once on the client side.
*
* @param {UseClientOnceFn} fn - The function to be executed once on the client side.
*
* @example ```ts
* useClientOnce(() => {
* console.log('This will be logged only once on the client side');
* });
* ```
* @see https://github.com/Telegram-Mini-Apps/telegram-apps/blob/master/playgrounds/next/src/hooks/useClientOnce.ts
*/
export function useClientOnce(fn: UseClientOnceFn): void {
const hasRun = useRef(false)

/**
* Confirms that the code is running in a browser environment (`typeof window !== 'undefined`),
* which prevents execution on the server side.
*
* By checking outside React lifecycle hooks (like `useEffect`), the hook minimizes unnecessary renders
* and allows `fn` to execute immediately on component initialization, but only once in the client environment.
* The `hasRun` ref ensures `fn` is only called once per component lifecycle.
*/
if (typeof window !== 'undefined' && !hasRun.current) {
hasRun.current = true
fn()
}

return
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
ANALYZE=false # true | false
# Set the Node.js environment (production: for live deployment, development: for local development)
NODE_ENV=production

NODE_ENV=production # production | development
# Enable or disable bundle analysis (true/false)
ANALYZE=false

# Open bundle analyzer automatically after build (true/false)
OPEN_ANALYZER=false

# Set the mode for bundle analyzer output (static: HTML file, json: JSON file)
ANALYZER_MODE=static
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@

# testing
/coverage
/analyze

# next.js
/.next/
Expand Down
7 changes: 4 additions & 3 deletions next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import bundleAnalyzer from '@next/bundle-analyzer'
import { env } from './src/env.mjs'

const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: false,
analyzerMode: 'static',
enabled: env.ANALYZE,
openAnalyzer: env.OPEN_ANALYZER,
analyzerMode: env.ANALYZER_MODE,
})

/**
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,14 +26,16 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/compat": "^1.2.1",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.13.0",
"@next/bundle-analyzer": "^14.2.15",
"@next/eslint-plugin-next": "^14.2.15",
"@t3-oss/env-nextjs": "^0.11.1",
"@types/node": "^22.7.6",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
Expand Down
36 changes: 36 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,10 +37,10 @@ const RootLayout: React.FC<PropsWithChildren> = ({ children }) => {
className={cn(
GeistSans.variable,
GeistMono.variable,
'top-[-25px] antialiased',
'bg-black text-white antialiased',
)}
>
<body className='bg-black text-white'>{children}</body>
<body>{children}</body>
</html>
)
}
Expand Down
26 changes: 26 additions & 0 deletions src/app/tma__/layout.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
import type React from 'react'
import type { PropsWithChildren } from 'react'
import type { Metadata, Viewport } from 'next'

import { Fragment } from 'react'
import { TelegramMiniAppRegister } from '~/src/features'

export const metadata: Metadata = {
//
}

export const viewport: Viewport = {
colorScheme: 'dark',
userScalable: false,
}

const TelegramWebAppsLayout: React.FC<PropsWithChildren> = ({ children }) => {
return (
<Fragment>
<TelegramMiniAppRegister>{children}</TelegramMiniAppRegister>
</Fragment>
)
}
TelegramWebAppsLayout.displayName = 'Root layout for telegram web app'

export default TelegramWebAppsLayout
25 changes: 25 additions & 0 deletions src/app/tma__/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import type React from 'react'

import { cn } from '#utils'

const Homepage: React.FC = () => {
return (
<section className='grid min-h-dvh place-content-center'>
<span
className={cn(
'bg-clip-text text-2xl font-semibold text-transparent',
'bg-gradient-to-r from-white via-slate-500 to-white',
'animate-[shimmer-bg_2s_infinite] from-40% to-60%',
)}
style={{
backgroundSize: '300% 100%',
backgroundPositionX: '100%',
}}
>
Goodbye World!
</span>
</section>
)
}

export default Homepage
13 changes: 13 additions & 0 deletions src/components/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
/**
* This file exports all components from the #components directory.
* It serves as a central point for importing components throughout the application.
* When adding new components, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { Button } from '#components/Button'
* import { Input } from '#components/Input'
*
* You can now use:
* import { Button, Input } from '#components'
*/
49 changes: 49 additions & 0 deletions src/env.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
/*
* Server-side Environment variables, not available on the client.
* Will throw if you access these variables on the client.
*/
server: {
NODE_ENV: z.enum(['development', 'production']),

ANALYZE: z.string().transform(v => v === 'true'),
OPEN_ANALYZER: z.string().transform(v => v === 'true'),
ANALYZER_MODE: z.enum(['static', 'json']),
},

/*
* Environment variables available on the client (and server).
*/
client: {
NEXT_PUBLIC_NODE_ENV: z.enum(['development', 'production']),
},

/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,

ANALYZE: process.env.ANALYZE,
OPEN_ANALYZER: process.env.OPEN_ANALYZER,
ANALYZER_MODE: process.env.ANALYZER_MODE,
},

/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* This is especially useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
})

export default env
Empty file.
Empty file.
Empty file.
66 changes: 66 additions & 0 deletions src/features/TelegramWebApp/register.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
'use client'

import type React from 'react'
import type { PropsWithChildren } from 'react'

import env from '#env'
import { Fragment } from 'react'
import { init } from '@/init'
import {
initDataUser,
useLaunchParams,
useSignal,
} from '@telegram-apps/sdk-react'
import { useClientOnce, useDidMount, useTelegramMock } from '#hooks'
import { cn } from '#utils'

const TelegramMiniApp: React.FC<PropsWithChildren> = ({ children }) => {
const isDev = env.NEXT_PUBLIC_NODE_ENV === 'development'

if (isDev) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useTelegramMock()
}

const lp = useLaunchParams()
const debug = isDev || lp.startParam === 'debug'

useClientOnce(() => {
init(debug)
})

const client = useSignal(initDataUser)

return (
<Fragment>
<section className='fixed left-0 top-0 bg-black/75 backdrop-blur-sm'>
<pre className='font-mono text-white'>
{JSON.stringify(client, null, 2)}
</pre>
</section>

{children}
</Fragment>
)
}

const TelegramMiniAppLoader: React.FC = () => {
return (
<div
className={cn(
'size-20 animate-spin rounded-full',
'border-4 border-white border-t-transparent',
)}
/>
)
}

export const TelegramMiniAppRegister: React.FC<PropsWithChildren> = props => {
const isMounted = useDidMount()

if (!isMounted) {
return <TelegramMiniAppLoader />
}

return <TelegramMiniApp {...props} />
}
15 changes: 15 additions & 0 deletions src/features/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
/**
* This file exports all features from the #features directory.
* It serves as a central point for importing features throughout the application.
* When adding new features, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { TelegramMiniAppRegister } from '#features/Button'
* import { SomeFeatureRegister } from '#features/Input'
*
* You can now use:
* import { TelegramMiniAppRegister, SomeFeatureRegister } from '#features'
*/

export { TelegramMiniAppRegister } from '#features/TelegramWebApp/register'
17 changes: 17 additions & 0 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
/**
* This file exports all functions from the #hooks directory.
* It serves as a central point for importing hook functions throughout the application.
* When adding new hooks, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { useDidMount } from '#hooks/useDidMount'
* import { useSomeFn } from '#hooks/useSomeFn'
*
* You can now use:
* import { useDidMount, useSomeFn } from '#hooks'
*/

export { useTelegramMock } from '#hooks/useTelegramMock'
export { useClientOnce } from '#hooks/useClientOnce'
export { useDidMount } from '#hooks/useDidMount'
34 changes: 34 additions & 0 deletions src/hooks/useClientOnce.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
import { useRef } from 'react'

type UseClientOnceFn = () => void

/**
* A custom React hook that ensures a function is called only once on the client side.
*
* @param {UseClientOnceFn} fn - The function to be executed once on the client side.
*
* @example ```ts
* useClientOnce(() => {
* console.log('This will be logged only once on the client side');
* });
* ```
* @see https://github.com/Telegram-Mini-Apps/telegram-apps/blob/master/playgrounds/next/src/hooks/useClientOnce.ts
*/
export function useClientOnce(fn: UseClientOnceFn): void {
const hasRun = useRef(false)

/**
* Confirms that the code is running in a browser environment (`typeof window !== 'undefined`),
* which prevents execution on the server side.
*
* By checking outside React lifecycle hooks (like `useEffect`), the hook minimizes unnecessary renders
* and allows `fn` to execute immediately on component initialization, but only once in the client environment.
* The `hasRun` ref ensures `fn` is only called once per component lifecycle.
*/
if (typeof window !== 'undefined' && !hasRun.current) {
hasRun.current = true
fn()
}

return
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
ANALYZE=false # true | false
# Set the Node.js environment (production: for live deployment, development: for local development)
NODE_ENV=production

NODE_ENV=production # production | development
# Enable or disable bundle analysis (true/false)
ANALYZE=false

# Open bundle analyzer automatically after build (true/false)
OPEN_ANALYZER=false

# Set the mode for bundle analyzer output (static: HTML file, json: JSON file)
ANALYZER_MODE=static
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@

# testing
/coverage
/analyze

# next.js
/.next/
Expand Down
7 changes: 4 additions & 3 deletions next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import bundleAnalyzer from '@next/bundle-analyzer'
import { env } from './src/env.mjs'

const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: false,
analyzerMode: 'static',
enabled: env.ANALYZE,
openAnalyzer: env.OPEN_ANALYZER,
analyzerMode: env.ANALYZER_MODE,
})

/**
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,14 +26,16 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/compat": "^1.2.1",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.13.0",
"@next/bundle-analyzer": "^14.2.15",
"@next/eslint-plugin-next": "^14.2.15",
"@t3-oss/env-nextjs": "^0.11.1",
"@types/node": "^22.7.6",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
Expand Down
36 changes: 36 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,10 +37,10 @@ const RootLayout: React.FC<PropsWithChildren> = ({ children }) => {
className={cn(
GeistSans.variable,
GeistMono.variable,
'top-[-25px] antialiased',
'bg-black text-white antialiased',
)}
>
<body className='bg-black text-white'>{children}</body>
<body>{children}</body>
</html>
)
}
Expand Down
26 changes: 26 additions & 0 deletions src/app/tma__/layout.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
import type React from 'react'
import type { PropsWithChildren } from 'react'
import type { Metadata, Viewport } from 'next'

import { Fragment } from 'react'
import { TelegramMiniAppRegister } from '~/src/features'

export const metadata: Metadata = {
//
}

export const viewport: Viewport = {
colorScheme: 'dark',
userScalable: false,
}

const TelegramWebAppsLayout: React.FC<PropsWithChildren> = ({ children }) => {
return (
<Fragment>
<TelegramMiniAppRegister>{children}</TelegramMiniAppRegister>
</Fragment>
)
}
TelegramWebAppsLayout.displayName = 'Root layout for telegram web app'

export default TelegramWebAppsLayout
25 changes: 25 additions & 0 deletions src/app/tma__/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import type React from 'react'

import { cn } from '#utils'

const Homepage: React.FC = () => {
return (
<section className='grid min-h-dvh place-content-center'>
<span
className={cn(
'bg-clip-text text-2xl font-semibold text-transparent',
'bg-gradient-to-r from-white via-slate-500 to-white',
'animate-[shimmer-bg_2s_infinite] from-40% to-60%',
)}
style={{
backgroundSize: '300% 100%',
backgroundPositionX: '100%',
}}
>
Goodbye World!
</span>
</section>
)
}

export default Homepage
13 changes: 13 additions & 0 deletions src/components/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
/**
* This file exports all components from the #components directory.
* It serves as a central point for importing components throughout the application.
* When adding new components, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { Button } from '#components/Button'
* import { Input } from '#components/Input'
*
* You can now use:
* import { Button, Input } from '#components'
*/
49 changes: 49 additions & 0 deletions src/env.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
/*
* Server-side Environment variables, not available on the client.
* Will throw if you access these variables on the client.
*/
server: {
NODE_ENV: z.enum(['development', 'production']),

ANALYZE: z.string().transform(v => v === 'true'),
OPEN_ANALYZER: z.string().transform(v => v === 'true'),
ANALYZER_MODE: z.enum(['static', 'json']),
},

/*
* Environment variables available on the client (and server).
*/
client: {
NEXT_PUBLIC_NODE_ENV: z.enum(['development', 'production']),
},

/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,

ANALYZE: process.env.ANALYZE,
OPEN_ANALYZER: process.env.OPEN_ANALYZER,
ANALYZER_MODE: process.env.ANALYZER_MODE,
},

/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* This is especially useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
})

export default env
Empty file.
Empty file.
Empty file.
66 changes: 66 additions & 0 deletions src/features/TelegramWebApp/register.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
'use client'

import type React from 'react'
import type { PropsWithChildren } from 'react'

import env from '#env'
import { Fragment } from 'react'
import { init } from '@/init'
import {
initDataUser,
useLaunchParams,
useSignal,
} from '@telegram-apps/sdk-react'
import { useClientOnce, useDidMount, useTelegramMock } from '#hooks'
import { cn } from '#utils'

const TelegramMiniApp: React.FC<PropsWithChildren> = ({ children }) => {
const isDev = env.NEXT_PUBLIC_NODE_ENV === 'development'

if (isDev) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useTelegramMock()
}

const lp = useLaunchParams()
const debug = isDev || lp.startParam === 'debug'

useClientOnce(() => {
init(debug)
})

const client = useSignal(initDataUser)

return (
<Fragment>
<section className='fixed left-0 top-0 bg-black/75 backdrop-blur-sm'>
<pre className='font-mono text-white'>
{JSON.stringify(client, null, 2)}
</pre>
</section>

{children}
</Fragment>
)
}

const TelegramMiniAppLoader: React.FC = () => {
return (
<div
className={cn(
'size-20 animate-spin rounded-full',
'border-4 border-white border-t-transparent',
)}
/>
)
}

export const TelegramMiniAppRegister: React.FC<PropsWithChildren> = props => {
const isMounted = useDidMount()

if (!isMounted) {
return <TelegramMiniAppLoader />
}

return <TelegramMiniApp {...props} />
}
15 changes: 15 additions & 0 deletions src/features/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
/**
* This file exports all features from the #features directory.
* It serves as a central point for importing features throughout the application.
* When adding new features, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { TelegramMiniAppRegister } from '#features/Button'
* import { SomeFeatureRegister } from '#features/Input'
*
* You can now use:
* import { TelegramMiniAppRegister, SomeFeatureRegister } from '#features'
*/

export { TelegramMiniAppRegister } from '#features/TelegramWebApp/register'
17 changes: 17 additions & 0 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
/**
* This file exports all functions from the #hooks directory.
* It serves as a central point for importing hook functions throughout the application.
* When adding new hooks, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { useDidMount } from '#hooks/useDidMount'
* import { useSomeFn } from '#hooks/useSomeFn'
*
* You can now use:
* import { useDidMount, useSomeFn } from '#hooks'
*/

export { useTelegramMock } from '#hooks/useTelegramMock'
export { useClientOnce } from '#hooks/useClientOnce'
export { useDidMount } from '#hooks/useDidMount'
34 changes: 34 additions & 0 deletions src/hooks/useClientOnce.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
import { useRef } from 'react'

type UseClientOnceFn = () => void

/**
* A custom React hook that ensures a function is called only once on the client side.
*
* @param {UseClientOnceFn} fn - The function to be executed once on the client side.
*
* @example ```ts
* useClientOnce(() => {
* console.log('This will be logged only once on the client side');
* });
* ```
* @see https://github.com/Telegram-Mini-Apps/telegram-apps/blob/master/playgrounds/next/src/hooks/useClientOnce.ts
*/
export function useClientOnce(fn: UseClientOnceFn): void {
const hasRun = useRef(false)

/**
* Confirms that the code is running in a browser environment (`typeof window !== 'undefined`),
* which prevents execution on the server side.
*
* By checking outside React lifecycle hooks (like `useEffect`), the hook minimizes unnecessary renders
* and allows `fn` to execute immediately on component initialization, but only once in the client environment.
* The `hasRun` ref ensures `fn` is only called once per component lifecycle.
*/
if (typeof window !== 'undefined' && !hasRun.current) {
hasRun.current = true
fn()
}

return
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .env.example
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
ANALYZE=false # true | false
# Set the Node.js environment (production: for live deployment, development: for local development)
NODE_ENV=production

NODE_ENV=production # production | development
# Enable or disable bundle analysis (true/false)
ANALYZE=false

# Open bundle analyzer automatically after build (true/false)
OPEN_ANALYZER=false

# Set the mode for bundle analyzer output (static: HTML file, json: JSON file)
ANALYZER_MODE=static
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@

# testing
/coverage
/analyze

# next.js
/.next/
Expand Down
7 changes: 4 additions & 3 deletions next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
import bundleAnalyzer from '@next/bundle-analyzer'
import { env } from './src/env.mjs'

const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
openAnalyzer: false,
analyzerMode: 'static',
enabled: env.ANALYZE,
openAnalyzer: env.OPEN_ANALYZER,
analyzerMode: env.ANALYZER_MODE,
})

/**
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,14 +26,16 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/compat": "^1.2.1",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.13.0",
"@next/bundle-analyzer": "^14.2.15",
"@next/eslint-plugin-next": "^14.2.15",
"@t3-oss/env-nextjs": "^0.11.1",
"@types/node": "^22.7.6",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
Expand Down
36 changes: 36 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,10 +37,10 @@ const RootLayout: React.FC<PropsWithChildren> = ({ children }) => {
className={cn(
GeistSans.variable,
GeistMono.variable,
'top-[-25px] antialiased',
'bg-black text-white antialiased',
)}
>
<body className='bg-black text-white'>{children}</body>
<body>{children}</body>
</html>
)
}
Expand Down
26 changes: 26 additions & 0 deletions src/app/tma__/layout.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
import type React from 'react'
import type { PropsWithChildren } from 'react'
import type { Metadata, Viewport } from 'next'

import { Fragment } from 'react'
import { TelegramMiniAppRegister } from '~/src/features'

export const metadata: Metadata = {
//
}

export const viewport: Viewport = {
colorScheme: 'dark',
userScalable: false,
}

const TelegramWebAppsLayout: React.FC<PropsWithChildren> = ({ children }) => {
return (
<Fragment>
<TelegramMiniAppRegister>{children}</TelegramMiniAppRegister>
</Fragment>
)
}
TelegramWebAppsLayout.displayName = 'Root layout for telegram web app'

export default TelegramWebAppsLayout
25 changes: 25 additions & 0 deletions src/app/tma__/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
import type React from 'react'

import { cn } from '#utils'

const Homepage: React.FC = () => {
return (
<section className='grid min-h-dvh place-content-center'>
<span
className={cn(
'bg-clip-text text-2xl font-semibold text-transparent',
'bg-gradient-to-r from-white via-slate-500 to-white',
'animate-[shimmer-bg_2s_infinite] from-40% to-60%',
)}
style={{
backgroundSize: '300% 100%',
backgroundPositionX: '100%',
}}
>
Goodbye World!
</span>
</section>
)
}

export default Homepage
13 changes: 13 additions & 0 deletions src/components/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
/**
* This file exports all components from the #components directory.
* It serves as a central point for importing components throughout the application.
* When adding new components, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { Button } from '#components/Button'
* import { Input } from '#components/Input'
*
* You can now use:
* import { Button, Input } from '#components'
*/
49 changes: 49 additions & 0 deletions src/env.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
/*
* Server-side Environment variables, not available on the client.
* Will throw if you access these variables on the client.
*/
server: {
NODE_ENV: z.enum(['development', 'production']),

ANALYZE: z.string().transform(v => v === 'true'),
OPEN_ANALYZER: z.string().transform(v => v === 'true'),
ANALYZER_MODE: z.enum(['static', 'json']),
},

/*
* Environment variables available on the client (and server).
*/
client: {
NEXT_PUBLIC_NODE_ENV: z.enum(['development', 'production']),
},

/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,

ANALYZE: process.env.ANALYZE,
OPEN_ANALYZER: process.env.OPEN_ANALYZER,
ANALYZER_MODE: process.env.ANALYZER_MODE,
},

/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* This is especially useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
})

export default env
Empty file.
Empty file.
Empty file.
66 changes: 66 additions & 0 deletions src/features/TelegramWebApp/register.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
'use client'

import type React from 'react'
import type { PropsWithChildren } from 'react'

import env from '#env'
import { Fragment } from 'react'
import { init } from '@/init'
import {
initDataUser,
useLaunchParams,
useSignal,
} from '@telegram-apps/sdk-react'
import { useClientOnce, useDidMount, useTelegramMock } from '#hooks'
import { cn } from '#utils'

const TelegramMiniApp: React.FC<PropsWithChildren> = ({ children }) => {
const isDev = env.NEXT_PUBLIC_NODE_ENV === 'development'

if (isDev) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useTelegramMock()
}

const lp = useLaunchParams()
const debug = isDev || lp.startParam === 'debug'

useClientOnce(() => {
init(debug)
})

const client = useSignal(initDataUser)

return (
<Fragment>
<section className='fixed left-0 top-0 bg-black/75 backdrop-blur-sm'>
<pre className='font-mono text-white'>
{JSON.stringify(client, null, 2)}
</pre>
</section>

{children}
</Fragment>
)
}

const TelegramMiniAppLoader: React.FC = () => {
return (
<div
className={cn(
'size-20 animate-spin rounded-full',
'border-4 border-white border-t-transparent',
)}
/>
)
}

export const TelegramMiniAppRegister: React.FC<PropsWithChildren> = props => {
const isMounted = useDidMount()

if (!isMounted) {
return <TelegramMiniAppLoader />
}

return <TelegramMiniApp {...props} />
}
15 changes: 15 additions & 0 deletions src/features/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
/**
* This file exports all features from the #features directory.
* It serves as a central point for importing features throughout the application.
* When adding new features, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { TelegramMiniAppRegister } from '#features/Button'
* import { SomeFeatureRegister } from '#features/Input'
*
* You can now use:
* import { TelegramMiniAppRegister, SomeFeatureRegister } from '#features'
*/

export { TelegramMiniAppRegister } from '#features/TelegramWebApp/register'
17 changes: 17 additions & 0 deletions src/hooks/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
/**
* This file exports all functions from the #hooks directory.
* It serves as a central point for importing hook functions throughout the application.
* When adding new hooks, make sure to export them here for easy access.
*
* Example usage:
* Instead of:
* import { useDidMount } from '#hooks/useDidMount'
* import { useSomeFn } from '#hooks/useSomeFn'
*
* You can now use:
* import { useDidMount, useSomeFn } from '#hooks'
*/

export { useTelegramMock } from '#hooks/useTelegramMock'
export { useClientOnce } from '#hooks/useClientOnce'
export { useDidMount } from '#hooks/useDidMount'
34 changes: 34 additions & 0 deletions src/hooks/useClientOnce.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
import { useRef } from 'react'

type UseClientOnceFn = () => void

/**
* A custom React hook that ensures a function is called only once on the client side.
*
* @param {UseClientOnceFn} fn - The function to be executed once on the client side.
*
* @example ```ts
* useClientOnce(() => {
* console.log('This will be logged only once on the client side');
* });
* ```
* @see https://github.com/Telegram-Mini-Apps/telegram-apps/blob/master/playgrounds/next/src/hooks/useClientOnce.ts
*/
export function useClientOnce(fn: UseClientOnceFn): void {
const hasRun = useRef(false)

/**
* Confirms that the code is running in a browser environment (`typeof window !== 'undefined`),
* which prevents execution on the server side.
*
* By checking outside React lifecycle hooks (like `useEffect`), the hook minimizes unnecessary renders
* and allows `fn` to execute immediately on component initialization, but only once in the client environment.
* The `hasRun` ref ensures `fn` is only called once per component lifecycle.
*/
if (typeof window !== 'undefined' && !hasRun.current) {
hasRun.current = true
fn()
}

return
}
Loading