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

605 lines
16 KiB
TypeScript

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.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,
]);
// 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.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) return { home: m[1], away: m[2], hasScore: true };
if (s && s !== "-" && s !== "0 - 0")
return { home: s, away: "", hasScore: true };
if (s === "0 - 0" || s === "0-0")
return { home: "0", away: "0", hasScore: true };
return { home: "", away: "", hasScore: 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 || odds.length === 0)
return null;
const item = odds.find((o) => o.odd_bookmakers === bookmakerName);
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}>
{match.homeTeamLogo && (
<Image
source={{ uri: match.homeTeamLogo }}
style={styles.teamLogo}
contentFit="contain"
/>
)}
<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}>
{match.awayTeamLogo && (
<Image
source={{ uri: match.awayTeamLogo }}
style={styles.teamLogo}
contentFit="contain"
/>
)}
<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 + favorite */}
<View style={styles.right}>
{scoreParts.hasScore ? (
<View
style={[
styles.scoreBox,
{
borderColor: isLive ? "#FF9500" : scoreBorder,
backgroundColor: scoreBg,
},
]}
>
<ThemedText style={styles.scoreBoxText} numberOfLines={1}>
{scoreParts.home}
</ThemedText>
<View style={styles.scoreDivider} />
<ThemedText style={styles.scoreBoxText} numberOfLines={1}>
{scoreParts.away}
</ThemedText>
</View>
) : (
<View style={styles.scoreBoxPlaceholder} />
)}
{(extraStats.home !== "" || extraStats.away !== "") && (
<View style={styles.extraStatsColumn}>
<ThemedText style={styles.extraStatText}>
{extraStats.home}
</ThemedText>
<ThemedText style={styles.extraStatText}>
{extraStats.away}
</ThemedText>
</View>
)}
<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>
</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,
},
teamLogo: {
width: 18,
height: 18,
},
teamName: {
fontSize: 12,
fontWeight: "600",
marginLeft: 6,
flex: 1,
minWidth: 0,
},
bookmakerOddsRow: {
marginLeft: 4,
flexDirection: "row",
gap: 4,
},
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",
gap: 6,
},
scoreBox: {
width: 30,
height: 54,
borderRadius: 8,
borderWidth: 1.5,
alignItems: "center",
justifyContent: "center",
},
scoreBoxText: {
fontSize: 20,
fontWeight: "900",
},
scoreDivider: {
width: "60%",
height: 1,
backgroundColor: "rgba(0,0,0,0.06)",
marginVertical: 1,
},
scoreBoxPlaceholder: {
width: 36,
height: 54,
},
cardsInline: {
flexDirection: "row",
alignItems: "center",
gap: 4,
marginLeft: 6,
flexShrink: 0,
},
cardBadge: {
minWidth: 16,
height: 16,
borderRadius: 3,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 3,
},
cardBadgeYellow: {
backgroundColor: "#FFC400",
},
cardBadgeRed: {
backgroundColor: "#FF3B30",
},
extraStatsColumn: {
alignItems: "center",
justifyContent: "space-between",
height: 48,
paddingVertical: 2,
},
extraStatText: {
fontSize: 12,
fontWeight: "500",
opacity: 0.6,
},
cardBadgeText: {
fontSize: 10,
fontWeight: "900",
lineHeight: 12,
color: "#fff",
},
cardBadgeTextDark: {
color: "#000",
},
});