up
All checks were successful
Deploy to Production / deploy (push) Successful in 8s

This commit is contained in:
lubukhu
2026-01-24 13:35:11 +07:00
parent 6c3e93636e
commit 65fd0158a3
145 changed files with 10262 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { GameApiConfig } from '../api';
export interface SessionStatus {
type: 'new' | 'resumed' | 'timeout' | 'completed' | 'assignment_ended' | 'not_started';
message: string;
data?: {
answered?: number;
total?: number;
score?: number;
remainingTime?: number;
startedAt?: string;
};
}
export interface GamePlayerError {
type: 'network' | 'api' | 'timeout' | 'session' | 'not_started' | 'unknown';
code?: number;
message: string;
details?: any;
canRetry?: boolean;
}
export declare const SessionErrorCodes: {
readonly SESSION_NOT_STARTED: -60;
readonly SESSION_ENDED: -61;
readonly SESSION_COMPLETED: -62;
readonly SESSION_TIMEOUT: -63;
readonly SESSION_NOT_FOUND: -64;
readonly SESSION_ALREADY_ANSWERED: -65;
};
export interface GamePlayerProps {
apiConfig: GameApiConfig;
assignmentId: number;
studentId: string;
className?: string;
style?: React.CSSProperties;
debug?: boolean;
onComplete?: (result: any) => void;
onError?: (error: GamePlayerError) => void;
onGameLoaded?: (gameInfo: any) => void;
extraGameData?: Record<string, any>;
onLog?: (message: string, type?: 'info' | 'success' | 'error' | 'warning') => void;
onSessionStatus?: (status: SessionStatus) => void;
renderLoading?: () => React.ReactNode;
renderError?: (error: GamePlayerError, retry: () => void) => React.ReactNode;
loadingTimeout?: number;
}
export declare const GamePlayer: React.FC<GamePlayerProps>;
//# sourceMappingURL=GamePlayer.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"GamePlayer.d.ts","sourceRoot":"","sources":["../../../src/kit/react/GamePlayer.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAExE,OAAO,EAAiB,aAAa,EAAE,MAAM,QAAQ,CAAC;AAItD,MAAM,WAAW,aAAa;IAC1B,IAAI,EAAE,KAAK,GAAG,SAAS,GAAG,SAAS,GAAG,WAAW,GAAG,kBAAkB,GAAG,aAAa,CAAC;IACvF,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE;QACH,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,SAAS,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;CACL;AAED,MAAM,WAAW,eAAe;IAC5B,IAAI,EAAE,SAAS,GAAG,KAAK,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,GAAG,SAAS,CAAC;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,GAAG,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;CACtB;AAGD,eAAO,MAAM,iBAAiB;;;;;;;CAOpB,CAAC;AAEX,MAAM,WAAW,eAAe;IAC5B,SAAS,EAAE,aAAa,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IAC3C,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,KAAK,IAAI,CAAC;IACvC,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACpC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,KAAK,IAAI,CAAC;IACnF,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,IAAI,CAAC;IAElD,aAAa,CAAC,EAAE,MAAM,KAAK,CAAC,SAAS,CAAC;IACtC,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,EAAE,KAAK,EAAE,MAAM,IAAI,KAAK,KAAK,CAAC,SAAS,CAAC;IAC7E,cAAc,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC,EAAE,CAAC,eAAe,CA8oBhD,CAAC"}

View File

@@ -0,0 +1,583 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GamePlayer = exports.SessionErrorCodes = void 0;
const jsx_runtime_1 = require("react/jsx-runtime");
const react_1 = require("react");
const useGameIframeSDK_1 = require("../../useGameIframeSDK");
const api_1 = require("../api");
const mappers_1 = require("../mappers");
// Session Error Codes (synced with backend)
exports.SessionErrorCodes = {
SESSION_NOT_STARTED: -60,
SESSION_ENDED: -61,
SESSION_COMPLETED: -62,
SESSION_TIMEOUT: -63,
SESSION_NOT_FOUND: -64,
SESSION_ALREADY_ANSWERED: -65,
};
const GamePlayer = ({ apiConfig, assignmentId, studentId, className, style, debug = false, onComplete, onError, onGameLoaded, extraGameData, onLog, onSessionStatus, renderLoading, renderError, loadingTimeout = 30000 }) => {
const iframeRef = (0, react_1.useRef)(null);
const [gameUrl, setGameUrl] = (0, react_1.useState)('');
const [gameState, setGameState] = (0, react_1.useState)(null);
const [loading, setLoading] = (0, react_1.useState)(true);
const [error, setError] = (0, react_1.useState)(null);
const [hasLoadedGame, setHasLoadedGame] = (0, react_1.useState)(false);
const [apiClient] = (0, react_1.useState)(() => new api_1.GameApiClient(apiConfig));
// Refs to prevent infinite loops
const sendLeaderboardRef = (0, react_1.useRef)(null);
const hasInitRef = (0, react_1.useRef)(false); // Track if init has been called
const callbacksRef = (0, react_1.useRef)({ onGameLoaded, onLog, onSessionStatus, onError }); // Stable callback refs
// Update callback refs on each render
callbacksRef.current = { onGameLoaded, onLog, onSessionStatus, onError };
// Helper: Create structured error
const createError = (0, react_1.useCallback)((err) => {
const errorMsg = err?.message || err?.error || String(err);
const errorCode = err?.code; // Error code from API response
// Check error code first (more reliable than message parsing)
if (errorCode !== undefined) {
// Session not started
if (errorCode === exports.SessionErrorCodes.SESSION_NOT_STARTED) {
return {
type: 'not_started',
code: errorCode,
message: errorMsg || 'Bài tập chưa bắt đầu',
details: err,
canRetry: false
};
}
// Session ended
if (errorCode === exports.SessionErrorCodes.SESSION_ENDED) {
return {
type: 'session',
code: errorCode,
message: errorMsg || 'Bài tập đã kết thúc',
details: err,
canRetry: false
};
}
// Session completed
if (errorCode === exports.SessionErrorCodes.SESSION_COMPLETED) {
return {
type: 'session',
code: errorCode,
message: errorMsg || 'Bạn đã hoàn thành bài tập này rồi',
details: err,
canRetry: false
};
}
// Session timeout
if (errorCode === exports.SessionErrorCodes.SESSION_TIMEOUT) {
return {
type: 'timeout',
code: errorCode,
message: errorMsg || 'Đã hết thời gian làm bài',
details: err,
canRetry: false
};
}
}
// Fallback: Parse error message
// API errors
if (errorMsg.includes('API Error') || errorMsg.includes('Failed to fetch')) {
// Check if it contains session-related message
if (errorMsg.includes('chưa bắt đầu')) {
return {
type: 'not_started',
message: 'Bài tập chưa bắt đầu',
details: err,
canRetry: false
};
}
return {
type: 'api',
message: errorMsg,
details: err,
canRetry: true
};
}
// Network errors
if (errorMsg.includes('network') || errorMsg.includes('ECONNREFUSED')) {
return {
type: 'network',
message: 'Lỗi kết nối mạng. Vui lòng kiểm tra internet.',
details: err,
canRetry: true
};
}
// Session errors (message-based fallback)
if (errorMsg.includes('chưa bắt đầu')) {
return {
type: 'not_started',
message: errorMsg || 'Bài tập chưa bắt đầu',
details: err,
canRetry: false
};
}
if (errorMsg.includes('hết thời gian') || errorMsg.includes('đã kết thúc') ||
errorMsg.includes('đã hoàn thành') || errorMsg.includes('session')) {
return {
type: 'session',
message: errorMsg,
details: err,
canRetry: false
};
}
// Timeout errors
if (errorMsg.includes('timeout') || errorMsg.includes('timed out')) {
return {
type: 'timeout',
message: 'Kết nối quá lâu. Vui lòng thử lại.',
details: err,
canRetry: true
};
}
return {
type: 'unknown',
message: errorMsg || 'Đã xảy ra lỗi không xác định',
details: err,
canRetry: true
};
}, []);
// Helper: Handle error with callback
const handleError = (0, react_1.useCallback)((err) => {
const gameError = createError(err);
setError(gameError);
setLoading(false);
if (onError) {
onError(gameError);
}
if (onLog) {
onLog(`[SDK ERR] ${gameError.type}: ${gameError.message}`, 'error');
}
}, [createError, onError, onLog]);
// Retry function
const retryInit = (0, react_1.useCallback)(() => {
if (callbacksRef.current.onLog)
callbacksRef.current.onLog('[SDK] Retrying initialization...', 'info');
hasInitRef.current = false; // Allow re-init
setError(null);
setLoading(true);
setGameUrl('');
setGameState(null);
}, []);
// Define Handlers
const handleAnswer = (0, react_1.useCallback)(async (data) => {
try {
if (onLog)
onLog(`[SDK RECV] Answer Raw: ${JSON.stringify(data)}`, 'info');
const report = (0, mappers_1.normalizeAnswerReport)(data);
if (onLog)
onLog(`[SDK] Processed Answer: ID ${report.question_id} | Choice: ${report.choice} | ${report.is_correct ? 'Correct' : 'Wrong'}`, 'info');
if (onLog)
onLog(`[API REQ] Submitting Answer ID ${report.question_id}...`, 'info');
const res = await apiClient.submitAnswer(assignmentId, studentId, report.question_id, report.choice, report.time_spent, report.is_timeout);
if (onLog)
onLog(`[API RES] Submit Result: ${JSON.stringify(res)}`, 'success');
}
catch (err) {
console.error('[GamePlayer] Submit error:', err);
if (onLog)
onLog(`[API ERR] Submit Failed: ${err.message}`, 'error');
}
}, [apiClient, assignmentId, studentId, onLog]);
const handleFinalResult = (0, react_1.useCallback)(async (data) => {
try {
if (onLog)
onLog(`[SDK RECV] Final Result Raw: ${JSON.stringify(data)}`, 'info');
if (onLog)
onLog('[SDK] Game Complete. Calling API...', 'info');
const res = await apiClient.completeSession(assignmentId, studentId);
if (onLog)
onLog(`[API RES] Session Completed. Score: ${res.data?.finalScore}`, 'success');
// Auto-refresh leaderboard after completion to ensure user rank is included
if (onLog)
onLog('[SDK] Auto-refreshing leaderboard after completion...', 'info');
await new Promise(resolve => setTimeout(resolve, 500)); // Small delay for backend to process
try {
const lbRes = await apiClient.getLeaderboard(assignmentId, studentId);
if (onLog)
onLog(`[API RES] Post-completion Leaderboard: ${JSON.stringify(lbRes)}`, 'success');
if (lbRes.success && lbRes.data && sendLeaderboardRef.current) {
const leaderboardData = {
top_players: (lbRes.data.topPlayers || []).map((p) => ({
rank: p.rank,
name: p.name || p.studentName || p.userId,
score: p.score ?? p.finalScore ?? 0,
student_id: p.studentId || p.userId,
time_spent: p.timeSpent ?? p.time_spent ?? 0,
completed_at: p.completedAt
})),
user_rank: lbRes.data.userRank ? {
rank: lbRes.data.userRank.rank,
name: lbRes.data.userRank.name || lbRes.data.userRank.studentName,
score: lbRes.data.userRank.score ?? lbRes.data.userRank.finalScore ?? 0,
student_id: lbRes.data.userRank.studentId || lbRes.data.userRank.userId,
time_spent: lbRes.data.userRank.timeSpent ?? lbRes.data.userRank.time_spent ?? 0,
completed_at: lbRes.data.userRank.completedAt
} : null
};
if (onLog)
onLog(`[SDK SEND] Updated Leaderboard: ${JSON.stringify(leaderboardData)}`, 'info');
sendLeaderboardRef.current(leaderboardData);
}
}
catch (lbErr) {
if (onLog)
onLog(`[API ERR] Leaderboard refresh failed: ${lbErr.message}`, 'warning');
}
if (onComplete)
onComplete(res);
}
catch (err) {
console.error('[GamePlayer] Complete error:', err);
if (onLog)
onLog(`[API ERR] Complete Failed: ${err.message}`, 'error');
if (onError)
onError(err);
}
}, [apiClient, assignmentId, studentId, onComplete, onError, onLog]);
// SDK Hook
const { isReady, sendGameData, sendLeaderboard } = (0, useGameIframeSDK_1.useGameIframeSDK)({
iframeRef,
iframeOrigin: '*',
debug,
onGameReady: () => {
if (onLog)
onLog('[SDK RECV] Iframe Ready', 'success');
},
onAnswerReport: handleAnswer,
onFinalResult: handleFinalResult,
onLeaderboardRequest: async (top) => {
try {
if (onLog)
onLog(`[SDK RECV] Leaderboard Request Raw: top=${top}`, 'info');
if (onLog)
onLog(`[API REQ] Fetching Leaderboard (top ${top})...`, 'info');
const res = await apiClient.getLeaderboard(assignmentId, studentId);
if (onLog)
onLog(`[API RES] Leaderboard Raw: ${JSON.stringify(res)}`, 'success');
if (res.success && res.data) {
const leaderboardData = {
top_players: (res.data.topPlayers || []).map((p) => ({
rank: p.rank,
name: p.name || p.studentName || p.userId,
score: p.score ?? p.finalScore ?? 0,
student_id: p.studentId || p.userId,
time_spent: p.timeSpent ?? p.time_spent ?? 0,
completed_at: p.completedAt
})),
user_rank: res.data.userRank ? {
rank: res.data.userRank.rank,
name: res.data.userRank.name || res.data.userRank.studentName,
score: res.data.userRank.score ?? res.data.userRank.finalScore ?? 0,
student_id: res.data.userRank.studentId || res.data.userRank.userId,
time_spent: res.data.userRank.timeSpent ?? res.data.userRank.time_spent ?? 0,
completed_at: res.data.userRank.completedAt
} : null
};
if (onLog)
onLog(`[SDK SEND] Leaderboard Payload: ${JSON.stringify(leaderboardData)}`, 'info');
const sent = sendLeaderboard(leaderboardData);
if (onLog)
onLog(sent ? '[SDK] Leaderboard sent successfully' : '[SDK ERR] Failed to send leaderboard', sent ? 'success' : 'error');
}
}
catch (err) {
console.error('[GamePlayer] Leaderboard error:', err);
if (onLog)
onLog(`[API ERR] Leaderboard Failed: ${err.message}`, 'error');
}
}
});
// Update ref after hook
sendLeaderboardRef.current = sendLeaderboard;
// 1. Fetch Game Data & Start Session
(0, react_1.useEffect)(() => {
let mounted = true;
let loadingTimeoutId;
const initGame = async () => {
try {
setLoading(true);
setError(null);
if (onLog)
onLog('[SDK] Initializing Game...', 'info');
// Set loading timeout
loadingTimeoutId = setTimeout(() => {
if (mounted && loading) {
if (onLog)
onLog('[SDK WARN] Loading taking longer than expected...', 'warning');
}
}, loadingTimeout);
// getGameWithProgress đã tự động tạo/restore session trong backend
// thông qua getOrCreateSubmissionCache - không cần gọi startLiveSession riêng
const res = await apiClient.getGameWithProgress(assignmentId, studentId);
if (!res.success || !res.data) {
throw new Error(res.message || 'Failed to load game');
}
if (mounted) {
// Check session status TRƯỚC để quyết định có load game hay không
const session = res.data.session;
const progress = res.data.progress;
// Case 1: Already completed
if (res.data.isFinished || session?.completed) {
// CHỈ set error nếu CHƯA load game (lần đầu vào)
if (!hasLoadedGame) {
const errorObj = {
type: 'session',
message: 'Bạn đã hoàn thành bài tập này rồi',
details: { score: progress?.score, answered: progress?.answered, total: progress?.total },
canRetry: false
};
setError(errorObj);
setLoading(false);
clearTimeout(loadingTimeoutId);
if (onError)
onError(errorObj);
if (onLog)
onLog(`[SDK] ${errorObj.message}`, 'warning');
}
// Luôn gọi callback (cho cả 2 trường hợp)
if (onSessionStatus) {
onSessionStatus({
type: 'completed',
message: 'Bạn đã hoàn thành bài tập này',
data: {
answered: progress?.answered || 0,
total: progress?.total || 0,
score: progress?.score || 0
}
});
}
// Nếu CHƯA load game → STOP
// Nếu ĐÃ load game → tiếp tục (cho game hiển thị leaderboard)
if (!hasLoadedGame) {
return; // ❌ STOP - không load game
}
}
// Case 2: Session timeout
if (session && session.remainingTime !== null && session.remainingTime <= 0) {
// CHỈ set error nếu CHƯA load game
if (!hasLoadedGame) {
const errorObj = {
type: 'timeout',
message: 'Thời gian làm bài đã hết',
details: { answered: progress?.answered, total: progress?.total },
canRetry: false
};
setError(errorObj);
setLoading(false);
clearTimeout(loadingTimeoutId);
if (onError)
onError(errorObj);
if (onLog)
onLog(`[SDK] ${errorObj.message}`, 'warning');
}
// Luôn gọi callback
if (onSessionStatus) {
onSessionStatus({
type: 'timeout',
message: 'Thời gian làm bài đã hết',
data: {
answered: progress?.answered || 0,
total: progress?.total || 0,
score: progress?.score || 0,
remainingTime: 0
}
});
}
// Nếu CHƯA load game → STOP
if (!hasLoadedGame) {
return; // ❌ STOP - không load game
}
}
// Case 3: Valid session - LOAD game
setGameState(res.data);
setGameUrl(res.data.gameUrl);
setLoading(false);
setHasLoadedGame(true); // ✅ Đánh dấu đã load game
clearTimeout(loadingTimeoutId);
if (onGameLoaded)
onGameLoaded(res.data);
if (onLog)
onLog(`[API RES] Game Loaded: ${res.data.title || res.data.gameType || assignmentId}`, 'success');
// Notify session status for valid sessions
if (session && onSessionStatus) {
// Resumed session (có câu đã trả lời)
if (progress && progress.answered > 0) {
onSessionStatus({
type: 'resumed',
message: `Tiếp tục làm bài (Đã làm ${progress.answered}/${progress.total} câu)`,
data: {
answered: progress.answered,
total: progress.total,
score: progress.score,
remainingTime: session.remainingTime,
startedAt: session.startedAt
}
});
}
// New session
else {
onSessionStatus({
type: 'new',
message: 'Bắt đầu làm bài mới',
data: {
answered: 0,
total: progress?.total || 0,
score: 0,
remainingTime: session.remainingTime,
startedAt: session.startedAt
}
});
}
}
// Log session info
if (session && onLog) {
const sessionInfo = `Session started at ${session.startedAt}, remaining: ${session.remainingTime}s`;
onLog(`[SDK] ${sessionInfo}`, 'info');
}
}
}
catch (err) {
console.error('[GamePlayer] Init error:', err);
if (mounted) {
handleError(err);
}
}
};
if (assignmentId && studentId && !hasInitRef.current) {
hasInitRef.current = true; // Prevent re-init
initGame();
}
return () => {
mounted = false;
hasInitRef.current = false; // ✅ Reset for StrictMode remount
if (loadingTimeoutId)
clearTimeout(loadingTimeoutId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignmentId, studentId]); // ✅ Chỉ re-init khi assignmentId hoặc studentId thay đổi
// 2. Auto Send Data when Ready
(0, react_1.useEffect)(() => {
if (isReady && gameState) {
// Get expiresAt from session (absolute timestamp for accurate sync)
const getEndTimeIso = () => {
const session = gameState.session;
if (!session)
return undefined;
// Ưu tiên dùng expiresAt
if (session.expiresAt) {
return session.expiresAt;
}
// Fallback: tính từ startedAt + duration
if (session.startedAt && session.duration) {
const startTimeMs = new Date(session.startedAt).getTime();
const expiresAtMs = startTimeMs + (session.duration * 1000);
return new Date(expiresAtMs).toISOString();
}
return undefined;
};
const payload = (0, mappers_1.createGamePayload)({
gameId: gameState.id,
userId: studentId,
gameData: gameState.jsonData,
answeredQuestions: gameState.completed_question_ids,
extraData: extraGameData,
endTimeIso: getEndTimeIso() // ✅ Absolute timestamp
});
if (onLog)
onLog(`[SDK SEND] Game Payload: ${JSON.stringify(payload)}`, 'info');
sendGameData(payload);
}
}, [isReady, gameState, studentId, sendGameData, extraGameData, onLog]);
// ===== RENDER =====
// 1. Error State
if (error) {
if (renderError) {
return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: renderError(error, retryInit) });
}
// Default error UI
return ((0, jsx_runtime_1.jsxs)("div", { className: "game-player-error", style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
padding: '2rem',
textAlign: 'center',
backgroundColor: '#f8f9fa',
...style
}, children: [(0, jsx_runtime_1.jsx)("div", { style: {
fontSize: '3rem',
marginBottom: '1rem'
}, children: error.type === 'network' ? '🌐' :
error.type === 'timeout' ? '⏱️' :
error.type === 'session' ? '🔒' :
error.type === 'not_started' ? '📅' : '⚠️' }), (0, jsx_runtime_1.jsx)("h2", { style: {
fontSize: '1.5rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
color: error.type === 'not_started' ? '#fd7e14' : '#dc3545'
}, children: error.type === 'network' ? 'Lỗi Kết Nối' :
error.type === 'timeout' ? 'Hết Giờ Làm Bài' :
error.type === 'session' ? 'Phiên Làm Bài Đã Kết Thúc' :
error.type === 'not_started' ? 'Bài Tập Chưa Bắt Đầu' :
'Đã Xảy Ra Lỗi' }), (0, jsx_runtime_1.jsx)("p", { style: {
fontSize: '1rem',
color: '#6c757d',
marginBottom: '1.5rem',
maxWidth: '500px'
}, children: error.message }), error.canRetry && ((0, jsx_runtime_1.jsx)("button", { onClick: retryInit, style: {
padding: '0.75rem 2rem',
fontSize: '1rem',
fontWeight: 'bold',
color: '#fff',
backgroundColor: '#007bff',
border: 'none',
borderRadius: '0.5rem',
cursor: 'pointer',
transition: 'background-color 0.2s'
}, onMouseEnter: (e) => e.currentTarget.style.backgroundColor = '#0056b3', onMouseLeave: (e) => e.currentTarget.style.backgroundColor = '#007bff', children: "\uD83D\uDD04 Th\u1EED L\u1EA1i" })), !error.canRetry && ((0, jsx_runtime_1.jsx)("p", { style: {
fontSize: '0.875rem',
color: '#6c757d',
marginTop: '1rem'
}, children: "Vui l\u00F2ng li\u00EAn h\u1EC7 gi\u00E1o vi\u00EAn ho\u1EB7c quay l\u1EA1i trang ch\u1EE7" }))] }));
}
// 2. Loading State
if (loading || !gameUrl) {
if (renderLoading) {
return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: renderLoading() });
}
// Default loading UI
return ((0, jsx_runtime_1.jsxs)("div", { className: "game-player-loading", style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
backgroundColor: '#f8f9fa',
...style
}, children: [(0, jsx_runtime_1.jsx)("div", { style: {
width: '50px',
height: '50px',
border: '5px solid #e9ecef',
borderTop: '5px solid #007bff',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: '1rem'
} }), (0, jsx_runtime_1.jsx)("p", { style: {
fontSize: '1.125rem',
color: '#6c757d',
fontWeight: '500'
}, children: "\u0110ang t\u1EA3i tr\u00F2 ch\u01A1i..." }), (0, jsx_runtime_1.jsx)("style", { children: `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
` })] }));
}
// 3. Game Iframe
return ((0, jsx_runtime_1.jsx)("iframe", { ref: iframeRef, src: gameUrl, className: className, style: { width: '100%', height: '100%', border: 'none', ...style }, allowFullScreen: true }));
};
exports.GamePlayer = GamePlayer;
//# sourceMappingURL=GamePlayer.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,96 @@
import React from 'react';
export interface GameTestPlayerProps {
/**
* URL của game iframe
*/
gameUrl: string;
/**
* Data game (array of questions)
*/
gameData: any[];
/**
* User ID (optional, default 'test_user')
*/
userId?: string;
/**
* Game ID (optional, default 'test_game')
*/
gameId?: string | number;
/**
* Extra data to pass to iframe
*/
extraData?: Record<string, any>;
/**
* End time ISO (optional - for countdown)
*/
endTimeIso?: string;
/**
* CSS class
*/
className?: string;
/**
* CSS style
*/
style?: React.CSSProperties;
/**
* Debug mode
*/
debug?: boolean;
/**
* Callback khi nhận answer từ iframe
*/
onAnswer?: (data: any) => void;
/**
* Callback khi game hoàn thành
*/
onComplete?: (result: any) => void;
/**
* Callback log
*/
onLog?: (message: string, type?: 'info' | 'success' | 'error' | 'warning') => void;
/**
* Callback khi iframe yêu cầu leaderboard
*/
onLeaderboardRequest?: (top: number) => void;
/**
* Mock leaderboard data (optional - sẽ tự gửi khi iframe request)
*/
mockLeaderboard?: {
top_players: Array<{
rank: number;
name: string;
score: number;
time_spent?: number;
}>;
user_rank?: {
rank: number;
name: string;
score: number;
time_spent?: number;
} | null;
};
}
/**
* GameTestPlayer - Component test đơn giản
*
* Chỉ load game data vào iframe, KHÔNG gọi API
* Dùng để test game iframe locally
*
* @example
* ```tsx
* <GameTestPlayer
* gameUrl="http://localhost:3000/game"
* gameData={[
* { id: 1, question: "What is 2+2?", options: ["3","4","5"], answer: "4" },
* { id: 2, question: "Capital of France?", options: ["London","Paris","Berlin"], answer: "Paris" }
* ]}
* debug={true}
* onLog={(msg, type) => console.log(`[${type}] ${msg}`)}
* onAnswer={(data) => console.log('Answer:', data)}
* onComplete={(result) => console.log('Complete:', result)}
* />
* ```
*/
export declare const GameTestPlayer: React.FC<GameTestPlayerProps>;
export default GameTestPlayer;
//# sourceMappingURL=GameTestPlayer.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"GameTestPlayer.d.ts","sourceRoot":"","sources":["../../../src/kit/react/GameTestPlayer.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAsC,MAAM,OAAO,CAAC;AAI3D,MAAM,WAAW,mBAAmB;IAChC;;OAEG;IACH,OAAO,EAAE,MAAM,CAAC;IAEhB;;OAEG;IACH,QAAQ,EAAE,GAAG,EAAE,CAAC;IAEhB;;OAEG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;OAEG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAEzB;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAEhC;;OAEG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;OAEG;IACH,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAE5B;;OAEG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAEhB;;OAEG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,CAAC;IAE/B;;OAEG;IACH,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;IAEnC;;OAEG;IACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,KAAK,IAAI,CAAC;IAEnF;;OAEG;IACH,oBAAoB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAE7C;;OAEG;IACH,eAAe,CAAC,EAAE;QACd,WAAW,EAAE,KAAK,CAAC;YACf,IAAI,EAAE,MAAM,CAAC;YACb,IAAI,EAAE,MAAM,CAAC;YACb,KAAK,EAAE,MAAM,CAAC;YACd,UAAU,CAAC,EAAE,MAAM,CAAC;SACvB,CAAC,CAAC;QACH,SAAS,CAAC,EAAE;YACR,IAAI,EAAE,MAAM,CAAC;YACb,IAAI,EAAE,MAAM,CAAC;YACb,KAAK,EAAE,MAAM,CAAC;YACd,UAAU,CAAC,EAAE,MAAM,CAAC;SACvB,GAAG,IAAI,CAAC;KACZ,CAAC;CACL;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,cAAc,EAAE,KAAK,CAAC,EAAE,CAAC,mBAAmB,CA2HxD,CAAC;AAEF,eAAe,cAAc,CAAC"}

View File

@@ -0,0 +1,116 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GameTestPlayer = void 0;
const jsx_runtime_1 = require("react/jsx-runtime");
const react_1 = require("react");
const useGameIframeSDK_1 = require("../../useGameIframeSDK");
/**
* GameTestPlayer - Component test đơn giản
*
* Chỉ load game data vào iframe, KHÔNG gọi API
* Dùng để test game iframe locally
*
* @example
* ```tsx
* <GameTestPlayer
* gameUrl="http://localhost:3000/game"
* gameData={[
* { id: 1, question: "What is 2+2?", options: ["3","4","5"], answer: "4" },
* { id: 2, question: "Capital of France?", options: ["London","Paris","Berlin"], answer: "Paris" }
* ]}
* debug={true}
* onLog={(msg, type) => console.log(`[${type}] ${msg}`)}
* onAnswer={(data) => console.log('Answer:', data)}
* onComplete={(result) => console.log('Complete:', result)}
* />
* ```
*/
const GameTestPlayer = ({ gameUrl, gameData, userId = 'test_user', gameId = 'test_game', extraData, endTimeIso, className, style, debug = false, onAnswer, onComplete, onLog, onLeaderboardRequest, mockLeaderboard }) => {
const iframeRef = (0, react_1.useRef)(null);
const [isLoading, setIsLoading] = (0, react_1.useState)(true);
// SDK Hook
const { isReady, sendGameData, sendLeaderboard } = (0, useGameIframeSDK_1.useGameIframeSDK)({
iframeRef,
iframeOrigin: '*',
debug,
onGameReady: () => {
if (onLog)
onLog('[TEST] Iframe Ready', 'success');
setIsLoading(false);
},
onAnswerReport: (data) => {
if (onLog)
onLog(`[TEST] Answer: ${JSON.stringify(data)}`, 'info');
if (onAnswer)
onAnswer(data);
},
onFinalResult: (result) => {
if (onLog)
onLog(`[TEST] Complete: ${JSON.stringify(result)}`, 'success');
if (onComplete)
onComplete(result);
},
onLeaderboardRequest: (top) => {
if (onLog)
onLog(`[TEST] Leaderboard Request: top=${top}`, 'info');
if (onLeaderboardRequest)
onLeaderboardRequest(top);
// Auto send mock leaderboard if provided
if (mockLeaderboard) {
if (onLog)
onLog(`[TEST] Sending mock leaderboard`, 'info');
sendLeaderboard(mockLeaderboard);
}
}
});
// Auto send game data when ready
(0, react_1.useEffect)(() => {
if (isReady && gameData) {
const payload = {
game_id: String(gameId),
user_id: userId,
data: gameData,
completed_question_ids: [],
...(extraData || {})
};
if (endTimeIso) {
payload.end_time_iso = endTimeIso;
}
if (onLog)
onLog(`[TEST] Sending Game Data: ${gameData.length} items`, 'info');
sendGameData(payload);
}
}, [isReady, gameData, gameId, userId, extraData, endTimeIso, sendGameData, onLog]);
return ((0, jsx_runtime_1.jsxs)("div", { style: { position: 'relative', width: '100%', height: '100%', ...style }, children: [isLoading && ((0, jsx_runtime_1.jsx)("div", { style: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.9)',
zIndex: 10
}, children: (0, jsx_runtime_1.jsxs)("div", { style: { textAlign: 'center' }, children: [(0, jsx_runtime_1.jsx)("div", { style: {
width: '40px',
height: '40px',
border: '4px solid #e9ecef',
borderTop: '4px solid #007bff',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 1rem'
} }), (0, jsx_runtime_1.jsx)("p", { style: { color: '#6c757d' }, children: "Loading game..." }), (0, jsx_runtime_1.jsx)("style", { children: `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
` })] }) })), (0, jsx_runtime_1.jsx)("iframe", { ref: iframeRef, src: gameUrl, className: className, style: {
width: '100%',
height: '100%',
border: 'none'
}, allowFullScreen: true })] }));
};
exports.GameTestPlayer = GameTestPlayer;
exports.default = exports.GameTestPlayer;
//# sourceMappingURL=GameTestPlayer.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"GameTestPlayer.js","sourceRoot":"","sources":["../../../src/kit/react/GameTestPlayer.tsx"],"names":[],"mappings":"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"}