705 lines
18 KiB
TypeScript
705 lines
18 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.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);
|
|
const hasOdds = !!item;
|
|
|
|
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}>
|
|
{hasOdds ? (
|
|
<>
|
|
<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 style={{ width: 98 }} />
|
|
)}
|
|
</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 + extra stats + favorite */}
|
|
<View style={styles.right}>
|
|
<View style={styles.scoreContainer}>
|
|
{scoreParts.hasScore ? (
|
|
<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 style={styles.scoreBoxPlaceholder} />
|
|
)}
|
|
</View>
|
|
|
|
<View style={styles.extraStatsContainer}>
|
|
{extraStats.home !== "" || extraStats.away !== "" ? (
|
|
<View style={styles.extraStatsColumn}>
|
|
<ThemedText style={styles.extraStatText}>
|
|
{extraStats.home}
|
|
</ThemedText>
|
|
<ThemedText style={styles.extraStatText}>
|
|
{extraStats.away}
|
|
</ThemedText>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
|
|
<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,
|
|
},
|
|
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: 16,
|
|
height: 16,
|
|
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: 10,
|
|
fontWeight: "900",
|
|
lineHeight: 12,
|
|
color: "#fff",
|
|
},
|
|
cardBadgeTextDark: {
|
|
color: "#000",
|
|
},
|
|
});
|