diff --git a/src/app/index.tsx b/src/app/index.tsx index 7e47d6a..5b9cd08 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -1,14 +1,22 @@ import { ArrowUp, ArrowUpRight, Plus } from 'lucide-react-native'; -import { useCallback } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import { Badge } from '@/components/Badge'; import { Button } from '@/components/Button'; +import { ThemeChip } from '@/components/ThemeChip'; import { useTheme } from '@/theme/useTheme'; import '../global.css'; export default function Index() { const { colors, spacing, typography } = useTheme(); + const [selectedThemes, setSelectedThemes] = useState(['Soirée', 'Techno', 'Festival']); + + const themeOptions = useMemo( + () => ['Soirée', 'Techno', 'Festival', 'Fête de jeunesse', 'Concert', 'Bal', 'Afterwork', 'Gratuit'], + [], + ); const simulateSubmit = useCallback(async () => { await new Promise((resolve) => { @@ -16,6 +24,20 @@ export default function Index() { }); }, []); + const handleThemeSelect = useCallback((value: string, nextSelected: boolean) => { + setSelectedThemes((prev) => { + if (nextSelected) { + if (prev.includes(value)) { + return prev; + } + + return [...prev, value]; + } + + return prev.filter((theme) => theme !== value); + }); + }, []); + return ( Icône / FAB + + + BADGES + + + + + + + + + + + + + CHIPS DE THÈME · CAPSULE 34 PX + + + + + + + + + + + + + + THÈMES · MULTI-SÉLECTION + + {themeOptions.map((theme) => { + const selected = selectedThemes.includes(theme); + + return ( + + ); + })} + + ); @@ -134,4 +203,16 @@ const styles = StyleSheet.create({ alignSelf: 'center', width: 140, }, + badgeWrap: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + alignItems: 'center', + }, + chipWrap: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + alignItems: 'center', + }, }); diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx new file mode 100644 index 0000000..3d08767 --- /dev/null +++ b/src/components/Badge.tsx @@ -0,0 +1,244 @@ +import { Check, type LucideIcon } from 'lucide-react-native'; +import { useEffect, useMemo, useRef } from 'react'; +import { + Animated, + StyleSheet, + Text, + View, + type StyleProp, + type TextStyle, + type ViewStyle, +} from 'react-native'; + +import { useTheme } from '@/theme/useTheme'; + +export type BadgeVariant = 'verified' | 'tonight' | 'free' | 'complete' | 'draft' | 'pro' | 'date'; + +export type BadgeDate = { + day: string; + month: string; +}; + +type BadgeProps = { + variant?: BadgeVariant; + label?: string; + icon?: LucideIcon; + date?: BadgeDate; + compact?: boolean; + style?: StyleProp; + textStyle?: StyleProp; +}; + +const DEFAULT_LABELS: Record = { + verified: 'Vérifié', + tonight: 'Ce soir', + free: 'Gratuit', + complete: 'Complet', + draft: 'Brouillon', + pro: 'Pro', + date: 'Date', +}; + +export function Badge({ + variant = 'verified', + label, + icon, + date, + compact = true, + style, + textStyle, +}: BadgeProps) { + const { colors, typography, borderRadius } = useTheme(); + const pulse = useRef(new Animated.Value(1)).current; + + useEffect(() => { + if (variant !== 'tonight') { + return; + } + + const animation = Animated.loop( + Animated.sequence([ + Animated.timing(pulse, { + toValue: 1.04, + duration: 640, + useNativeDriver: true, + }), + Animated.timing(pulse, { + toValue: 0.98, + duration: 640, + useNativeDriver: true, + }), + ]), + ); + + animation.start(); + + return () => animation.stop(); + }, [pulse, variant]); + + const config = useMemo(() => { + if (variant === 'verified') { + return { + backgroundColor: 'rgba(40, 224, 240, 0.12)', + borderColor: 'rgba(40, 224, 240, 0.6)', + textColor: '#A9F4FF', + }; + } + + if (variant === 'tonight') { + return { + backgroundColor: 'rgba(255, 46, 136, 0.18)', + borderColor: 'rgba(255, 46, 136, 0.7)', + textColor: '#FFD0E3', + }; + } + + if (variant === 'free') { + return { + backgroundColor: colors.successSoft, + borderColor: colors.successBorder, + textColor: '#A6F5CD', + }; + } + + if (variant === 'complete') { + return { + backgroundColor: 'rgba(255, 255, 255, 0.06)', + borderColor: 'rgba(255, 255, 255, 0.12)', + textColor: '#F4F5FA', + }; + } + + if (variant === 'draft') { + return { + backgroundColor: colors.warningSoft, + borderColor: 'rgba(255, 196, 120, 0.55)', + textColor: '#FFE0AE', + }; + } + + if (variant === 'pro') { + return { + backgroundColor: colors.secondarySoft, + borderColor: 'rgba(123, 61, 255, 0.55)', + textColor: '#DCCBFF', + }; + } + + return { + backgroundColor: 'rgba(255, 255, 255, 0.04)', + borderColor: 'rgba(255, 255, 255, 0.12)', + textColor: colors.textPrimary, + }; + }, [colors, variant]); + + if (variant === 'date' || date) { + const day = date?.day ?? '04'; + const month = date?.month ?? 'sept'; + + return ( + + + {day} + + + {month} + + + ); + } + + const Icon = icon ?? (variant === 'verified' ? Check : undefined); + const resolvedLabel = (label ?? DEFAULT_LABELS[variant] ?? '').toUpperCase(); + + const badgeStyle: ViewStyle = { + backgroundColor: config.backgroundColor, + borderColor: config.borderColor, + borderWidth: 1, + borderRadius: borderRadius.chip, + paddingHorizontal: compact ? 12 : 16, + paddingVertical: compact ? 8 : 10, + minHeight: compact ? 30 : 34, + alignSelf: 'flex-start', + justifyContent: 'center', + flexDirection: 'row', + alignItems: 'center', + gap: 6, + ...(variant === 'tonight' + ? { shadowColor: '#FF2E88', shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.45, shadowRadius: 12, elevation: 6 } + : {}), + }; + + const textStyleResolved: TextStyle = { + color: config.textColor, + ...typography.badge, + letterSpacing: 1.45, + opacity: 1, + fontWeight: '600', + }; + + return ( + + {Icon ? : null} + {resolvedLabel} + + ); +} + +const styles = StyleSheet.create({ + dateBadge: { + width: 54, + aspectRatio: 1, + borderRadius: 14, + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + paddingVertical: 6, + }, + dateDay: { + fontFamily: 'SpaceGrotesk_700Bold', + fontSize: 18, + lineHeight: 20, + letterSpacing: -0.24, + textAlign: 'center', + }, + dateMonth: { + fontFamily: 'JetBrainsMono_500Medium', + fontSize: 9, + lineHeight: 10, + letterSpacing: 1.2, + textAlign: 'center', + textTransform: 'uppercase', + marginTop: 1, + }, +}); diff --git a/src/components/ThemeChip.tsx b/src/components/ThemeChip.tsx new file mode 100644 index 0000000..6b86bd8 --- /dev/null +++ b/src/components/ThemeChip.tsx @@ -0,0 +1,131 @@ +import { LinearGradient } from 'expo-linear-gradient'; +import { X } from 'lucide-react-native'; +import { useCallback } from 'react'; +import { + Pressable, + type StyleProp, + StyleSheet, + Text, + type TextStyle, + View, + type ViewStyle, +} from 'react-native'; + +import { useTheme } from '@/theme/useTheme'; + +export type ThemeChipSelectedVariant = 'gradient' | 'accent'; + +export type ThemeChipProps = { + label: string; + value?: string; + selected?: boolean; + selectedVariant?: ThemeChipSelectedVariant; + disabled?: boolean; + showCross?: boolean; + showRemoveIcon?: boolean; + onSelect?: (value: string, nextSelected: boolean) => void; + style?: StyleProp; + textStyle?: StyleProp; + testID?: string; + accessibilityLabel?: string; +}; + +const CHIP_HEIGHT = 34; + +export function ThemeChip({ + label, + value, + selected = false, + selectedVariant = 'accent', + disabled = false, + showCross, + showRemoveIcon = true, + onSelect, + style, + textStyle, + testID, + accessibilityLabel, +}: ThemeChipProps) { + const { colors, gradients, gradientDirection, borderRadius, typography } = useTheme(); + + const resolvedValue = value ?? label; + const isGradient = selected && selectedVariant === 'gradient'; + const showSelectedIcon = selected && (showCross ?? showRemoveIcon); + + const handlePress = useCallback(() => { + if (disabled) { + return; + } + + onSelect?.(resolvedValue, !selected); + }, [disabled, onSelect, resolvedValue, selected]); + + const containerStyle: ViewStyle = { + minHeight: CHIP_HEIGHT, + borderRadius: borderRadius.chip, + paddingHorizontal: 14, + borderWidth: 1, + alignSelf: 'flex-start', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + opacity: disabled ? 0.45 : 1, + }; + + const accentSelectedStyle: ViewStyle = { + backgroundColor: colors.primarySoft, + borderColor: colors.primary, + }; + + const idleStyle: ViewStyle = { + backgroundColor: colors.surface, + borderColor: '#2A2A3C', + }; + + const textColor = isGradient + ? colors.textPrimary + : selected + ? colors.textOnPrimarySoft + : colors.textPrimary; + + return ( + + {isGradient ? ( + + {label} + {showSelectedIcon ? : null} + + ) : ( + + {label} + {showSelectedIcon ? : null} + + )} + + ); +} + +export default ThemeChip; + +const styles = StyleSheet.create({ + root: { + alignSelf: 'flex-start', + }, + label: { + fontWeight: '600', + }, +});