添加网球详情
This commit is contained in:
@@ -5,6 +5,9 @@ import { LiveScoreHeader } from "@/components/live-detail/live-score-header";
|
||||
import { OddsCard } from "@/components/live-detail/odds-card";
|
||||
import { OtherInfoCard } from "@/components/live-detail/other-info-card";
|
||||
import { StatsCard } from "@/components/live-detail/stats-card";
|
||||
import { TennisPowerGraph } from "@/components/live-detail/tennis-power-graph";
|
||||
import { TennisScoreboard } from "@/components/live-detail/tennis-scoreboard";
|
||||
import { TennisStatsCard } from "@/components/live-detail/tennis-stats-card";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { Colors } from "@/constants/theme";
|
||||
@@ -99,14 +102,37 @@ export default function LiveDetailScreen() {
|
||||
|
||||
const renderTabContent = () => {
|
||||
const numericSportId = parseInt(sport_id || "1");
|
||||
// Tennis Check
|
||||
const isTennis =
|
||||
!!match.event_first_player ||
|
||||
(match.league_name &&
|
||||
/ATP|WTA|ITF|Challenger/i.test(match.league_name || ""));
|
||||
|
||||
switch (activeTab) {
|
||||
case "stats":
|
||||
return <StatsCard match={match} isDark={isDark} />;
|
||||
return !isTennis ? (
|
||||
<StatsCard match={match} isDark={isDark} />
|
||||
) : (
|
||||
<View style={styles.center}>
|
||||
<ThemedText style={{ opacity: 0.5 }}>
|
||||
{t("detail.empty_stats")}
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
case "odds":
|
||||
return (
|
||||
<OddsCard match={match} isDark={isDark} sportId={numericSportId} />
|
||||
);
|
||||
case "detail":
|
||||
if (isTennis) {
|
||||
return (
|
||||
<>
|
||||
<TennisScoreboard match={match} isDark={isDark} />
|
||||
<TennisStatsCard match={match} isDark={isDark} />
|
||||
<TennisPowerGraph match={match} isDark={isDark} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<OddsCard match={match} isDark={isDark} sportId={numericSportId} />
|
||||
|
||||
@@ -151,9 +151,24 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
|
||||
};
|
||||
|
||||
const lastServerMatchRef = React.useRef(
|
||||
`${match.event_status}-${match.event_time}`
|
||||
`${match.event_status}-${match.event_time}`,
|
||||
);
|
||||
|
||||
// Tennis Logic
|
||||
const isTennis =
|
||||
!!match.event_first_player ||
|
||||
(match.league_name &&
|
||||
/ATP|WTA|ITF|Challenger/i.test(match.league_name || ""));
|
||||
|
||||
const homeName = isTennis ? match.event_first_player : match.event_home_team;
|
||||
const awayName = isTennis ? match.event_second_player : match.event_away_team;
|
||||
const homeLogo = isTennis
|
||||
? match.event_first_player_logo
|
||||
: match.home_team_logo;
|
||||
const awayLogo = isTennis
|
||||
? match.event_second_player_logo
|
||||
: match.away_team_logo;
|
||||
|
||||
// 服务器时间同步
|
||||
React.useEffect(() => {
|
||||
const currentKey = `${match.event_status}-${match.event_time}`;
|
||||
@@ -251,13 +266,13 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
|
||||
</TouchableOpacity>
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
source={{ uri: match.home_team_logo }}
|
||||
style={styles.teamLogo}
|
||||
source={{ uri: homeLogo }}
|
||||
style={[styles.teamLogo, isTennis && styles.tennisAvatar]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<ThemedText style={styles.teamName} numberOfLines={2}>
|
||||
{match.event_home_team}
|
||||
{homeName}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
@@ -274,10 +289,12 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
|
||||
{match.event_status}
|
||||
</ThemedText>
|
||||
)}
|
||||
{!isTennis && (
|
||||
<ThemedText style={styles.timeText}>{displayTime}</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{match.goalscorers && match.goalscorers.length > 0 && (
|
||||
{!isTennis && match.goalscorers && match.goalscorers.length > 0 && (
|
||||
<View style={styles.lastGoalContainer}>
|
||||
<IconSymbol name="football-outline" size={12} color="#FFF" />
|
||||
<ThemedText style={styles.lastGoalText}>
|
||||
@@ -286,14 +303,24 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isTennis && match.event_serve && (
|
||||
<View style={styles.serveIndicator}>
|
||||
<IconSymbol name="tennisball" size={12} color="#CCFF00" />
|
||||
<ThemedText style={styles.serveText}>
|
||||
{match.event_serve === "First Player" ? homeName : awayName}{" "}
|
||||
Serving
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.teamInfo}>
|
||||
<View style={styles.teamLogoRow}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
source={{ uri: match.away_team_logo }}
|
||||
style={styles.teamLogo}
|
||||
source={{ uri: awayLogo }}
|
||||
style={[styles.teamLogo, isTennis && styles.tennisAvatar]}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
@@ -309,7 +336,7 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ThemedText style={styles.teamName} numberOfLines={2}>
|
||||
{match.event_away_team}
|
||||
{awayName}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
@@ -442,4 +469,21 @@ const styles = StyleSheet.create({
|
||||
fontSize: 10,
|
||||
marginLeft: 4,
|
||||
},
|
||||
serveIndicator: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginTop: 6,
|
||||
backgroundColor: "rgba(0,0,0,0.3)",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
gap: 4,
|
||||
},
|
||||
serveText: {
|
||||
color: "#FFF",
|
||||
fontSize: 10,
|
||||
},
|
||||
tennisAvatar: {
|
||||
borderRadius: 30,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -87,12 +87,13 @@ export function StatsCard({ match, isDark }: StatsCardProps) {
|
||||
const progressPercent = Math.min(100, (totalSeconds / (90 * 60)) * 100);
|
||||
|
||||
// 从 statistics 中提取数据
|
||||
const stats = match.statistics || [];
|
||||
const stats = (match.statistics || []) as any[];
|
||||
const getStatValue = (type: string) => {
|
||||
const stat = stats.find((s) => s.type === type);
|
||||
return stat
|
||||
? { home: stat.home, away: stat.away }
|
||||
: { home: "0", away: "0" };
|
||||
if (stat && stat.home) {
|
||||
return { home: stat.home, away: stat.away };
|
||||
}
|
||||
return { home: "0", away: "0" };
|
||||
};
|
||||
|
||||
const possession = getStatValue("Ball Possession");
|
||||
|
||||
332
components/live-detail/tennis-power-graph.tsx
Normal file
332
components/live-detail/tennis-power-graph.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { LiveScoreMatch } from "@/types/api";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image, ScrollView, StyleSheet, View } from "react-native";
|
||||
|
||||
interface TennisPowerGraphProps {
|
||||
match: LiveScoreMatch;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
export function TennisPowerGraph({ match, isDark }: TennisPowerGraphProps) {
|
||||
const { t } = useTranslation();
|
||||
const pointData = match.pointbypoint;
|
||||
|
||||
if (!pointData || pointData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Grouped Data by Game
|
||||
// Structure: { gameIndex: number, score: string, set: string, points: {winner: 1|2}[] }
|
||||
const gameData = useMemo(() => {
|
||||
return pointData.map((game) => {
|
||||
const points = game.points || [];
|
||||
const bars: { winner: 1 | 2 }[] = [];
|
||||
let prevP1Score = 0;
|
||||
let prevP2Score = 0;
|
||||
|
||||
const parseScore = (s: string) => {
|
||||
if (s === "A") return 45;
|
||||
return parseInt(s) || 0;
|
||||
};
|
||||
|
||||
points.forEach((point: { score: string }) => {
|
||||
const parts = point.score.split("-").map((s) => s.trim());
|
||||
if (parts.length !== 2) return;
|
||||
const p1 = parseScore(parts[0]);
|
||||
const p2 = parseScore(parts[1]);
|
||||
|
||||
let winner: 1 | 2 | null = null;
|
||||
if (p1 > prevP1Score && p2 === prevP2Score) winner = 1;
|
||||
else if (p2 > prevP2Score && p1 === prevP1Score) winner = 2;
|
||||
else if (p1 === 45 && prevP1Score === 40) winner = 1;
|
||||
else if (p2 === 45 && prevP2Score === 40) winner = 2;
|
||||
else if (p1 === 40 && prevP1Score === 45) winner = 2;
|
||||
else if (p2 === 40 && prevP2Score === 45) winner = 1;
|
||||
else if (
|
||||
points.length === 1 &&
|
||||
prevP1Score === 0 &&
|
||||
prevP2Score === 0
|
||||
) {
|
||||
if (p1 > 0) winner = 1;
|
||||
if (p2 > 0) winner = 2;
|
||||
} else if (p1 > prevP1Score || (p2 < prevP2Score && prevP2Score === 45))
|
||||
winner = 1;
|
||||
else if (p2 > prevP2Score || (p1 < prevP1Score && prevP1Score === 45))
|
||||
winner = 2;
|
||||
|
||||
if (winner) {
|
||||
bars.push({ winner });
|
||||
}
|
||||
prevP1Score = p1;
|
||||
prevP2Score = p2;
|
||||
});
|
||||
|
||||
// Find if this game ended a set? Or just carry current set score/game score.
|
||||
// `game.score` is e.g. "1 - 0" (Set Score or Game Score within set?)
|
||||
// Usually `pointbypoint` item has `score` field which is the score in Games for that Set.
|
||||
|
||||
return {
|
||||
gameIndex: parseInt(game.number_game),
|
||||
setNumber: game.set_number, // "Set 1"
|
||||
score: game.score, // "6 - 4" or "1 - 0"
|
||||
serveWinner: game.serve_winner, // "First Player" or "Second Player"
|
||||
bars,
|
||||
};
|
||||
});
|
||||
}, [pointData]);
|
||||
|
||||
if (gameData.length === 0) return null;
|
||||
|
||||
// Render logic
|
||||
// Left side: Avatars
|
||||
// Right side: ScrollView of Game Blocks
|
||||
|
||||
// Avatars
|
||||
const p1Logo = match.event_first_player_logo;
|
||||
const p2Logo = match.event_second_player_logo;
|
||||
|
||||
return (
|
||||
<ThemedView
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: isDark ? "#1E1E20" : "#FFF",
|
||||
borderRadius: 12,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText style={[styles.title, { color: isDark ? "#FFF" : "#000" }]}>
|
||||
{t("detail.power_graph")}
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.contentRow}>
|
||||
{/* Sidebar - Avatars */}
|
||||
<View style={styles.sidebar}>
|
||||
<View style={{ flex: 1 }} /> {/* Spacer top */}
|
||||
<View style={styles.avatarContainer}>
|
||||
{p1Logo ? (
|
||||
<Image source={{ uri: p1Logo }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, { backgroundColor: "#ccc" }]} />
|
||||
)}
|
||||
</View>
|
||||
<View style={{ height: 10 }} />{" "}
|
||||
{/* Gap corresponding to center line if needed, but styling is easier with even spacing */}
|
||||
<View style={styles.avatarContainer}>
|
||||
{p2Logo ? (
|
||||
<Image source={{ uri: p2Logo }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, { backgroundColor: "#ccc" }]} />
|
||||
)}
|
||||
</View>
|
||||
<View style={{ flex: 1 }} /> {/* Spacer bottom */}
|
||||
</View>
|
||||
|
||||
{/* Scrollable Graph */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.graphScroll}
|
||||
>
|
||||
{gameData.map((game, i) => {
|
||||
// Updated to match screenshot: Split background (Blue top, Yellow bottom)
|
||||
const topBg = isDark ? "rgba(33, 150, 243, 0.15)" : "#E3F2FD";
|
||||
const botBg = isDark ? "rgba(255, 193, 7, 0.15)" : "#FFF8E1";
|
||||
|
||||
return (
|
||||
<View key={i} style={styles.gameBlock}>
|
||||
{/* Top Score Label if needed */}
|
||||
<View style={styles.gameTopLabel} />
|
||||
|
||||
{/* Bars Area */}
|
||||
<View style={styles.barsArea}>
|
||||
{/* Backgrounds */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "50%",
|
||||
backgroundColor: topBg,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "50%",
|
||||
backgroundColor: botBg,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Center Line */}
|
||||
<View
|
||||
style={[
|
||||
styles.centerLine,
|
||||
{ backgroundColor: "#FFF", height: 2, zIndex: 1 },
|
||||
]}
|
||||
/>
|
||||
|
||||
{game.bars.map((bar, bi) => (
|
||||
<View key={bi} style={[styles.barColumn, { zIndex: 2 }]}>
|
||||
{/* P1 Bar (Top) */}
|
||||
<View
|
||||
style={[
|
||||
styles.barSegment,
|
||||
{
|
||||
height: 24,
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{bar.winner === 1 && (
|
||||
<View
|
||||
style={[
|
||||
styles.activeBar,
|
||||
{
|
||||
backgroundColor: "#2196F3",
|
||||
height:
|
||||
bi === game.bars.length - 1 ? "100%" : "60%",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* P2 Bar (Bottom) */}
|
||||
<View
|
||||
style={[
|
||||
styles.barSegment,
|
||||
{
|
||||
height: 24,
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{bar.winner === 2 && (
|
||||
<View
|
||||
style={[
|
||||
styles.activeBar,
|
||||
{
|
||||
backgroundColor: "#FFC107",
|
||||
height:
|
||||
bi === game.bars.length - 1 ? "100%" : "60%",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Bottom Game Number */}
|
||||
<View style={styles.gameBottomLabel}>
|
||||
<ThemedText style={styles.gameNumberText}>
|
||||
{game.gameIndex}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
margin: 16,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 20,
|
||||
},
|
||||
contentRow: {
|
||||
flexDirection: "row",
|
||||
height: 120, // Total height for graph area
|
||||
},
|
||||
sidebar: {
|
||||
width: 40,
|
||||
alignItems: "center",
|
||||
justifyContent: "center", // Center avatars vertically relative to bars
|
||||
paddingBottom: 20, // Adjust for game number row
|
||||
},
|
||||
avatarContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
borderWidth: 1,
|
||||
borderColor: "#333",
|
||||
},
|
||||
avatar: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
graphScroll: {
|
||||
flex: 1,
|
||||
marginLeft: 8,
|
||||
},
|
||||
gameBlock: {
|
||||
paddingHorizontal: 4,
|
||||
marginRight: 2,
|
||||
minWidth: 40,
|
||||
borderRadius: 4,
|
||||
},
|
||||
gameTopLabel: {
|
||||
height: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
barsArea: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
height: 60, // Central bar area
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
},
|
||||
centerLine: {
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: 1,
|
||||
top: 30, // Middle of 60
|
||||
},
|
||||
barColumn: {
|
||||
width: 6,
|
||||
marginHorizontal: 1,
|
||||
height: "100%",
|
||||
justifyContent: "center",
|
||||
},
|
||||
barSegment: {
|
||||
width: "100%",
|
||||
},
|
||||
activeBar: {
|
||||
width: "100%",
|
||||
borderRadius: 2,
|
||||
},
|
||||
gameBottomLabel: {
|
||||
height: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
gameNumberText: {
|
||||
fontSize: 10,
|
||||
opacity: 0.6,
|
||||
},
|
||||
});
|
||||
244
components/live-detail/tennis-scoreboard.tsx
Normal file
244
components/live-detail/tennis-scoreboard.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { LiveScoreMatch } from "@/types/api";
|
||||
import { Image } from "expo-image";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
|
||||
interface TennisScoreboardProps {
|
||||
match: LiveScoreMatch;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
export function TennisScoreboard({ match, isDark }: TennisScoreboardProps) {
|
||||
const { t } = useTranslation();
|
||||
// Tennis Logic
|
||||
const isTennis =
|
||||
!!match.event_first_player ||
|
||||
(match.league_name &&
|
||||
/ATP|WTA|ITF|Challenger/i.test(match.league_name || ""));
|
||||
|
||||
if (!isTennis) return null;
|
||||
|
||||
const homeName = isTennis ? match.event_first_player : match.event_home_team;
|
||||
const awayName = isTennis ? match.event_second_player : match.event_away_team;
|
||||
const homeLogo = isTennis
|
||||
? match.event_first_player_logo
|
||||
: match.home_team_logo;
|
||||
const awayLogo = isTennis
|
||||
? match.event_second_player_logo
|
||||
: match.away_team_logo;
|
||||
|
||||
const tennisScores = React.useMemo(() => {
|
||||
if (!match.scores) return [];
|
||||
try {
|
||||
const s = match.scores;
|
||||
return Array.isArray(s) ? s : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [match.scores]);
|
||||
|
||||
const gamePoints = React.useMemo(() => {
|
||||
if (!match.event_game_result) return { p1: "0", p2: "0" };
|
||||
const parts = match.event_game_result.split("-");
|
||||
if (parts.length === 2) {
|
||||
return { p1: parts[0].trim(), p2: parts[1].trim() };
|
||||
}
|
||||
return { p1: "0", p2: "0" };
|
||||
}, [match.event_game_result]);
|
||||
|
||||
const totalSets = React.useMemo(() => {
|
||||
if (!match.event_final_result) return { p1: 0, p2: 0 };
|
||||
const parts = match.event_final_result.split("-");
|
||||
if (parts.length === 2) {
|
||||
return {
|
||||
p1: parseInt(parts[0].trim()) || 0,
|
||||
p2: parseInt(parts[1].trim()) || 0,
|
||||
};
|
||||
}
|
||||
return { p1: 0, p2: 0 };
|
||||
}, [match.event_final_result]);
|
||||
|
||||
const bgColor = isDark ? "#1E1E20" : "#FFF";
|
||||
const textColor = isDark ? "#FFF" : "#000";
|
||||
const headerColor = isDark ? "#888" : "#666";
|
||||
const borderColor = isDark ? "#333" : "#F0F0F0";
|
||||
|
||||
return (
|
||||
<View style={[styles.tennisBoardContainer, { backgroundColor: bgColor }]}>
|
||||
<View style={styles.header}>
|
||||
<ThemedText
|
||||
style={{ fontSize: 16, fontWeight: "700", marginBottom: 12 }}
|
||||
>
|
||||
{t("detail.scoreboard", "Scoreboard")}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View
|
||||
style={[styles.tennisBoardHeader, { borderBottomColor: borderColor }]}
|
||||
>
|
||||
<ThemedText style={[styles.boardHeaderLabel, { color: headerColor }]}>
|
||||
Player
|
||||
</ThemedText>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.boardHeaderLabel,
|
||||
styles.boardColCenter,
|
||||
{ color: "#4CAF50" },
|
||||
]}
|
||||
>
|
||||
Game
|
||||
</ThemedText>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<ThemedText
|
||||
key={i}
|
||||
style={[
|
||||
styles.boardHeaderLabel,
|
||||
styles.boardColCenter,
|
||||
{
|
||||
color:
|
||||
i.toString() === match.event_status?.replace("Set ", "")
|
||||
? "#4CAF50"
|
||||
: headerColor,
|
||||
},
|
||||
]}
|
||||
>
|
||||
S{i}
|
||||
</ThemedText>
|
||||
))}
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.boardHeaderLabel,
|
||||
styles.boardColCenter,
|
||||
{ color: headerColor },
|
||||
]}
|
||||
>
|
||||
Sets
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.tennisRow}>
|
||||
<View style={styles.boardPlayerCol}>
|
||||
<Image source={{ uri: homeLogo }} style={styles.miniAvatar} />
|
||||
</View>
|
||||
<ThemedText
|
||||
style={[styles.boardVal, styles.boardColCenter, { color: "#4CAF50" }]}
|
||||
>
|
||||
{gamePoints.p1}
|
||||
</ThemedText>
|
||||
{[1, 2, 3, 4, 5].map((i) => {
|
||||
const setScore = tennisScores.find((s: any) => s.score_set == i);
|
||||
return (
|
||||
<ThemedText
|
||||
key={i}
|
||||
style={[
|
||||
styles.boardVal,
|
||||
styles.boardColCenter,
|
||||
{
|
||||
color:
|
||||
i.toString() === match.event_status?.replace("Set ", "")
|
||||
? "#4CAF50"
|
||||
: textColor,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{setScore ? setScore.score_first : "-"}
|
||||
</ThemedText>
|
||||
);
|
||||
})}
|
||||
<ThemedText
|
||||
style={[styles.boardVal, styles.boardColCenter, { color: textColor }]}
|
||||
>
|
||||
{totalSets.p1}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.tennisRow}>
|
||||
<View style={styles.boardPlayerCol}>
|
||||
<Image source={{ uri: awayLogo }} style={styles.miniAvatar} />
|
||||
</View>
|
||||
<ThemedText
|
||||
style={[styles.boardVal, styles.boardColCenter, { color: "#4CAF50" }]}
|
||||
>
|
||||
{gamePoints.p2}
|
||||
</ThemedText>
|
||||
{[1, 2, 3, 4, 5].map((i) => {
|
||||
const setScore = tennisScores.find((s: any) => s.score_set == i);
|
||||
return (
|
||||
<ThemedText
|
||||
key={i}
|
||||
style={[
|
||||
styles.boardVal,
|
||||
styles.boardColCenter,
|
||||
{
|
||||
color:
|
||||
i.toString() === match.event_status?.replace("Set ", "")
|
||||
? "#4CAF50"
|
||||
: textColor,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{setScore ? setScore.score_second : "-"}
|
||||
</ThemedText>
|
||||
);
|
||||
})}
|
||||
<ThemedText
|
||||
style={[styles.boardVal, styles.boardColCenter, { color: textColor }]}
|
||||
>
|
||||
{totalSets.p2}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tennisBoardContainer: {
|
||||
marginHorizontal: 16,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginTop: 12,
|
||||
marginBottom: 16,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 10,
|
||||
elevation: 2,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
tennisBoardHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderBottomWidth: 1,
|
||||
paddingBottom: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
boardHeaderLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: "500",
|
||||
},
|
||||
boardPlayerCol: {
|
||||
width: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
boardColCenter: {
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
},
|
||||
tennisRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
height: 36,
|
||||
},
|
||||
miniAvatar: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
},
|
||||
boardVal: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
408
components/live-detail/tennis-stats-card.tsx
Normal file
408
components/live-detail/tennis-stats-card.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { LiveScoreMatch } from "@/types/api";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import Svg, { Circle, G } from "react-native-svg";
|
||||
|
||||
interface TennisStatsCardProps {
|
||||
match: LiveScoreMatch;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
// Helper for Circular Progress
|
||||
// Updated to match screenshot: Labels on top, circle in middle, values on sides.
|
||||
const CircularStat = ({
|
||||
value1,
|
||||
value2,
|
||||
label,
|
||||
color1 = "#2196F3", // Blue
|
||||
color2 = "#FFC107", // Gold
|
||||
size = 50,
|
||||
strokeWidth = 5,
|
||||
isDark,
|
||||
}: {
|
||||
value1: string; // "67%" or "67"
|
||||
value2: string;
|
||||
label: string;
|
||||
color1?: string;
|
||||
color2?: string;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
isDark: boolean;
|
||||
}) => {
|
||||
const parse = (v: string) => parseFloat(v.replace("%", "")) || 0;
|
||||
const v1 = parse(value1);
|
||||
const v2 = parse(value2);
|
||||
|
||||
// Radius
|
||||
const r = (size - strokeWidth) / 2;
|
||||
const c = size / 2;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
|
||||
// Inner radius
|
||||
const innerR = r - strokeWidth - 2; // small gap
|
||||
const innerCircumference = 2 * Math.PI * innerR;
|
||||
|
||||
// Colors for text need to be readable on white background if card is always white
|
||||
// Screenshot shows white card on dark background.
|
||||
// BUT the text "First Serve %" is dark/grey.
|
||||
// The values 67.4 are Blue/Gold.
|
||||
// The circle tracks are light grey maybe.
|
||||
|
||||
const trackColor = isDark ? "#333" : "#E0E0E0";
|
||||
const labelColor = isDark ? "#AAA" : "#666";
|
||||
|
||||
return (
|
||||
<View style={styles.circularStatContainer}>
|
||||
{/* Label centered */}
|
||||
<ThemedText
|
||||
style={[styles.circularLabel, { color: labelColor }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{label}
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.circularRow}>
|
||||
{/* Left Value P1 */}
|
||||
<ThemedText style={[styles.statValueSide, { color: color1 }]}>
|
||||
{value1}
|
||||
</ThemedText>
|
||||
|
||||
{/* Circle Graphic */}
|
||||
<View style={{ width: size, height: size, marginHorizontal: 6 }}>
|
||||
<Svg width={size} height={size}>
|
||||
<G rotation="-90" origin={`${c}, ${c}`}>
|
||||
{/* Outer Track */}
|
||||
<Circle
|
||||
cx={c}
|
||||
cy={c}
|
||||
r={r}
|
||||
stroke={trackColor}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
/>
|
||||
|
||||
{/* P1 Progress (Outer) */}
|
||||
<Circle
|
||||
cx={c}
|
||||
cy={c}
|
||||
r={r}
|
||||
stroke={color1}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference - (v1 / 100) * circumference}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Inner Track */}
|
||||
<Circle
|
||||
cx={c}
|
||||
cy={c}
|
||||
r={innerR}
|
||||
stroke={trackColor}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
/>
|
||||
|
||||
{/* P2 Progress (Inner) */}
|
||||
<Circle
|
||||
cx={c}
|
||||
cy={c}
|
||||
r={innerR}
|
||||
stroke={color2}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
strokeDasharray={innerCircumference}
|
||||
strokeDashoffset={
|
||||
innerCircumference - (v2 / 100) * innerCircumference
|
||||
}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</G>
|
||||
</Svg>
|
||||
</View>
|
||||
|
||||
{/* Right Value P2 */}
|
||||
<ThemedText style={[styles.statValueSide, { color: color2 }]}>
|
||||
{value2}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper for Bar Stat
|
||||
const BarStat = ({
|
||||
label,
|
||||
value1,
|
||||
value2,
|
||||
color1 = "#2196F3",
|
||||
color2 = "#FFC107",
|
||||
isDark,
|
||||
}: {
|
||||
label: string;
|
||||
value1: string;
|
||||
value2: string;
|
||||
color1?: string;
|
||||
color2?: string;
|
||||
isDark: boolean;
|
||||
}) => {
|
||||
// Parse to number for bars
|
||||
const v1 = parseFloat(value1) || 0;
|
||||
const v2 = parseFloat(value2) || 0;
|
||||
const total = v1 + v2 || 1;
|
||||
const p1 = (v1 / total) * 100;
|
||||
const p2 = (v2 / total) * 100;
|
||||
|
||||
return (
|
||||
<View style={styles.barStatContainer}>
|
||||
<View style={styles.barHeader}>
|
||||
<View style={[styles.pill, { backgroundColor: color1 }]}>
|
||||
<ThemedText style={styles.pillText}>{value1}</ThemedText>
|
||||
</View>
|
||||
<ThemedText style={styles.barLabel}>{label}</ThemedText>
|
||||
<View
|
||||
style={[
|
||||
styles.pill,
|
||||
{ backgroundColor: color2, borderColor: color2, borderWidth: 1 },
|
||||
]}
|
||||
>
|
||||
<ThemedText style={[styles.pillText, { color: "#000" }]}>
|
||||
{value2}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.barTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.barFill,
|
||||
{
|
||||
flex: v1,
|
||||
backgroundColor: color1,
|
||||
borderTopLeftRadius: 4,
|
||||
borderBottomLeftRadius: 4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View style={{ width: 4 }} />
|
||||
<View
|
||||
style={[
|
||||
styles.barFill,
|
||||
{
|
||||
flex: v2,
|
||||
backgroundColor: color2,
|
||||
borderTopRightRadius: 4,
|
||||
borderBottomRightRadius: 4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export function TennisStatsCard({ match, isDark }: TennisStatsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const statistics = match.statistics;
|
||||
|
||||
if (!statistics || !Array.isArray(statistics) || statistics.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check structure. If it's the Football type (home/away keys), we shouldn't be here (LiveDetail logic checks sport).
|
||||
// But safely handle:
|
||||
const isTennisFormat =
|
||||
"player_key" in statistics[0] || "name" in statistics[0];
|
||||
|
||||
if (!isTennisFormat) return null;
|
||||
|
||||
// Group by name
|
||||
// Map Name -> { p1Value: string, p2Value: string }
|
||||
const statsMap: Record<string, { p1: string; p2: string; type?: string }> =
|
||||
{};
|
||||
|
||||
// Assume P1 is first_player_key
|
||||
// Using home_team_key as fallback/primary since first_player_key isn't in type definition
|
||||
// In many APIs, for tennis, home_team_key === first_player_key
|
||||
const p1Key = match.home_team_key;
|
||||
|
||||
// In generic response, keys might be strings vs numbers.
|
||||
// Let's rely on finding two entries for same "name" and "period".
|
||||
// Or just iterate.
|
||||
|
||||
(statistics as any[]).forEach((stat) => {
|
||||
// Filter for 'match' period (total)
|
||||
if (stat.period !== "match") return;
|
||||
|
||||
const name = stat.name;
|
||||
if (!statsMap[name]) statsMap[name] = { p1: "0", p2: "0", type: stat.type };
|
||||
|
||||
// Assign to P1 or P2
|
||||
// If we know p1Key
|
||||
if (p1Key && stat.player_key == p1Key) {
|
||||
statsMap[name].p1 = stat.value;
|
||||
} else {
|
||||
statsMap[name].p2 = stat.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Define intended stats to show
|
||||
// Top (Circles):
|
||||
// "1st serve percentage" (1st Serve %)
|
||||
// "1st serve points won" (1st Serve Win %)
|
||||
// "Break Points Converted" -> "Break Success %" (Screenshot: 破发成功率)
|
||||
// Or match keys exactly: "1st serve percentage", "1st serve points won", "Break Points Converted"
|
||||
|
||||
// Middle (Bars):
|
||||
// "Aces" (Screenshot: 发球得分? Or implies Aces).
|
||||
// "Double Faults" (Screenshot: 双误)
|
||||
// "Service Points Won"
|
||||
|
||||
const circleStats = [
|
||||
{ key: "1st serve percentage", label: t("detail.stats.first_serve") },
|
||||
{
|
||||
key: "1st serve points won",
|
||||
label: t("detail.stats.first_serve_points"),
|
||||
},
|
||||
{ key: "Break Points Converted", label: t("detail.stats.break_points") },
|
||||
];
|
||||
|
||||
const barStats = [
|
||||
{ key: "Aces", label: t("detail.stats.aces") },
|
||||
{ key: "Double Faults", label: t("detail.stats.double_faults") },
|
||||
];
|
||||
|
||||
// Check if any of the target stats actually exist in the map
|
||||
const hasAnyStat = [...circleStats, ...barStats].some((s) => statsMap[s.key]);
|
||||
if (!hasAnyStat) return null;
|
||||
|
||||
return (
|
||||
<ThemedView
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: isDark ? "#1E1E20" : "#FFF",
|
||||
borderRadius: 12,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText style={[styles.title, { color: isDark ? "#FFF" : "#000" }]}>
|
||||
{t("detail.statistics")}
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.circlesRow}>
|
||||
{circleStats.map((s) =>
|
||||
statsMap[s.key] ? (
|
||||
<CircularStat
|
||||
key={s.key}
|
||||
label={s.label}
|
||||
value1={statsMap[s.key].p1}
|
||||
value2={statsMap[s.key].p2}
|
||||
size={50}
|
||||
isDark={false}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.barsColumn}>
|
||||
{barStats.map((s) =>
|
||||
statsMap[s.key] ? (
|
||||
<BarStat
|
||||
key={s.key}
|
||||
label={s.label}
|
||||
value1={statsMap[s.key].p1}
|
||||
value2={statsMap[s.key].p2}
|
||||
isDark={isDark}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
margin: 16,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 20,
|
||||
},
|
||||
circlesRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-around",
|
||||
marginBottom: 24,
|
||||
},
|
||||
circularStatContainer: {
|
||||
alignItems: "center",
|
||||
width: "30%",
|
||||
},
|
||||
circularLabel: {
|
||||
fontSize: 12,
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
height: 32, // Fixed height for alignment
|
||||
},
|
||||
circularRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
statValueSide: {
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
},
|
||||
barsColumn: {
|
||||
gap: 16,
|
||||
},
|
||||
barStatContainer: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
barHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 8,
|
||||
},
|
||||
pill: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
minWidth: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
pillText: {
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: 12,
|
||||
},
|
||||
barLabel: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
barTrack: {
|
||||
flexDirection: "row",
|
||||
height: 6,
|
||||
backgroundColor: "#333",
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
},
|
||||
barFill: {
|
||||
height: "100%",
|
||||
},
|
||||
});
|
||||
@@ -262,14 +262,15 @@ export function MatchCard({
|
||||
return { home: "", away: "" };
|
||||
}
|
||||
|
||||
const corners = liveDetail.statistics.find((s) => s.type === "Corners");
|
||||
// Cast statistics to any[] to avoid union type issues when accessing 'home'/'away' properties
|
||||
const stats = liveDetail.statistics as any[];
|
||||
|
||||
const corners = stats.find((s) => s.type === "Corners");
|
||||
if (corners) {
|
||||
return { home: corners.home, away: corners.away };
|
||||
}
|
||||
|
||||
const dangerousAttacks = liveDetail.statistics.find(
|
||||
(s) => s.type === "Dangerous Attacks",
|
||||
);
|
||||
const dangerousAttacks = stats.find((s) => s.type === "Dangerous Attacks");
|
||||
if (dangerousAttacks) {
|
||||
return { home: dangerousAttacks.home, away: dangerousAttacks.away };
|
||||
}
|
||||
|
||||
@@ -91,6 +91,18 @@
|
||||
"retry": "Retry",
|
||||
"fetch_failed": "Failed to fetch details",
|
||||
"not_found": "Match data not found",
|
||||
"scoreboard": "Scoreboard",
|
||||
"power_graph": "Power Graph",
|
||||
"statistics": "Statistics",
|
||||
"stats": {
|
||||
"first_serve": "1st Serve %",
|
||||
"first_serve_points": "1st Serve Pts Won",
|
||||
"break_points": "Break Success %",
|
||||
"aces": "Aces",
|
||||
"double_faults": "Double Faults",
|
||||
"service_points": "Service Points Won",
|
||||
"total_points": "Total Points Won"
|
||||
},
|
||||
"tabs": {
|
||||
"info": "Details",
|
||||
"stats": "Statistics",
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"h2h": "आमने-सामने",
|
||||
"chat": "चैट"
|
||||
},
|
||||
"scoreboard": "स्कोरबोर्ड",
|
||||
"info_card": {
|
||||
"title": "मैच जानकारी",
|
||||
"country": "देश/क्षेत्र",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"retry": "Coba lagi",
|
||||
"fetch_failed": "Gagal mengambil data",
|
||||
"not_found": "Data pertandingan tidak ditemukan",
|
||||
"scoreboard": "Papan Skor",
|
||||
"tabs": {
|
||||
"info": "Detail",
|
||||
"stats": "Statistik",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"retry": "Cuba semula",
|
||||
"fetch_failed": "Gagal mendapatkan maklumat",
|
||||
"not_found": "Data perlawanan tidak ditemui",
|
||||
"scoreboard": "Papan Skor",
|
||||
"tabs": {
|
||||
"info": "Maklumat",
|
||||
"stats": "Statistik",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"retry": "ลองอีกครั้ง",
|
||||
"fetch_failed": "ไม่สามารถดึงข้อมูลได้",
|
||||
"not_found": "ไม่พบข้อมูลการแข่งขัน",
|
||||
"scoreboard": "สกอร์บอร์ด",
|
||||
"tabs": {
|
||||
"info": "รายละเอียด",
|
||||
"stats": "สถิติ",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"retry": "Thử lại",
|
||||
"fetch_failed": "Không thể tải dữ liệu",
|
||||
"not_found": "Không tìm thấy dữ liệu trận đấu",
|
||||
"scoreboard": "Bảng điểm",
|
||||
"tabs": {
|
||||
"info": "Thông tin",
|
||||
"stats": "Thống kê",
|
||||
|
||||
@@ -91,6 +91,18 @@
|
||||
"retry": "重试",
|
||||
"fetch_failed": "获取详情失败",
|
||||
"not_found": "未找到比赛数据",
|
||||
"scoreboard": "记分牌",
|
||||
"power_graph": "功率图",
|
||||
"statistics": "统计数据",
|
||||
"stats": {
|
||||
"first_serve": "一发成功率",
|
||||
"first_serve_points": "一发得分率",
|
||||
"break_points": "破发成功率",
|
||||
"aces": "Ace球",
|
||||
"double_faults": "双误",
|
||||
"service_points": "发球得分",
|
||||
"total_points": "总得分"
|
||||
},
|
||||
"tabs": {
|
||||
"info": "详情",
|
||||
"stats": "统计数据",
|
||||
|
||||
10
lib/api.ts
10
lib/api.ts
@@ -214,11 +214,11 @@ export const fetchLiveScore = async (
|
||||
leagueId?: number,
|
||||
timezone?: string,
|
||||
): Promise<LiveScoreMatch[]> => {
|
||||
// console.log("Fetching live scores with params:", {
|
||||
// sportId,
|
||||
// leagueId,
|
||||
// timezone,
|
||||
// });
|
||||
console.log("Fetching live scores with params:", {
|
||||
sportId,
|
||||
leagueId,
|
||||
timezone,
|
||||
});
|
||||
try {
|
||||
const params: { sport_id: number; league_id?: number; timezone?: string } =
|
||||
{
|
||||
|
||||
27
types/api.ts
27
types/api.ts
@@ -58,6 +58,7 @@ export interface LiveScoreMatch {
|
||||
away_team_logo: string;
|
||||
event_final_result: string;
|
||||
event_halftime_result: string;
|
||||
event_game_result?: string;
|
||||
event_status: string;
|
||||
event_live: string;
|
||||
league_key: number;
|
||||
@@ -113,10 +114,34 @@ export interface LiveScoreMatch {
|
||||
score: string;
|
||||
}[];
|
||||
lineups?: unknown;
|
||||
statistics?: {
|
||||
statistics?:
|
||||
| {
|
||||
type: string;
|
||||
home: string;
|
||||
away: string;
|
||||
}[]
|
||||
| {
|
||||
name: string;
|
||||
period: string;
|
||||
player_key: number;
|
||||
value: string;
|
||||
type?: string;
|
||||
total?: number | null;
|
||||
won?: number | null;
|
||||
}[];
|
||||
pointbypoint?: {
|
||||
number_game: string;
|
||||
player_served: string;
|
||||
score: string;
|
||||
serve_winner: string;
|
||||
set_number: string;
|
||||
points: {
|
||||
break_point: null | string;
|
||||
match_point: null | string;
|
||||
number_point: string;
|
||||
score: string;
|
||||
set_point: null | string;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user