skills/react-vite/SKILL.md
React 18+ with Vite patterns - use for Mini App frontend development, component structure, hooks, and TypeScript setup
npx skillsauth add andvl1/claude-plugin react-viteInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
mini-app/
├── public/
│ └── favicon.ico
├── src/
│ ├── components/ # Reusable UI components
│ │ ├── common/ # Generic UI (Button, Card, Modal, etc.)
│ │ ├── layout/ # Layout components (AppLayout, Navigation)
│ │ └── features/ # Feature-specific components
│ │ ├── chat/ # Chat selector, chat card
│ │ ├── settings/ # Settings toggles, forms
│ │ ├── blocklist/ # Blocklist CRUD components
│ │ └── locks/ # Lock toggles, categories
│ ├── hooks/ # Custom React hooks
│ │ ├── api/ # Data fetching hooks
│ │ ├── telegram/ # Telegram-specific hooks
│ │ └── ui/ # UI state hooks
│ ├── pages/ # Route pages (one per route)
│ ├── services/ # API client and external services
│ ├── stores/ # Zustand stores (global state)
│ ├── types/ # TypeScript type definitions
│ ├── utils/ # Helper functions
│ ├── constants/ # App constants, enums
│ ├── App.tsx # App root with providers
│ ├── main.tsx # Entry point
│ ├── mockEnv.ts # Mock Telegram environment
│ └── vite-env.d.ts # Vite type declarations
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
└── .env.example
useQuery-like data fetchinguseTelegramAuth, useMainButtonuseModal, useToast// src/components/features/chat/ChatCard.tsx
import { memo } from 'react';
import { Cell } from '@telegram-apps/ui';
import type { Chat } from '@/types';
import styles from './ChatCard.module.css';
interface ChatCardProps {
chat: Chat;
isActive?: boolean;
onSelect: (chatId: number) => void;
}
export const ChatCard = memo(function ChatCard({
chat,
isActive = false,
onSelect,
}: ChatCardProps) {
return (
<Cell
className={isActive ? styles.active : undefined}
onClick={() => onSelect(chat.id)}
subtitle={`${chat.memberCount} members`}
>
{chat.title}
</Cell>
);
});
// src/pages/SettingsPage.tsx
import { useParams, Navigate } from 'react-router-dom';
import { Section, Spinner, Placeholder } from '@telegram-apps/ui';
import { useSettings } from '@/hooks/api/useSettings';
import { SettingsForm } from '@/components/features/settings/SettingsForm';
import { useSelectedChat } from '@/stores/chatStore';
export function SettingsPage() {
const { chatId } = useParams<{ chatId: string }>();
const numericChatId = Number(chatId);
const { data: settings, isLoading, error } = useSettings(numericChatId);
if (!chatId || isNaN(numericChatId)) {
return <Navigate to="/" replace />;
}
if (isLoading) {
return <Spinner size="large" />;
}
if (error || !settings) {
return (
<Placeholder
header="Error"
description={error?.message || 'Failed to load settings'}
/>
);
}
return (
<Section header="Chat Settings">
<SettingsForm settings={settings} chatId={numericChatId} />
</Section>
);
}
// src/components/features/locks/LockGrid.tsx
import { createContext, useContext, ReactNode } from 'react';
import type { LockType, LockCategory } from '@/types';
interface LockGridContextValue {
lockedTypes: Set<LockType>;
onToggle: (type: LockType) => void;
}
const LockGridContext = createContext<LockGridContextValue | null>(null);
function useLockGridContext() {
const ctx = useContext(LockGridContext);
if (!ctx) throw new Error('LockGrid.* must be used within LockGrid');
return ctx;
}
// Root component
interface LockGridProps {
lockedTypes: Set<LockType>;
onToggle: (type: LockType) => void;
children: ReactNode;
}
function LockGridRoot({ lockedTypes, onToggle, children }: LockGridProps) {
return (
<LockGridContext.Provider value={{ lockedTypes, onToggle }}>
<div className="lock-grid">{children}</div>
</LockGridContext.Provider>
);
}
// Category component
interface CategoryProps {
category: LockCategory;
types: LockType[];
}
function Category({ category, types }: CategoryProps) {
const { lockedTypes, onToggle } = useLockGridContext();
return (
<Section header={category}>
{types.map(type => (
<LockToggle
key={type}
type={type}
locked={lockedTypes.has(type)}
onToggle={() => onToggle(type)}
/>
))}
</Section>
);
}
// Export compound component
export const LockGrid = Object.assign(LockGridRoot, {
Category,
});
// Usage:
// <LockGrid lockedTypes={locked} onToggle={handleToggle}>
// <LockGrid.Category category="CONTENT" types={contentTypes} />
// <LockGrid.Category category="URL" types={urlTypes} />
// </LockGrid>
// src/hooks/api/useSettings.ts
import { useState, useEffect, useCallback } from 'react';
import { api } from '@/services/api';
import type { ChatSettings } from '@/types';
interface UseSettingsResult {
data: ChatSettings | null;
isLoading: boolean;
error: Error | null;
mutate: (settings: Partial<ChatSettings>) => Promise<void>;
refetch: () => Promise<void>;
}
export function useSettings(chatId: number): UseSettingsResult {
const [data, setData] = useState<ChatSettings | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchSettings = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const settings = await api.getSettings(chatId);
setData(settings);
} catch (err) {
setError(err as Error);
} finally {
setIsLoading(false);
}
}, [chatId]);
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
const mutate = useCallback(async (updates: Partial<ChatSettings>) => {
// Optimistic update
setData(prev => prev ? { ...prev, ...updates } : null);
try {
const updated = await api.updateSettings(chatId, updates);
setData(updated);
} catch (err) {
// Rollback on error
await fetchSettings();
throw err;
}
}, [chatId, fetchSettings]);
return {
data,
isLoading,
error,
mutate,
refetch: fetchSettings,
};
}
// src/hooks/telegram/useTelegramAuth.ts
import { useMemo } from 'react';
import { useInitData, useInitDataRaw } from '@telegram-apps/sdk-react';
export interface TelegramUser {
id: number;
firstName: string;
lastName?: string;
username?: string;
isPremium: boolean;
languageCode?: string;
}
export function useTelegramAuth() {
const initData = useInitData();
const initDataRaw = useInitDataRaw();
const user = useMemo<TelegramUser | null>(() => {
if (!initData?.user) return null;
return {
id: initData.user.id,
firstName: initData.user.firstName,
lastName: initData.user.lastName,
username: initData.user.username,
isPremium: initData.user.isPremium ?? false,
languageCode: initData.user.languageCode,
};
}, [initData]);
const getAuthHeader = useCallback(() => {
if (!initDataRaw) return {};
return { Authorization: `tma ${initDataRaw}` };
}, [initDataRaw]);
return {
user,
isAuthenticated: !!user && !!initDataRaw,
initDataRaw,
getAuthHeader,
};
}
// src/hooks/telegram/useMainButton.ts
import { useEffect, useCallback } from 'react';
import { useMainButton as useTMAMainButton } from '@telegram-apps/sdk-react';
interface UseMainButtonOptions {
text: string;
onClick: () => void | Promise<void>;
disabled?: boolean;
visible?: boolean;
}
export function useMainButton({
text,
onClick,
disabled = false,
visible = true,
}: UseMainButtonOptions) {
const mainButton = useTMAMainButton();
useEffect(() => {
mainButton.setParams({
text,
isEnabled: !disabled,
isVisible: visible,
});
}, [mainButton, text, disabled, visible]);
useEffect(() => {
const handler = async () => {
mainButton.showProgress();
try {
await onClick();
} finally {
mainButton.hideProgress();
}
};
mainButton.on('click', handler);
return () => mainButton.off('click', handler);
}, [mainButton, onClick]);
const showProgress = useCallback(() => mainButton.showProgress(), [mainButton]);
const hideProgress = useCallback(() => mainButton.hideProgress(), [mainButton]);
return { showProgress, hideProgress };
}
// src/hooks/ui/useConfirmDialog.ts
import { useState, useCallback } from 'react';
import { usePopup } from '@telegram-apps/sdk-react';
export function useConfirmDialog() {
const popup = usePopup();
const confirm = useCallback(async (
message: string,
title?: string
): Promise<boolean> => {
const result = await popup.open({
title: title || 'Confirm',
message,
buttons: [
{ id: 'cancel', type: 'cancel' },
{ id: 'ok', type: 'destructive', text: 'Delete' },
],
});
return result === 'ok';
}, [popup]);
return { confirm };
}
// src/stores/chatStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Chat } from '@/types';
interface ChatState {
// State
selectedChatId: number | null;
chats: Chat[];
// Actions
setSelectedChat: (chatId: number | null) => void;
setChats: (chats: Chat[]) => void;
// Selectors
getSelectedChat: () => Chat | undefined;
}
export const useChatStore = create<ChatState>()(
persist(
(set, get) => ({
// Initial state
selectedChatId: null,
chats: [],
// Actions
setSelectedChat: (chatId) => set({ selectedChatId: chatId }),
setChats: (chats) => set({ chats }),
// Selectors
getSelectedChat: () => {
const { selectedChatId, chats } = get();
return chats.find(c => c.id === selectedChatId);
},
}),
{
name: 'chat-storage',
partialize: (state) => ({ selectedChatId: state.selectedChatId }),
}
)
);
// Selector hooks for performance
export const useSelectedChatId = () => useChatStore(s => s.selectedChatId);
export const useChats = () => useChatStore(s => s.chats);
export const useSelectedChat = () => useChatStore(s => s.getSelectedChat());
// src/stores/settingsStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import type { ChatSettings, LockType } from '@/types';
interface SettingsState {
// Cache by chatId
settingsCache: Record<number, ChatSettings>;
pendingChanges: Record<number, Partial<ChatSettings>>;
// Actions
setSettings: (chatId: number, settings: ChatSettings) => void;
updatePending: (chatId: number, updates: Partial<ChatSettings>) => void;
commitPending: (chatId: number) => void;
clearPending: (chatId: number) => void;
// Lock-specific
toggleLock: (chatId: number, lockType: LockType) => void;
}
export const useSettingsStore = create<SettingsState>()(
immer((set, get) => ({
settingsCache: {},
pendingChanges: {},
setSettings: (chatId, settings) => {
set(state => {
state.settingsCache[chatId] = settings;
});
},
updatePending: (chatId, updates) => {
set(state => {
state.pendingChanges[chatId] = {
...state.pendingChanges[chatId],
...updates,
};
});
},
commitPending: (chatId) => {
set(state => {
const pending = state.pendingChanges[chatId];
if (pending && state.settingsCache[chatId]) {
Object.assign(state.settingsCache[chatId], pending);
}
delete state.pendingChanges[chatId];
});
},
clearPending: (chatId) => {
set(state => {
delete state.pendingChanges[chatId];
});
},
toggleLock: (chatId, lockType) => {
set(state => {
const settings = state.settingsCache[chatId];
if (settings) {
const current = settings.lockedTypes[lockType]?.locked ?? false;
settings.lockedTypes[lockType] = { locked: !current };
}
});
},
}))
);
// src/services/api.ts
import ky from 'ky';
import type {
Chat,
ChatSettings,
BlocklistPattern,
LockSettings,
ChannelReplySettings,
} from '@/types';
const API_BASE = import.meta.env.VITE_API_URL || '/api/v1/miniapp';
// Auth header will be set via hook
let authHeader: Record<string, string> = {};
export function setAuthHeader(header: Record<string, string>) {
authHeader = header;
}
const client = ky.create({
prefixUrl: API_BASE,
timeout: 30000,
hooks: {
beforeRequest: [
(request) => {
Object.entries(authHeader).forEach(([key, value]) => {
request.headers.set(key, value);
});
},
],
afterResponse: [
async (_request, _options, response) => {
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new ApiError(response.status, error.message || 'Request failed');
}
},
],
},
});
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = 'ApiError';
}
}
export const api = {
// Chats
getChats: () => client.get('chats').json<Chat[]>(),
// Settings
getSettings: (chatId: number) =>
client.get(`chats/${chatId}/settings`).json<ChatSettings>(),
updateSettings: (chatId: number, settings: Partial<ChatSettings>) =>
client.put(`chats/${chatId}/settings`, { json: settings }).json<ChatSettings>(),
// Blocklist
getBlocklist: (chatId: number) =>
client.get(`chats/${chatId}/blocklist`).json<BlocklistPattern[]>(),
addBlocklistPattern: (chatId: number, pattern: Omit<BlocklistPattern, 'id' | 'createdAt'>) =>
client.post(`chats/${chatId}/blocklist`, { json: pattern }).json<BlocklistPattern>(),
deleteBlocklistPattern: (chatId: number, patternId: number) =>
client.delete(`chats/${chatId}/blocklist/${patternId}`),
// Locks
getLocks: (chatId: number) =>
client.get(`chats/${chatId}/locks`).json<LockSettings>(),
updateLocks: (chatId: number, locks: Partial<LockSettings>) =>
client.put(`chats/${chatId}/locks`, { json: locks }).json<LockSettings>(),
// Channel Reply
getChannelReply: (chatId: number) =>
client.get(`chats/${chatId}/channel-reply`).json<ChannelReplySettings>(),
updateChannelReply: (chatId: number, settings: Partial<ChannelReplySettings>) =>
client.put(`chats/${chatId}/channel-reply`, { json: settings }).json<ChannelReplySettings>(),
};
// src/types/index.ts
// === Domain Types ===
export interface Chat {
id: number;
title: string;
type: 'group' | 'supergroup' | 'channel';
memberCount: number;
isAdmin: boolean;
}
export interface ChatSettings {
chatId: number;
chatTitle: string;
collectionEnabled: boolean;
cleanServiceEnabled: boolean;
maxWarnings: number;
warningTtlHours: number;
thresholdAction: PunishmentType;
thresholdDurationHours: number;
defaultBlocklistAction: PunishmentType;
logChannelId: number | null;
lockwarnsEnabled: boolean;
lockedTypes: Record<LockType, LockInfo>;
}
export type PunishmentType = 'NOTHING' | 'WARN' | 'MUTE' | 'BAN' | 'KICK';
export interface LockInfo {
locked: boolean;
reason?: string;
}
export type LockCategory = 'CONTENT' | 'FORWARD' | 'URL' | 'TEXT' | 'ENTITY' | 'OTHER';
export type LockType =
// CONTENT
| 'PHOTO' | 'VIDEO' | 'GIF' | 'AUDIO' | 'VOICE' | 'VIDEO_NOTE'
| 'DOCUMENT' | 'STICKER' | 'POLL' | 'CONTACT' | 'LOCATION' | 'VENUE'
// FORWARD
| 'FORWARD' | 'FORWARD_USER' | 'FORWARD_BOT' | 'FORWARD_CHANNEL' | 'FORWARD_ANONYMOUS'
// URL
| 'URL' | 'TELEGRAM_LINK' | 'EMAIL' | 'PHONE'
// TEXT
| 'TEXT_TOO_LONG' | 'RTLO' | 'ZALGO' | 'ARABIC' | 'CHINESE' | 'CYRILLIC'
// ENTITY
| 'MENTION' | 'BOT_COMMAND' | 'HASHTAG' | 'CASHTAG' | 'SPOILER' | 'CUSTOM_EMOJI'
// OTHER
| 'DICE' | 'GAME' | 'STORY' | 'PREMIUM_EMOJI' | 'TOPIC_CHANGE' | 'INLINE_BOT';
export interface BlocklistPattern {
id: number;
pattern: string;
matchType: 'EXACT' | 'WILDCARD';
action: PunishmentType;
severity?: number;
createdAt: string;
}
export interface LockSettings {
lockedTypes: Record<LockType, LockInfo>;
lockwarnsEnabled: boolean;
allowlist: AllowlistEntry[];
}
export interface AllowlistEntry {
id: number;
type: 'URL' | 'DOMAIN' | 'COMMAND';
value: string;
}
export interface ChannelReplySettings {
enabled: boolean;
replyText: string | null;
mediaFileId: string | null;
mediaType: 'photo' | 'video' | 'animation' | null;
buttons: ReplyButton[];
}
export interface ReplyButton {
text: string;
url: string;
}
// === Lock Type Metadata ===
export const LOCK_CATEGORIES: Record<LockCategory, LockType[]> = {
CONTENT: ['PHOTO', 'VIDEO', 'GIF', 'AUDIO', 'VOICE', 'VIDEO_NOTE', 'DOCUMENT', 'STICKER', 'POLL', 'CONTACT', 'LOCATION', 'VENUE'],
FORWARD: ['FORWARD', 'FORWARD_USER', 'FORWARD_BOT', 'FORWARD_CHANNEL', 'FORWARD_ANONYMOUS'],
URL: ['URL', 'TELEGRAM_LINK', 'EMAIL', 'PHONE'],
TEXT: ['TEXT_TOO_LONG', 'RTLO', 'ZALGO', 'ARABIC', 'CHINESE', 'CYRILLIC'],
ENTITY: ['MENTION', 'BOT_COMMAND', 'HASHTAG', 'CASHTAG', 'SPOILER', 'CUSTOM_EMOJI'],
OTHER: ['DICE', 'GAME', 'STORY', 'PREMIUM_EMOJI', 'TOPIC_CHANGE', 'INLINE_BOT'],
};
export const LOCK_TYPE_LABELS: Record<LockType, string> = {
PHOTO: 'Photos',
VIDEO: 'Videos',
GIF: 'GIFs',
// ... etc
};
// src/App.tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { SDKProvider } from '@telegram-apps/sdk-react';
import { AppRoot } from '@telegram-apps/ui';
import { AppLayout } from '@/components/layout/AppLayout';
import { HomePage } from '@/pages/HomePage';
import { SettingsPage } from '@/pages/SettingsPage';
import { BlocklistPage } from '@/pages/BlocklistPage';
import { LocksPage } from '@/pages/LocksPage';
import { ChannelReplyPage } from '@/pages/ChannelReplyPage';
import { AuthGuard } from '@/components/common/AuthGuard';
import './mockEnv'; // Import mock environment
export function App() {
return (
<SDKProvider acceptCustomStyles debug={import.meta.env.DEV}>
<AppRoot>
<BrowserRouter>
<AuthGuard>
<Routes>
<Route element={<AppLayout />}>
<Route index element={<HomePage />} />
<Route path="chat/:chatId">
<Route path="settings" element={<SettingsPage />} />
<Route path="blocklist" element={<BlocklistPage />} />
<Route path="locks" element={<LocksPage />} />
<Route path="channel-reply" element={<ChannelReplyPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</AuthGuard>
</BrowserRouter>
</AppRoot>
</SDKProvider>
);
}
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import basicSsl from '@vitejs/plugin-basic-ssl';
import { resolve } from 'path';
export default defineConfig({
plugins: [
react(),
// Uncomment for HTTPS in development
// basicSsl(),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
host: true, // Expose to network for mobile testing
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
telegram: ['@telegram-apps/sdk', '@telegram-apps/sdk-react', '@telegram-apps/ui'],
},
},
},
},
base: './',
});
useCallbackuseMemomemo() for list item componentsany typetesting
Android WorkManager for guaranteed background execution - use for deferred tasks, periodic syncs, file uploads, notifications, and task chains. Covers CoroutineWorker, constraints, chaining, testing, and troubleshooting. Use when implementing background work that needs reliable execution across app restarts and doze mode.
development
Telegram Mini Apps development - use for building Mini App frontend, WebApp API, initData authentication, and Telegram integration
tools
Systematic feature planning workflow - use when starting complex features requiring structured approach
testing
Publish E2E/QA test reports (markdown + screenshots) as secret GitHub Gists. Uses two-gist pattern to work around GitHub rendering limits. Trigger when: report needs to be shared via gist, E2E test run completed and report must be published, user asks to "upload report", "publish to gist", "share test results", or after manual-qa produces a report with screenshots.