feat(telegram): add Telegram Mini App for Pezkuwichain

- Add @twa-dev/sdk dependency for Telegram WebApp integration
- Create useTelegram hook for Telegram SDK integration (haptics, popups, etc.)
- Create usePezkuwiApi hook for blockchain API connection
- Add Discord-like Sidebar with 5 sections navigation
- Add Announcements section with like/dislike reactions
- Add Forum section with thread creation and replies
- Add Rewards section with referral program and epoch claims
- Add APK section for Pezwallet download with changelog
- Add Wallet section with balance, staking info, and transactions
- Create main TelegramApp component with routing
- Add /telegram route to App.tsx

UI Structure:
- Left: Discord-style icon sidebar (Announcements, Forum, Rewards, APK, Wallet)
- Right: Active section content area
- Mobile-first responsive design with Telegram theme integration

Integrations:
- Uses existing shared/lib functions for referral, staking, scores
- Supports Telegram startParam for referral codes
- Haptic feedback for native Telegram experience
- Telegram Main/Back button integration
This commit is contained in:
2026-01-26 17:42:35 +03:00
parent 2e0b5d73fd
commit 568fca33f2
18 changed files with 15579 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
export { useTelegram } from './useTelegram';
export type { TelegramUser, TelegramTheme } from './useTelegram';
export { usePezkuwiApi, getApiInstance } from './usePezkuwiApi';
+159
View File
@@ -0,0 +1,159 @@
import { useState, useEffect } from 'react';
import { ApiPromise, WsProvider } from '@pezkuwi/api';
// RPC endpoint - uses environment variable or falls back to mainnet
const RPC_ENDPOINT = import.meta.env.VITE_WS_ENDPOINT || 'wss://rpc.pezkuwichain.io:9944';
const FALLBACK_ENDPOINTS = [
RPC_ENDPOINT,
import.meta.env.VITE_WS_ENDPOINT_FALLBACK_1,
import.meta.env.VITE_WS_ENDPOINT_FALLBACK_2,
].filter(Boolean) as string[];
interface UsePezkuwiApiReturn {
api: ApiPromise | null;
isReady: boolean;
isConnecting: boolean;
error: string | null;
reconnect: () => Promise<void>;
}
// Singleton API instance to avoid multiple connections
let globalApi: ApiPromise | null = null;
let connectionPromise: Promise<ApiPromise> | null = null;
async function createApiConnection(): Promise<ApiPromise> {
// Return existing connection promise if one is in progress
if (connectionPromise) {
return connectionPromise;
}
// Return existing API if already connected
if (globalApi && globalApi.isConnected) {
return globalApi;
}
// Create new connection
connectionPromise = (async () => {
for (const endpoint of FALLBACK_ENDPOINTS) {
try {
if (import.meta.env.DEV) {
console.log('[PezkuwiApi] Connecting to:', endpoint);
}
const provider = new WsProvider(endpoint);
const api = await ApiPromise.create({ provider });
await api.isReady;
globalApi = api;
if (import.meta.env.DEV) {
const [chain, nodeName, nodeVersion] = await Promise.all([
api.rpc.system.chain(),
api.rpc.system.name(),
api.rpc.system.version(),
]);
console.log(`[PezkuwiApi] Connected to ${chain} (${nodeName} v${nodeVersion})`);
}
return api;
} catch (err) {
if (import.meta.env.DEV) {
console.warn(`[PezkuwiApi] Failed to connect to ${endpoint}:`, err);
}
continue;
}
}
throw new Error('Failed to connect to any endpoint');
})();
try {
return await connectionPromise;
} finally {
connectionPromise = null;
}
}
export function usePezkuwiApi(): UsePezkuwiApiReturn {
const [api, setApi] = useState<ApiPromise | null>(globalApi);
const [isReady, setIsReady] = useState(globalApi?.isConnected || false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const connect = async () => {
if (isConnecting) return;
setIsConnecting(true);
setError(null);
try {
const apiInstance = await createApiConnection();
setApi(apiInstance);
setIsReady(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Connection failed';
setError(errorMessage);
setIsReady(false);
} finally {
setIsConnecting(false);
}
};
const reconnect = async () => {
// Disconnect existing connection
if (globalApi) {
await globalApi.disconnect();
globalApi = null;
}
await connect();
};
useEffect(() => {
// If we already have a global API, use it
if (globalApi && globalApi.isConnected) {
setApi(globalApi);
setIsReady(true);
return;
}
// Otherwise, establish connection
connect();
// Cleanup on unmount - don't disconnect global API, just clean up local state
return () => {
// Note: We don't disconnect globalApi here to maintain connection across components
};
}, []);
// Handle disconnection events
useEffect(() => {
if (!api) return;
const handleDisconnected = () => {
if (import.meta.env.DEV) {
console.log('[PezkuwiApi] Disconnected, attempting to reconnect...');
}
setIsReady(false);
reconnect();
};
api.on('disconnected', handleDisconnected);
return () => {
api.off('disconnected', handleDisconnected);
};
}, [api]);
return {
api,
isReady,
isConnecting,
error,
reconnect,
};
}
// Export helper to get the global API instance (for non-hook usage)
export function getApiInstance(): ApiPromise | null {
return globalApi;
}
+314
View File
@@ -0,0 +1,314 @@
import { useEffect, useState, useCallback } from 'react';
import WebApp from '@twa-dev/sdk';
export interface TelegramUser {
id: number;
first_name: string;
last_name?: string;
username?: string;
language_code?: string;
is_premium?: boolean;
photo_url?: string;
}
export interface TelegramTheme {
bg_color: string;
text_color: string;
hint_color: string;
link_color: string;
button_color: string;
button_text_color: string;
secondary_bg_color: string;
}
interface UseTelegramReturn {
// State
isReady: boolean;
isTelegram: boolean;
user: TelegramUser | null;
startParam: string | null;
theme: TelegramTheme | null;
colorScheme: 'light' | 'dark';
viewportHeight: number;
viewportStableHeight: number;
isExpanded: boolean;
// Actions
ready: () => void;
expand: () => void;
close: () => void;
showAlert: (message: string) => void;
showConfirm: (message: string) => Promise<boolean>;
showPopup: (params: { title?: string; message: string; buttons?: Array<{ id: string; type?: string; text: string }> }) => Promise<string>;
openLink: (url: string, options?: { try_instant_view?: boolean }) => void;
openTelegramLink: (url: string) => void;
sendData: (data: string) => void;
enableClosingConfirmation: () => void;
disableClosingConfirmation: () => void;
setHeaderColor: (color: string) => void;
setBackgroundColor: (color: string) => void;
// Main Button
showMainButton: (text: string, onClick: () => void) => void;
hideMainButton: () => void;
setMainButtonLoading: (loading: boolean) => void;
// Back Button
showBackButton: (onClick: () => void) => void;
hideBackButton: () => void;
// Haptic Feedback
hapticImpact: (style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft') => void;
hapticNotification: (type: 'error' | 'success' | 'warning') => void;
hapticSelection: () => void;
}
export function useTelegram(): UseTelegramReturn {
const [isReady, setIsReady] = useState(false);
const [isTelegram, setIsTelegram] = useState(false);
const [user, setUser] = useState<TelegramUser | null>(null);
const [startParam, setStartParam] = useState<string | null>(null);
const [theme, setTheme] = useState<TelegramTheme | null>(null);
const [colorScheme, setColorScheme] = useState<'light' | 'dark'>('dark');
const [viewportHeight, setViewportHeight] = useState(window.innerHeight);
const [viewportStableHeight, setViewportStableHeight] = useState(window.innerHeight);
const [isExpanded, setIsExpanded] = useState(false);
// Initialize Telegram WebApp
useEffect(() => {
try {
// Check if running in Telegram WebApp environment
const tg = WebApp;
if (tg && tg.initData) {
setIsTelegram(true);
// Get user info
if (tg.initDataUnsafe?.user) {
setUser(tg.initDataUnsafe.user as TelegramUser);
}
// Get start parameter (referral code, etc.)
if (tg.initDataUnsafe?.start_param) {
setStartParam(tg.initDataUnsafe.start_param);
}
// Get theme
if (tg.themeParams) {
setTheme(tg.themeParams as TelegramTheme);
}
// Get color scheme
setColorScheme(tg.colorScheme as 'light' | 'dark' || 'dark');
// Get viewport
setViewportHeight(tg.viewportHeight || window.innerHeight);
setViewportStableHeight(tg.viewportStableHeight || window.innerHeight);
setIsExpanded(tg.isExpanded || false);
// Listen for viewport changes
tg.onEvent('viewportChanged', (event: { isStateStable: boolean }) => {
setViewportHeight(tg.viewportHeight);
if (event.isStateStable) {
setViewportStableHeight(tg.viewportStableHeight);
}
});
// Listen for theme changes
tg.onEvent('themeChanged', () => {
setTheme(tg.themeParams as TelegramTheme);
setColorScheme(tg.colorScheme as 'light' | 'dark' || 'dark');
});
// Signal that app is ready
tg.ready();
setIsReady(true);
// Expand by default for better UX
tg.expand();
if (import.meta.env.DEV) {
console.log('[Telegram] Mini App initialized');
console.log('[Telegram] User:', tg.initDataUnsafe?.user);
console.log('[Telegram] Start param:', tg.initDataUnsafe?.start_param);
}
} else {
// Not running in Telegram, but still mark as ready
setIsReady(true);
if (import.meta.env.DEV) {
console.log('[Telegram] Not running in Telegram WebApp environment');
}
}
} catch (err) {
console.error('[Telegram] Initialization error:', err);
setIsReady(true); // Mark as ready even on error for graceful fallback
}
}, []);
// Actions
const ready = useCallback(() => {
if (isTelegram) WebApp.ready();
}, [isTelegram]);
const expand = useCallback(() => {
if (isTelegram) {
WebApp.expand();
setIsExpanded(true);
}
}, [isTelegram]);
const close = useCallback(() => {
if (isTelegram) WebApp.close();
}, [isTelegram]);
const showAlert = useCallback((message: string) => {
if (isTelegram) {
WebApp.showAlert(message);
} else {
alert(message);
}
}, [isTelegram]);
const showConfirm = useCallback((message: string): Promise<boolean> => {
return new Promise((resolve) => {
if (isTelegram) {
WebApp.showConfirm(message, (confirmed) => {
resolve(confirmed);
});
} else {
resolve(confirm(message));
}
});
}, [isTelegram]);
const showPopup = useCallback((params: { title?: string; message: string; buttons?: Array<{ id: string; type?: string; text: string }> }): Promise<string> => {
return new Promise((resolve) => {
if (isTelegram) {
WebApp.showPopup(params, (buttonId) => {
resolve(buttonId || '');
});
} else {
// Fallback for non-Telegram environment
const result = confirm(params.message);
resolve(result ? 'ok' : 'cancel');
}
});
}, [isTelegram]);
const openLink = useCallback((url: string, options?: { try_instant_view?: boolean }) => {
if (isTelegram) {
WebApp.openLink(url, options);
} else {
window.open(url, '_blank');
}
}, [isTelegram]);
const openTelegramLink = useCallback((url: string) => {
if (isTelegram) {
WebApp.openTelegramLink(url);
} else {
window.open(url, '_blank');
}
}, [isTelegram]);
const sendData = useCallback((data: string) => {
if (isTelegram) WebApp.sendData(data);
}, [isTelegram]);
const enableClosingConfirmation = useCallback(() => {
if (isTelegram) WebApp.enableClosingConfirmation();
}, [isTelegram]);
const disableClosingConfirmation = useCallback(() => {
if (isTelegram) WebApp.disableClosingConfirmation();
}, [isTelegram]);
const setHeaderColor = useCallback((color: string) => {
if (isTelegram) WebApp.setHeaderColor(color as `#${string}`);
}, [isTelegram]);
const setBackgroundColor = useCallback((color: string) => {
if (isTelegram) WebApp.setBackgroundColor(color as `#${string}`);
}, [isTelegram]);
// Main Button
const showMainButton = useCallback((text: string, onClick: () => void) => {
if (isTelegram) {
WebApp.MainButton.setText(text);
WebApp.MainButton.onClick(onClick);
WebApp.MainButton.show();
}
}, [isTelegram]);
const hideMainButton = useCallback(() => {
if (isTelegram) WebApp.MainButton.hide();
}, [isTelegram]);
const setMainButtonLoading = useCallback((loading: boolean) => {
if (isTelegram) {
if (loading) {
WebApp.MainButton.showProgress();
} else {
WebApp.MainButton.hideProgress();
}
}
}, [isTelegram]);
// Back Button
const showBackButton = useCallback((onClick: () => void) => {
if (isTelegram) {
WebApp.BackButton.onClick(onClick);
WebApp.BackButton.show();
}
}, [isTelegram]);
const hideBackButton = useCallback(() => {
if (isTelegram) WebApp.BackButton.hide();
}, [isTelegram]);
// Haptic Feedback
const hapticImpact = useCallback((style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft') => {
if (isTelegram) WebApp.HapticFeedback.impactOccurred(style);
}, [isTelegram]);
const hapticNotification = useCallback((type: 'error' | 'success' | 'warning') => {
if (isTelegram) WebApp.HapticFeedback.notificationOccurred(type);
}, [isTelegram]);
const hapticSelection = useCallback(() => {
if (isTelegram) WebApp.HapticFeedback.selectionChanged();
}, [isTelegram]);
return {
isReady,
isTelegram,
user,
startParam,
theme,
colorScheme,
viewportHeight,
viewportStableHeight,
isExpanded,
ready,
expand,
close,
showAlert,
showConfirm,
showPopup,
openLink,
openTelegramLink,
sendData,
enableClosingConfirmation,
disableClosingConfirmation,
setHeaderColor,
setBackgroundColor,
showMainButton,
hideMainButton,
setMainButtonLoading,
showBackButton,
hideBackButton,
hapticImpact,
hapticNotification,
hapticSelection,
};
}