diff --git a/.env.example b/.env.example index 2bbcc47..eb02943 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 33caad6..3883958 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # testing /coverage +/analyze # next.js /.next/ diff --git a/next.config.mjs b/next.config.mjs index b0a4641..162c8e1 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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, }) /** diff --git a/package.json b/package.json index 0eae193..019b645 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "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", @@ -34,6 +35,7 @@ "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0660836..54dd8ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ dependencies: typescript: specifier: ^5.6.3 version: 5.6.3 + zod: + specifier: ^3.23.8 + version: 3.23.8 devDependencies: '@eslint/compat': @@ -43,6 +46,9 @@ devDependencies: '@next/eslint-plugin-next': specifier: ^14.2.15 version: 14.2.15 + '@t3-oss/env-nextjs': + specifier: ^0.11.1 + version: 0.11.1(typescript@5.6.3)(zod@3.23.8) '@types/node': specifier: ^22.7.6 version: 22.7.6 @@ -389,6 +395,33 @@ packages: tslib: 2.8.0 dev: false + /@t3-oss/env-core@0.11.1(typescript@5.6.3)(zod@3.23.8): + resolution: {integrity: sha512-MaxOwEoG1ntCFoKJsS7nqwgcxLW1SJw238AJwfJeaz3P/8GtkxXZsPPolsz1AdYvUTbe3XvqZ/VCdfjt+3zmKw==} + peerDependencies: + typescript: '>=5.0.0' + zod: ^3.0.0 + peerDependenciesMeta: + typescript: + optional: true + dependencies: + typescript: 5.6.3 + zod: 3.23.8 + dev: true + + /@t3-oss/env-nextjs@0.11.1(typescript@5.6.3)(zod@3.23.8): + resolution: {integrity: sha512-rx2XL9+v6wtOqLNJbD5eD8OezKlQD1BtC0WvvtHwBgK66jnF5+wGqtgkKK4Ygie1LVmoDClths2T4tdFmRvGrQ==} + peerDependencies: + typescript: '>=5.0.0' + zod: ^3.0.0 + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@t3-oss/env-core': 0.11.1(typescript@5.6.3)(zod@3.23.8) + typescript: 5.6.3 + zod: 3.23.8 + dev: true + /@telegram-apps/bridge@1.3.0: resolution: {integrity: sha512-9P2SEhr1SSo8JQsbfm4fIzYhZY0wwH6PEOmSZ0kNXZ4xX1iSftJ3T7kSbcaQL2zIVrKO8PJxKeH4Etmkk9Oqgg==} dependencies: @@ -2973,3 +3006,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true + + /zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index da8f3ea..e5a5bb8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -37,10 +37,10 @@ const RootLayout: React.FC = ({ children }) => { className={cn( GeistSans.variable, GeistMono.variable, - 'top-[-25px] antialiased', + 'bg-black text-white antialiased', )} > - {children} + {children} ) } diff --git a/src/app/tma__/layout.tsx b/src/app/tma__/layout.tsx new file mode 100644 index 0000000..62fd039 --- /dev/null +++ b/src/app/tma__/layout.tsx @@ -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 = ({ children }) => { + return ( + + {children} + + ) +} +TelegramWebAppsLayout.displayName = 'Root layout for telegram web app' + +export default TelegramWebAppsLayout diff --git a/src/app/tma__/page.tsx b/src/app/tma__/page.tsx new file mode 100644 index 0000000..0116070 --- /dev/null +++ b/src/app/tma__/page.tsx @@ -0,0 +1,25 @@ +import type React from 'react' + +import { cn } from '#utils' + +const Homepage: React.FC = () => { + return ( +
+ + Goodbye World! + +
+ ) +} + +export default Homepage diff --git a/src/components/index.ts b/src/components/index.ts index e69de29..730ec5a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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' + */ diff --git a/src/env.mjs b/src/env.mjs new file mode 100644 index 0000000..cc673ea --- /dev/null +++ b/src/env.mjs @@ -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 diff --git a/src/features/TelegramWebApp/components/Register/index.tsx b/src/features/TelegramWebApp/components/Register/index.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/features/TelegramWebApp/components/index.ts b/src/features/TelegramWebApp/components/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/TelegramWebApp/index.ts b/src/features/TelegramWebApp/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/TelegramWebApp/register.tsx b/src/features/TelegramWebApp/register.tsx new file mode 100644 index 0000000..0fd82e7 --- /dev/null +++ b/src/features/TelegramWebApp/register.tsx @@ -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 = ({ 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 ( + +
+
+          {JSON.stringify(client, null, 2)}
+        
+
+ + {children} +
+ ) +} + +const TelegramMiniAppLoader: React.FC = () => { + return ( +
+ ) +} + +export const TelegramMiniAppRegister: React.FC = props => { + const isMounted = useDidMount() + + if (!isMounted) { + return + } + + return +} diff --git a/src/features/index.ts b/src/features/index.ts new file mode 100644 index 0000000..420b263 --- /dev/null +++ b/src/features/index.ts @@ -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' diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..743e1cc --- /dev/null +++ b/src/hooks/index.ts @@ -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' diff --git a/src/hooks/useClientOnce.ts b/src/hooks/useClientOnce.ts new file mode 100644 index 0000000..b9b6a42 --- /dev/null +++ b/src/hooks/useClientOnce.ts @@ -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 +} diff --git a/src/hooks/useDidMount.ts b/src/hooks/useDidMount.ts new file mode 100644 index 0000000..27b7f1d --- /dev/null +++ b/src/hooks/useDidMount.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' + +/** + * A custom React hook that determines if a component has mounted. + * + * @returns {boolean} true if the component has mounted, false otherwise. + * + * @example ``` + * const isMounted = useDidMount(); + * if (isMounted) { + * // Perform actions only after component has mounted + * } + * ``` + * @see https://github.com/Telegram-Mini-Apps/telegram-apps/blob/master/playgrounds/next/src/hooks/useDidMount.ts + */ +export function useDidMount(): boolean { + const [didMount, setDidMount] = useState(false) + + useEffect(() => { + setDidMount(true) + }, []) + + return didMount +} diff --git a/src/hooks/useTelegramMock.ts b/src/hooks/useTelegramMock.ts new file mode 100644 index 0000000..80f7ce2 --- /dev/null +++ b/src/hooks/useTelegramMock.ts @@ -0,0 +1,117 @@ +import { parseInitData, isTMA, mockTelegramEnv } from '@telegram-apps/sdk-react' +import { useClientOnce } from '#hooks' + +/** + * A hook for simulating the Telegram environment during development. + * This hook only works on the client side and is only active in development mode. + * + * @returns {void} + * @see https://github.com/Telegram-Mini-Apps/telegram-apps/blob/master/playgrounds/next/src/hooks/useTelegramMock.ts + */ +export function useTelegramMock(): void { + useClientOnce(() => { + if (!shouldMockEnvironment()) { + return + } + + const initDataRaw = createMockInitData() + applyMockEnvironment(initDataRaw) + + logMockWarning() + }) +} + +/** + * Determines if the environment should be mocked. + * + * @returns {boolean} Whether the environment should be mocked or not + */ +function shouldMockEnvironment(): boolean { + const MOCK_KEY = '____mocked' + + // Returns true if the current environment is Telegram Mini Apps. + // We don't mock if we are already in a mini app. + if (isTMA('simple')) { + // We could previously mock the environment. + // In case we did, we should do it again. + // The reason is the page could be reloaded, and we should apply mock again, + // because mocking also enables modifying the window object. + return !!sessionStorage.getItem(MOCK_KEY) + } + + return true +} + +/** + * Creates mocked initData. + * + * @returns {string} initData in URLSearchParams format + */ +function createMockInitData(): string { + return new URLSearchParams([ + [ + 'user', + JSON.stringify({ + id: 99281932, + first_name: 'Ryan', + last_name: 'Gosling', + username: 'gosling', + language_code: 'en', + is_premium: true, + allows_write_to_pm: true, + }), + ], + [ + 'hash', + '89d6079ad6762351f38c6dbbc41bb53048019256a9443988af7a48bcad16ba31', + ], + ['auth_date', '1716922846'], + ['start_param', 'debug'], + ['chat_type', 'sender'], + ['chat_instance', '8428209589180549439'], + ]).toString() +} + +/** + * Applies the mocked Telegram environment. + * + * @param {string} initDataRaw - initData in URLSearchParams format + */ +function applyMockEnvironment(initDataRaw: string): void { + mockTelegramEnv({ + themeParams: { + accentTextColor: '#6ab2f2', + bgColor: '#17212b', + buttonColor: '#5288c1', + buttonTextColor: '#ffffff', + destructiveTextColor: '#ec3942', + headerBgColor: '#17212b', + hintColor: '#708499', + linkColor: '#6ab3f3', + secondaryBgColor: '#232e3c', + sectionBgColor: '#17212b', + sectionHeaderTextColor: '#6ab3f3', + subtitleTextColor: '#708499', + textColor: '#f5f5f5', + }, + initData: parseInitData(initDataRaw), + initDataRaw, + version: '8', + platform: 'tdesktop', + }) + + sessionStorage.setItem('____mocked', '1') +} + +/** + * Logs a warning message about the mocking to the console. + */ +function logMockWarning(): void { + console.info( + '⚠️ As the current environment was not considered Telegram-based, it has been mocked. ' + + 'Please note that you should not do this in production, and this behavior is specific ' + + 'to the development process only. Environment mocking is applied only in development mode. ' + + 'Therefore, after building the application, you will not see this behavior or the related ' + + 'warning, which would lead to the application crashing outside of Telegram.', + ) +} diff --git a/src/init.ts b/src/init.ts new file mode 100644 index 0000000..f9d1f58 --- /dev/null +++ b/src/init.ts @@ -0,0 +1,44 @@ +import { + $debug, + backButton, + initData, + miniApp, + themeParams, + viewport, + init as initSDK, +} from '@telegram-apps/sdk-react' + +/** + * Initializes the Telegram Mini App environment. + * @param {boolean} debug - Whether to enable debug mode. + */ +export function init(debug: boolean): void { + // Enable debug mode if specified + $debug.set(debug) + + // Initialize SDK + initSDK() + + // Mount necessary components + mountComponents() + + // Restore initial data + initData.restore() + + // Mount viewport and handle any errors + void viewport.mount().catch(error => { + console.error('Something went wrong mounting the viewport:', error) + throw new Error('Something went wrong mounting the viewport!') + }) +} + +/** + * Mounts necessary components for the Mini App. + */ +function mountComponents(): void { + if (backButton.isSupported()) { + backButton.mount() + } + miniApp.mount() + themeParams.mount() +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 29f1080..61a4c0d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,15 @@ +/** + * This file exports all utility functions from the #utils directory. + * It serves as a central point for importing utility functions throughout the application. + * When adding new utility functions, make sure to export them here for easy access. + * + * Example usage: + * Instead of: + * import { cn } from '#utils/cn' + * import { someUtilFn } from '#utils/someUtilFn' + * + * You can now use: + * import { cn, someUtilFn } from '#utils' + */ + export { cn } from '#utils/cn' diff --git a/tailwind.config.ts b/tailwind.config.ts index bb4dcda..77da026 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -7,6 +7,7 @@ const config: Config = { content: [ 'src/app/**/*.{ts,tsx}', 'src/components/**/*.{ts,tsx}', + 'src/features/**/*.{ts,tsx}', 'src/layouts/**/*.{ts,tsx}', 'src/widgets/**/*.{ts,tsx}', ], @@ -26,10 +27,14 @@ const config: Config = { mono: ['var(--font-geist-mono)', ...fontFamily.mono], }, keyframes: { - shimmer: { + 'shimmer': { from: { transform: 'translateX(-100%)' }, to: { transform: 'translateX(100%)' }, }, + 'shimmer-bg': { + from: { backgroundPositionX: '100%' }, + to: { backgroundPositionX: '0%' }, + }, }, }, }, diff --git a/tsconfig.json b/tsconfig.json index 5eac31c..d7449c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,9 +18,14 @@ "paths": { "~/*": ["./*"], "@/*": ["./src/*"], + "#env": ["./src/env.mjs"], "#assets/*": ["./src/assets/*"], "#components": ["./src/components"], "#components/*": ["./src/components/*"], + "#features": ["./src/features"], + "#features/*": ["./src/features/*"], + "#hooks": ["./src/hooks"], + "#hooks/*": ["./src/hooks/*"], "#utils": ["./src/utils"], "#utils/*": ["./src/utils/*"] }