diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d8022e..8537a1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,8 +6,8 @@ on: pull_request: branches: [develop, main] -# Caller workflow invokes reusable workflow (quality.yml). -# It must allow the permissions required by the called workflow checkout. +# Caller workflows that only invoke reusable jobs need an empty permissions block. +# The called workflow (quality.yml) declares its own token scope. permissions: contents: read diff --git a/locales/es.json b/locales/es.json index b10ee1f..e8c0272 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1,6 +1,6 @@ { "ios": { "CFBundleDisplayName": "jugaMUS", - "NSPhotoLibraryUsageDescription": "jugaMUS accede a tus fotos para que puedas elegir una imagen de perfil. La foto se sube a tu cuenta y la verán otros jugadores en las partidas y torneos en los que participes." + "NSPhotoLibraryUsageDescription": "jugaMUS accede a tus fotos para que puedas elegir una imagen de perfil. La foto se sube a tu cuenta y la verán otros jugadores en las partidas, torneos y ligas en los que participes." } } diff --git a/package-lock.json b/package-lock.json index 0bd9135..573ce58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "react-hook-form": "^7.83.0", "react-native": "0.81.5", "react-native-chart-kit": "^6.12.2", + "react-native-confetti-cannon": "^1.5.2", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "15.12.1", @@ -16109,6 +16110,12 @@ "react-native-svg": "> 6.4.1" } }, + "node_modules/react-native-confetti-cannon": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/react-native-confetti-cannon/-/react-native-confetti-cannon-1.5.2.tgz", + "integrity": "sha512-IZuWjlW7QsdxEGNnvpD6W+7iKCCQhnd5BvuNvMtirU7Nxm8WS2N6LPGMBz1ZYDuusG+GRZkoXXTNCdoAAGpCTg==", + "license": "MIT" + }, "node_modules/react-native-is-edge-to-edge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", diff --git a/package.json b/package.json index 29c0072..c7c2d1f 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "react-hook-form": "^7.83.0", "react-native": "0.81.5", "react-native-chart-kit": "^6.12.2", + "react-native-confetti-cannon": "^1.5.2", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "15.12.1", diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index c25070f..f97c7b7 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -31,6 +31,7 @@ export default function TabsLayout() { return ( + + + diff --git a/src/app/(tabs)/explore/index.tsx b/src/app/(tabs)/explore/index.tsx index 0690c76..593127e 100644 --- a/src/app/(tabs)/explore/index.tsx +++ b/src/app/(tabs)/explore/index.tsx @@ -29,14 +29,25 @@ import { parseIsoToDate, } from '@/components/ui/dateTimePickerUtils' import { MunicipalityPicker } from '@/components/ui/MunicipalityPicker' -import { MATCH_STATUS, TOURNAMENT_STATUS, type ExploreContentType } from '@/constants' -import { useInfinitePublicMatches, usePublicTournamentsExplore } from '@/hooks/useMatches' +import { + LEAGUE_STATUS, + MATCH_STATUS, + TOURNAMENT_STATUS, + type ExploreContentType, +} from '@/constants' +import { + useInfinitePublicMatches, + usePublicLeaguesExplore, + usePublicTournamentsExplore, +} from '@/hooks/useMatches' import type { PublicMatchExplorerRow, PublicMatchesListFilters, VisibilityFilter, } from '@/services/matches.service' +import type { LeagueRow, PublicLeaguesListFilters } from '@/services/leagues.service' import type { PublicTournamentsListFilters, TournamentRow } from '@/services/tournaments.service' +import { leagueFormatDisplay, leagueStatusDisplay } from '@/utils/leagueDisplay' import { Colors } from '@/theme/colors' import { Fonts } from '@/theme/typography' import { screenTopPadding } from '@/theme/layout' @@ -97,6 +108,10 @@ function tournamentStatusTone(tournament: TournamentRow): StatusDotTone { return 'upcoming' } +function leagueStatusTone(league: LeagueRow): StatusDotTone { + return league.status === LEAGUE_STATUS.IN_PROGRESS ? 'active' : 'upcoming' +} + function ExploreMatchRow({ row, onPress }: { row: PublicMatchExplorerRow; onPress: () => void }) { const tone = matchStatusTone(row.status) return ( @@ -158,6 +173,37 @@ function ExploreTournamentRow({ row, onPress }: { row: TournamentRow; onPress: ( ) } +function ExploreLeagueRow({ row, onPress }: { row: LeagueRow; onPress: () => void }) { + const tone = leagueStatusTone(row) + return ( + + + + Liga · {leagueFormatDisplay(row.format)} + + {row.title} + + + {formatCityAndPlace(row.city, row.place_defined, row.place_text)} + + + + + {leagueStatusDisplay(row).text} + + {formatDisplay(row.start_at)} + + {row.status === LEAGUE_STATUS.REGISTRATION ? 'Sin iniciar' : 'Ver clasificación'} + + + + ) +} + export default function ExploreScreen() { const router = useRouter() const insets = useSafeAreaInsets() @@ -213,10 +259,36 @@ export default function ExploreScreen() { const { data: tournaments, isLoading: tournamentsLoading, + isError: tournamentsIsError, + error: tournamentsError, isRefetching: tournamentsRefetching, refetch: refetchTournaments, } = usePublicTournamentsExplore(tournamentFilters) + const leagueFilters: PublicLeaguesListFilters = useMemo( + () => ({ + search: filters.search, + city: filters.city, + status: filters.status, + hideCelebrated: filters.hideCelebrated, + startAfter: filters.startAfter, + startBefore: filters.startBefore, + minFreeSlots: filters.minFreeSlots, + contentType: filters.contentType, + visibility: filters.visibility ?? 'all', + }), + [filters] + ) + + const { + data: leagues, + isLoading: leaguesLoading, + isError: leaguesIsError, + error: leaguesError, + isRefetching: leaguesRefetching, + refetch: refetchLeagues, + } = usePublicLeaguesExplore(leagueFilters) + const matchRows = useMemo(() => data?.pages.flatMap((p) => p.rows) ?? [], [data?.pages]) const exploreItems = useMemo((): ExploreItem[] => { @@ -232,16 +304,23 @@ export default function ExploreScreen() { start_at: row.start_at, row, })) - const merged = [...matchItems, ...tournamentItems].sort( + const leagueItems: ExploreItem[] = (leagues ?? []).map((row) => ({ + kind: 'league', + id: row.id, + start_at: row.start_at, + row, + })) + const merged = [...matchItems, ...tournamentItems, ...leagueItems].sort( (a, b) => new Date(a.start_at).getTime() - new Date(b.start_at).getTime() ) return filterExploreItemsForCelebrated(merged, filters.hideCelebrated) - }, [matchRows, tournaments, filters.hideCelebrated]) + }, [matchRows, tournaments, leagues, filters.hideCelebrated]) const refetchAll = useCallback(() => { void refetch() void refetchTournaments() - }, [refetch, refetchTournaments]) + void refetchLeagues() + }, [refetch, refetchTournaments, refetchLeagues]) const openFilterModal = useCallback(() => { setDraftCity(filters.city) @@ -323,7 +402,7 @@ export default function ExploreScreen() { const listHeader = ( - + ) - const showMatches = filters.contentType !== 'tournaments' - const showTournaments = filters.contentType !== 'matches' + const showMatches = filters.contentType !== 'tournaments' && filters.contentType !== 'leagues' + const showTournaments = filters.contentType !== 'matches' && filters.contentType !== 'leagues' + const showLeagues = filters.contentType !== 'matches' && filters.contentType !== 'tournaments' const listFooter = isFetchingNextPage ? ( @@ -368,17 +448,27 @@ export default function ExploreScreen() { if ( (showMatches && isLoading && !data) || - (showTournaments && tournamentsLoading && !tournaments) + (showTournaments && tournamentsLoading && !tournaments) || + (showLeagues && leaguesLoading && !leagues) ) { return ( - Cargando partidas y torneos… + Cargando partidas, torneos y ligas… ) } - if (isError) { + if ( + (showMatches && isError) || + (showTournaments && tournamentsIsError) || + (showLeagues && leaguesIsError) + ) { + const shownError = + (showMatches && isError ? error : null) ?? + (showTournaments && tournamentsIsError ? tournamentsError : null) ?? + (showLeagues && leaguesIsError ? leaguesError : null) + return ( No se pudo cargar el listado - {error instanceof Error ? error.message : 'Error desconocido'} + {shownError instanceof Error ? shownError.message : 'Error desconocido'} - - Si acabas de actualizar la app, aplica la migración Supabase `009_list_public_matches` en - tu proyecto. - -