Files
physical-expo/components/match-card.tsx

809 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ThemedText } from "@/components/themed-text";
import { IconSymbol } from "@/components/ui/icon-symbol";
import { Colors } from "@/constants/theme";
import { useAppState } from "@/context/AppStateContext";
import { useTheme } from "@/context/ThemeContext";
import {
addFavorite,
fetchLiveScore,
fetchOdds,
removeFavorite,
} from "@/lib/api";
import { LiveScoreMatch, Match, OddsItem } from "@/types/api";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import React, { useEffect, useState } from "react";
import { Pressable, StyleSheet, TouchableOpacity, View } from "react-native";
interface MatchCardProps {
match: Match;
onPress?: (match: Match) => void;
onFavoriteToggle?: (matchId: string, isFav: boolean) => void;
}
export function MatchCard({
match,
onPress,
onFavoriteToggle,
}: MatchCardProps) {
const router = useRouter();
const { theme } = useTheme();
const { state } = useAppState();
const [isFav, setIsFav] = useState(match.fav);
const [loading, setLoading] = useState(false);
const [odds, setOdds] = useState<OddsItem[]>(match.odds || []);
const [liveDetail, setLiveDetail] = useState<LiveScoreMatch | null>(null);
// console.log("MatchCard render:", JSON.stringify(match));
const oddsSettings = state.oddsSettings;
const cardsSettings = state.cardsSettings;
const cornerSettings = state.cornerSettings;
useEffect(() => {
if (
oddsSettings.enabled &&
oddsSettings.selectedBookmakers.length > 0 &&
match.isLive &&
!match.odds
) {
fetchOdds(match.sportId || 1, parseInt(match.id))
.then((res) => {
const matchOdds = res[match.id]?.data || [];
setOdds(matchOdds);
})
.catch((err) => console.log("Fetch match card odds error:", err));
}
}, [
oddsSettings.enabled,
oddsSettings.selectedBookmakers,
match.id,
match.odds,
match.isLive,
match.sportId,
]);
// Fetch live score detail for cards and corners info
useEffect(() => {
if (
(cardsSettings.enabled || cornerSettings.enabled) &&
isLive &&
match.leagueKey
) {
fetchLiveScore(match.sportId || 1, Number(match.leagueKey))
.then((matches) => {
const detail = matches.find((m) => String(m.event_key) === match.id);
if (detail) {
setLiveDetail(detail);
}
})
.catch((err) => console.log("Fetch live detail for cards error:", err));
}
}, [
cardsSettings.enabled,
cornerSettings.enabled,
match.id,
match.leagueKey,
match.sportId,
match.isLive,
]);
// 当外部传入的 match.fav 改变时,更新内部状态
useEffect(() => {
setIsFav(match.fav);
}, [match.fav]);
const isDark = theme === "dark";
const iconColor = isDark ? Colors.dark.icon : Colors.light.icon;
const cardBg = isDark ? "#1C1C1E" : "#FFFFFF";
const borderColor = isDark ? "#2C2C2E" : "rgba(0,0,0,0.06)";
const scoreBorder = isDark ? "rgba(255,255,255,0.18)" : "rgba(0,0,0,0.12)";
const scoreBg = isDark ? "rgba(255,255,255,0.04)" : "rgba(255,255,255,0.6)";
const oddBadgeBg = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)";
const isLive = React.useMemo(() => {
return !!match.isLive;
}, [match.isLive]);
// Tennis logic
const isTennis = match.sportId === 3 || match.sport === "tennis";
const homeName = isTennis
? liveDetail?.event_first_player || match.eventFirstPlayer
: liveDetail?.event_home_team || match.homeTeamName || match.home;
const awayName = isTennis
? liveDetail?.event_second_player || match.eventSecondPlayer
: liveDetail?.event_away_team || match.awayTeamName || match.away;
const homeLogo = isTennis
? liveDetail?.event_first_player_logo || match.eventFirstPlayerLogo
: liveDetail?.home_team_logo || match.homeTeamLogo;
const awayLogo = isTennis
? liveDetail?.event_second_player_logo || match.eventSecondPlayerLogo
: liveDetail?.away_team_logo || match.awayTeamLogo;
const tennisScores = React.useMemo(() => {
// 优先使用 liveDetail 中的 scores
if (isTennis && liveDetail?.scores && Array.isArray(liveDetail.scores)) {
return liveDetail.scores;
}
if (!isTennis || !match.scores) return [];
try {
const parsed = JSON.parse(match.scores);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}, [match.scores, isTennis, liveDetail]);
const timeLabel = React.useMemo(() => {
const raw = (match.time || "").trim();
if (!raw) return "";
if (/^\d{1,3}$/.test(raw)) return `${raw}'`;
return raw;
}, [match.time]);
const leagueShort = React.useMemo(() => {
const league = (match.leagueName || match.league || "").trim();
if (!league) return "--";
const first = league.split(/[^A-Za-z0-9]+/).filter(Boolean)[0] || league;
return first.slice(0, 3).toUpperCase();
}, [match.leagueName, match.league]);
const scoreParts = React.useMemo(() => {
if (isTennis) {
if (tennisScores.length > 0) {
const last = tennisScores[tennisScores.length - 1];
const h = parseInt(last.score_first || "0");
const a = parseInt(last.score_second || "0");
return {
home: last.score_first || "0",
away: last.score_second || "0",
hasScore: true,
homeLead: h > a,
awayLead: a > h,
};
}
// 如果没有分数(如未开始),显示 0-0 或 -
return {
home: "0",
away: "0",
hasScore: true, // 保持 ScoreBox 显示,以便显示 0-0
homeLead: false,
awayLead: false,
};
}
const s = (match.scoreText || "").trim();
const m = s.match(/(\d+)\s*[-:]\s*(\d+)/);
if (m) {
const h = parseInt(m[1]);
const a = parseInt(m[2]);
return {
home: m[1],
away: m[2],
hasScore: true,
homeLead: h > a,
awayLead: a > h,
};
}
if (s && s !== "-" && s !== "0 - 0") {
return {
home: s,
away: "",
hasScore: true,
homeLead: false,
awayLead: false,
};
}
if (s === "0 - 0" || s === "0-0") {
return {
home: "0",
away: "0",
hasScore: true,
homeLead: false,
awayLead: false,
};
}
return {
home: "",
away: "",
hasScore: false,
homeLead: false,
awayLead: false,
};
}, [match.scoreText]);
const cardsCount = React.useMemo(() => {
if (!liveDetail?.cards || !cardsSettings.enabled) {
return { homeYellow: 0, homeRed: 0, awayYellow: 0, awayRed: 0 };
}
let homeYellow = 0,
homeRed = 0,
awayYellow = 0,
awayRed = 0;
liveDetail.cards.forEach((card) => {
const cardType = (card.card || "").toLowerCase();
const isYellow = cardType.includes("yellow");
const isRed = cardType.includes("red");
if (!isYellow && !isRed) return;
const info = (card.info || "").toLowerCase();
const sideFromInfo = info.includes("home")
? "home"
: info.includes("away")
? "away"
: null;
const sideFromFault = card.home_fault
? "home"
: card.away_fault
? "away"
: null;
const side = sideFromInfo || sideFromFault;
if (!side) return;
if (side === "home") {
if (isYellow) homeYellow++;
if (isRed) homeRed++;
} else {
if (isYellow) awayYellow++;
if (isRed) awayRed++;
}
});
return { homeYellow, homeRed, awayYellow, awayRed };
}, [liveDetail, cardsSettings.enabled]);
const extraStats = React.useMemo(() => {
if (!liveDetail?.statistics || !cornerSettings.enabled) {
return { home: "", away: "" };
}
// 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 = stats.find((s) => s.type === "Dangerous Attacks");
if (dangerousAttacks) {
return { home: dangerousAttacks.home, away: dangerousAttacks.away };
}
return { home: "", away: "" };
}, [liveDetail, cornerSettings.enabled]);
const handlePress = () => {
if (onPress) {
onPress(match);
} else {
if (isLive) {
router.push({
pathname: "/live-detail/[id]",
params: {
id: match.id,
league_id: match.leagueId?.toString() || "",
sport_id: match.sportId?.toString() || "",
},
});
} else {
router.push(`/match-detail/${match.id}`);
}
}
};
const toggleFavorite = async () => {
if (loading) return;
setLoading(true);
const newFavState = !isFav;
try {
if (newFavState) {
await addFavorite({
matchId: parseInt(match.id),
type: "match",
typeId: match.id,
notify: true,
});
} else {
await removeFavorite({
type: "match",
typeId: match.id,
});
}
setIsFav(newFavState);
if (onFavoriteToggle) {
onFavoriteToggle(match.id, newFavState);
}
} catch (error) {
console.error("Toggle favorite error:", error);
} finally {
setLoading(false);
}
};
const renderTennisSetScores = (isHome: boolean) => {
// 显示除最后一盘之外的盘分 (当前盘分在右侧大框显示)
// 如果只有一盘,则这里不显示任何内容
if (tennisScores.length <= 1) return null;
const prevSets = tennisScores.slice(0, tennisScores.length - 1);
return (
<View style={styles.tennisScoresRow}>
{prevSets.map((s: any, i: number) => (
<ThemedText
key={i}
style={[
styles.tennisScoreText,
{ color: isDark ? "#CCC" : "#666" },
]}
>
{isHome ? s.score_first : s.score_second}
</ThemedText>
))}
</View>
);
};
const renderOddsRow = (bookmakerName: string, isHighlight: boolean) => {
if (isTennis) return renderTennisSetScores(isHighlight); // Reuse isHighlight param as 'isHome' (true for first player logic)
if (!oddsSettings.enabled || !bookmakerName) return null;
const item = odds.find((o) => o.odd_bookmakers === bookmakerName);
// 如果没有找到该博彩公司的赔率数据,直接返回 null不进行占位
if (!item) return null;
const val1 = item?.odd_1 || item?.ah0_1 || "-";
const val2 = item?.odd_x || "0" || "-";
const val3 = item?.odd_2 || item?.ah0_2 || "-";
return (
<View style={styles.bookmakerOddsRow}>
<View style={[styles.oddBadge, { backgroundColor: oddBadgeBg }]}>
<ThemedText
style={[
styles.oddText,
{ color: isDark ? "#fff" : "#333" },
isHighlight && styles.oddTextHighlight,
]}
>
{val1}
</ThemedText>
</View>
<View style={[styles.oddBadge, { backgroundColor: oddBadgeBg }]}>
<ThemedText
style={[
styles.oddText,
{ color: isDark ? "#fff" : "#333" },
isHighlight && styles.oddTextHighlight,
]}
>
{val2}
</ThemedText>
</View>
<View style={[styles.oddBadge, { backgroundColor: oddBadgeBg }]}>
<ThemedText
style={[
styles.oddText,
{ color: isDark ? "#fff" : "#333" },
isHighlight && styles.oddTextHighlight,
]}
>
{val3}
</ThemedText>
</View>
</View>
);
};
const renderCardsInline = (yellow: number, red: number) => {
if (!cardsSettings.enabled || !liveDetail) return null;
if (yellow <= 0 && red <= 0) return null;
return (
<View style={styles.cardsInline}>
{yellow > 0 && (
<View style={[styles.cardBadge, styles.cardBadgeYellow]}>
<ThemedText
style={[styles.cardBadgeText, styles.cardBadgeTextDark]}
>
{yellow}
</ThemedText>
</View>
)}
{red > 0 && (
<View style={[styles.cardBadge, styles.cardBadgeRed]}>
<ThemedText style={styles.cardBadgeText}>{red}</ThemedText>
</View>
)}
</View>
);
};
return (
<Pressable
onPress={handlePress}
style={({ pressed }) => [
styles.card,
{ backgroundColor: cardBg, borderColor, opacity: pressed ? 0.7 : 1 },
]}
>
{isLive && (
<LinearGradient
colors={
isDark
? ["rgba(255, 149, 0, 0.15)", "transparent"]
: ["rgba(255, 149, 0, 0.1)", "transparent"]
}
start={{ x: 0, y: 0 }}
end={{ x: 0.5, y: 0 }}
style={StyleSheet.absoluteFill}
/>
)}
<View style={styles.row}>
{/* Left: League short + time */}
<View style={styles.left}>
<ThemedText style={styles.leagueShortText} numberOfLines={1}>
{leagueShort}
</ThemedText>
<ThemedText
style={[styles.timeText, isLive && styles.timeTextLive]}
numberOfLines={1}
>
{timeLabel}
</ThemedText>
</View>
{/* Middle: Teams & Odds Row Integration */}
<View style={styles.middle}>
<View style={styles.contentRow}>
<View style={styles.teamInfo}>
<View style={styles.teamLogoPlaceholder}>
<Image
source={{
uri: homeLogo,
}}
style={styles.teamLogo}
contentFit="contain"
/>
</View>
<ThemedText
type="defaultSemiBold"
style={styles.teamName}
numberOfLines={1}
ellipsizeMode="tail"
>
{homeName}
</ThemedText>
{!isTennis &&
renderCardsInline(cardsCount.homeYellow, cardsCount.homeRed)}
</View>
{renderOddsRow(oddsSettings.selectedBookmakers[0], true)}
</View>
<View style={styles.contentRow}>
<View style={styles.teamInfo}>
<View style={styles.teamLogoPlaceholder}>
<Image
source={{
uri: awayLogo,
}}
style={styles.teamLogo}
contentFit="contain"
/>
</View>
<ThemedText
type="defaultSemiBold"
style={styles.teamName}
numberOfLines={1}
ellipsizeMode="tail"
>
{awayName}
</ThemedText>
{!isTennis &&
renderCardsInline(cardsCount.awayYellow, cardsCount.awayRed)}
</View>
{renderOddsRow(oddsSettings.selectedBookmakers[1], false)}
</View>
</View>
{/* Right: Score box + extra stats + favorite */}
<View style={styles.right}>
{scoreParts.hasScore ? (
<View style={styles.scoreContainer}>
<View
style={[
styles.scoreBox,
{
borderColor:
scoreParts.homeLead || scoreParts.awayLead
? "#e6e6e648"
: scoreBorder,
backgroundColor: scoreBg,
},
]}
>
<View
style={[
styles.scoreHalf,
scoreParts.homeLead && styles.scoreHalfLead,
]}
>
<ThemedText
style={[
styles.scoreBoxText,
scoreParts.homeLead && styles.scoreTextLead,
]}
numberOfLines={1}
>
{scoreParts.home}
</ThemedText>
</View>
<View style={styles.scoreDivider} />
<View
style={[
styles.scoreHalf,
scoreParts.awayLead && styles.scoreHalfLead,
]}
>
<ThemedText
style={[
styles.scoreBoxText,
scoreParts.awayLead && styles.scoreTextLead,
]}
numberOfLines={1}
>
{scoreParts.away}
</ThemedText>
</View>
</View>
</View>
) : (
<View style={styles.scoreContainer}>
<View style={styles.scoreBoxPlaceholder} />
</View>
)}
{extraStats.home !== "" || extraStats.away !== "" ? (
<View style={styles.extraStatsContainer}>
<View style={styles.extraStatsColumn}>
<ThemedText style={styles.extraStatText}>
{extraStats.home}
</ThemedText>
<ThemedText style={styles.extraStatText}>
{extraStats.away}
</ThemedText>
</View>
</View>
) : (
<View style={styles.extraStatsContainer} />
)}
<View style={styles.favoriteContainer}>
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
toggleFavorite();
}}
disabled={loading}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
>
<IconSymbol
name={isFav ? "star" : "star-outline"}
size={15}
color={isFav ? "#FFD700" : iconColor}
/>
</TouchableOpacity>
</View>
</View>
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
card: {
height: 78,
paddingHorizontal: 8,
marginBottom: 8,
borderRadius: 14,
borderWidth: 1,
justifyContent: "center",
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
row: {
flexDirection: "row",
alignItems: "center",
},
left: {
width: 33,
alignItems: "flex-start",
justifyContent: "center",
gap: 4,
},
leagueShortText: {
fontSize: 12,
fontWeight: "700",
opacity: 0.8,
},
timeText: {
fontSize: 12,
opacity: 0.5,
},
timeTextLive: {
color: "#FF3B30",
opacity: 1,
fontWeight: "800",
},
middle: {
flex: 1,
justifyContent: "center",
gap: 10,
marginHorizontal: 8,
},
contentRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
teamInfo: {
flexDirection: "row",
alignItems: "center",
flex: 1,
minWidth: 0,
marginRight: 8,
},
teamLogo: {
width: 18,
height: 18,
},
teamLogoPlaceholder: {
width: 18,
height: 18,
justifyContent: "center",
alignItems: "center",
},
teamName: {
fontSize: 12,
fontWeight: "600",
marginLeft: 6,
flexShrink: 1,
minWidth: 0,
},
bookmakerOddsRow: {
width: 98,
flexDirection: "row",
gap: 4,
justifyContent: "flex-end",
},
oddBadge: {
paddingHorizontal: 5,
borderRadius: 8,
minWidth: 30,
alignItems: "center",
justifyContent: "center",
},
oddText: {
fontSize: 10,
fontWeight: "700",
opacity: 0.9,
},
oddTextHighlight: {
color: "#FF9500",
},
right: {
flexDirection: "row",
alignItems: "center",
},
scoreContainer: {
width: 25,
alignItems: "center",
},
extraStatsContainer: {
width: 18,
alignItems: "center",
marginHorizontal: 4,
},
favoriteContainer: {
width: 25,
alignItems: "center",
},
scoreBox: {
width: 28,
height: 55,
borderRadius: 8,
borderWidth: 1.2,
alignItems: "stretch",
justifyContent: "center",
overflow: "hidden",
},
scoreHalf: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
scoreHalfLead: {
backgroundColor: "rgba(255, 149, 0, 0.08)",
},
scoreBoxText: {
fontSize: 15,
fontWeight: "900",
},
scoreTextLead: {
color: "#ff9500",
},
scoreDivider: {
width: "100%",
height: 1,
backgroundColor: "rgba(0,0,0,0.06)",
},
scoreBoxPlaceholder: {
width: 28,
height: 48,
},
cardsInline: {
flexDirection: "row",
alignItems: "center",
gap: 6,
marginLeft: 8,
flexShrink: 0,
},
cardBadge: {
minWidth: 10,
height: 15,
borderRadius: 3,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 4,
},
cardBadgeYellow: {
backgroundColor: "#FFC400",
},
cardBadgeRed: {
backgroundColor: "#FF3B30",
},
extraStatsColumn: {
alignItems: "center",
justifyContent: "space-between",
height: 55,
paddingVertical: 2,
},
extraStatText: {
fontSize: 11,
fontWeight: "500",
opacity: 0.4,
},
cardBadgeText: {
fontSize: 8,
fontWeight: "900",
lineHeight: 10,
color: "#fff",
},
cardBadgeTextDark: {
color: "#000",
},
tennisScoresRow: {
flexDirection: "row",
gap: 12,
marginRight: 4,
alignItems: "center",
},
tennisScoreText: {
fontSize: 14,
fontWeight: "500",
minWidth: 14,
textAlign: "center",
},
});