mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
feat: Linear-style UI redesign with improved mobile experience
Major changes: - Redesign cabinet with Linear-style components and top navigation - Replace detail modals with dedicated pages (users, broadcasts, email templates) - Add email channel support for broadcasts - Remove pull-to-refresh, improve drag-and-drop on touch devices - Fix Telegram Mini App: fullscreen, back button, scroll restoration - Unify admin pages color scheme to design system - Mobile-first improvements: horizontal tabs for settings, better touch targets
This commit is contained in:
7
src/platform/PlatformContext.tsx
Normal file
7
src/platform/PlatformContext.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createContext } from 'react';
|
||||
import type { PlatformContext as PlatformContextType } from './types';
|
||||
|
||||
// Create the context with undefined default (will be provided by PlatformProvider)
|
||||
export const PlatformContext = createContext<PlatformContextType | null>(null);
|
||||
|
||||
PlatformContext.displayName = 'PlatformContext';
|
||||
43
src/platform/PlatformProvider.tsx
Normal file
43
src/platform/PlatformProvider.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { PlatformContext } from '@/platform/PlatformContext';
|
||||
import { createTelegramAdapter } from '@/platform/adapters/TelegramAdapter';
|
||||
import { createWebAdapter } from '@/platform/adapters/WebAdapter';
|
||||
import type { PlatformContext as PlatformContextType } from '@/platform/types';
|
||||
|
||||
interface PlatformProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function detectPlatform(): 'telegram' | 'web' {
|
||||
// Check if Telegram WebApp is available and actually initialized with data
|
||||
// initData is an empty string when not in real Telegram context
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
window.Telegram?.WebApp?.initData &&
|
||||
window.Telegram.WebApp.initData.length > 0
|
||||
) {
|
||||
return 'telegram';
|
||||
}
|
||||
return 'web';
|
||||
}
|
||||
|
||||
function createAdapter(): PlatformContextType {
|
||||
const platform = detectPlatform();
|
||||
|
||||
if (platform === 'telegram') {
|
||||
return createTelegramAdapter();
|
||||
}
|
||||
|
||||
return createWebAdapter();
|
||||
}
|
||||
|
||||
export function PlatformProvider({ children }: PlatformProviderProps) {
|
||||
// Create adapter once on mount
|
||||
// Using useMemo to ensure stable reference
|
||||
const platformContext = useMemo(() => createAdapter(), []);
|
||||
|
||||
return <PlatformContext.Provider value={platformContext}>{children}</PlatformContext.Provider>;
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { PlatformContextType as PlatformContext };
|
||||
375
src/platform/adapters/TelegramAdapter.ts
Normal file
375
src/platform/adapters/TelegramAdapter.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import type {
|
||||
PlatformContext,
|
||||
PlatformCapabilities,
|
||||
BackButtonController,
|
||||
MainButtonController,
|
||||
HapticController,
|
||||
DialogController,
|
||||
ThemeController,
|
||||
CloudStorageController,
|
||||
MainButtonConfig,
|
||||
PopupOptions,
|
||||
InvoiceStatus,
|
||||
HapticImpactStyle,
|
||||
HapticNotificationType,
|
||||
} from '@/platform/types';
|
||||
|
||||
function getTelegram(): TelegramWebApp | null {
|
||||
return window.Telegram?.WebApp ?? null;
|
||||
}
|
||||
|
||||
function isVersionAtLeast(version: string): boolean {
|
||||
const tg = getTelegram();
|
||||
if (!tg) return false;
|
||||
return tg.isVersionAtLeast?.(version) ?? false;
|
||||
}
|
||||
|
||||
function createCapabilities(): PlatformCapabilities {
|
||||
const tg = getTelegram();
|
||||
return {
|
||||
hasBackButton: !!tg?.BackButton,
|
||||
hasMainButton: !!tg?.MainButton,
|
||||
hasHapticFeedback: isVersionAtLeast('6.1') && !!tg?.HapticFeedback,
|
||||
hasNativeDialogs: isVersionAtLeast('6.2'),
|
||||
hasThemeSync: isVersionAtLeast('6.1'),
|
||||
hasInvoice: !!tg?.openInvoice,
|
||||
hasCloudStorage: isVersionAtLeast('6.9') && !!tg?.CloudStorage,
|
||||
hasShare: true, // openTelegramLink is always available
|
||||
version: tg?.version,
|
||||
};
|
||||
}
|
||||
|
||||
function createBackButtonController(): BackButtonController {
|
||||
const tg = getTelegram();
|
||||
let currentCallback: (() => void) | null = null;
|
||||
|
||||
return {
|
||||
get isVisible() {
|
||||
return tg?.BackButton?.isVisible ?? false;
|
||||
},
|
||||
|
||||
show(onClick: () => void) {
|
||||
if (!tg?.BackButton) return;
|
||||
|
||||
// Remove previous callback if exists
|
||||
if (currentCallback) {
|
||||
tg.BackButton.offClick(currentCallback);
|
||||
}
|
||||
|
||||
currentCallback = onClick;
|
||||
tg.BackButton.onClick(onClick);
|
||||
tg.BackButton.show();
|
||||
},
|
||||
|
||||
hide() {
|
||||
if (!tg?.BackButton) return;
|
||||
|
||||
if (currentCallback) {
|
||||
tg.BackButton.offClick(currentCallback);
|
||||
currentCallback = null;
|
||||
}
|
||||
tg.BackButton.hide();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMainButtonController(): MainButtonController {
|
||||
const tg = getTelegram();
|
||||
let currentCallback: (() => void) | null = null;
|
||||
|
||||
return {
|
||||
get isVisible() {
|
||||
return tg?.MainButton?.isVisible ?? false;
|
||||
},
|
||||
|
||||
show(config: MainButtonConfig) {
|
||||
if (!tg?.MainButton) return;
|
||||
|
||||
// Remove previous callback if exists
|
||||
if (currentCallback) {
|
||||
tg.MainButton.offClick(currentCallback);
|
||||
}
|
||||
|
||||
currentCallback = config.onClick;
|
||||
|
||||
// Set button parameters
|
||||
if (tg.MainButton.setParams) {
|
||||
tg.MainButton.setParams({
|
||||
text: config.text,
|
||||
color: config.color,
|
||||
text_color: config.textColor,
|
||||
is_active: config.isActive !== false,
|
||||
is_visible: true,
|
||||
});
|
||||
} else {
|
||||
tg.MainButton.text = config.text;
|
||||
if (config.color) tg.MainButton.color = config.color;
|
||||
if (config.textColor) tg.MainButton.textColor = config.textColor;
|
||||
}
|
||||
|
||||
tg.MainButton.onClick(config.onClick);
|
||||
|
||||
if (config.isActive === false) {
|
||||
tg.MainButton.disable();
|
||||
} else {
|
||||
tg.MainButton.enable();
|
||||
}
|
||||
|
||||
if (config.isLoading) {
|
||||
tg.MainButton.showProgress?.(true);
|
||||
} else {
|
||||
tg.MainButton.hideProgress?.();
|
||||
}
|
||||
|
||||
tg.MainButton.show();
|
||||
},
|
||||
|
||||
hide() {
|
||||
if (!tg?.MainButton) return;
|
||||
|
||||
if (currentCallback) {
|
||||
tg.MainButton.offClick(currentCallback);
|
||||
currentCallback = null;
|
||||
}
|
||||
|
||||
tg.MainButton.hideProgress?.();
|
||||
tg.MainButton.hide();
|
||||
},
|
||||
|
||||
showProgress(show: boolean) {
|
||||
if (!tg?.MainButton) return;
|
||||
|
||||
if (show) {
|
||||
tg.MainButton.showProgress?.(true);
|
||||
} else {
|
||||
tg.MainButton.hideProgress?.();
|
||||
}
|
||||
},
|
||||
|
||||
setText(text: string) {
|
||||
if (!tg?.MainButton) return;
|
||||
|
||||
if (tg.MainButton.setText) {
|
||||
tg.MainButton.setText(text);
|
||||
} else {
|
||||
tg.MainButton.text = text;
|
||||
}
|
||||
},
|
||||
|
||||
setActive(active: boolean) {
|
||||
if (!tg?.MainButton) return;
|
||||
|
||||
if (active) {
|
||||
tg.MainButton.enable();
|
||||
} else {
|
||||
tg.MainButton.disable();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHapticController(): HapticController {
|
||||
const tg = getTelegram();
|
||||
|
||||
return {
|
||||
impact(style: HapticImpactStyle = 'medium') {
|
||||
tg?.HapticFeedback?.impactOccurred(style);
|
||||
},
|
||||
|
||||
notification(type: HapticNotificationType) {
|
||||
tg?.HapticFeedback?.notificationOccurred(type);
|
||||
},
|
||||
|
||||
selection() {
|
||||
tg?.HapticFeedback?.selectionChanged();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDialogController(): DialogController {
|
||||
const tg = getTelegram();
|
||||
|
||||
return {
|
||||
alert(message: string, _title?: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (tg?.showAlert) {
|
||||
tg.showAlert(message, () => resolve());
|
||||
} else {
|
||||
window.alert(message);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
confirm(message: string, _title?: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (tg?.showConfirm) {
|
||||
tg.showConfirm(message, (confirmed) => resolve(confirmed));
|
||||
} else {
|
||||
resolve(window.confirm(message));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
popup(options: PopupOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (tg?.showPopup) {
|
||||
tg.showPopup(
|
||||
{
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
buttons: options.buttons?.map((btn) => ({
|
||||
id: btn.id,
|
||||
type: btn.type,
|
||||
text: btn.text,
|
||||
})),
|
||||
},
|
||||
(buttonId) => resolve(buttonId),
|
||||
);
|
||||
} else {
|
||||
// Fallback to confirm for web
|
||||
const confirmed = window.confirm(options.message);
|
||||
resolve(confirmed ? 'ok' : null);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createThemeController(): ThemeController {
|
||||
const tg = getTelegram();
|
||||
|
||||
return {
|
||||
setHeaderColor(color: string) {
|
||||
tg?.setHeaderColor?.(color);
|
||||
},
|
||||
|
||||
setBottomBarColor(color: string) {
|
||||
tg?.setBottomBarColor?.(color);
|
||||
},
|
||||
|
||||
getThemeParams() {
|
||||
return tg?.themeParams ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createCloudStorageController(): CloudStorageController | null {
|
||||
const tg = getTelegram();
|
||||
if (!tg?.CloudStorage) return null;
|
||||
|
||||
return {
|
||||
getItem(key: string): Promise<string | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tg.CloudStorage.getItem(key, (error, value) => {
|
||||
if (error) reject(error);
|
||||
else resolve(value || null);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
setItem(key: string, value: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tg.CloudStorage.setItem(key, value, (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
removeItem(key: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tg.CloudStorage.removeItem(key, (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
getKeys(): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tg.CloudStorage.getKeys((error, keys) => {
|
||||
if (error) reject(error);
|
||||
else resolve(keys);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTelegramAdapter(): PlatformContext {
|
||||
const tg = getTelegram();
|
||||
|
||||
return {
|
||||
platform: 'telegram',
|
||||
capabilities: createCapabilities(),
|
||||
backButton: createBackButtonController(),
|
||||
mainButton: createMainButtonController(),
|
||||
haptic: createHapticController(),
|
||||
dialog: createDialogController(),
|
||||
theme: createThemeController(),
|
||||
cloudStorage: createCloudStorageController(),
|
||||
|
||||
openInvoice(url: string): Promise<InvoiceStatus> {
|
||||
return new Promise((resolve) => {
|
||||
if (tg?.openInvoice) {
|
||||
tg.openInvoice(url, (status) => resolve(status));
|
||||
} else {
|
||||
// Fallback: open in new window
|
||||
window.open(url, '_blank');
|
||||
resolve('pending');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
openLink(url: string, options?: { tryInstantView?: boolean }) {
|
||||
if (tg?.openLink) {
|
||||
tg.openLink(url, { try_instant_view: options?.tryInstantView });
|
||||
} else {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
},
|
||||
|
||||
openTelegramLink(url: string) {
|
||||
if (tg?.openTelegramLink) {
|
||||
tg.openTelegramLink(url);
|
||||
} else {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
},
|
||||
|
||||
async share(text: string, url?: string): Promise<boolean> {
|
||||
const shareText = url ? `${text}\n${url}` : text;
|
||||
|
||||
// Try native share API first (if available on mobile)
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ text: shareText, url });
|
||||
return true;
|
||||
} catch {
|
||||
// User cancelled or share failed, continue to Telegram share
|
||||
}
|
||||
}
|
||||
|
||||
// Use Telegram share
|
||||
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME;
|
||||
if (botUsername && tg?.openTelegramLink) {
|
||||
const encoded = encodeURIComponent(shareText);
|
||||
tg.openTelegramLink(`https://t.me/share/url?url=${encoded}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
setClosingConfirmation(enabled: boolean) {
|
||||
if (enabled) {
|
||||
tg?.enableClosingConfirmation?.();
|
||||
} else {
|
||||
tg?.disableClosingConfirmation?.();
|
||||
}
|
||||
},
|
||||
|
||||
telegram: tg,
|
||||
};
|
||||
}
|
||||
277
src/platform/adapters/WebAdapter.ts
Normal file
277
src/platform/adapters/WebAdapter.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import type {
|
||||
PlatformContext,
|
||||
PlatformCapabilities,
|
||||
BackButtonController,
|
||||
MainButtonController,
|
||||
HapticController,
|
||||
DialogController,
|
||||
ThemeController,
|
||||
CloudStorageController,
|
||||
MainButtonConfig,
|
||||
PopupOptions,
|
||||
InvoiceStatus,
|
||||
HapticImpactStyle,
|
||||
HapticNotificationType,
|
||||
} from '@/platform/types';
|
||||
|
||||
// Storage key for local storage fallback
|
||||
const STORAGE_PREFIX = 'bedolaga_';
|
||||
|
||||
function createCapabilities(): PlatformCapabilities {
|
||||
return {
|
||||
hasBackButton: false, // No native back button in web
|
||||
hasMainButton: false, // No native main button in web
|
||||
hasHapticFeedback: 'vibrate' in navigator, // Web Vibration API
|
||||
hasNativeDialogs: false, // Use custom dialogs
|
||||
hasThemeSync: false, // No header/bottom bar sync in web
|
||||
hasInvoice: false, // No native invoice in web
|
||||
hasCloudStorage: true, // Use localStorage
|
||||
hasShare: 'share' in navigator, // Web Share API
|
||||
version: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function createBackButtonController(): BackButtonController {
|
||||
// Web doesn't have a native back button - this is a no-op
|
||||
// The UI will render its own back buttons
|
||||
return {
|
||||
isVisible: false,
|
||||
|
||||
show(_onClick: () => void) {
|
||||
// No-op in web - handled by UI components
|
||||
},
|
||||
|
||||
hide() {
|
||||
// No-op in web
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMainButtonController(): MainButtonController {
|
||||
// Web doesn't have a native main button - this is a no-op
|
||||
// The UI will render its own submit buttons
|
||||
return {
|
||||
isVisible: false,
|
||||
|
||||
show(_config: MainButtonConfig) {
|
||||
// No-op in web - handled by UI components
|
||||
},
|
||||
|
||||
hide() {
|
||||
// No-op in web
|
||||
},
|
||||
|
||||
showProgress(_show: boolean) {
|
||||
// No-op in web
|
||||
},
|
||||
|
||||
setText(_text: string) {
|
||||
// No-op in web
|
||||
},
|
||||
|
||||
setActive(_active: boolean) {
|
||||
// No-op in web
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHapticController(): HapticController {
|
||||
// Web Vibration API fallback (works on mobile browsers)
|
||||
const canVibrate = 'vibrate' in navigator;
|
||||
|
||||
const vibrationPatterns: Record<HapticImpactStyle, number[]> = {
|
||||
light: [10],
|
||||
medium: [20],
|
||||
heavy: [30],
|
||||
rigid: [15, 10, 15],
|
||||
soft: [5, 5, 5],
|
||||
};
|
||||
|
||||
const notificationPatterns: Record<HapticNotificationType, number[]> = {
|
||||
success: [10, 50, 10],
|
||||
warning: [20, 50, 20],
|
||||
error: [30, 30, 30, 30, 30],
|
||||
};
|
||||
|
||||
return {
|
||||
impact(style: HapticImpactStyle = 'medium') {
|
||||
if (canVibrate) {
|
||||
navigator.vibrate(vibrationPatterns[style]);
|
||||
}
|
||||
},
|
||||
|
||||
notification(type: HapticNotificationType) {
|
||||
if (canVibrate) {
|
||||
navigator.vibrate(notificationPatterns[type]);
|
||||
}
|
||||
},
|
||||
|
||||
selection() {
|
||||
if (canVibrate) {
|
||||
navigator.vibrate(5);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDialogController(): DialogController {
|
||||
// Web uses native browser dialogs as fallback
|
||||
// UI components can override with custom modals
|
||||
return {
|
||||
alert(message: string, _title?: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.alert(message);
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
|
||||
confirm(message: string, _title?: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
resolve(window.confirm(message));
|
||||
});
|
||||
},
|
||||
|
||||
popup(options: PopupOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
// Simple confirm fallback
|
||||
const confirmed = window.confirm(options.message);
|
||||
if (confirmed && options.buttons?.length) {
|
||||
// Return first non-cancel button id
|
||||
const button = options.buttons.find((b) => b.type !== 'cancel' && b.type !== 'close');
|
||||
resolve(button?.id ?? 'ok');
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createThemeController(): ThemeController {
|
||||
// Web doesn't have native theme sync
|
||||
// These are no-ops; theme is handled via CSS
|
||||
return {
|
||||
setHeaderColor(_color: string) {
|
||||
// Could update meta theme-color tag if needed
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) {
|
||||
meta.setAttribute('content', _color);
|
||||
}
|
||||
},
|
||||
|
||||
setBottomBarColor(_color: string) {
|
||||
// No-op in web - no bottom bar to sync
|
||||
},
|
||||
|
||||
getThemeParams() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createCloudStorageController(): CloudStorageController {
|
||||
// Use localStorage as cloud storage fallback
|
||||
return {
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_PREFIX + key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_PREFIX + key, value);
|
||||
} catch {
|
||||
// Storage might be full or disabled
|
||||
console.warn('Failed to save to localStorage:', key);
|
||||
}
|
||||
},
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_PREFIX + key);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
},
|
||||
|
||||
async getKeys(): Promise<string[]> {
|
||||
try {
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(STORAGE_PREFIX)) {
|
||||
keys.push(key.slice(STORAGE_PREFIX.length));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createWebAdapter(): PlatformContext {
|
||||
return {
|
||||
platform: 'web',
|
||||
capabilities: createCapabilities(),
|
||||
backButton: createBackButtonController(),
|
||||
mainButton: createMainButtonController(),
|
||||
haptic: createHapticController(),
|
||||
dialog: createDialogController(),
|
||||
theme: createThemeController(),
|
||||
cloudStorage: createCloudStorageController(),
|
||||
|
||||
openInvoice(_url: string): Promise<InvoiceStatus> {
|
||||
// Web can't handle Telegram invoices natively
|
||||
// Open in new tab and return pending
|
||||
window.open(_url, '_blank');
|
||||
return Promise.resolve('pending');
|
||||
},
|
||||
|
||||
openLink(url: string, _options?: { tryInstantView?: boolean }) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
|
||||
openTelegramLink(url: string) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
|
||||
async share(text: string, url?: string): Promise<boolean> {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ text, url });
|
||||
return true;
|
||||
} catch {
|
||||
// User cancelled share
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: copy to clipboard
|
||||
try {
|
||||
const shareText = url ? `${text}\n${url}` : text;
|
||||
await navigator.clipboard.writeText(shareText);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setClosingConfirmation(enabled: boolean) {
|
||||
if (enabled) {
|
||||
window.onbeforeunload = (e) => {
|
||||
e.preventDefault();
|
||||
return '';
|
||||
};
|
||||
} else {
|
||||
window.onbeforeunload = null;
|
||||
}
|
||||
},
|
||||
|
||||
telegram: null,
|
||||
};
|
||||
}
|
||||
75
src/platform/hooks/useBackButton.ts
Normal file
75
src/platform/hooks/useBackButton.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
||||
|
||||
interface UseBackButtonOptions {
|
||||
/**
|
||||
* Whether the back button should be visible
|
||||
* @default true
|
||||
*/
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage the Telegram BackButton
|
||||
* Automatically shows/hides based on component lifecycle
|
||||
*
|
||||
* @param onBack - Callback when back button is pressed
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyPage() {
|
||||
* const navigate = useNavigate();
|
||||
* useBackButton(() => navigate(-1));
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useBackButton(
|
||||
onBack: (() => void) | null | undefined,
|
||||
options: UseBackButtonOptions = {},
|
||||
): void {
|
||||
const { backButton, capabilities } = usePlatform();
|
||||
const { visible = true } = options;
|
||||
|
||||
// Use ref to prevent callback recreation issues
|
||||
const callbackRef = useRef(onBack);
|
||||
callbackRef.current = onBack;
|
||||
|
||||
// Stable callback wrapper
|
||||
const handleBack = useCallback(() => {
|
||||
callbackRef.current?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// If no native back button support, do nothing
|
||||
if (!capabilities.hasBackButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If callback is null/undefined or visible is false, hide button
|
||||
if (!onBack || !visible) {
|
||||
backButton.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the back button with our handler
|
||||
backButton.show(handleBack);
|
||||
|
||||
// Cleanup: hide button when component unmounts
|
||||
return () => {
|
||||
backButton.hide();
|
||||
};
|
||||
}, [backButton, capabilities.hasBackButton, handleBack, onBack, visible]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to conditionally show back button based on navigation depth
|
||||
* Useful for showing back button only when there's history to go back to
|
||||
*
|
||||
* @param canGoBack - Whether navigation back is possible
|
||||
* @param onBack - Callback when back button is pressed
|
||||
*/
|
||||
export function useConditionalBackButton(canGoBack: boolean, onBack: () => void): void {
|
||||
useBackButton(canGoBack ? onBack : null);
|
||||
}
|
||||
125
src/platform/hooks/useHaptic.ts
Normal file
125
src/platform/hooks/useHaptic.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useCallback } from 'react';
|
||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
||||
import type { HapticImpactStyle, HapticNotificationType } from '@/platform/types';
|
||||
|
||||
interface HapticMethods {
|
||||
/**
|
||||
* Trigger impact feedback (for button presses, collisions)
|
||||
*/
|
||||
impact: (style?: HapticImpactStyle) => void;
|
||||
|
||||
/**
|
||||
* Trigger notification feedback (for success/warning/error events)
|
||||
*/
|
||||
notification: (type: HapticNotificationType) => void;
|
||||
|
||||
/**
|
||||
* Trigger selection feedback (for selection changes)
|
||||
*/
|
||||
selection: () => void;
|
||||
|
||||
/**
|
||||
* Whether haptic feedback is available
|
||||
*/
|
||||
isAvailable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access haptic feedback
|
||||
* Works in Telegram Mini Apps and falls back to Web Vibration API
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyButton() {
|
||||
* const haptic = useHaptic();
|
||||
*
|
||||
* const handleClick = () => {
|
||||
* haptic.impact('medium');
|
||||
* doSomething();
|
||||
* };
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHaptic(): HapticMethods {
|
||||
const { haptic, capabilities } = usePlatform();
|
||||
|
||||
const impact = useCallback(
|
||||
(style: HapticImpactStyle = 'medium') => {
|
||||
haptic.impact(style);
|
||||
},
|
||||
[haptic],
|
||||
);
|
||||
|
||||
const notification = useCallback(
|
||||
(type: HapticNotificationType) => {
|
||||
haptic.notification(type);
|
||||
},
|
||||
[haptic],
|
||||
);
|
||||
|
||||
const selection = useCallback(() => {
|
||||
haptic.selection();
|
||||
}, [haptic]);
|
||||
|
||||
return {
|
||||
impact,
|
||||
notification,
|
||||
selection,
|
||||
isAvailable: capabilities.hasHapticFeedback,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that returns a click handler with haptic feedback
|
||||
* Useful for buttons that need haptic on press
|
||||
*
|
||||
* @param onClick - Original click handler
|
||||
* @param style - Haptic impact style
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyButton({ onClick }) {
|
||||
* const handleClick = useHapticClick(onClick, 'light');
|
||||
* return <button onClick={handleClick}>Press me</button>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHapticClick(
|
||||
onClick: (() => void) | undefined,
|
||||
style: HapticImpactStyle = 'light',
|
||||
): (() => void) | undefined {
|
||||
const haptic = useHaptic();
|
||||
|
||||
return useCallback(() => {
|
||||
haptic.impact(style);
|
||||
onClick?.();
|
||||
}, [haptic, onClick, style]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns individual haptic trigger functions
|
||||
* Useful when you need specific feedback types
|
||||
*/
|
||||
export function useHapticFeedback() {
|
||||
const haptic = useHaptic();
|
||||
|
||||
return {
|
||||
// Common actions
|
||||
buttonPress: useCallback(() => haptic.impact('light'), [haptic]),
|
||||
buttonPressHeavy: useCallback(() => haptic.impact('medium'), [haptic]),
|
||||
toggle: useCallback(() => haptic.impact('rigid'), [haptic]),
|
||||
|
||||
// Notifications
|
||||
success: useCallback(() => haptic.notification('success'), [haptic]),
|
||||
warning: useCallback(() => haptic.notification('warning'), [haptic]),
|
||||
error: useCallback(() => haptic.notification('error'), [haptic]),
|
||||
|
||||
// Selection
|
||||
selectionChanged: useCallback(() => haptic.selection(), [haptic]),
|
||||
|
||||
// Raw access
|
||||
impact: haptic.impact,
|
||||
notification: haptic.notification,
|
||||
selection: haptic.selection,
|
||||
};
|
||||
}
|
||||
111
src/platform/hooks/useMainButton.ts
Normal file
111
src/platform/hooks/useMainButton.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
||||
import type { MainButtonConfig } from '@/platform/types';
|
||||
|
||||
interface UseMainButtonOptions extends Omit<MainButtonConfig, 'onClick'> {
|
||||
/**
|
||||
* Whether the main button should be visible
|
||||
* @default true
|
||||
*/
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage the Telegram MainButton
|
||||
* Automatically shows/hides based on component lifecycle
|
||||
*
|
||||
* @param onClick - Callback when main button is pressed
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function SubmitForm() {
|
||||
* const mutation = useMutation(...);
|
||||
*
|
||||
* useMainButton(handleSubmit, {
|
||||
* text: t('actions.submit'),
|
||||
* isLoading: mutation.isPending,
|
||||
* isActive: isFormValid,
|
||||
* });
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useMainButton(
|
||||
onClick: (() => void) | null | undefined,
|
||||
options: UseMainButtonOptions = { text: '' },
|
||||
): void {
|
||||
const { mainButton, capabilities } = usePlatform();
|
||||
const { visible = true, text, isLoading, isActive, color, textColor } = options;
|
||||
|
||||
// Use ref to prevent callback recreation issues
|
||||
const callbackRef = useRef(onClick);
|
||||
callbackRef.current = onClick;
|
||||
|
||||
// Stable callback wrapper
|
||||
const handleClick = useCallback(() => {
|
||||
callbackRef.current?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// If no native main button support, do nothing
|
||||
// UI components will render their own submit buttons
|
||||
if (!capabilities.hasMainButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If callback is null/undefined or visible is false, hide button
|
||||
if (!onClick || !visible || !text) {
|
||||
mainButton.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the main button with configuration
|
||||
mainButton.show({
|
||||
text,
|
||||
onClick: handleClick,
|
||||
isLoading,
|
||||
isActive,
|
||||
color,
|
||||
textColor,
|
||||
});
|
||||
|
||||
// Cleanup: hide button when component unmounts
|
||||
return () => {
|
||||
mainButton.hide();
|
||||
};
|
||||
}, [
|
||||
mainButton,
|
||||
capabilities.hasMainButton,
|
||||
handleClick,
|
||||
onClick,
|
||||
visible,
|
||||
text,
|
||||
isLoading,
|
||||
isActive,
|
||||
color,
|
||||
textColor,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for simple main button usage
|
||||
* Shows button only when conditions are met
|
||||
*
|
||||
* @param text - Button text
|
||||
* @param onClick - Click handler
|
||||
* @param enabled - Whether button should be shown and active
|
||||
* @param loading - Whether to show loading state
|
||||
*/
|
||||
export function useSimpleMainButton(
|
||||
text: string,
|
||||
onClick: () => void,
|
||||
enabled: boolean = true,
|
||||
loading: boolean = false,
|
||||
): void {
|
||||
useMainButton(enabled ? onClick : null, {
|
||||
text,
|
||||
isLoading: loading,
|
||||
isActive: enabled && !loading,
|
||||
visible: enabled,
|
||||
});
|
||||
}
|
||||
116
src/platform/hooks/useNativeDialog.ts
Normal file
116
src/platform/hooks/useNativeDialog.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useCallback } from 'react';
|
||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
||||
import type { PopupOptions, PopupButton } from '@/platform/types';
|
||||
|
||||
interface DialogMethods {
|
||||
/**
|
||||
* Show a simple alert dialog
|
||||
* @param message - Alert message
|
||||
* @param title - Optional title (only shown if platform supports it)
|
||||
*/
|
||||
alert: (message: string, title?: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Show a confirmation dialog
|
||||
* @param message - Confirmation message
|
||||
* @param title - Optional title
|
||||
* @returns true if confirmed, false otherwise
|
||||
*/
|
||||
confirm: (message: string, title?: string) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Show a popup with custom buttons
|
||||
* @param options - Popup configuration
|
||||
* @returns ID of the pressed button, or null if cancelled
|
||||
*/
|
||||
popup: (options: PopupOptions) => Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Whether native dialogs are available
|
||||
*/
|
||||
isNative: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access native dialog functionality
|
||||
* Uses Telegram popups in Mini Apps, browser dialogs in web
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const dialog = useNativeDialog();
|
||||
*
|
||||
* const handleDelete = async () => {
|
||||
* const confirmed = await dialog.confirm('Delete this item?');
|
||||
* if (confirmed) {
|
||||
* deleteItem();
|
||||
* }
|
||||
* };
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useNativeDialog(): DialogMethods {
|
||||
const { dialog, capabilities } = usePlatform();
|
||||
|
||||
const alert = useCallback(
|
||||
async (message: string, title?: string) => {
|
||||
await dialog.alert(message, title);
|
||||
},
|
||||
[dialog],
|
||||
);
|
||||
|
||||
const confirm = useCallback(
|
||||
async (message: string, title?: string) => {
|
||||
return dialog.confirm(message, title);
|
||||
},
|
||||
[dialog],
|
||||
);
|
||||
|
||||
const popup = useCallback(
|
||||
async (options: PopupOptions) => {
|
||||
return dialog.popup(options);
|
||||
},
|
||||
[dialog],
|
||||
);
|
||||
|
||||
return {
|
||||
alert,
|
||||
confirm,
|
||||
popup,
|
||||
isNative: capabilities.hasNativeDialogs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create popup button configurations
|
||||
*/
|
||||
export const PopupButtons = {
|
||||
ok: (text = 'OK'): PopupButton => ({ id: 'ok', type: 'ok', text }),
|
||||
cancel: (text = 'Cancel'): PopupButton => ({ id: 'cancel', type: 'cancel', text }),
|
||||
close: (text = 'Close'): PopupButton => ({ id: 'close', type: 'close', text }),
|
||||
destructive: (id: string, text: string): PopupButton => ({ id, type: 'destructive', text }),
|
||||
default: (id: string, text: string): PopupButton => ({ id, type: 'default', text }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a destructive action confirmation
|
||||
* Red "Delete" button style in Telegram
|
||||
*/
|
||||
export function useDestructiveConfirm() {
|
||||
const dialog = useNativeDialog();
|
||||
|
||||
return useCallback(
|
||||
async (message: string, actionText = 'Delete', title?: string) => {
|
||||
if (dialog.isNative) {
|
||||
const result = await dialog.popup({
|
||||
title,
|
||||
message,
|
||||
buttons: [PopupButtons.cancel(), PopupButtons.destructive('confirm', actionText)],
|
||||
});
|
||||
return result === 'confirm';
|
||||
}
|
||||
return dialog.confirm(message, title);
|
||||
},
|
||||
[dialog],
|
||||
);
|
||||
}
|
||||
35
src/platform/hooks/usePlatform.ts
Normal file
35
src/platform/hooks/usePlatform.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useContext } from 'react';
|
||||
import { PlatformContext } from '@/platform/PlatformContext';
|
||||
import type { PlatformContext as PlatformContextType } from '@/platform/types';
|
||||
|
||||
/**
|
||||
* Hook to access the platform context
|
||||
* Provides platform-aware APIs for Telegram Mini Apps and web fallback
|
||||
*/
|
||||
export function usePlatform(): PlatformContextType {
|
||||
const context = useContext(PlatformContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('usePlatform must be used within a PlatformProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in Telegram Mini App
|
||||
*/
|
||||
export function useIsTelegram(): boolean {
|
||||
const { platform } = usePlatform();
|
||||
return platform === 'telegram';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific capability is available
|
||||
*/
|
||||
export function useCapability(capability: keyof PlatformContextType['capabilities']): boolean {
|
||||
const { capabilities } = usePlatform();
|
||||
const value = capabilities[capability];
|
||||
// version is the only string capability, all others are boolean
|
||||
return typeof value === 'boolean' ? value : !!value;
|
||||
}
|
||||
33
src/platform/index.ts
Normal file
33
src/platform/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Platform abstraction layer
|
||||
// Provides unified APIs for Telegram Mini Apps and web browser
|
||||
|
||||
// Context and Provider
|
||||
export { PlatformContext } from './PlatformContext';
|
||||
export { PlatformProvider } from './PlatformProvider';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
PlatformType,
|
||||
PlatformContext as PlatformContextType,
|
||||
PlatformCapabilities,
|
||||
MainButtonConfig,
|
||||
PopupOptions,
|
||||
PopupButton,
|
||||
InvoiceStatus,
|
||||
HapticImpactStyle,
|
||||
HapticNotificationType,
|
||||
BackButtonController,
|
||||
MainButtonController,
|
||||
HapticController,
|
||||
DialogController,
|
||||
ThemeController,
|
||||
CloudStorageController,
|
||||
TelegramThemeParams,
|
||||
} from './types';
|
||||
|
||||
// Hooks
|
||||
export { usePlatform, useIsTelegram, useCapability } from './hooks/usePlatform';
|
||||
export { useBackButton, useConditionalBackButton } from './hooks/useBackButton';
|
||||
export { useMainButton, useSimpleMainButton } from './hooks/useMainButton';
|
||||
export { useHaptic, useHapticClick, useHapticFeedback } from './hooks/useHaptic';
|
||||
export { useNativeDialog, useDestructiveConfirm, PopupButtons } from './hooks/useNativeDialog';
|
||||
140
src/platform/types.ts
Normal file
140
src/platform/types.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// Platform type definitions
|
||||
|
||||
export type PlatformType = 'telegram' | 'web';
|
||||
|
||||
export type HapticImpactStyle = 'light' | 'medium' | 'heavy' | 'rigid' | 'soft';
|
||||
export type HapticNotificationType = 'success' | 'warning' | 'error';
|
||||
|
||||
export type InvoiceStatus = 'paid' | 'cancelled' | 'failed' | 'pending';
|
||||
|
||||
export interface MainButtonConfig {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
isLoading?: boolean;
|
||||
isActive?: boolean;
|
||||
color?: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
export interface PopupButton {
|
||||
id: string;
|
||||
type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface PopupOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
buttons?: PopupButton[];
|
||||
}
|
||||
|
||||
export interface PlatformCapabilities {
|
||||
hasBackButton: boolean;
|
||||
hasMainButton: boolean;
|
||||
hasHapticFeedback: boolean;
|
||||
hasNativeDialogs: boolean;
|
||||
hasThemeSync: boolean;
|
||||
hasInvoice: boolean;
|
||||
hasCloudStorage: boolean;
|
||||
hasShare: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface BackButtonController {
|
||||
show: (onClick: () => void) => void;
|
||||
hide: () => void;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export interface MainButtonController {
|
||||
show: (config: MainButtonConfig) => void;
|
||||
hide: () => void;
|
||||
showProgress: (show: boolean) => void;
|
||||
setText: (text: string) => void;
|
||||
setActive: (active: boolean) => void;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export interface HapticController {
|
||||
impact: (style?: HapticImpactStyle) => void;
|
||||
notification: (type: HapticNotificationType) => void;
|
||||
selection: () => void;
|
||||
}
|
||||
|
||||
export interface DialogController {
|
||||
alert: (message: string, title?: string) => Promise<void>;
|
||||
confirm: (message: string, title?: string) => Promise<boolean>;
|
||||
popup: (options: PopupOptions) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface ThemeController {
|
||||
setHeaderColor: (color: string) => void;
|
||||
setBottomBarColor: (color: string) => void;
|
||||
getThemeParams: () => TelegramThemeParams | null;
|
||||
}
|
||||
|
||||
export interface TelegramThemeParams {
|
||||
bg_color?: string;
|
||||
text_color?: string;
|
||||
hint_color?: string;
|
||||
link_color?: string;
|
||||
button_color?: string;
|
||||
button_text_color?: string;
|
||||
secondary_bg_color?: string;
|
||||
header_bg_color?: string;
|
||||
bottom_bar_bg_color?: string;
|
||||
accent_text_color?: string;
|
||||
section_bg_color?: string;
|
||||
section_header_text_color?: string;
|
||||
subtitle_text_color?: string;
|
||||
destructive_text_color?: string;
|
||||
}
|
||||
|
||||
export interface CloudStorageController {
|
||||
getItem: (key: string) => Promise<string | null>;
|
||||
setItem: (key: string, value: string) => Promise<void>;
|
||||
removeItem: (key: string) => Promise<void>;
|
||||
getKeys: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
export interface PlatformContext {
|
||||
platform: PlatformType;
|
||||
capabilities: PlatformCapabilities;
|
||||
|
||||
// Navigation
|
||||
backButton: BackButtonController;
|
||||
|
||||
// Main action button
|
||||
mainButton: MainButtonController;
|
||||
|
||||
// Haptic feedback
|
||||
haptic: HapticController;
|
||||
|
||||
// Native dialogs
|
||||
dialog: DialogController;
|
||||
|
||||
// Theme synchronization
|
||||
theme: ThemeController;
|
||||
|
||||
// Cloud storage (Telegram only)
|
||||
cloudStorage: CloudStorageController | null;
|
||||
|
||||
// Invoice/Payment
|
||||
openInvoice: (url: string) => Promise<InvoiceStatus>;
|
||||
|
||||
// Links
|
||||
openLink: (url: string, options?: { tryInstantView?: boolean }) => void;
|
||||
openTelegramLink: (url: string) => void;
|
||||
|
||||
// Share
|
||||
share: (text: string, url?: string) => Promise<boolean>;
|
||||
|
||||
// Closing confirmation
|
||||
setClosingConfirmation: (enabled: boolean) => void;
|
||||
|
||||
// Raw Telegram WebApp access (null in web)
|
||||
telegram: TelegramWebApp | null;
|
||||
}
|
||||
|
||||
// Note: TelegramWebApp types are defined in vite-env.d.ts
|
||||
// This file only re-exports the interface from the global scope
|
||||
Reference in New Issue
Block a user