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

714 lines
19 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]);
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(() => {
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: "" };
}
const corners = liveDetail.statistics.find((s) => s.type === "Corners");
if (corners) {
return { home: corners.home, away: corners.away };
}
const dangerousAttacks = liveDetail.statistics.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 renderOddsRow = (bookmakerName: string, isHighlight: boolean) => {
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: match.homeTeamLogo,
}}
style={styles.teamLogo}
contentFit="contain"
/>
</View>
<ThemedText
type="defaultSemiBold"
style={styles.teamName}
numberOfLines={1}
ellipsizeMode="tail"
>
{match.homeTeamName || match.home}
</ThemedText>
{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: match.awayTeamLogo,
}}
style={styles.teamLogo}
contentFit="contain"
/>
</View>
<ThemedText
type="defaultSemiBold"
style={styles.teamName}
numberOfLines={1}
ellipsizeMode="tail"
>
{match.awayTeamName || match.away}
</ThemedText>
{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
? "#FF9500"
: 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",
},
});