chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
+58
View File
@@ -0,0 +1,58 @@
import React from "react";
import { TouchableOpacity, Text, StyleSheet } from "react-native";
import { spacing, borderRadius, fontSize, fontWeight } from "../theme";
import { useTheme } from "../context/ThemeContext";
import { getCategoryColor } from "../utils/constants";
interface CategoryPillProps {
label: string;
active: boolean;
onPress: () => void;
}
export default function CategoryPill({
label,
active,
onPress,
}: CategoryPillProps) {
const { colors } = useTheme();
const catColor = getCategoryColor(label, colors);
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={[
styles.pill,
active
? { backgroundColor: catColor, borderColor: catColor }
: {
backgroundColor: "transparent",
borderColor: colors.border,
},
]}
>
<Text
style={[
styles.text,
{ color: active ? colors.black : colors.textSecondary },
]}
>
{label}
</Text>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
pill: {
paddingHorizontal: spacing.l,
paddingVertical: spacing.s,
borderRadius: borderRadius.xl,
borderWidth: 1,
marginRight: spacing.s,
},
text: {
fontSize: fontSize.sm,
fontWeight: fontWeight.medium,
},
});
+152
View File
@@ -0,0 +1,152 @@
import React from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, borderRadius, fontSize, fontWeight, shadows } from "../theme";
import { useTheme } from "../context/ThemeContext";
import StatusBadge from "./StatusBadge";
import { formatOrderDate, calculateOrderTotal, formatPrice } from "../api/api";
interface OrderCardProps {
order: {
id: number;
status: string;
adresse?: string;
delivery_address?: string;
created_at: string;
total?: number;
total_prix?: number;
livreur_assign?: string;
items?: any[];
};
onPress: () => void;
}
export default function OrderCard({ order, onPress }: OrderCardProps) {
const { colors } = useTheme();
const total = calculateOrderTotal(order);
const address =
order.delivery_address || order.adresse || "Adresse inconnue";
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={[
styles.card,
shadows.sm,
{
backgroundColor: colors.bgCard,
borderColor: colors.borderLight,
},
]}
>
<View style={styles.header}>
<Text style={[styles.orderId, { color: colors.textWhite }]}>
Commande #{order.id}
</Text>
<StatusBadge status={order.status} />
</View>
<View style={styles.body}>
<View style={styles.row}>
<Ionicons
name="location-outline"
size={14}
color={colors.textMuted}
/>
<Text
style={[styles.detail, { color: colors.textSecondary }]}
numberOfLines={1}
>
{address}
</Text>
</View>
<View style={styles.row}>
<Ionicons
name="time-outline"
size={14}
color={colors.textMuted}
/>
<Text
style={[styles.detail, { color: colors.textSecondary }]}
>
{formatOrderDate(order.created_at)}
</Text>
</View>
{order.livreur_assign && (
<View style={styles.row}>
<Ionicons
name="bicycle-outline"
size={14}
color={colors.textMuted}
/>
<Text
style={[
styles.detail,
{ color: colors.textSecondary },
]}
>
{order.livreur_assign}
</Text>
</View>
)}
</View>
<View
style={[styles.footer, { borderTopColor: colors.borderLight }]}
>
<Text style={[styles.total, { color: colors.success }]}>
{formatPrice(total)}
</Text>
<Ionicons
name="chevron-forward"
size={18}
color={colors.textMuted}
/>
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: borderRadius.md,
padding: spacing.l,
marginBottom: spacing.m,
borderWidth: 1,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.m,
},
orderId: {
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
body: {
marginBottom: spacing.m,
},
row: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.xs,
},
detail: {
fontSize: fontSize.sm,
marginLeft: spacing.s,
flex: 1,
},
footer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderTopWidth: 1,
paddingTop: spacing.m,
},
total: {
fontSize: fontSize.lg,
fontWeight: fontWeight.bold,
},
});
+569
View File
@@ -0,0 +1,569 @@
import React, { useState } from "react";
import {
View,
Text,
TouchableOpacity,
Image,
StyleSheet,
Dimensions,
Modal,
Pressable,
ScrollView,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Video, ResizeMode } from "expo-av";
import { spacing, borderRadius, fontSize, fontWeight } from "../theme";
import { useTheme } from "../context/ThemeContext";
import { getCategoryColor } from "../utils/constants";
import { API_BASE_URL } from "../api/client";
import { useCart } from "../context/CartContext";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const CARD_WIDTH = SCREEN_WIDTH - 48;
interface ProductCardProps {
product: {
id: number;
name: string;
category: string;
stock: number;
prices?: Array<{ quantity: number; price: number }>;
media?: Array<{ url: string; type: string }>;
};
onPress: () => void;
}
export default function ProductCard({ product, onPress }: ProductCardProps) {
const { colors } = useTheme();
const { addToCart } = useCart();
const catColor = getCategoryColor(product.category, colors);
const isSoldOut = product.stock <= 0;
const firstPrice = product.prices?.[0]?.price ?? null;
const imageMedia = product.media?.find((m) => m.type === "image");
const videoMedia = product.media?.find((m) => m.type === "video");
const imageUri = imageMedia ? `${API_BASE_URL}${imageMedia.url}` : null;
const videoUri = videoMedia ? `${API_BASE_URL}${videoMedia.url}` : null;
const [showQuantitySelect, setShowQuantitySelect] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const [showVideo, setShowVideo] = useState(false);
const handleQuickAdd = () => {
if (!isSoldOut && product.prices && product.prices.length > 0) {
setShowQuantitySelect(!showQuantitySelect);
}
};
const handleSelectQuantity = (priceOption: {
quantity: number;
price: number;
}) => {
addToCart({
product_id: product.id,
name_product: product.name,
category: (product.category || "autre").toLowerCase().trim(),
quantity: priceOption.quantity,
price: priceOption.price,
});
setShowSuccess(true);
setShowQuantitySelect(false);
setTimeout(() => setShowSuccess(false), 1500);
};
return (
<View
style={[
styles.card,
{
backgroundColor: colors.bgSecondary,
borderColor: catColor,
shadowColor: catColor,
},
isSoldOut && styles.soldOut,
]}
>
<View
style={[
styles.imageContainer,
{ backgroundColor: colors.bgInput },
]}
>
{imageUri ? (
<Image
source={{ uri: imageUri }}
style={styles.image}
resizeMode="cover"
/>
) : (
<View
style={[
styles.imagePlaceholder,
{ backgroundColor: catColor + "15" },
]}
>
<Ionicons
name="leaf-outline"
size={60}
color={catColor}
/>
</View>
)}
{videoUri && !isSoldOut && (
<TouchableOpacity
style={[
styles.videoBtn,
{ backgroundColor: catColor + "DD" },
]}
onPress={() => setShowVideo(true)}
activeOpacity={0.7}
>
<Ionicons
name="videocam"
size={18}
color={colors.white}
/>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.detailsBtn}
onPress={onPress}
activeOpacity={0.7}
>
<Ionicons
name="information-circle-outline"
size={16}
color={colors.white}
/>
<Text
style={[styles.detailsBtnText, { color: colors.white }]}
>
Details
</Text>
</TouchableOpacity>
{isSoldOut && (
<View style={styles.soldOutOverlay}>
<Text style={styles.soldOutText}>SOLD OUT</Text>
</View>
)}
</View>
<View
style={[
styles.info,
{
backgroundColor: colors.bgSecondary,
borderTopColor: colors.border,
},
]}
>
<Text
style={[styles.name, { color: colors.textWhite }]}
numberOfLines={1}
>
{product.name}
</Text>
<Text style={[styles.price, { color: colors.success }]}>
{firstPrice !== null
? `${firstPrice.toFixed(2)}`
: "Prix non disponible"}
</Text>
</View>
<View
style={[
styles.quickAddSection,
{
backgroundColor: colors.bgPrimary,
borderTopColor: colors.border,
},
]}
>
{showSuccess ? (
<View
style={[
styles.successBanner,
{ backgroundColor: catColor },
]}
>
<Text
style={[
styles.successText,
{
color: product.category
?.toLowerCase()
.includes("zipette")
? colors.black
: colors.white,
},
]}
>
Ajoute !
</Text>
</View>
) : (
<TouchableOpacity
style={[
styles.quickAddBtn,
{ backgroundColor: catColor },
isSoldOut && {
backgroundColor: colors.textMuted,
opacity: 0.6,
},
]}
onPress={handleQuickAdd}
disabled={isSoldOut}
activeOpacity={0.7}
>
<Text
style={[
styles.quickAddBtnText,
{
color: product.category
?.toLowerCase()
.includes("zipette")
? colors.black
: colors.white,
},
]}
>
{isSoldOut
? "Rupture de stock"
: "Ajouter rapidement"}
</Text>
</TouchableOpacity>
)}
</View>
<Modal
visible={showQuantitySelect}
transparent
animationType="slide"
onRequestClose={() => setShowQuantitySelect(false)}
>
<Pressable
style={styles.pickerOverlay}
onPress={() => setShowQuantitySelect(false)}
>
<View
style={[
styles.pickerSheet,
{ backgroundColor: colors.bgSecondary },
]}
>
<View
style={[
styles.pickerHandle,
{ backgroundColor: colors.textMuted },
]}
/>
<Text
style={[
styles.pickerTitle,
{ color: colors.textWhite },
]}
>
Choisir une quantite
</Text>
<ScrollView
style={styles.pickerScroll}
showsVerticalScrollIndicator={false}
>
{product.prices?.map((p) => (
<TouchableOpacity
key={p.quantity}
style={[
styles.pickerOption,
{
backgroundColor: colors.bgInput,
borderColor: catColor + "44",
},
]}
onPress={() => handleSelectQuantity(p)}
activeOpacity={0.6}
>
<View style={styles.pickerOptionLeft}>
<Text
style={[
styles.pickerOptionQty,
{ color: colors.textWhite },
]}
>
{p.quantity}g
</Text>
<Text
style={[
styles.pickerOptionPrice,
{ color: catColor },
]}
>
{p.price.toFixed(2)}
</Text>
</View>
<Ionicons
name="add-circle"
size={28}
color={catColor}
/>
</TouchableOpacity>
))}
</ScrollView>
<TouchableOpacity
style={[
styles.pickerCloseBtn,
{ backgroundColor: colors.border },
]}
onPress={() => setShowQuantitySelect(false)}
>
<Text
style={[
styles.pickerCloseBtnText,
{ color: colors.textSecondary },
]}
>
Fermer
</Text>
</TouchableOpacity>
</View>
</Pressable>
</Modal>
<Modal
visible={showVideo}
transparent
animationType="fade"
onRequestClose={() => setShowVideo(false)}
>
<Pressable
style={styles.videoModalOverlay}
onPress={() => setShowVideo(false)}
>
<View
style={[
styles.videoModalContent,
{ backgroundColor: colors.black },
]}
>
<TouchableOpacity
style={styles.videoCloseBtn}
onPress={() => setShowVideo(false)}
>
<Ionicons
name="close"
size={22}
color={colors.white}
/>
</TouchableOpacity>
{videoUri && (
<Video
source={{ uri: videoUri }}
style={styles.videoPlayer}
useNativeControls
resizeMode={ResizeMode.CONTAIN}
shouldPlay
/>
)}
</View>
</Pressable>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: 15,
borderWidth: 2,
overflow: "hidden",
width: CARD_WIDTH,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 20,
elevation: 8,
},
soldOut: { opacity: 0.75 },
imageContainer: {
width: "100%",
aspectRatio: 1,
position: "relative",
overflow: "hidden",
},
image: { width: "100%", height: "100%" },
imagePlaceholder: {
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
},
videoBtn: {
position: "absolute",
top: 12,
right: 12,
width: 40,
height: 40,
borderRadius: 20,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "rgba(255,255,255,0.3)",
},
detailsBtn: {
position: "absolute",
bottom: 12,
alignSelf: "center",
flexDirection: "row",
alignItems: "center",
gap: 6,
backgroundColor: "rgba(0,0,0,0.85)",
borderRadius: 25,
paddingHorizontal: 20,
paddingVertical: 8,
borderWidth: 2,
borderColor: "rgba(255,255,255,0.3)",
},
detailsBtnText: {
fontSize: 14,
fontWeight: fontWeight.semibold,
letterSpacing: 0.5,
},
soldOutOverlay: {
position: "absolute",
top: "50%",
left: "50%",
transform: [
{ translateX: -80 },
{ translateY: -25 },
{ rotate: "-15deg" },
],
backgroundColor: "rgba(0,0,0,0.8)",
borderWidth: 4,
borderColor: "rgba(255,0,0,0.95)",
paddingHorizontal: 30,
paddingVertical: 12,
},
soldOutText: {
color: "rgba(255,0,0,0.95)",
fontSize: 28,
fontWeight: "900",
letterSpacing: 3,
textTransform: "uppercase",
textShadowColor: "rgba(0,0,0,0.9)",
textShadowOffset: { width: 2, height: 2 },
textShadowRadius: 6,
},
info: { padding: spacing.m, borderTopWidth: 1 },
name: {
fontSize: fontSize.lg,
fontWeight: fontWeight.semibold,
textAlign: "center",
marginBottom: spacing.xs,
},
price: {
fontSize: fontSize.xl,
fontWeight: fontWeight.bold,
textAlign: "center",
},
quickAddSection: { padding: spacing.m, borderTopWidth: 1 },
quickAddBtn: {
width: "100%",
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
alignItems: "center",
},
quickAddBtnText: {
fontSize: fontSize.md,
fontWeight: "700",
textTransform: "uppercase",
letterSpacing: 0.5,
},
successBanner: {
width: "100%",
paddingVertical: 12,
borderRadius: 8,
alignItems: "center",
},
successText: { fontSize: fontSize.md, fontWeight: "700" },
pickerOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.7)",
justifyContent: "flex-end",
},
pickerSheet: {
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingHorizontal: 24,
paddingBottom: 40,
maxHeight: "70%",
},
pickerHandle: {
width: 40,
height: 4,
borderRadius: 2,
alignSelf: "center",
marginTop: 12,
marginBottom: 16,
},
pickerTitle: {
fontSize: fontSize.lg,
fontWeight: fontWeight.bold,
textAlign: "center",
marginBottom: 16,
},
pickerScroll: { maxHeight: 350 },
pickerOption: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderRadius: 12,
borderWidth: 1,
paddingVertical: 16,
paddingHorizontal: 20,
marginBottom: 10,
},
pickerOptionLeft: { gap: 2 },
pickerOptionQty: { fontSize: fontSize.lg, fontWeight: fontWeight.bold },
pickerOptionPrice: {
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
pickerCloseBtn: {
marginTop: 12,
borderRadius: 10,
paddingVertical: 14,
alignItems: "center",
},
pickerCloseBtnText: {
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
videoModalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.95)",
justifyContent: "center",
alignItems: "center",
padding: 20,
},
videoModalContent: {
width: "100%",
maxWidth: 500,
borderRadius: 12,
overflow: "hidden",
position: "relative",
},
videoCloseBtn: {
position: "absolute",
top: 15,
right: 15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: 2,
borderColor: "rgba(255,255,255,0.3)",
justifyContent: "center",
alignItems: "center",
zIndex: 10,
},
videoPlayer: { width: "100%", height: 300 },
});
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import Badge from "./ui/Badge";
import { STATUS_LABELS, getStatusColors } from "../utils/constants";
import { useTheme } from "../context/ThemeContext";
interface StatusBadgeProps {
status: string;
}
export default function StatusBadge({ status }: StatusBadgeProps) {
const { colors } = useTheme();
const statusColors = getStatusColors(colors);
const label = STATUS_LABELS[status] || status;
const color = statusColors[status] || colors.textMuted;
return <Badge label={label} color={color} />;
}
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { spacing, borderRadius, fontSize, fontWeight } from '../../theme';
interface BadgeProps {
label: string;
color: string;
textColor?: string;
}
export default function Badge({ label, color, textColor = '#fff' }: BadgeProps) {
return (
<View style={[styles.badge, { backgroundColor: color + '22', borderColor: color }]}>
<Text style={[styles.text, { color }]}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
paddingHorizontal: spacing.m,
paddingVertical: spacing.xs,
borderRadius: borderRadius.xl,
borderWidth: 1,
alignSelf: 'flex-start',
},
text: {
fontSize: fontSize.xs,
fontWeight: fontWeight.semibold,
},
});
+108
View File
@@ -0,0 +1,108 @@
import React from "react";
import {
TouchableOpacity,
Text,
StyleSheet,
ActivityIndicator,
type ViewStyle,
type TextStyle,
} from "react-native";
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface ButtonProps {
title: string;
onPress: () => void;
variant?:
| "primary"
| "secondary"
| "danger"
| "success"
| "outline"
| "ghost";
size?: "sm" | "md" | "lg";
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
fullWidth?: boolean;
}
export default function Button({
title,
onPress,
variant = "primary",
size = "md",
loading = false,
disabled = false,
style,
textStyle,
fullWidth = false,
}: ButtonProps) {
const { colors } = useTheme();
const bgColor = {
primary: colors.accent,
secondary: colors.bgCard,
danger: colors.danger,
success: colors.success,
outline: "transparent",
ghost: "transparent",
}[variant];
const txtColor = variant === "success" ? colors.black : colors.textWhite;
const borderColor = variant === "outline" ? colors.border : "transparent";
const paddingV = { sm: spacing.s, md: spacing.m, lg: spacing.l }[size];
const paddingH = { sm: spacing.m, md: spacing.xl, lg: spacing.xxl }[size];
const fSize = { sm: fontSize.sm, md: fontSize.md, lg: fontSize.lg }[size];
return (
<TouchableOpacity
onPress={onPress}
disabled={disabled || loading}
activeOpacity={0.7}
style={[
styles.base,
{
backgroundColor: bgColor,
borderColor,
paddingVertical: paddingV,
paddingHorizontal: paddingH,
opacity: disabled ? 0.5 : 1,
},
fullWidth && styles.fullWidth,
style,
]}
>
{loading ? (
<ActivityIndicator color={txtColor} size="small" />
) : (
<Text
style={[
styles.text,
{ color: txtColor, fontSize: fSize },
textStyle,
]}
>
{title}
</Text>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
base: {
borderRadius: borderRadius.md,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
flexDirection: "row",
},
fullWidth: {
width: "100%",
},
text: {
fontWeight: fontWeight.semibold,
},
});
+36
View File
@@ -0,0 +1,36 @@
import React, { type ReactNode } from "react";
import { View, StyleSheet, type ViewStyle } from "react-native";
import { spacing, borderRadius, shadows } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface CardProps {
children: ReactNode;
style?: ViewStyle;
}
export default function Card({ children, style }: CardProps) {
const { colors } = useTheme();
return (
<View
style={[
styles.card,
shadows.md,
{
backgroundColor: colors.bgCard,
borderColor: colors.borderLight,
},
style,
]}
>
{children}
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: borderRadius.md,
padding: spacing.l,
borderWidth: 1,
},
});
@@ -0,0 +1,39 @@
import React from "react";
import { View, ActivityIndicator, Text, StyleSheet } from "react-native";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface LoadingSpinnerProps {
message?: string;
size?: "small" | "large";
}
export default function LoadingSpinner({
message,
size = "large",
}: LoadingSpinnerProps) {
const { colors } = useTheme();
return (
<View style={[styles.container, { backgroundColor: colors.bgPrimary }]}>
<ActivityIndicator size={size} color={colors.accent} />
{message && (
<Text style={[styles.text, { color: colors.textSecondary }]}>
{message}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
text: {
fontSize: fontSize.md,
marginTop: spacing.l,
},
});
+195
View File
@@ -0,0 +1,195 @@
import React, { type ReactNode, useEffect, useRef } from "react";
import {
Modal as RNModal,
View,
TouchableOpacity,
Text,
StyleSheet,
Animated,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, borderRadius, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface ModalProps {
visible: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
icon?: keyof typeof Ionicons.glyphMap;
iconColor?: string;
}
export default function Modal({
visible,
onClose,
title,
children,
icon,
iconColor,
}: ModalProps) {
const { colors } = useTheme();
const scale = useRef(new Animated.Value(0.85)).current;
const opacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
tension: 65,
friction: 8,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else {
scale.setValue(0.85);
opacity.setValue(0);
}
}, [visible]);
return (
<RNModal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<View style={styles.overlay}>
<Animated.View
style={[
styles.content,
{
backgroundColor: colors.bgSecondary,
borderColor: colors.borderSubtle,
shadowColor: colors.accent,
transform: [{ scale }],
opacity,
},
]}
>
<View
style={[
styles.accentBar,
{ backgroundColor: colors.accent },
]}
/>
<View style={styles.header}>
<View style={styles.titleRow}>
{icon && (
<View
style={[
styles.iconCircle,
{
backgroundColor:
(iconColor || colors.accent) +
"20",
},
]}
>
<Ionicons
name={icon}
size={20}
color={iconColor || colors.accent}
/>
</View>
)}
{title && (
<Text
style={[
styles.title,
{ color: colors.textWhite },
]}
>
{title}
</Text>
)}
</View>
<TouchableOpacity
onPress={onClose}
style={[
styles.closeBtn,
{ backgroundColor: colors.borderSubtle },
]}
activeOpacity={0.7}
>
<Ionicons
name="close"
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
<View style={styles.body}>{children}</View>
</Animated.View>
</View>
</RNModal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.85)",
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
content: {
borderRadius: 20,
width: "100%",
maxHeight: "80%",
borderWidth: 1,
overflow: "hidden",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.15,
shadowRadius: 30,
elevation: 20,
},
accentBar: {
height: 3,
width: "100%",
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: spacing.xl,
paddingTop: spacing.l,
paddingBottom: spacing.m,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: 10,
flex: 1,
},
iconCircle: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: "center",
alignItems: "center",
},
title: {
fontSize: fontSize.lg,
fontWeight: "700",
flex: 1,
},
closeBtn: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
},
body: {
paddingHorizontal: spacing.xl,
paddingBottom: spacing.xl,
},
});
+94
View File
@@ -0,0 +1,94 @@
import React from "react";
import {
View,
TextInput as RNTextInput,
Text,
StyleSheet,
type TextInputProps,
} from "react-native";
import { spacing, borderRadius, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface CustomTextInputProps extends TextInputProps {
label?: string;
error?: string;
icon?: React.ReactNode;
}
export default function TextInput({
label,
error,
icon,
style,
...props
}: CustomTextInputProps) {
const { colors } = useTheme();
return (
<View style={styles.container}>
{label && (
<Text style={[styles.label, { color: colors.textSecondary }]}>
{label}
</Text>
)}
<View
style={[
styles.inputWrapper,
{
backgroundColor: colors.bgInput,
borderColor: error ? colors.danger : colors.border,
},
]}
>
{icon && <View style={styles.icon}>{icon}</View>}
<RNTextInput
style={[
styles.input,
{ color: colors.textPrimary },
icon && styles.inputWithIcon,
style,
]}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accent}
{...props}
/>
</View>
{error && (
<Text style={[styles.errorText, { color: colors.danger }]}>
{error}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: spacing.l,
},
label: {
fontSize: fontSize.sm,
marginBottom: spacing.s,
},
inputWrapper: {
flexDirection: "row",
alignItems: "center",
borderRadius: borderRadius.sm,
borderWidth: 1,
},
icon: {
paddingLeft: spacing.m,
},
input: {
flex: 1,
fontSize: fontSize.md,
paddingVertical: spacing.m,
paddingHorizontal: spacing.l,
},
inputWithIcon: {
paddingLeft: spacing.s,
},
errorText: {
fontSize: fontSize.xs,
marginTop: spacing.xs,
},
});
+102
View File
@@ -0,0 +1,102 @@
import React, { useEffect, useRef } from "react";
import { Animated, Text, StyleSheet } from "react-native";
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface ToastProps {
message: string;
type: "success" | "error" | "warning" | "info";
visible: boolean;
onHide: () => void;
duration?: number;
}
export default function Toast({
message,
type,
visible,
onHide,
duration = 3000,
}: ToastProps) {
const { colors } = useTheme();
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-50)).current;
const TYPE_COLORS = {
success: colors.success,
error: colors.danger,
warning: colors.warning,
info: colors.info,
};
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(opacity, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => {
Animated.parallel([
Animated.timing(opacity, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: -50,
duration: 300,
useNativeDriver: true,
}),
]).start(() => onHide());
}, duration);
return () => clearTimeout(timer);
}
}, [visible]);
if (!visible) return null;
return (
<Animated.View
style={[
styles.container,
{
backgroundColor: TYPE_COLORS[type],
opacity,
transform: [{ translateY }],
},
]}
>
<Text style={[styles.text, { color: colors.black }]}>
{message}
</Text>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: "absolute",
top: 60,
left: spacing.l,
right: spacing.l,
paddingVertical: spacing.m,
paddingHorizontal: spacing.l,
borderRadius: borderRadius.sm,
zIndex: 9999,
},
text: {
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
textAlign: "center",
},
});