diff --git a/src/App.test.tsx b/src/App.test.tsx index 2a68616..e91c8b4 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -2,8 +2,23 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import App from './App'; -test('renders learn react link', () => { +// Mock heavy sections to avoid side effects and async fetches during App render +jest.mock('./components', () => { + const React = require('react'); + return { + MinimalFooter: () => React.createElement('div'), + Navigation: () => React.createElement('div'), + HeroSection: () => React.createElement('div'), + CTASection: () => React.createElement('div'), + CardsSection: () => React.createElement('div'), + RiverSection: () => React.createElement('div'), + NextEventsSection: () => React.createElement('h2', null, 'Próximos eventos'), + PastEventsSection: () => React.createElement('div'), + }; +}); + +test('renders app shell with Upcoming events heading', () => { render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); + const heading = screen.getByText(/Próximos eventos/i); + expect(heading).toBeInTheDocument(); }); diff --git a/src/App.tsx b/src/App.tsx index eab98bc..16f512f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,25 +1,26 @@ import '@primer/react-brand/lib/css/main.css' import '@primer/react-brand/fonts/fonts.css' import { ThemeProvider } from '@primer/react-brand'; -import { MinimalFooter, Navigation, TimelineSection, HeroSection, CTASection, CardsSection, RiverSection } from './components'; +import { MinimalFooter, Navigation, PastEventsSection, HeroSection, CTASection, CardsSection, RiverSection, NextEventsSection } from './components'; const designTokenOverrides = ` + /* Map Primer brand tokens to Design System tokens */ .custom-colors[data-color-mode='dark'] { - /* - * Modify the value of these tokens. - * Remember to apply light mode equivalents if you're enabling theme switching. - */ - --brand-CTABanner-shadow-color-start: var(--base-color-scale-purple-5); - --brand-CTABanner-shadow-color-end: var(--base-color-scale-pink-5); + --brand-color-canvas-default: var(--ds-bg-default); + --brand-color-canvas-subtle: var(--ds-bg-subtle); + --brand-color-text-default: var(--ds-text-default); + --brand-color-text-muted: var(--ds-text-muted); + --brand-CTABanner-shadow-color-start: var(--ds-accent-purple); + --brand-CTABanner-shadow-color-end: #d946ef; } .custom-colors[data-color-mode='light'] { - /* - * Modify the value of these tokens. - * Remember to apply light mode equivalents if you're enabling theme switching. - */ - --brand-CTABanner-shadow-color-start: var(--base-color-scale-purple-5); - --brand-CTABanner-shadow-color-end: var(--base-color-scale-pink-5); + --brand-color-canvas-default: var(--ds-bg-default); + --brand-color-canvas-subtle: var(--ds-bg-subtle); + --brand-color-text-default: var(--ds-text-default); + --brand-color-text-muted: var(--ds-text-muted); + --brand-CTABanner-shadow-color-start: var(--ds-accent-purple); + --brand-CTABanner-shadow-color-end: #d946ef; } ` @@ -31,14 +32,17 @@ function App() { position: 'relative', width: '100%', minHeight: '100vh', - backgroundColor: 'var(--brand-color-canvas-default)', - color: 'var(--brand-color-text-default)' + backgroundColor: 'var(--ds-bg-default)', + color: 'var(--ds-text-default)' }}> + {/* Upcoming events highlight */} + - + {/* Past events list (rendered with Timeline subcomponent) */} + diff --git a/src/components/CardsSection.tsx b/src/components/CardsSection.tsx index 035f91e..a6e3b68 100644 --- a/src/components/CardsSection.tsx +++ b/src/components/CardsSection.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Section, Stack, Animate, SectionIntro } from '@primer/react-brand'; -import { Card } from './subcomponents/Card'; +import { Card, CardCopy } from './subcomponents/Card'; const CardsSection: React.FC = () => { return ( @@ -22,22 +22,22 @@ const CardsSection: React.FC = () => { > - + Altruista y generosa Nadie en la organización obtiene algún tipo de beneficio monetario de la misma. Queremos aportar valor al conjunto de la sociedad y al sector tecnológico con nuestros proyectos, nuestras ideas y nuestras experiencias. - + - + Abierta y participativa Cualquier persona que quiera colaborar y ayudar a dinamizar nuestra comunidad es bienvenida. Queremos expandir nuestra red por diferentes lugares y ciudades de España, para llegar a más personas y crear más oportunidades. - + - + Basada en Github GitHub nos permite gestionar nuestros proyectos, documentar nuestro trabajo y compartir nuestro código con otros desarrolladores. Reconocemos el valor de GitHub como herramienta, como filosofía y como un pilar fundamental para el desarrollo. - + diff --git a/src/components/NextEventsSection.tsx b/src/components/NextEventsSection.tsx new file mode 100644 index 0000000..e06196d --- /dev/null +++ b/src/components/NextEventsSection.tsx @@ -0,0 +1,71 @@ +import React, { useMemo } from 'react'; +import { Section, SectionIntro, Stack, AnimationProvider, Animate } from '@primer/react-brand' +import EventCard from './subcomponents/EventCard'; +import eventStyles from './css/EventSection.module.css' +import { filterAndSortEvents } from '../utils/eventFilters' +import { useEvents } from '../hooks/useEvents' + +const NextEventsSection: React.FC = () => { + const { events, loading, error } = useEvents(); + const { upcomingEvents } = useMemo(() => filterAndSortEvents(events), [events]); + + if (loading) { + return ( +
+ + Próximos eventos + + + Cargando eventos... + +
+ ); + } + + if (error) { + return ( +
+ + Próximos eventos + + + Error cargando eventos: {error} + +
+ ); + } + + if (upcomingEvents.length === 0) { + return ( +
+ + Próximos eventos + + + No hay eventos próximos ahora mismo. ¡Vuelve pronto! + +
+ ); + } + + return ( + +
+ + Próximos eventos + +
+ + {upcomingEvents.map((event) => ( + + + + ))} + +
+
+
+ ); +}; + +export default NextEventsSection; diff --git a/src/components/PastEventsSection.tsx b/src/components/PastEventsSection.tsx new file mode 100644 index 0000000..646dc3d --- /dev/null +++ b/src/components/PastEventsSection.tsx @@ -0,0 +1,113 @@ +import React, { useState, useEffect } from 'react'; +import { Timeline, Section, Stack, SectionIntro, AnimationProvider } from '@primer/react-brand' +import eventStyles from './css/EventSection.module.css' +import { EVENT_CONFIG } from '../utils/events.constants' +import { EventData, filterAndSortEvents } from '../utils/eventFilters' +import ListMessage from './subcomponents/ListMessage' +import PastEventsItem from './subcomponents/PastEventsItem' + +const PastEventsSection: React.FC = () => { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadEvents = async () => { + try { + // Usar la misma URL tanto en desarrollo como en producción + const jsonUrl = `${process.env.PUBLIC_URL}/data/issues.json`; + + console.log('Loading events from:', jsonUrl); // Debug log + + const response = await fetch(jsonUrl); + + if (!response.ok) { + throw new Error(`Error loading events: ${response.status}`); + } + + const eventsData: EventData[] = await response.json(); + + console.log('Loaded events:', eventsData.length); // Debug log + + // Don't sort here; we'll sort after filtering + setEvents(eventsData); + // Upcoming events are handled in EventsSection; image sizing measurement removed. + } catch (err) { + console.error('Error loading events:', err); + setError(err instanceof Error ? err.message : 'Error desconocido'); + } finally { + setLoading(false); + } + }; + + loadEvents(); + }, []); + + + if (loading) { + return ( +
+ + Eventos + + +
+ ); + } + + if (error) { + return ( +
+ + Eventos + + +
+ ); + } + + if (events.length === 0) { + return ( +
+ + Eventos + + +
+ ); + } + + return ( + +
+ + Eventos pasados + + + {(() => { + const { pastEvents } = filterAndSortEvents(events); + + if (pastEvents.length === 0) return null; + + return ( +
+

+ {EVENT_CONFIG.PAST_TITLE} +

+
+ + {pastEvents.map((event) => ( + + ))} + +
+
+ ); + })()} +
+
+
+ ); +}; + +export default PastEventsSection; diff --git a/src/components/TimelineSection.tsx b/src/components/TimelineSection.tsx deleted file mode 100644 index c4675f6..0000000 --- a/src/components/TimelineSection.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Timeline, Section, Link, Stack, SectionIntro, AnimationProvider, Animate } from '@primer/react-brand' - -interface EventData { - event_id: string; - event_name: string; - event_link: string; - event_date: string; -} - -const TimelineSection: React.FC = () => { - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const loadEvents = async () => { - try { - // Usar la misma URL tanto en desarrollo como en producción - const jsonUrl = `${process.env.PUBLIC_URL}/data/issues.json`; - - console.log('Loading events from:', jsonUrl); // Debug log - - const response = await fetch(jsonUrl); - - if (!response.ok) { - throw new Error(`Error loading events: ${response.status}`); - } - - const eventsData: EventData[] = await response.json(); - - console.log('Loaded events:', eventsData.length); // Debug log - - // Ordenar eventos por fecha (más recientes primero) - const sortedEvents = eventsData.sort((a, b) => { - const dateA = new Date(a.event_date); - const dateB = new Date(b.event_date); - return dateB.getTime() - dateA.getTime(); - }); - - setEvents(sortedEvents); - } catch (err) { - console.error('Error loading events:', err); - setError(err instanceof Error ? err.message : 'Error desconocido'); - } finally { - setLoading(false); - } - }; - - loadEvents(); - }, []); - - const formatDate = (dateString: string): string => { - try { - const date = new Date(dateString); - return date.toLocaleDateString('es-ES', { - day: '2-digit', - month: '2-digit', - year: 'numeric' - }); - } catch { - return dateString; // Fallback al string original si no se puede parsear - } - }; - - if (loading) { - return ( -
- - Últimos eventos - - - - - Cargando eventos... - - - -
- ); - } - - if (error) { - return ( -
- - Últimos eventos - - - - - Error cargando eventos: {error} - - - -
- ); - } - - if (events.length === 0) { - return ( -
- - Últimos eventos - - - - - No hay eventos disponibles - - - -
- ); - } - - return ( - -
- - Últimos eventos - - - - {events.slice(0, 5).map((event) => { - const isFuture = new Date(event.event_date) > new Date(); - return ( - - - {formatDate(event.event_date)}{' '} - - - - {isFuture ? 'Próximamente: ' : ''}{event.event_name} - - - - ); - })} - - -
-
- ); -}; - -export default TimelineSection; diff --git a/src/components/css/Card.module.css b/src/components/css/Card.module.css index 7420b2c..bc812cb 100644 --- a/src/components/css/Card.module.css +++ b/src/components/css/Card.module.css @@ -150,8 +150,17 @@ } .Card__heading { + /* NOTE (lines R284-R289): Heading typography was previously defined in multiple places + which caused a conflicting font-size (originally 1.125rem) to be overridden later + by the event-specific rule (1.05rem). Consolidated the heading font-size, + line-height and font-weight here so there's a single source of truth. If a + variant needs a different size, prefer adding a modifier class explicitly. + */ margin-bottom: var(--base-size-20); grid-area: heading; + font-size: 1.05rem; + line-height: 1.2; + font-weight: 600; } .Card--fullWidth:not(.Card--align-center) .Card__heading { @@ -180,7 +189,10 @@ .Card__action, .Card__action span { - color: var(--brand-Link-color-accent); + /* Don't force link accent color here; let the actual button component define its own text color + so it matches Hero.PrimaryAction styling (black text on light button). + */ + color: inherit; } .Card--skew { @@ -223,6 +235,74 @@ z-index: -1; } +/* Variant used for events to enforce uniform size and image behavior (consolidated) */ + +.Card--event { + width: 100%; + /* let inline styles from JS control visual width; keep centered */ + max-width: none; + min-height: 320px; + box-shadow: 0 8px 28px rgba(15, 23, 42, 0.10); + display: flex; + flex-direction: column; + justify-content: flex-start; + border-radius: 12px; + overflow: hidden; + background-clip: padding-box; + transition: transform 220ms var(--brand-Card-animation-easing), box-shadow 220ms var(--brand-Card-animation-easing); + margin: 0 auto 20px auto; + box-sizing: border-box; + border: 1px solid transparent; +} + +.Card--event:hover { + transform: translateY(-6px) scale(1.01); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.14); + border-color: rgba(99,102,241,0.06); +} + +.Card--event .Card__image { + /* Make the image span the full card width, let height follow aspect ratio */ + height: auto; + width: 100%; + margin: 0; + border-radius: 0; + overflow: hidden; + position: relative; + order: -1; +} + +.Card--event .Card__image img, +.Card--event .Card__image picture, +.Card--event .Card__image span { + width: 100%; + height: auto; + object-fit: contain; + display: block; +} + +.Card.Card--event { + display: flex; + flex-direction: column; + padding: 0; + align-items: stretch; +} + +.Card.Card--event .Card__action { + display: flex; + justify-content: center; + padding: var(--base-size-12) var(--base-size-20) var(--base-size-20) var(--base-size-20); +} + +.Card--event .Card__heading { + margin: var(--base-size-16) var(--base-size-20) 0 var(--base-size-20); +} + +.Card--event .Card__description { + margin: var(--base-size-8) var(--base-size-20) var(--base-size-20) var(--base-size-20); + color: var(--brand-color-text-muted); +} + .Card--expandableArrow { margin-inline-start: var(--base-size-4); } diff --git a/src/components/css/EventSection.module.css b/src/components/css/EventSection.module.css new file mode 100644 index 0000000..6d6c514 --- /dev/null +++ b/src/components/css/EventSection.module.css @@ -0,0 +1,48 @@ +/* EventSection.module.css */ +/* Centralized styles for the events section */ + +.sectionTitle { + text-align: center; + margin-bottom: 1rem; + font-weight: 600; + color: var(--brand-color-text-default, inherit); + font-size: 1.25rem; + letter-spacing: -0.01em; +} + +.upcomingContainer { + width: 100%; + /* Match section intro width by not constraining container; the card itself controls visual width */ + max-width: none; + margin: 0 auto; + box-sizing: border-box; +} + +.pastEventsContainer { + width: 100%; + max-width: 900px; + margin: 2rem auto 0 auto; + box-sizing: border-box; +} + +.timelineWrapper { + width: 100%; + margin: 0 auto; + padding-left: 0; + display: flex; + justify-content: center; + box-sizing: border-box; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .sectionTitle { + font-size: 1.1rem; + margin-bottom: 0.75rem; + } + + .upcomingContainer, + .pastEventsContainer { + padding: 0 1rem; + } +} diff --git a/src/components/index.ts b/src/components/index.ts index f97f274..fe1b2e9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,5 +3,6 @@ export { default as HeroSection } from './HeroSection'; export { default as CTASection } from './CTASection'; export { default as CardsSection } from './CardsSection'; export { default as RiverSection } from './RiverSection'; -export { default as TimelineSection } from './TimelineSection'; +export { default as PastEventsSection } from './PastEventsSection'; +export { default as NextEventsSection } from './NextEventsSection'; export { MinimalFooter } from './subcomponents/MinimalFooter'; diff --git a/src/components/subcomponents/Card.tsx b/src/components/subcomponents/Card.tsx index b3595e6..b9df8bc 100644 --- a/src/components/subcomponents/Card.tsx +++ b/src/components/subcomponents/Card.tsx @@ -2,6 +2,7 @@ import React, {RefObject, forwardRef} from 'react' import {isFragment} from 'react-is' import clsx from 'clsx' import {Heading, HeadingProps, Text, useTheme, CardSkewEffect, Image, type ImageProps, Label, LabelColors} from '@primer/react-brand/lib' +import { Hero } from '@primer/react-brand' import {Icon, type IconProps} from '@primer/react-brand/' import type {BaseProps} from '@primer/react-brand/lib/component-helpers' import {useProvidedRefOrCreate} from './useRef' @@ -17,7 +18,6 @@ import '@primer/brand-primitives/lib/design-tokens/css/tokens/functional/compone * Main stylesheet (as a CSS Module) */ import styles from '../css/Card.module.css' -import stylesLink from '../Link/Link.module.css' export const CardVariants = ['default', 'minimal', 'torchlight'] as const @@ -25,13 +25,13 @@ export const CardIconColors = Colors export const defaultCardIconColor = CardIconColors[0] -export type CardVariants = (typeof CardVariants)[number] +export type CardVariant = (typeof CardVariants)[number] export type CardProps = { /** * Specify alternative card appearance */ - variant?: CardVariants + variant?: CardVariant /** * Valid children include Card.Image, Card.Heading, and Card.Description */ @@ -54,6 +54,10 @@ export type CardProps = { * Changes the cta text of the card * */ ctaText?: string + /** + * Controls whether the CTA button is rendered. Event cards should keep it; copy cards should set this to false. + */ + showCTA?: boolean hasBorder?: boolean /** * Fills the width of the parent container and removes the default max-width. @@ -63,6 +67,14 @@ export type CardProps = { * Aligns the card content */ align?: 'start' | 'center' + /** + * Anchor target for the heading link and CTA link (e.g., '_blank'). + */ + target?: React.HTMLAttributeAnchorTarget + /** + * Anchor rel for the heading link and CTA link (e.g., 'noopener noreferrer'). + */ + rel?: string } & Omit, 'animate'> & Omit, 'onMouseEnter' | 'onMouseLeave' | 'onFocus' | 'onBlur'> & Pick, 'onMouseEnter' | 'onMouseLeave' | 'onFocus' | 'onBlur'> @@ -81,9 +93,12 @@ const CardRoot = forwardRef( disableAnimation = false, fullWidth = false, href, + target, + rel, hasBorder = false, style, variant = 'default', + showCTA = true, ...props }, ref, @@ -105,6 +120,8 @@ const CardRoot = forwardRef( if (isCardHeading(child)) { acc.cardHeading = React.cloneElement(child, { href, + target, + rel, }) } else if (isCardImage(child)) { acc.cardImage = child @@ -137,7 +154,6 @@ const CardRoot = forwardRef( className={clsx( styles.Card, disableAnimation && styles['Card--disableAnimation'], - styles[`Card--colorMode-${colorMode}`], styles[`Card--variant-${variant}`], cardIcon && styles['Card--icon'], showBorder && styles['Card--border'], @@ -154,8 +170,17 @@ const CardRoot = forwardRef( {cardLabel} {cardDescription} -
-
+
+ {showCTA && ctaText && href && ( + // Use the same component as HeroSection to perfectly match styling + + {ctaText} + + )} +
) @@ -210,7 +235,7 @@ type CardHeadingProps = BaseProps & { React.ComponentPropsWithoutRef<'a'> const CardHeading = forwardRef( - ({children, as = 'h3', className, href, onMouseEnter, onMouseLeave, onBlur, onFocus, ...rest}, ref) => { + ({children, as = 'h3', className, href, target, rel, onMouseEnter, onMouseLeave, onBlur, onFocus, ...rest}, ref) => { return ( ( onMouseLeave={onMouseLeave} onBlur={onBlur} onFocus={onFocus} + target={target} + rel={rel} > {children} @@ -263,4 +290,12 @@ export const Card = Object.assign(CardRoot, { Icon: CardIcon, Heading: CardHeading, Description: CardDescription, -}) \ No newline at end of file +}) + +// Convenience components: Event cards keep the CTA, Copy cards hide it. +export const CardEvent = (props: CardProps) => +export const CardCopy = (props: CardProps) => + +// Also keep runtime aliases on the Card object for backwards compatibility +;(Card as any).Event = CardEvent +;(Card as any).Copy = CardCopy \ No newline at end of file diff --git a/src/components/subcomponents/Constants.tsx b/src/components/subcomponents/Constants.tsx index 74f238d..fdeafd7 100644 --- a/src/components/subcomponents/Constants.tsx +++ b/src/components/subcomponents/Constants.tsx @@ -20,4 +20,4 @@ export const TriColorGradients = ['green-blue-purple'] as const // TODO: consider generating the scale from style dictionary and serve from the brand-primitives package export const BaseSizeScale = [4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 64, 80, 96, 112, 128] as const -type BaseSizeScale = (typeof BaseSizeScale)[number] \ No newline at end of file +export type BaseSize = (typeof BaseSizeScale)[number] \ No newline at end of file diff --git a/src/components/subcomponents/EventCard.tsx b/src/components/subcomponents/EventCard.tsx new file mode 100644 index 0000000..1c1b2a7 --- /dev/null +++ b/src/components/subcomponents/EventCard.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { Card } from './Card' +import type { EventData } from '../../utils/eventFilters' +import { EVENT_CONFIG } from '../../utils/events.constants' +import cardStyles from '../css/Card.module.css' +import { formatDateEs } from '../../utils/date' + +export type EventCardProps = { + event: EventData + /** + * When true, links open in a new tab (adds target="_blank" and rel="noopener noreferrer"). + * Defaults to true to match Timeline behavior. + */ + openInNewTab?: boolean +} + +/** + * Presentation component for a single event card, + * using the shared Card and the same CTA style as HeroSection. + */ +export const EventCard: React.FC = ({ event, openInNewTab = true }) => { + const target = openInNewTab ? '_blank' : undefined + const rel = openInNewTab ? 'noopener noreferrer' : undefined + return ( + + + {event.event_name} + {formatDateEs(event.event_date)} + + ) +} + +export default EventCard diff --git a/src/components/subcomponents/ListMessage.tsx b/src/components/subcomponents/ListMessage.tsx new file mode 100644 index 0000000..1c19ff0 --- /dev/null +++ b/src/components/subcomponents/ListMessage.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { Timeline, Stack } from '@primer/react-brand' + +export type ListMessageProps = { + message: string + fullWidth?: boolean +} + +/** + * Generic message renderer for list-like sections. + * Internally uses the Timeline subcomponent for consistent styling. + */ +const ListMessage: React.FC = ({ message, fullWidth = true }) => { + return ( + + + {message} + + + ) +} + +export default ListMessage diff --git a/src/components/subcomponents/MinimalFooter.tsx b/src/components/subcomponents/MinimalFooter.tsx index fe0bbde..9800f62 100644 --- a/src/components/subcomponents/MinimalFooter.tsx +++ b/src/components/subcomponents/MinimalFooter.tsx @@ -81,7 +81,7 @@ const socialLinkData = { } as const type SocialLinkName = keyof typeof socialLinkData -type SocialLink = (typeof socialLinkData)[SocialLinkName] +// type SocialLinkInfo = (typeof socialLinkData)[SocialLinkName] (not used) const socialLinkNames = Object.keys(socialLinkData) as SocialLinkName[] @@ -112,13 +112,8 @@ function Root({ // find Footer.Footnotes children const footerFootnoteChild = () => { const footnotes = React.Children.toArray(children).find(child => { - if (!React.isValidElement(child)) { - return false - } - - if (child.type && child.type === Footnotes) { - return true - } + if (!React.isValidElement(child)) return false + return child.type === Footnotes }) return footnotes } @@ -128,14 +123,7 @@ function Root({ * If more than 5 links are required, we should encourage usage of Footer instead. */ const LinkChildren = React.Children.toArray(children) - .filter(child => { - // if not valid element - if (React.isValidElement(child)) { - if (child.type === Link) { - return child - } - } - }) + .filter(child => React.isValidElement(child) && child.type === Link) .slice(0, 5) const currentYear = new Date().getFullYear() diff --git a/src/components/subcomponents/PastEventsItem.tsx b/src/components/subcomponents/PastEventsItem.tsx new file mode 100644 index 0000000..ed0fef7 --- /dev/null +++ b/src/components/subcomponents/PastEventsItem.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { Timeline, Link, Animate } from '@primer/react-brand' +import type { EventData } from '../../utils/eventFilters' +import { formatDateEs } from '../../utils/date' + +export type PastEventsItemProps = { + event: EventData + /** Open link in a new tab. Defaults true to match public site behavior. */ + openInNewTab?: boolean +} + +/** + * Small timeline item for past events to unify date + link rendering. + */ +const PastEventsItem: React.FC = ({ event, openInNewTab = true }) => { + const target = openInNewTab ? '_blank' : undefined + const rel = openInNewTab ? 'noopener noreferrer' : undefined + + return ( + + {formatDateEs(event.event_date)} + + + {event.event_name} + + + + ) +} + +export default PastEventsItem diff --git a/src/hooks/useEvents.ts b/src/hooks/useEvents.ts new file mode 100644 index 0000000..fc889a7 --- /dev/null +++ b/src/hooks/useEvents.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import type { EventData } from '../utils/eventFilters'; + +/** + * Fetch events from public/data/issues.json with loading & error state. + * Uses PUBLIC_URL so it works in dev and on GitHub Pages. + */ +export function useEvents() { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const jsonUrl = `${process.env.PUBLIC_URL}/data/issues.json`; + const res = await fetch(jsonUrl); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: EventData[] = await res.json(); + if (!cancelled) setEvents(Array.isArray(data) ? data : []); + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : 'Unknown error'); + } finally { + if (!cancelled) setLoading(false); + } + }; + load(); + return () => { + cancelled = true; + }; + }, []); + + return { events, loading, error }; +} + +export default useEvents; diff --git a/src/index.css b/src/index.css index 30f1230..2dcab2b 100644 --- a/src/index.css +++ b/src/index.css @@ -1,16 +1,30 @@ +/* Design System Imports */ +@import './styles/design-tokens.css'; +@import './styles/typography.css'; +@import './styles/buttons.css'; +@import './styles/cards.css'; +@import './styles/animations.css'; +@import './styles/responsive.css'; + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + font-family: var(--font-family-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background-color: var(--brand-color-canvas-default); - color: var(--brand-color-text-default); - transition: background-color 0.2s ease, color 0.2s ease; + background-color: var(--ds-bg-default); + color: var(--ds-text-default); + transition: background-color var(--duration-normal) ease, + color var(--duration-normal) ease; } code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; + font-family: var(--font-family-mono); } diff --git a/src/setupTests.ts b/src/setupTests.ts index 8f2609b..94fac71 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -3,3 +3,18 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom'; + +// Polyfill matchMedia for @primer/react-brand ThemeProvider and components +if (!window.matchMedia) { + // @ts-ignore + window.matchMedia = (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, // deprecated + removeListener: () => {}, // deprecated + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }); +} diff --git a/src/styles/animations.css b/src/styles/animations.css new file mode 100644 index 0000000..7684c60 --- /dev/null +++ b/src/styles/animations.css @@ -0,0 +1,233 @@ +/* Animations & Transitions */ + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideInUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes slideInDown { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes slideInLeft { + from { + transform: translateX(-20px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideInRight { + from { + transform: translateX(20px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes scaleIn { + from { + transform: scale(0.95); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +@keyframes shimmer { + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } +} + +@keyframes gradient-shift { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} + +/* Animation Utility Classes */ + +.animate-fade-in { + animation: fadeIn var(--duration-normal) var(--ease-out) forwards; +} + +.animate-slide-in-up { + animation: slideInUp var(--duration-normal) var(--ease-out) forwards; +} + +.animate-slide-in-down { + animation: slideInDown var(--duration-normal) var(--ease-out) forwards; +} + +.animate-slide-in-left { + animation: slideInLeft var(--duration-slow) var(--ease-out) forwards; +} + +.animate-slide-in-right { + animation: slideInRight var(--duration-slow) var(--ease-out) forwards; +} + +.animate-scale-in { + animation: scaleIn var(--duration-normal) var(--ease-out) forwards; +} + +.animate-pulse { + animation: pulse var(--duration-slower) infinite; +} + +/* Stagger Animation Helper */ + +.animate-stagger > * { + animation: slideInUp var(--duration-slow) var(--ease-out) backwards; +} + +.animate-stagger > *:nth-child(1) { + animation-delay: 0ms; +} + +.animate-stagger > *:nth-child(2) { + animation-delay: 100ms; +} + +.animate-stagger > *:nth-child(3) { + animation-delay: 200ms; +} + +.animate-stagger > *:nth-child(4) { + animation-delay: 300ms; +} + +.animate-stagger > *:nth-child(5) { + animation-delay: 400ms; +} + +.animate-stagger > *:nth-child(n + 6) { + animation-delay: 500ms; +} + +/* Loading Skeleton */ + +.skeleton { + background: linear-gradient( + 90deg, + var(--ds-bg-muted) 0%, + var(--ds-bg-hover) 50%, + var(--ds-bg-muted) 100% + ); + background-size: 1000px 100%; + animation: shimmer 2s infinite; + border-radius: var(--radius-md); +} + +/* Smooth Transitions */ + +.transition-smooth { + transition: var(--transition-normal); +} + +.transition-fast { + transition: var(--transition-fast); +} + +.transition-slow { + transition: var(--transition-slow); +} + +/* Hover Effects */ + +.hover-lift { + transition: var(--transition-fast); +} + +.hover-lift:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.hover-glow { + transition: var(--transition-fast); +} + +[data-color-mode='dark'] .hover-glow:hover { + box-shadow: 0 0 20px rgba(88, 166, 255, 0.3); +} + +[data-color-mode='light'] .hover-glow:hover { + box-shadow: 0 0 20px rgba(9, 105, 218, 0.2); +} + +.hover-opacity { + transition: var(--transition-fast); +} + +.hover-opacity:hover { + opacity: 0.8; +} + +/* Focus Visible */ + +.focus-ring:focus-visible { + outline: 2px solid var(--ds-accent-primary); + outline-offset: 2px; + border-radius: var(--radius-md); +} + +/* Disable Animations for Reduced Motion */ + +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/styles/buttons.css b/src/styles/buttons.css new file mode 100644 index 0000000..31f7994 --- /dev/null +++ b/src/styles/buttons.css @@ -0,0 +1,158 @@ +/* Button Styles */ + +.btn, +button { + font-family: var(--font-family-sans); + font-size: var(--font-size-body); + font-weight: var(--font-weight-semibold); + padding: var(--space-md) var(--space-lg); + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition-fast); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + outline: none; + text-decoration: none; + user-select: none; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Primary Button */ +.btn-primary, +button.primary { + background-color: var(--ds-accent-primary); + color: white; + box-shadow: var(--shadow-xs); +} + +.btn-primary:hover:not(:disabled), +button.primary:hover:not(:disabled) { + background-color: color-mix(in srgb, var(--ds-accent-primary) 90%, black); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); +} + +.btn-primary:focus, +button.primary:focus { + box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.2); +} + +.btn-primary:active:not(:disabled), +button.primary:active:not(:disabled) { + transform: translateY(0); + box-shadow: var(--shadow-xs); +} + +/* Secondary Button */ +.btn-secondary, +button.secondary { + background-color: var(--ds-bg-subtle); + color: var(--ds-accent-primary); + border: 1px solid var(--ds-border-default); +} + +.btn-secondary:hover:not(:disabled), +button.secondary:hover:not(:disabled) { + background-color: var(--ds-bg-muted); + border-color: var(--ds-accent-primary); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); +} + +.btn-secondary:focus, +button.secondary:focus { + box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.15); + border-color: var(--ds-accent-primary); +} + +/* Tertiary Button (Minimal) */ +.btn-tertiary, +button.tertiary { + background-color: transparent; + color: var(--ds-accent-primary); + border: none; +} + +.btn-tertiary:hover:not(:disabled), +button.tertiary:hover:not(:disabled) { + background-color: var(--ds-bg-hover); + box-shadow: none; +} + +.btn-tertiary:focus, +button.tertiary:focus { + background-color: var(--ds-bg-subtle); + outline: 2px solid var(--ds-accent-primary); + outline-offset: 2px; +} + +/* Small Button */ +.btn-sm { + padding: var(--space-sm) var(--space-md); + font-size: var(--font-size-sm); +} + +/* Large Button */ +.btn-lg { + padding: var(--space-lg) var(--space-2xl); + font-size: var(--font-size-body-lg); +} + +/* Full Width */ +.btn-block { + width: 100%; +} + +/* Icon Button */ +.btn-icon { + padding: var(--space-md); + border-radius: var(--radius-md); + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; +} + +/* Danger Button */ +.btn-danger { + background-color: var(--ds-accent-red); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background-color: color-mix(in srgb, var(--ds-accent-red) 90%, black); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); +} + +/* Success Button */ +.btn-success { + background-color: var(--ds-accent-green); + color: white; +} + +.btn-success:hover:not(:disabled) { + background-color: color-mix(in srgb, var(--ds-accent-green) 90%, black); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); +} + +/* Link Button */ +.btn-link { + background-color: transparent; + color: var(--ds-accent-primary); + padding: 0; + text-decoration: underline; +} + +.btn-link:hover:not(:disabled) { + opacity: 0.8; +} diff --git a/src/styles/cards.css b/src/styles/cards.css new file mode 100644 index 0000000..71cfe30 --- /dev/null +++ b/src/styles/cards.css @@ -0,0 +1,159 @@ +/* Card Styles */ + +.card { + background-color: var(--ds-bg-subtle); + border: 1px solid var(--ds-border-default); + border-radius: var(--radius-lg); + padding: var(--space-lg); + transition: var(--transition-normal); + box-shadow: var(--shadow-xs); +} + +.card:hover { + border-color: var(--ds-accent-primary); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.card:focus { + outline: 2px solid var(--ds-accent-primary); + outline-offset: 2px; +} + +/* Card Variants */ + +.card-flat { + border: 1px solid var(--ds-border-default); + background-color: var(--ds-bg-subtle); + box-shadow: none; +} + +.card-elevated { + border: none; + box-shadow: var(--shadow-md); +} + +.card-elevated:hover { + box-shadow: var(--shadow-lg); +} + +/* Card Sections */ + +.card-header { + padding-bottom: var(--space-md); + border-bottom: 1px solid var(--ds-border-default); + margin-bottom: var(--space-md); +} + +.card-header h3 { + margin: 0; + font-size: var(--font-size-h4); + color: var(--ds-text-default); +} + +.card-body { + padding: var(--space-lg) 0; +} + +.card-body p { + margin-bottom: var(--space-md); + color: var(--ds-text-muted); +} + +.card-body p:last-child { + margin-bottom: 0; +} + +.card-footer { + padding-top: var(--space-md); + border-top: 1px solid var(--ds-border-default); + margin-top: var(--space-md); + display: flex; + gap: var(--space-md); + align-items: center; + justify-content: flex-end; +} + +/* Card with Image */ + +.card-image { + position: relative; + overflow: hidden; + border-radius: var(--radius-lg); + margin-bottom: var(--space-lg); +} + +.card-image img { + width: 100%; + height: auto; + display: block; + transition: var(--transition-normal); +} + +.card:hover .card-image img { + transform: scale(1.05); +} + +/* Card Grid */ + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--space-lg); + margin: var(--space-2xl) 0; +} + +@media (max-width: 768px) { + .card-grid { + grid-template-columns: 1fr; + gap: var(--space-md); + } +} + +/* Card Badge */ + +.card-badge { + display: inline-block; + padding: var(--space-xs) var(--space-md); + background-color: var(--ds-accent-primary); + color: white; + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + margin-bottom: var(--space-md); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.card-badge.secondary { + background-color: var(--ds-bg-muted); + color: var(--ds-accent-primary); + border: 1px solid var(--ds-border-default); +} + +.card-badge.success { + background-color: var(--ds-accent-green); +} + +.card-badge.warning { + background-color: var(--ds-accent-yellow); + color: black; +} + +.card-badge.danger { + background-color: var(--ds-accent-red); +} + +/* Card Compact */ + +.card-compact { + padding: var(--space-md); + border-radius: var(--radius-md); +} + +/* Card Large */ + +.card-lg { + padding: var(--space-2xl); + border-radius: var(--radius-xl); +} diff --git a/src/styles/design-tokens.css b/src/styles/design-tokens.css new file mode 100644 index 0000000..6653986 --- /dev/null +++ b/src/styles/design-tokens.css @@ -0,0 +1,125 @@ +/* Design Tokens - GitHub Community Spain + * Inspired by GitHub Copilot CLI design aesthetic + */ + +:root, +[data-color-mode='auto'], +[data-color-mode='light'] { + /* Light Mode Colors */ + --ds-bg-default: #ffffff; + --ds-bg-subtle: #f6f8fa; + --ds-bg-muted: #eaeef2; + --ds-bg-hover: #f3f5f7; + --ds-bg-active: #e8ebf0; + + --ds-text-default: #24292f; + --ds-text-muted: #656d76; + --ds-text-subtle: #8c959f; + --ds-text-disabled: #d0d7de; + + /* Accent Colors - Light */ + --ds-accent-primary: #0969da; + --ds-accent-purple: #8957e5; + --ds-accent-green: #1a7f37; + --ds-accent-red: #d1242f; + --ds-accent-yellow: #9e6a03; + + /* Borders */ + --ds-border-default: #d0d7de; + --ds-border-muted: #e5e7eb; + --ds-border-subtle: #f0f0f0; +} + +[data-color-mode='dark'] { + /* Dark Mode Colors */ + --ds-bg-default: #0d1117; + --ds-bg-subtle: #161b22; + --ds-bg-muted: #21262d; + --ds-bg-hover: #30363d; + --ds-bg-active: #3d444d; + + --ds-text-default: #e6edf3; + --ds-text-muted: #8b949e; + --ds-text-subtle: #6e7681; + --ds-text-disabled: #444c56; + + /* Accent Colors - Dark */ + --ds-accent-primary: #58a6ff; + --ds-accent-purple: #d2a8ff; + --ds-accent-green: #3fb950; + --ds-accent-red: #f85149; + --ds-accent-yellow: #d29922; + + /* Borders */ + --ds-border-default: #30363d; + --ds-border-muted: #21262d; + --ds-border-subtle: #1c2128; +} + +/* Typography */ +--font-family-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', + 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', + 'Helvetica Neue', sans-serif; +--font-family-mono: 'Cascadia Code', 'Courier New', monospace; + +--font-size-h1: 2.5rem; +--font-size-h2: 2rem; +--font-size-h3: 1.5rem; +--font-size-h4: 1.25rem; +--font-size-body-lg: 1.125rem; +--font-size-body: 1rem; +--font-size-sm: 0.875rem; +--font-size-xs: 0.75rem; + +--font-weight-regular: 400; +--font-weight-semibold: 600; +--font-weight-bold: 700; + +--line-height-condensed: 1.2; +--line-height-default: 1.5; +--line-height-relaxed: 1.6; +--line-height-loose: 1.8; + +/* Spacing System (8px base) */ +--space-xs: 0.25rem; +--space-sm: 0.5rem; +--space-md: 1rem; +--space-lg: 1.5rem; +--space-xl: 2rem; +--space-2xl: 2.5rem; +--space-3xl: 3rem; +--space-4xl: 4rem; + +/* Border Radius */ +--radius-sm: 0.25rem; +--radius-md: 0.5rem; +--radius-lg: 0.75rem; +--radius-xl: 1rem; +--radius-full: 9999px; + +/* Shadows */ +--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.06); +--shadow-sm: 0 3px 6px rgba(0, 0, 0, 0.12); +--shadow-md: 0 6px 12px rgba(0, 0, 0, 0.16); +--shadow-lg: 0 12px 24px rgba(0, 0, 0, 0.20); +--shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.24); + +/* Animations */ +--ease-in: cubic-bezier(0.4, 0, 1, 1); +--ease-out: cubic-bezier(0, 0, 0.2, 1); +--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + +--duration-fast: 100ms; +--duration-normal: 200ms; +--duration-slow: 300ms; +--duration-slower: 500ms; + +/* Transitions */ +--transition-fast: all var(--duration-fast) var(--ease-out); +--transition-normal: all var(--duration-normal) var(--ease-in-out); +--transition-slow: all var(--duration-slow) var(--ease-in-out); + +/* Layout */ +--max-width-container: 1280px; +--max-width-lg: 1024px; +--max-width-md: 768px; diff --git a/src/styles/responsive.css b/src/styles/responsive.css new file mode 100644 index 0000000..41c6b2b --- /dev/null +++ b/src/styles/responsive.css @@ -0,0 +1,371 @@ +/* Responsive & Layout Styles */ + +/* Container */ + +.container { + width: 100%; + max-width: var(--max-width-container); + margin: 0 auto; + padding: 0 var(--space-xl); +} + +.container-lg { + max-width: var(--max-width-lg); +} + +.container-md { + max-width: var(--max-width-md); +} + +@media (max-width: 768px) { + .container { + padding: 0 var(--space-lg); + } +} + +@media (max-width: 480px) { + .container { + padding: 0 var(--space-md); + } +} + +/* Section Spacing */ + +section { + padding: var(--space-4xl) 0; +} + +section.section-tight { + padding: var(--space-2xl) 0; +} + +section.section-loose { + padding: var(--space-4xl) 0; +} + +@media (max-width: 768px) { + section { + padding: var(--space-2xl) 0; + } + + section.section-loose { + padding: var(--space-3xl) 0; + } +} + +@media (max-width: 480px) { + section { + padding: var(--space-xl) 0; + } +} + +/* Grid System */ + +.grid { + display: grid; + gap: var(--space-lg); +} + +.grid-cols-2 { + grid-template-columns: repeat(2, 1fr); +} + +.grid-cols-3 { + grid-template-columns: repeat(3, 1fr); +} + +.grid-cols-4 { + grid-template-columns: repeat(4, 1fr); +} + +@media (max-width: 1024px) { + .grid-cols-4 { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .grid-cols-2, + .grid-cols-3, + .grid-cols-4 { + grid-template-columns: 1fr; + } +} + +/* Flexbox Utilities */ + +.flex { + display: flex; +} + +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +.flex-between { + display: flex; + align-items: center; + justify-content: space-between; +} + +.flex-column { + flex-direction: column; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.gap-sm { + gap: var(--space-sm); +} + +.gap-md { + gap: var(--space-md); +} + +.gap-lg { + gap: var(--space-lg); +} + +.gap-xl { + gap: var(--space-xl); +} + +/* Alignment */ + +.text-center { + text-align: center; +} + +.text-left { + text-align: left; +} + +.text-right { + text-align: right; +} + +/* Margin Utilities */ + +.m-0 { + margin: 0; +} + +.mx-auto { + margin-left: auto; + margin-right: auto; +} + +.mt-xs { + margin-top: var(--space-xs); +} + +.mt-sm { + margin-top: var(--space-sm); +} + +.mt-md { + margin-top: var(--space-md); +} + +.mt-lg { + margin-top: var(--space-lg); +} + +.mt-xl { + margin-top: var(--space-xl); +} + +.mt-2xl { + margin-top: var(--space-2xl); +} + +.mb-xs { + margin-bottom: var(--space-xs); +} + +.mb-sm { + margin-bottom: var(--space-sm); +} + +.mb-md { + margin-bottom: var(--space-md); +} + +.mb-lg { + margin-bottom: var(--space-lg); +} + +.mb-xl { + margin-bottom: var(--space-xl); +} + +.mb-2xl { + margin-bottom: var(--space-2xl); +} + +/* Padding Utilities */ + +.p-sm { + padding: var(--space-sm); +} + +.p-md { + padding: var(--space-md); +} + +.p-lg { + padding: var(--space-lg); +} + +.p-xl { + padding: var(--space-xl); +} + +.px-md { + padding: 0 var(--space-md); +} + +.px-lg { + padding: 0 var(--space-lg); +} + +.py-md { + padding: var(--space-md) 0; +} + +.py-lg { + padding: var(--space-lg) 0; +} + +/* Display Utilities */ + +.hidden { + display: none; +} + +.visible { + display: block; +} + +.inline-block { + display: inline-block; +} + +.inline { + display: inline; +} + +/* Visibility on Different Screens */ + +@media (max-width: 768px) { + .hide-mobile { + display: none !important; + } + + .show-mobile { + display: block !important; + } +} + +@media (min-width: 769px) { + .hide-desktop { + display: none !important; + } + + .show-desktop { + display: block !important; + } +} + +/* Overflow Handling */ + +.overflow-hidden { + overflow: hidden; +} + +.overflow-auto { + overflow: auto; +} + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Aspect Ratio */ + +.aspect-square { + aspect-ratio: 1 / 1; +} + +.aspect-video { + aspect-ratio: 16 / 9; +} + +.aspect-thumbnail { + aspect-ratio: 4 / 3; +} + +/* Width & Height */ + +.w-full { + width: 100%; +} + +.h-full { + height: 100%; +} + +.min-h-screen { + min-height: 100vh; +} + +.min-h-full { + min-height: 100%; +} + +/* Z-Index Scale */ + +.z-0 { + z-index: 0; +} + +.z-10 { + z-index: 10; +} + +.z-20 { + z-index: 20; +} + +.z-50 { + z-index: 50; +} + +.z-100 { + z-index: 100; +} + +/* Position */ + +.relative { + position: relative; +} + +.absolute { + position: absolute; +} + +.fixed { + position: fixed; +} + +.sticky { + position: sticky; + top: 0; +} diff --git a/src/styles/typography.css b/src/styles/typography.css new file mode 100644 index 0000000..2301ef5 --- /dev/null +++ b/src/styles/typography.css @@ -0,0 +1,145 @@ +/* Typography Styles */ + +h1 { + font-size: var(--font-size-h1); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-condensed); + color: var(--ds-text-default); + margin: var(--space-2xl) 0 var(--space-lg); + font-family: var(--font-family-sans); +} + +h2 { + font-size: var(--font-size-h2); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-condensed); + color: var(--ds-text-default); + margin: var(--space-2xl) 0 var(--space-md); + font-family: var(--font-family-sans); +} + +h3 { + font-size: var(--font-size-h3); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-condensed); + color: var(--ds-text-default); + margin: var(--space-lg) 0 var(--space-md); + font-family: var(--font-family-sans); +} + +h4 { + font-size: var(--font-size-h4); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-default); + color: var(--ds-text-default); + margin: var(--space-lg) 0 var(--space-sm); + font-family: var(--font-family-sans); +} + +p { + font-size: var(--font-size-body); + line-height: var(--line-height-relaxed); + color: var(--ds-text-muted); + margin: 0 0 var(--space-md); + font-family: var(--font-family-sans); +} + +p.large { + font-size: var(--font-size-body-lg); + color: var(--ds-text-default); +} + +p.small { + font-size: var(--font-size-sm); + color: var(--ds-text-subtle); +} + +/* Links */ +a { + color: var(--ds-accent-primary); + text-decoration: none; + font-weight: var(--font-weight-semibold); + transition: var(--transition-fast); +} + +a:hover { + text-decoration: underline; + opacity: 0.8; +} + +a:focus { + outline: 2px solid var(--ds-accent-primary); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +/* Code */ +code { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + background-color: var(--ds-bg-muted); + color: var(--ds-accent-primary); + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-sm); +} + +pre { + background-color: var(--ds-bg-muted); + border: 1px solid var(--ds-border-default); + border-radius: var(--radius-lg); + padding: var(--space-lg); + overflow-x: auto; + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + line-height: var(--line-height-default); +} + +pre code { + background-color: transparent; + color: var(--ds-text-default); + padding: 0; +} + +/* Lists */ +ul, +ol { + font-size: var(--font-size-body); + line-height: var(--line-height-relaxed); + color: var(--ds-text-muted); + margin: var(--space-md) 0; + padding-left: var(--space-2xl); +} + +li { + margin-bottom: var(--space-sm); +} + +/* Blockquote */ +blockquote { + border-left: 4px solid var(--ds-accent-primary); + padding: var(--space-md) var(--space-lg); + margin: var(--space-lg) 0; + background-color: var(--ds-bg-subtle); + border-radius: var(--radius-md); + font-style: italic; + color: var(--ds-text-muted); +} + +/* Strong & Emphasis */ +strong, +b { + font-weight: var(--font-weight-bold); + color: var(--ds-text-default); +} + +em, +i { + font-style: italic; +} + +/* Horizontal Rule */ +hr { + border: none; + border-top: 1px solid var(--ds-border-default); + margin: var(--space-3xl) 0; +} diff --git a/src/utils/__tests__/date.test.ts b/src/utils/__tests__/date.test.ts new file mode 100644 index 0000000..edeba08 --- /dev/null +++ b/src/utils/__tests__/date.test.ts @@ -0,0 +1,15 @@ +import { formatDateEs } from '../date' + +describe('formatDateEs', () => { + it('formats a valid date as dd/mm/yyyy in es-ES', () => { + const formatted = formatDateEs('2025-10-05') + // Spanish locale uses dd/mm/yyyy + expect(formatted).toBe('05/10/2025') + }) + + it('returns the original string for invalid dates', () => { + const input = 'not-a-date' + const formatted = formatDateEs(input) + expect(formatted).toBe(input) + }) +}) diff --git a/src/utils/__tests__/eventFilters.test.ts b/src/utils/__tests__/eventFilters.test.ts new file mode 100644 index 0000000..614f9b9 --- /dev/null +++ b/src/utils/__tests__/eventFilters.test.ts @@ -0,0 +1,32 @@ +import { filterAndSortEvents, type EventData } from '../eventFilters' + +describe('filterAndSortEvents', () => { + const fixedNow = new Date('2025-10-28T12:00:00Z') + + beforeAll(() => { + jest.useFakeTimers() + // Use system time to make new Date() deterministic + // Note: Date parsing without time is local; using UTC time avoids DST issues here + jest.setSystemTime(fixedNow) + }) + + afterAll(() => { + jest.useRealTimers() + }) + + it('splits into upcoming and past and sorts correctly', () => { + const events: EventData[] = [ + { event_id: '1', event_name: 'Past Early', event_link: '#1', event_date: '2025-10-01' }, + { event_id: '2', event_name: 'Today Midnight', event_link: '#2', event_date: '2025-10-28' }, + { event_id: '3', event_name: 'Tomorrow', event_link: '#3', event_date: '2025-10-29' }, + ] + + const { upcomingEvents, pastEvents } = filterAndSortEvents(events) + + // upcoming: strictly greater than now -> only 2025-10-29 + expect(upcomingEvents.map(e => e.event_id)).toEqual(['3']) + + // past: <= now, sorted descending -> today, then early month + expect(pastEvents.map(e => e.event_id)).toEqual(['2', '1']) + }) +}) diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000..7742b56 --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,16 @@ +// Shared date formatting helpers for the site +// Keep minimal and reusable to avoid duplicating logic across sections + +export function formatDateEs(dateString: string): string { + try { + const date = new Date(dateString); + if (isNaN(date.getTime())) return dateString; + return date.toLocaleDateString('es-ES', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + } catch { + return dateString; + } +} diff --git a/src/utils/eventFilters.ts b/src/utils/eventFilters.ts new file mode 100644 index 0000000..d93ed8e --- /dev/null +++ b/src/utils/eventFilters.ts @@ -0,0 +1,66 @@ +import { EVENT_CONFIG } from './events.constants'; + +/** + * Interface for event data structure + */ +export interface EventData { + event_id: string; + event_name: string; + event_link: string; + event_date: string; + event_image?: string; +} + +/** + * Interface for grouped events + */ +export interface EventsGrouped { + upcomingEvents: EventData[]; + pastEvents: EventData[]; +} + +/** + * Filters and sorts events into two groups: upcoming and past + * @param events - Array of events to filter + * @returns Object with upcoming and past events, each sorted appropriately + */ +export const filterAndSortEvents = (events: EventData[]): EventsGrouped => { + const now = new Date(); + + const upcomingEvents = events + .filter(event => new Date(event.event_date) > now) + .sort((a, b) => + new Date(a.event_date).getTime() - new Date(b.event_date).getTime() + ); + + const pastEvents = events + .filter(event => new Date(event.event_date) <= now) + .sort((a, b) => + new Date(b.event_date).getTime() - new Date(a.event_date).getTime() + ); + + return { upcomingEvents, pastEvents }; +}; + +/** + * Calculates responsive card width based on viewport and card width + * @param cardWidth - Natural width of card in px (null = not measured) + * @param viewportWidth - Viewport width + * @param breakpoint - Breakpoint for responsive switching (defaults to RESPONSIVE_BREAKPOINT) + * @returns CSS value string (px or %) + */ +export const calculateResponsiveWidth = ( + cardWidth: number | null, + viewportWidth: number, + breakpoint: number = EVENT_CONFIG.RESPONSIVE_BREAKPOINT +): string => { + if (!cardWidth) return '100%'; + + // Use percentage on small screens + if (viewportWidth < breakpoint) { + return '90%'; + } + + // Use measured px on large screens + return `${cardWidth}px`; +}; diff --git a/src/utils/events.constants.ts b/src/utils/events.constants.ts new file mode 100644 index 0000000..8cecbff --- /dev/null +++ b/src/utils/events.constants.ts @@ -0,0 +1,30 @@ +/** + * Centralized constants for the events section + * Eliminates magic numbers scattered throughout the codebase + */ + +export const EVENT_CONFIG = { + // Default logo when event has no image + DEFAULT_LOGO: `${process.env.PUBLIC_URL}/images/logos/svg/Meetup.svg`, + + // Card width constraints + MIN_CARD_WIDTH: 320, // Minimum width in px + MAX_CARD_WIDTH: 900, // Maximum width in px + + // Breakpoint for responsive design + RESPONSIVE_BREAKPOINT: 640, // In px, below uses % + + // Image dimensions within card + CARD_IMAGE_HEIGHT: 260, // Image height in px + CARD_MIN_HEIGHT: 320, // Minimum card height + + // Text labels + CTA_TEXT: 'Ver evento', + UPCOMING_TITLE: 'Próximos eventos', + PAST_TITLE: 'Eventos pasados', + + // Animation timing + ANIMATION_DURATION: 220, // ms +} as const; + +export type EventConfig = typeof EVENT_CONFIG;