mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user