feat(figgos): face image upload, loading/reveal screens + error handling

- Add optional face photo upload (expo-image-picker + expo-image-manipulator)
- Wire face image through backend to Gemini as inline base64 data
- Add loading screen with blurred placeholder card + pulsing animation
- Add reveal/unboxing screen with aligned banner layout
- Handle generation failures (check figure.status, show error on form)
- Add Gemini safety settings (BLOCK_NONE) to reduce false rejections
- Increase body limit to 5mb for base64 image payloads
- Add errorMessage to FigureResponse shared type
- Extract FlippableCard to reusable component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chr1st1anG 2026-02-12 13:22:39 +01:00
parent 0eac48cdc4
commit a117d5479b
18 changed files with 943 additions and 965 deletions

View file

@ -18,4 +18,8 @@ export class CreateFigureDto {
@IsString()
@IsIn(['en', 'de'])
language?: FigureLanguage = 'en';
@IsOptional()
@IsString()
faceImage?: string;
}

View file

@ -23,7 +23,8 @@ export class FiguresController {
user.userId,
dto.name,
dto.description,
dto.language || 'en'
dto.language || 'en',
dto.faceImage
);
return { figure };
}

View file

@ -33,7 +33,8 @@ export class FiguresService {
userId: string,
name: string,
description: string,
language: FigureLanguage = 'en'
language: FigureLanguage = 'en',
faceImage?: string
): Promise<Figure> {
const rarity = this.rollRarity();
const cardStyle = getCardStyle(rarity);
@ -58,6 +59,7 @@ export class FiguresService {
language,
rarity,
cardStyle,
faceImage,
});
return completed;

View file

@ -1,6 +1,6 @@
import type { CardStyle, FigureLanguage, FigureRarity, GeneratedProfile } from '@figgos/shared';
import { STAT_RANGES } from '@figgos/shared';
import { GoogleGenAI } from '@google/genai';
import { GoogleGenAI, HarmCategory, HarmBlockThreshold } from '@google/genai';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
@ -18,6 +18,19 @@ export class GeminiService {
private readonly logger = new Logger(GeminiService.name);
private readonly client: GoogleGenAI;
private readonly safetySettings = [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },
{
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
{
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
];
constructor(private config: ConfigService) {
const apiKey = this.config.get<string>('GEMINI_API_KEY');
if (!apiKey) {
@ -46,6 +59,7 @@ export class GeminiService {
responseSchema: PROFILE_JSON_SCHEMA,
temperature: 1.0,
thinkingConfig: { thinkingBudget: 0 },
safetySettings: this.safetySettings,
},
});
@ -87,26 +101,28 @@ export class GeminiService {
visualDescription: string,
items: string[],
cardStyle: CardStyle,
faceImageUrl?: string | null
faceImage?: string | null
): Promise<Buffer> {
const prompt = buildImagePrompt(
name,
subtitle,
visualDescription,
items,
cardStyle,
!!faceImageUrl
const hasFace = !!faceImage;
const prompt = buildImagePrompt(name, subtitle, visualDescription, items, cardStyle, hasFace);
this.logger.log(
`Generating image for "${name}" (${cardStyle})${hasFace ? ' with face reference' : ''}...`
);
this.logger.log(`Generating image for "${name}" (${cardStyle})...`);
// Build contents array — if face image provided, include it
// Build contents array — if face image provided, include it as inline data
const contents: Array<string | { inlineData: { mimeType: string; data: string } }> = [];
if (faceImageUrl) {
// TODO: Download face image from S3, convert to base64, add as inline data
// For now, face transfer is not yet supported in the backend
this.logger.warn('Face transfer not yet implemented in backend');
if (faceImage) {
// Strip data URL prefix if present (e.g. "data:image/jpeg;base64,...")
const base64Data = faceImage.includes(',') ? faceImage.split(',')[1] : faceImage;
contents.push({
inlineData: {
mimeType: 'image/jpeg',
data: base64Data,
},
});
this.logger.log('Face reference image attached to generation request');
}
contents.push(prompt);
@ -116,13 +132,16 @@ export class GeminiService {
contents,
config: {
responseModalities: ['IMAGE', 'TEXT'],
safetySettings: this.safetySettings,
},
});
// Extract image from response
const parts = response.candidates?.[0]?.content?.parts;
if (!parts) {
throw new Error('Gemini returned no content parts');
throw new Error(
'The AI could not generate this figure — try a different description or photo'
);
}
for (const part of parts) {

View file

@ -35,6 +35,7 @@ export class GenerationService {
language: FigureLanguage;
rarity: FigureRarity;
cardStyle: CardStyle;
faceImage?: string;
}
): Promise<Figure> {
try {
@ -58,7 +59,8 @@ export class GenerationService {
profile.subtitle,
profile.visualDescription,
itemLabels,
input.cardStyle
input.cardStyle,
input.faceImage
);
// Phase 3: Process image (bg removal + WebP)

View file

@ -53,6 +53,7 @@ export class ImageProcessingService implements OnModuleInit {
}
return sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } })
.trim()
.webp({ quality: 85 })
.toBuffer();
}
@ -84,7 +85,10 @@ export class ImageProcessingService implements OnModuleInit {
const { data, width, height, channels } = img;
const buf = Buffer.from(data);
return sharp(buf, { raw: { width, height, channels } }).webp({ quality: 85 }).toBuffer();
return sharp(buf, { raw: { width, height, channels } })
.trim()
.webp({ quality: 85 })
.toBuffer();
} finally {
await unlink(tmpPath).catch(() => {});
}

View file

@ -5,4 +5,5 @@ bootstrapApp(AppModule, {
defaultPort: 3025,
serviceName: 'Figgos',
additionalCorsOrigins: ['http://localhost:5196'],
bodyLimit: '5mb',
});

View file

@ -31,7 +31,7 @@
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": ["expo-router", "expo-secure-store"],
"plugins": ["expo-router", "expo-secure-store", "expo-image-picker"],
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true

View file

@ -7,16 +7,18 @@ import {
Animated,
Dimensions,
ActivityIndicator,
Modal,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useFocusEffect } from 'expo-router';
import { useFocusEffect } from 'expo-router';
import type { FigureResponse, FigureRarity } from '@figgos/shared';
import { api } from '../../services/api';
import FlippableCard from '../../components/FlippableCard';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const CARD_WIDTH = SCREEN_WIDTH * 0.65;
const CARD_WIDTH = SCREEN_WIDTH * 0.8;
const CARD_HEIGHT = CARD_WIDTH * 1.45;
const SPACING = 14;
const SPACING = 12;
const SIDE_SPACE = (SCREEN_WIDTH - CARD_WIDTH) / 2;
const RARITY_COLORS: Record<FigureRarity, string> = {
@ -27,16 +29,29 @@ const RARITY_COLORS: Record<FigureRarity, string> = {
};
export default function CarouselScreen() {
const router = useRouter();
const scrollX = useRef(new Animated.Value(0)).current;
const [figures, setFigures] = useState<FigureResponse[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<FigureResponse | null>(null);
useFocusEffect(
useCallback(() => {
api.figures
.list()
.then(({ figures }) => setFigures(figures))
.then(async ({ figures }) => {
const withImages = figures.filter((f) => f.imageUrl);
const checks = await Promise.all(
withImages.map(async (f) => {
try {
await Image.prefetch(f.imageUrl!);
return f;
} catch {
return null;
}
})
);
setFigures(checks.filter((f): f is FigureResponse => f !== null));
})
.finally(() => setLoading(false));
}, [])
);
@ -61,7 +76,7 @@ export default function CarouselScreen() {
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View style={{ paddingHorizontal: 20, paddingTop: 24, paddingBottom: 16 }}>
<View style={{ paddingHorizontal: 20, paddingTop: 16, paddingBottom: 8 }}>
<Text
className="text-foreground"
style={{ fontSize: 28, fontWeight: '900', letterSpacing: -0.5 }}
@ -69,8 +84,7 @@ export default function CarouselScreen() {
Showcase
</Text>
</View>
<View style={{ flex: 1, justifyContent: 'center' }}>
<View style={{ flex: 1, justifyContent: 'center', marginTop: -80 }}>
<Animated.FlatList
data={figures}
keyExtractor={(item) => item.id}
@ -119,10 +133,7 @@ export default function CarouselScreen() {
opacity,
}}
>
<Pressable
onPress={() => router.push(`/card/v2/${item.id}` as any)}
className="active:opacity-90"
>
<Pressable onPress={() => setSelected(item)} className="active:opacity-90">
<View
style={{
width: CARD_WIDTH,
@ -131,31 +142,11 @@ export default function CarouselScreen() {
overflow: 'hidden',
}}
>
{item.imageUrl ? (
<Image
source={{ uri: item.imageUrl }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
) : (
<View
className="bg-surface items-center justify-center"
style={{
width: '100%',
height: '100%',
borderWidth: 2,
borderColor: RARITY_COLORS[item.rarity],
borderRadius: 14,
}}
>
<Text
className="text-foreground"
style={{ fontSize: 14, fontWeight: '800' }}
>
{item.name}
</Text>
</View>
)}
<Image
source={{ uri: item.imageUrl! }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
</View>
</Pressable>
</Animated.View>
@ -164,41 +155,41 @@ export default function CarouselScreen() {
/>
</View>
<View className="flex-row items-center justify-center" style={{ paddingBottom: 24, gap: 8 }}>
{figures.map((figure, i) => {
const inputRange = [
(i - 1) * (CARD_WIDTH + SPACING),
i * (CARD_WIDTH + SPACING),
(i + 1) * (CARD_WIDTH + SPACING),
];
const dotScale = scrollX.interpolate({
inputRange,
outputRange: [1, 1.4, 1],
extrapolate: 'clamp',
});
const dotOpacity = scrollX.interpolate({
inputRange,
outputRange: [0.3, 1, 0.3],
extrapolate: 'clamp',
});
return (
<Animated.View
key={figure.id}
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: RARITY_COLORS[figure.rarity],
transform: [{ scale: dotScale }],
opacity: dotOpacity,
}}
/>
);
})}
</View>
{/* Card Detail Overlay */}
<Modal
visible={selected !== null}
transparent
animationType="fade"
onRequestClose={() => setSelected(null)}
>
<Pressable
onPress={() => setSelected(null)}
style={{
flex: 1,
backgroundColor: 'rgba(5, 5, 15, 0.92)',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Pressable onPress={() => {}}>
{selected && (
<View style={{ alignItems: 'center' }}>
<Text
className="text-muted-foreground"
style={{
fontSize: 12,
fontWeight: '600',
marginBottom: 12,
}}
>
Drag to rotate · Double-tap to flip
</Text>
<FlippableCard figure={selected} maxWidth={SCREEN_WIDTH - 48} />
</View>
)}
</Pressable>
</Pressable>
</Modal>
</SafeAreaView>
);
}

View file

@ -7,11 +7,13 @@ import {
FlatList,
Dimensions,
ActivityIndicator,
Modal,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useFocusEffect } from 'expo-router';
import { useFocusEffect } from 'expo-router';
import type { FigureResponse } from '@figgos/shared';
import { api } from '../../services/api';
import FlippableCard from '../../components/FlippableCard';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const GAP = 10;
@ -20,12 +22,16 @@ const COLUMNS = 2;
const CARD_WIDTH = (SCREEN_WIDTH - PADDING * 2 - GAP * (COLUMNS - 1)) / COLUMNS;
const CARD_HEIGHT = CARD_WIDTH * 1.45;
function CardThumbnail({ figure }: { figure: FigureResponse }) {
const router = useRouter();
function CardThumbnail({
figure,
onPress,
}: {
figure: FigureResponse;
onPress: (f: FigureResponse) => void;
}) {
return (
<Pressable
onPress={() => router.push(`/card/v2/${figure.id}` as any)}
onPress={() => onPress(figure)}
style={{ width: CARD_WIDTH }}
className="active:opacity-80"
>
@ -37,28 +43,11 @@ function CardThumbnail({ figure }: { figure: FigureResponse }) {
overflow: 'hidden',
}}
>
{figure.imageUrl ? (
<Image
source={{ uri: figure.imageUrl }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
) : (
<View
className="bg-surface items-center justify-center"
style={{
width: '100%',
height: '100%',
borderWidth: 2,
borderColor: 'rgb(50, 50, 80)',
borderRadius: 12,
}}
>
<Text className="text-muted-foreground" style={{ fontSize: 11 }}>
{figure.name}
</Text>
</View>
)}
<Image
source={{ uri: figure.imageUrl! }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
</View>
</Pressable>
);
@ -68,14 +57,26 @@ export default function CollectionScreen() {
const [figures, setFigures] = useState<FigureResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<FigureResponse | null>(null);
useFocusEffect(
useCallback(() => {
setLoading(true);
api.figures
.list()
.then(({ figures }) => {
setFigures(figures);
.then(async ({ figures }) => {
const withImages = figures.filter((f) => f.imageUrl);
const checks = await Promise.all(
withImages.map(async (f) => {
try {
await Image.prefetch(f.imageUrl!);
return f;
} catch {
return null;
}
})
);
setFigures(checks.filter((f): f is FigureResponse => f !== null));
setError(null);
})
.catch((e) => setError(e.message))
@ -126,9 +127,41 @@ export default function CollectionScreen() {
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingHorizontal: PADDING, paddingBottom: 40 }}
columnWrapperStyle={{ gap: GAP, marginBottom: GAP }}
renderItem={({ item }) => <CardThumbnail figure={item} />}
renderItem={({ item }) => <CardThumbnail figure={item} onPress={setSelected} />}
/>
)}
{/* Card Detail Overlay */}
<Modal
visible={selected !== null}
transparent
animationType="fade"
onRequestClose={() => setSelected(null)}
>
<Pressable
onPress={() => setSelected(null)}
style={{
flex: 1,
backgroundColor: 'rgba(5, 5, 15, 0.92)',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Pressable onPress={() => {}}>
{selected && (
<View style={{ alignItems: 'center' }}>
<Text
className="text-muted-foreground"
style={{ fontSize: 12, fontWeight: '600', marginBottom: 12 }}
>
Drag to rotate · Double-tap to flip
</Text>
<FlippableCard figure={selected} maxWidth={SCREEN_WIDTH - 48} />
</View>
)}
</Pressable>
</Pressable>
</Modal>
</SafeAreaView>
);
}

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import {
View,
Text,
@ -9,105 +9,120 @@ import {
Platform,
ActivityIndicator,
Image,
Alert,
Dimensions,
} from 'react-native';
import type { ScrollView as ScrollViewType } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { FigureResponse, FigureRarity } from '@figgos/shared';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
withSequence,
Easing,
FadeIn,
FadeOut,
} from 'react-native-reanimated';
import * as ImagePicker from 'expo-image-picker';
import * as ImageManipulator from 'expo-image-manipulator';
import type { FigureResponse } from '@figgos/shared';
import { api } from '../../services/api';
import FlippableCard from '../../components/FlippableCard';
// ── Rarity ──
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const CARD_WIDTH = SCREEN_WIDTH - 64;
const PLACEHOLDER_HEIGHT = CARD_WIDTH * 1.5;
const RARITY_SHADOW: Record<FigureRarity, string> = {
common: 'rgb(80, 90, 100)',
rare: 'rgb(60, 120, 180)',
epic: 'rgb(120, 80, 180)',
legendary: 'rgb(180, 130, 20)',
};
// Random placeholder from our cole cards
const PLACEHOLDER_IMAGES = [
require('../../assets/images/cole-common.png'),
require('../../assets/images/cole-rare.png'),
require('../../assets/images/cole-epic.png'),
require('../../assets/images/cole-kraft.png'),
];
function RarityBadge({ rarity }: { rarity: FigureRarity }) {
const shadowColor = RARITY_SHADOW[rarity];
const bgClass = `bg-rarity-${rarity}`;
const fgClass = `text-rarity-${rarity}-foreground`;
return (
<View style={{ position: 'relative', alignSelf: 'center' }}>
<View
style={{
position: 'absolute',
top: 3,
left: 2,
right: -2,
bottom: -3,
borderRadius: 999,
backgroundColor: shadowColor,
}}
/>
<View
className={`${bgClass} rounded-full`}
style={{
paddingHorizontal: 20,
paddingVertical: 8,
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.2)',
}}
>
<Text
className={`${fgClass}`}
style={{ fontSize: 12, fontWeight: '900', letterSpacing: 2, textTransform: 'uppercase' }}
>
{rarity}
</Text>
</View>
</View>
);
}
// ── Stat Bar ──
const STAT_COLORS = {
attack: 'rgb(255, 51, 102)',
defense: 'rgb(0, 210, 170)',
special: 'rgb(180, 130, 255)',
};
function StatBar({ label, value, color }: { label: string; value: number; color: string }) {
return (
<View className="flex-row items-center mb-1.5" style={{ gap: 6 }}>
<Text
className="text-muted-foreground"
style={{ fontSize: 10, fontWeight: '900', width: 26, letterSpacing: 1 }}
>
{label}
</Text>
<View
className="flex-1 bg-input rounded-full"
style={{ height: 8, borderWidth: 1, borderColor: 'rgb(50, 50, 80)' }}
>
<View
style={{
width: `${value}%`,
height: '100%',
backgroundColor: color,
borderRadius: 999,
}}
/>
</View>
<Text
className="text-foreground"
style={{ fontSize: 10, fontWeight: '800', width: 22, textAlign: 'right' }}
>
{value}
</Text>
</View>
);
}
const LOADING_MESSAGES = [
'Rolling rarity...',
'Crafting backstory...',
'Forging stats...',
'Designing figure...',
'Painting details...',
'Assembling packaging...',
'Almost there...',
];
// ── Screen ──
export default function CreateScreen() {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const storyRef = useRef<TextInput>(null);
const scrollRef = useRef<ScrollViewType>(null);
const storyLayoutY = useRef(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<FigureResponse | null>(null);
const [imageUri, setImageUri] = useState<string | null>(null);
const [loadingMsg, setLoadingMsg] = useState(0);
const [placeholderImg] = useState(
() => PLACEHOLDER_IMAGES[Math.floor(Math.random() * PLACEHOLDER_IMAGES.length)]
);
const pickImage = () => {
Alert.alert('Reference Photo', 'How would you like to add a photo?', [
{
text: 'Take a Selfie',
onPress: async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
setError('Camera permission is required for selfies');
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
quality: 0.7,
cameraType: ImagePicker.CameraType.front,
});
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
}
},
},
{
text: 'Choose from Library',
onPress: async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
quality: 0.7,
});
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
}
},
},
{ text: 'Cancel', style: 'cancel' },
]);
};
// Cycle loading messages
useEffect(() => {
if (!loading) return;
const interval = setInterval(() => {
setLoadingMsg((prev) => (prev + 1) % LOADING_MESSAGES.length);
}, 2500);
return () => clearInterval(interval);
}, [loading]);
const prepareImage = async (uri: string): Promise<string> => {
const result = await ImageManipulator.manipulateAsync(uri, [{ resize: { width: 512 } }], {
compress: 0.8,
format: ImageManipulator.SaveFormat.JPEG,
base64: true,
});
return result.base64!;
};
const handleGenerate = async () => {
if (!name.trim() || !description.trim()) {
@ -116,12 +131,21 @@ export default function CreateScreen() {
}
setLoading(true);
setError(null);
setLoadingMsg(0);
try {
const { figure } = await api.figures.create(name.trim(), description.trim());
let faceImage: string | undefined;
if (imageUri) {
faceImage = await prepareImage(imageUri);
}
const { figure } = await api.figures.create(name.trim(), description.trim(), 'en', faceImage);
setLoading(false);
if (figure.status === 'failed') {
setError(figure.errorMessage || 'Generation failed — try a different description');
return;
}
setResult(figure);
} catch (e: any) {
setError(e.message || 'Something went wrong');
} finally {
setLoading(false);
}
};
@ -131,200 +155,133 @@ export default function CreateScreen() {
setDescription('');
setResult(null);
setError(null);
setImageUri(null);
};
const profile = result?.generatedProfile;
// ── Loading / Generating ──
if (loading) {
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
{/* Banner + hint */}
<View className="items-center" style={{ paddingTop: 16, paddingBottom: 8 }}>
<View
className="bg-secondary rounded mb-2"
style={{
paddingHorizontal: 18,
paddingVertical: 6,
transform: [{ rotate: '-2deg' }],
}}
>
<Text
className="text-secondary-foreground"
style={{
fontSize: 14,
fontWeight: '900',
letterSpacing: 3,
textTransform: 'uppercase',
}}
>
Generating
</Text>
</View>
<Text className="text-muted-foreground" style={{ fontSize: 13, fontWeight: '600' }}>
{LOADING_MESSAGES[loadingMsg]}
</Text>
</View>
{/* Blurred placeholder card — shifted slightly upward */}
<View className="flex-1 items-center justify-center" style={{ paddingBottom: 120 }}>
<View style={{ width: CARD_WIDTH, height: PLACEHOLDER_HEIGHT }}>
<Animated.Image
entering={FadeIn.duration(400)}
source={placeholderImg}
style={{
width: CARD_WIDTH,
height: PLACEHOLDER_HEIGHT,
borderRadius: 16,
opacity: 0.25,
}}
resizeMode="cover"
blurRadius={20}
/>
<PulsingOverlay width={CARD_WIDTH} height={PLACEHOLDER_HEIGHT} />
</View>
</View>
</SafeAreaView>
);
}
// ── Result ──
if (result) {
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<ScrollView className="flex-1" contentContainerStyle={{ paddingBottom: 40 }}>
<View className="px-6 pt-8 items-center">
{/* Badge */}
<View
className="bg-secondary rounded mb-5"
{/* Banner + hint */}
<View className="items-center" style={{ paddingTop: 16, paddingBottom: 8 }}>
<View
className="bg-secondary rounded mb-2"
style={{
paddingHorizontal: 18,
paddingVertical: 6,
transform: [{ rotate: '-2deg' }],
}}
>
<Text
className="text-secondary-foreground"
style={{
paddingHorizontal: 14,
paddingVertical: 4,
transform: [{ rotate: '-2deg' }],
fontSize: 14,
fontWeight: '900',
letterSpacing: 3,
textTransform: 'uppercase',
}}
>
Unboxing
</Text>
</View>
<Text className="text-muted-foreground" style={{ fontSize: 13, fontWeight: '600' }}>
Drag to rotate · Double-tap to flip
</Text>
</View>
{/* Flippable Card — shifted slightly upward */}
<View className="flex-1 items-center justify-center" style={{ paddingBottom: 120 }}>
<FlippableCard figure={result} maxWidth={CARD_WIDTH} />
</View>
{/* Create Another — overlaid at bottom, above tab bar */}
<View
style={{
position: 'absolute',
bottom: 110,
left: 0,
right: 0,
alignItems: 'center',
}}
>
<Pressable onPress={handleReset} className="active:opacity-80">
<View
className="bg-accent rounded-full flex-row items-center"
style={{
paddingHorizontal: 16,
paddingVertical: 8,
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.15)',
gap: 6,
}}
>
<Text
className="text-secondary-foreground"
style={{
fontSize: 11,
fontSize: 13,
fontWeight: '900',
letterSpacing: 3,
color: 'rgb(15, 15, 30)',
letterSpacing: 1,
textTransform: 'uppercase',
}}
>
Unboxing
+ Create Another
</Text>
</View>
{/* Figure Card */}
<View className="w-full" style={{ position: 'relative' }}>
<View
className="bg-primary-dark rounded-lg"
style={{ position: 'absolute', top: 5, left: 5, right: -5, bottom: -5 }}
/>
<View
className="bg-surface rounded-lg"
style={{ borderWidth: 3, borderColor: 'rgb(255, 204, 0)', padding: 24 }}
>
{/* Image */}
{result.imageUrl ? (
<Image
source={{ uri: result.imageUrl }}
style={{
width: 200,
height: 200,
alignSelf: 'center',
marginBottom: 20,
borderRadius: 12,
}}
resizeMode="contain"
/>
) : (
<View
className="bg-input rounded-lg self-center items-center justify-center mb-5"
style={{
width: 200,
height: 200,
borderWidth: 2,
borderColor: 'rgb(50, 50, 80)',
}}
>
<Text className="text-muted-foreground" style={{ fontSize: 12 }}>
{result.status === 'failed' ? 'Generation failed' : 'No image'}
</Text>
</View>
)}
<Text
className="text-foreground text-center"
style={{ fontSize: 22, fontWeight: '900', letterSpacing: -0.3 }}
>
{result.name}
</Text>
{profile?.subtitle && (
<Text
className="text-muted-foreground text-center mt-1"
style={{
fontSize: 13,
fontWeight: '700',
letterSpacing: 1,
textTransform: 'uppercase',
}}
>
{profile.subtitle}
</Text>
)}
{profile?.backstory && (
<Text
className="text-muted-foreground text-center mt-3"
style={{ fontSize: 14, lineHeight: 20 }}
>
{profile.backstory}
</Text>
)}
{/* Stats */}
{profile?.stats && (
<View className="mt-4 w-full">
<StatBar label="ATK" value={profile.stats.attack} color={STAT_COLORS.attack} />
<StatBar
label="DEF"
value={profile.stats.defense}
color={STAT_COLORS.defense}
/>
<StatBar
label="SPL"
value={profile.stats.special}
color={STAT_COLORS.special}
/>
</View>
)}
{/* Special Attack */}
{profile?.specialAttack && (
<View className="mt-3 bg-input rounded-lg" style={{ padding: 12 }}>
<Text
className="text-primary"
style={{
fontSize: 11,
fontWeight: '900',
letterSpacing: 1,
textTransform: 'uppercase',
}}
>
{profile.specialAttack.name}
</Text>
<Text
className="text-muted-foreground mt-1"
style={{ fontSize: 12, lineHeight: 16 }}
>
{profile.specialAttack.description}
</Text>
</View>
)}
<View className="mt-4">
<RarityBadge rarity={result.rarity} />
</View>
{/* Error message */}
{result.status === 'failed' && result.errorMessage && (
<Text className="text-destructive text-center mt-3" style={{ fontSize: 12 }}>
{result.errorMessage}
</Text>
)}
</View>
</View>
{/* Create Another */}
<View className="w-full mt-8">
<Pressable onPress={handleReset} className="active:opacity-90">
<View style={{ position: 'relative' }}>
<View
style={{
position: 'absolute',
top: 5,
left: 3,
right: -3,
bottom: -5,
borderRadius: 8,
backgroundColor: 'rgb(0, 150, 120)',
}}
/>
<View
className="bg-accent rounded-lg items-center justify-center"
style={{
paddingVertical: 16,
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.15)',
}}
>
<Text
style={{
fontSize: 16,
fontWeight: '900',
color: 'rgb(15, 15, 30)',
letterSpacing: 1,
textTransform: 'uppercase',
}}
>
Create Another
</Text>
</View>
</View>
</Pressable>
</View>
</View>
</ScrollView>
</Pressable>
</View>
</SafeAreaView>
);
}
@ -337,6 +294,7 @@ export default function CreateScreen() {
className="flex-1"
>
<ScrollView
ref={scrollRef}
className="flex-1"
contentContainerStyle={{ paddingBottom: 40 }}
keyboardShouldPersistTaps="handled"
@ -404,6 +362,12 @@ export default function CreateScreen() {
value={name}
onChangeText={setName}
maxLength={200}
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => {
storyRef.current?.focus();
scrollRef.current?.scrollTo({ y: storyLayoutY.current - 20, animated: true });
}}
/>
</View>
@ -416,6 +380,9 @@ export default function CreateScreen() {
letterSpacing: 3,
textTransform: 'uppercase',
}}
onLayout={(e) => {
storyLayoutY.current = e.nativeEvent.layout.y;
}}
>
Story
</Text>
@ -425,6 +392,7 @@ export default function CreateScreen() {
style={{ position: 'absolute', top: 5, left: 5, right: -5, bottom: -5 }}
/>
<TextInput
ref={storyRef}
className="bg-input text-foreground rounded-lg"
style={{
borderWidth: 3,
@ -442,9 +410,94 @@ export default function CreateScreen() {
multiline
numberOfLines={4}
maxLength={2000}
onFocus={() => {
scrollRef.current?.scrollToEnd({ animated: true });
}}
/>
</View>
{/* Reference Photo */}
<View style={{ position: 'relative', marginBottom: 24 }}>
<View
className="bg-primary-dark rounded-lg"
style={{ position: 'absolute', top: 3, left: 3, right: -3, bottom: -3 }}
/>
{imageUri ? (
<View
className="bg-input rounded-lg flex-row items-center"
style={{
borderWidth: 3,
borderColor: 'rgb(255, 204, 0)',
paddingHorizontal: 14,
paddingVertical: 10,
gap: 10,
}}
>
<Image
source={{ uri: imageUri }}
style={{
width: 40,
height: 40,
borderRadius: 20,
borderWidth: 2,
borderColor: 'rgb(255, 204, 0)',
}}
/>
<Text
className="text-foreground"
style={{ fontSize: 14, fontWeight: '600', flex: 1 }}
>
Photo added
</Text>
<Pressable
onPress={() => setImageUri(null)}
className="active:opacity-70"
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: 'rgba(255, 80, 80, 0.2)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 14, fontWeight: '800', color: 'rgb(255, 80, 80)' }}>
×
</Text>
</Pressable>
</View>
) : (
<Pressable onPress={pickImage} className="active:opacity-80">
<View
className="bg-input rounded-lg flex-row items-center"
style={{
borderWidth: 3,
borderColor: 'rgb(255, 204, 0)',
paddingHorizontal: 14,
paddingVertical: 12,
}}
>
<Text style={{ fontSize: 20 }}>📷</Text>
<Text
className="text-muted-foreground"
style={{ fontSize: 14, fontWeight: '600', flex: 1, marginLeft: 10 }}
>
Add a face photo
</Text>
<Text
className="text-muted-foreground"
style={{ fontSize: 10, fontWeight: '600', marginRight: 4 }}
>
optional
</Text>
<Text className="text-muted-foreground" style={{ fontSize: 16 }}>
</Text>
</View>
</Pressable>
)}
</View>
{/* Error */}
{error && (
<View
@ -512,3 +565,40 @@ export default function CreateScreen() {
</SafeAreaView>
);
}
// ── Pulsing shimmer overlay for loading card ──
function PulsingOverlay({ width, height }: { width: number; height: number }) {
const opacity = useSharedValue(0.3);
useEffect(() => {
opacity.value = withRepeat(
withSequence(
withTiming(0.7, { duration: 1200, easing: Easing.inOut(Easing.ease) }),
withTiming(0.3, { duration: 1200, easing: Easing.inOut(Easing.ease) })
),
-1,
false
);
}, []);
const style = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
return (
<Animated.View
style={[
{
position: 'absolute',
top: 0,
left: 0,
width,
height,
borderRadius: 16,
backgroundColor: 'rgb(255, 204, 0)',
},
style,
]}
/>
);
}

View file

@ -1,30 +1,10 @@
import { useState, useEffect } from 'react';
import { View, Text, Pressable, Image, Dimensions, ActivityIndicator } from 'react-native';
import { View, Text, Pressable, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import type { FigureResponse, FigureRarity } from '@figgos/shared';
import type { FigureResponse } from '@figgos/shared';
import { api } from '../../../services/api';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const CONTAINER_WIDTH = SCREEN_WIDTH - 48;
const CONTAINER_HEIGHT = CONTAINER_WIDTH * 1.5;
const RARITY_COLORS: Record<FigureRarity, string> = {
common: 'rgb(136, 136, 170)',
rare: 'rgb(100, 180, 255)',
epic: 'rgb(180, 130, 255)',
legendary: 'rgb(255, 185, 30)',
};
const STAT_COLORS = {
attack: 'rgb(255, 51, 102)',
defense: 'rgb(0, 210, 170)',
special: 'rgb(180, 130, 255)',
};
const SPRING_CONFIG = { damping: 20, stiffness: 200, mass: 0.8 };
import FlippableCard from '../../../components/FlippableCard';
export default function CardDetailV2Screen() {
const { id } = useLocalSearchParams<{ id: string }>();
@ -41,61 +21,6 @@ export default function CardDetailV2Screen() {
.finally(() => setLoading(false));
}, [id]);
// Track the actual rendered image size
const [imageSize, setImageSize] = useState({ width: CONTAINER_WIDTH, height: CONTAINER_HEIGHT });
const computeContainSize = (srcW: number, srcH: number) => {
const ratio = Math.min(CONTAINER_WIDTH / srcW, CONTAINER_HEIGHT / srcH);
setImageSize({
width: Math.round(srcW * ratio),
height: Math.round(srcH * ratio),
});
};
// Rotation around vertical axis only
const rotateY = useSharedValue(0);
const savedRotateY = useSharedValue(0);
const pan = Gesture.Pan()
.onUpdate((e) => {
rotateY.value = savedRotateY.value + e.translationX * 0.5;
})
.onEnd(() => {
const normalised = ((rotateY.value % 360) + 360) % 360;
let target: number;
if (normalised < 90) {
target = 0;
} else if (normalised < 270) {
target = 180;
} else {
target = 360;
}
const diff = target - normalised;
const snapTo = rotateY.value + diff;
savedRotateY.value = snapTo % 360;
rotateY.value = withSpring(snapTo, SPRING_CONFIG);
});
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => {
const target = savedRotateY.value + 180;
savedRotateY.value = target % 360;
rotateY.value = withSpring(target, { damping: 18, stiffness: 150, mass: 1 });
});
const composed = Gesture.Race(doubleTap, pan);
const frontStyle = useAnimatedStyle(() => ({
transform: [{ perspective: 1200 }, { rotateY: `${rotateY.value}deg` }],
backfaceVisibility: 'hidden' as const,
}));
const backStyle = useAnimatedStyle(() => ({
transform: [{ perspective: 1200 }, { rotateY: `${rotateY.value + 180}deg` }],
backfaceVisibility: 'hidden' as const,
}));
if (loading) {
return (
<SafeAreaView className="flex-1 bg-background items-center justify-center">
@ -114,8 +39,6 @@ export default function CardDetailV2Screen() {
);
}
const profile = figure.generatedProfile;
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
{/* Back button */}
@ -134,249 +57,8 @@ export default function CardDetailV2Screen() {
</View>
<View className="flex-1 items-center justify-center">
<GestureDetector gesture={composed}>
<View style={{ width: imageSize.width, height: imageSize.height }}>
{/* ── Front: the image ── */}
<Animated.View
style={[
{
position: 'absolute',
width: imageSize.width,
height: imageSize.height,
},
frontStyle,
]}
>
{figure.imageUrl ? (
<Image
source={{ uri: figure.imageUrl }}
style={{ width: '100%', height: '100%' }}
resizeMode="contain"
onLoad={(e) => {
const { width: srcW, height: srcH } = e.nativeEvent.source;
computeContainSize(srcW, srcH);
}}
/>
) : (
<View
className="bg-surface items-center justify-center rounded-2xl"
style={{
width: '100%',
height: '100%',
borderWidth: 3,
borderColor: RARITY_COLORS[figure.rarity],
}}
>
<Text className="text-foreground" style={{ fontSize: 18, fontWeight: '800' }}>
{figure.name}
</Text>
<Text className="text-muted-foreground mt-2" style={{ fontSize: 12 }}>
No image
</Text>
</View>
)}
</Animated.View>
{/* ── Back ── */}
<Animated.View
style={[
{
position: 'absolute',
width: imageSize.width,
height: imageSize.height,
},
backStyle,
]}
>
{/* Shadow layer */}
<View
style={{
position: 'absolute',
top: 5,
left: 5,
right: -5,
bottom: -5,
borderRadius: 16,
backgroundColor: RARITY_COLORS[figure.rarity],
opacity: 0.3,
}}
/>
{/* Card back */}
<View
className="bg-surface rounded-2xl"
style={{
flex: 1,
borderWidth: 3,
borderColor: RARITY_COLORS[figure.rarity],
padding: 20,
justifyContent: 'space-between',
}}
>
{/* Header */}
<View>
<View
className="bg-secondary rounded self-start mb-2"
style={{
paddingHorizontal: 10,
paddingVertical: 2,
transform: [{ rotate: '-2deg' }],
}}
>
<Text
className="text-secondary-foreground"
style={{
fontSize: 9,
fontWeight: '900',
letterSpacing: 2,
textTransform: 'uppercase',
}}
>
Backstory
</Text>
</View>
<Text
className="text-foreground"
style={{ fontSize: 20, fontWeight: '900', letterSpacing: -0.5 }}
>
{figure.name}
</Text>
{profile?.subtitle && (
<Text
style={{
fontSize: 10,
fontWeight: '800',
letterSpacing: 2,
textTransform: 'uppercase',
marginTop: 2,
color: RARITY_COLORS[figure.rarity],
}}
>
{profile.subtitle}
</Text>
)}
</View>
{/* Description */}
<Text
className="text-muted-foreground"
style={{ fontSize: 13, lineHeight: 20 }}
numberOfLines={5}
>
{profile?.backstory || figure.userInput.description}
</Text>
{/* Stats */}
{profile?.stats && (
<View>
<Text
style={{
fontSize: 10,
fontWeight: '900',
letterSpacing: 3,
textTransform: 'uppercase',
color: RARITY_COLORS[figure.rarity],
marginBottom: 6,
}}
>
Stats
</Text>
<StatBar label="ATK" value={profile.stats.attack} color={STAT_COLORS.attack} />
<StatBar
label="DEF"
value={profile.stats.defense}
color={STAT_COLORS.defense}
/>
<StatBar
label="SPL"
value={profile.stats.special}
color={STAT_COLORS.special}
/>
</View>
)}
{/* Special Attack */}
{profile?.specialAttack && (
<View className="bg-input rounded" style={{ padding: 8 }}>
<Text
className="text-primary"
style={{
fontSize: 9,
fontWeight: '900',
letterSpacing: 1,
textTransform: 'uppercase',
}}
>
{profile.specialAttack.name}
</Text>
</View>
)}
{/* Bottom: rarity + ID */}
<View className="flex-row items-center justify-between">
<View
className="rounded-full"
style={{
paddingHorizontal: 10,
paddingVertical: 3,
backgroundColor: RARITY_COLORS[figure.rarity],
}}
>
<Text
style={{
fontSize: 9,
fontWeight: '900',
letterSpacing: 1.5,
textTransform: 'uppercase',
color: 'rgb(15, 15, 30)',
}}
>
{figure.rarity}
</Text>
</View>
<Text
className="text-muted-foreground"
style={{ fontSize: 9, fontWeight: '600', letterSpacing: 1 }}
>
#{figure.id.split('-').pop()?.toUpperCase()}
</Text>
</View>
</View>
</Animated.View>
</View>
</GestureDetector>
<FlippableCard figure={figure} />
</View>
</SafeAreaView>
);
}
function StatBar({ label, value, color }: { label: string; value: number; color: string }) {
return (
<View className="flex-row items-center mb-1.5" style={{ gap: 6 }}>
<Text
className="text-muted-foreground"
style={{ fontSize: 10, fontWeight: '900', width: 26, letterSpacing: 1 }}
>
{label}
</Text>
<View
className="flex-1 bg-input rounded-full"
style={{ height: 8, borderWidth: 1, borderColor: 'rgb(50, 50, 80)' }}
>
<View
style={{
width: `${value}%`,
height: '100%',
backgroundColor: color,
borderRadius: 999,
}}
/>
</View>
<Text
className="text-foreground"
style={{ fontSize: 10, fontWeight: '800', width: 22, textAlign: 'right' }}
>
{value}
</Text>
</View>
);
}

View file

@ -0,0 +1,304 @@
import { useState } from 'react';
import { View, Text, Image, Dimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import type { FigureResponse, FigureRarity } from '@figgos/shared';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const RARITY_COLORS: Record<FigureRarity, string> = {
common: 'rgb(136, 136, 170)',
rare: 'rgb(100, 180, 255)',
epic: 'rgb(180, 130, 255)',
legendary: 'rgb(255, 185, 30)',
};
const STAT_COLORS = {
attack: 'rgb(255, 51, 102)',
defense: 'rgb(0, 210, 170)',
special: 'rgb(180, 130, 255)',
};
const SPRING_CONFIG = { damping: 20, stiffness: 200, mass: 0.8 };
interface FlippableCardProps {
figure: FigureResponse;
maxWidth?: number;
maxHeight?: number;
}
export default function FlippableCard({ figure, maxWidth, maxHeight }: FlippableCardProps) {
const mW = maxWidth ?? SCREEN_WIDTH - 48;
const mH = maxHeight ?? SCREEN_HEIGHT * 0.7;
// Card size starts at max bounds, then snaps to image aspect ratio on load
const [cardSize, setCardSize] = useState({ width: mW, height: mH });
const [imageLoaded, setImageLoaded] = useState(false);
const onImageLoad = (srcW: number, srcH: number) => {
// Fit the source image dimensions into max bounds
const scale = Math.min(mW / srcW, mH / srcH);
setCardSize({
width: Math.round(srcW * scale),
height: Math.round(srcH * scale),
});
setImageLoaded(true);
};
const { width: cardWidth, height: cardHeight } = cardSize;
// ── Gestures ──
const rotateY = useSharedValue(0);
const savedRotateY = useSharedValue(0);
const pan = Gesture.Pan()
.onUpdate((e) => {
rotateY.value = savedRotateY.value + e.translationX * 0.5;
})
.onEnd(() => {
const normalised = ((rotateY.value % 360) + 360) % 360;
let target: number;
if (normalised < 90) target = 0;
else if (normalised < 270) target = 180;
else target = 360;
const snapTo = rotateY.value + (target - normalised);
savedRotateY.value = snapTo % 360;
rotateY.value = withSpring(snapTo, SPRING_CONFIG);
});
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => {
const target = savedRotateY.value + 180;
savedRotateY.value = target % 360;
rotateY.value = withSpring(target, { damping: 18, stiffness: 150, mass: 1 });
});
const composed = Gesture.Race(doubleTap, pan);
const frontStyle = useAnimatedStyle(() => ({
transform: [{ perspective: 1200 }, { rotateY: `${rotateY.value}deg` }],
backfaceVisibility: 'hidden' as const,
}));
const backStyle = useAnimatedStyle(() => ({
transform: [{ perspective: 1200 }, { rotateY: `${rotateY.value + 180}deg` }],
backfaceVisibility: 'hidden' as const,
}));
const profile = figure.generatedProfile;
const rarityColor = RARITY_COLORS[figure.rarity];
return (
<GestureDetector gesture={composed}>
<View style={{ width: cardWidth, height: cardHeight }}>
{/* ── Front ── */}
<Animated.View
style={[{ position: 'absolute', width: cardWidth, height: cardHeight }, frontStyle]}
>
{figure.imageUrl ? (
<Image
source={{ uri: figure.imageUrl }}
style={{ width: cardWidth, height: cardHeight }}
resizeMode="contain"
onLoad={(e) => {
const { width: w, height: h } = e.nativeEvent.source;
onImageLoad(w, h);
}}
/>
) : (
<View
className="bg-surface items-center justify-center rounded-2xl"
style={{
width: '100%',
height: '100%',
borderWidth: 3,
borderColor: rarityColor,
}}
>
<Text className="text-foreground" style={{ fontSize: 18, fontWeight: '800' }}>
{figure.name}
</Text>
<Text className="text-muted-foreground mt-2" style={{ fontSize: 12 }}>
{figure.status === 'failed' ? 'Generation failed' : 'No image'}
</Text>
</View>
)}
</Animated.View>
{/* ── Back ── */}
<Animated.View
style={[
{
position: 'absolute',
width: cardWidth,
height: cardHeight,
borderRadius: 16,
overflow: 'hidden',
},
backStyle,
]}
>
<View
className="bg-surface rounded-2xl"
style={{
flex: 1,
borderWidth: 3,
borderColor: rarityColor,
padding: 16,
justifyContent: 'space-between',
}}
>
{/* Header: name + subtitle */}
<View>
<Text
className="text-foreground"
style={{ fontSize: 18, fontWeight: '900', letterSpacing: -0.5 }}
>
{figure.name}
</Text>
{profile?.subtitle && (
<Text
style={{
fontSize: 9,
fontWeight: '800',
letterSpacing: 2,
textTransform: 'uppercase',
marginTop: 1,
color: rarityColor,
}}
>
{profile.subtitle}
</Text>
)}
</View>
{/* Backstory */}
<Text
className="text-muted-foreground"
style={{ fontSize: 11, lineHeight: 16 }}
numberOfLines={4}
>
{profile?.backstory || figure.userInput.description}
</Text>
{/* Stats */}
{profile?.stats && (
<View>
<StatBar label="ATK" value={profile.stats.attack} color={STAT_COLORS.attack} />
<StatBar label="DEF" value={profile.stats.defense} color={STAT_COLORS.defense} />
<StatBar label="SPL" value={profile.stats.special} color={STAT_COLORS.special} />
</View>
)}
{/* Special Attack */}
{profile?.specialAttack && (
<View className="bg-input rounded" style={{ padding: 8 }}>
<Text
className="text-primary"
style={{
fontSize: 9,
fontWeight: '900',
letterSpacing: 1,
textTransform: 'uppercase',
}}
>
{profile.specialAttack.name}
</Text>
<Text
className="text-muted-foreground"
style={{ fontSize: 9, lineHeight: 13, marginTop: 2 }}
numberOfLines={2}
>
{profile.specialAttack.description}
</Text>
</View>
)}
{/* Items */}
{profile?.items && profile.items.length > 0 && (
<View className="flex-row flex-wrap" style={{ gap: 4 }}>
{profile.items.map((item) => (
<View
key={item.name}
className="bg-input rounded-full"
style={{ paddingHorizontal: 8, paddingVertical: 3 }}
>
<Text
className="text-foreground"
style={{ fontSize: 8, fontWeight: '800', letterSpacing: 0.5 }}
>
{item.name}
</Text>
</View>
))}
</View>
)}
{/* Bottom: rarity + ID */}
<View className="flex-row items-center justify-between">
<View
className="rounded-full"
style={{
paddingHorizontal: 10,
paddingVertical: 3,
backgroundColor: rarityColor,
}}
>
<Text
style={{
fontSize: 9,
fontWeight: '900',
letterSpacing: 1.5,
textTransform: 'uppercase',
color: 'rgb(15, 15, 30)',
}}
>
{figure.rarity}
</Text>
</View>
<Text
className="text-muted-foreground"
style={{ fontSize: 9, fontWeight: '600', letterSpacing: 1 }}
>
#{figure.id.split('-').pop()?.toUpperCase()}
</Text>
</View>
</View>
</Animated.View>
</View>
</GestureDetector>
);
}
function StatBar({ label, value, color }: { label: string; value: number; color: string }) {
return (
<View className="flex-row items-center mb-1.5" style={{ gap: 6 }}>
<Text
className="text-muted-foreground"
style={{ fontSize: 10, fontWeight: '900', width: 26, letterSpacing: 1 }}
>
{label}
</Text>
<View
className="flex-1 bg-input rounded-full"
style={{ height: 8, borderWidth: 1, borderColor: 'rgb(50, 50, 80)' }}
>
<View
style={{
width: `${value}%`,
height: '100%',
backgroundColor: color,
borderRadius: 999,
}}
/>
</View>
<Text
className="text-foreground"
style={{ fontSize: 10, fontWeight: '800', width: 22, textAlign: 'right' }}
>
{value}
</Text>
</View>
);
}

View file

@ -18,10 +18,13 @@
"@expo/vector-icons": "^15.0.3",
"@figgos/shared": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
"expo-image-manipulator": "^14.0.8",
"expo-image-picker": "^17.0.10",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
@ -38,8 +41,7 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"@react-native-async-storage/async-storage": "2.2.0"
"react-native-worklets": "0.5.1"
},
"devDependencies": {
"@babel/core": "^7.20.0",

View file

@ -26,10 +26,10 @@ export const api = {
health: () => fetchApi('/health'),
figures: {
create: (name: string, description: string, language: string = 'en') =>
create: (name: string, description: string, language: string = 'en', faceImage?: string) =>
fetchApi<{ figure: FigureResponse }>('/api/v1/figures', {
method: 'POST',
body: JSON.stringify({ name, description, language }),
body: JSON.stringify({ name, description, language, ...(faceImage && { faceImage }) }),
}),
list: () => fetchApi<{ figures: FigureResponse[] }>('/api/v1/figures'),

View file

@ -16,10 +16,10 @@ async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
export const api = {
figures: {
create: (name: string, description: string, language = 'en') =>
create: (name: string, description: string, language = 'en', faceImage?: string) =>
fetchApi<{ figure: FigureResponse }>('/api/v1/figures', {
method: 'POST',
body: JSON.stringify({ name, description, language }),
body: JSON.stringify({ name, description, language, ...(faceImage && { faceImage }) }),
}),
list: () => fetchApi<{ figures: FigureResponse[] }>('/api/v1/figures'),
get: (id: string) => fetchApi<{ figure: FigureResponse }>(`/api/v1/figures/${id}`),

View file

@ -55,7 +55,6 @@ export type FigureStatus =
export interface FigureUserInput {
description: string;
faceImageUrl?: string | null;
language: FigureLanguage;
}
@ -100,6 +99,7 @@ export interface FigureResponse {
language: FigureLanguage;
status: FigureStatus;
isPublic: boolean;
errorMessage: string | null;
isArchived: boolean;
createdAt: string;
updatedAt: string;

341
pnpm-lock.yaml generated
View file

@ -626,19 +626,19 @@ importers:
version: 18.3.27
'@typescript-eslint/eslint-plugin':
specifier: ^7.7.0
version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/parser':
specifier: ^7.7.0
version: 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
version: 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
dotenv:
specifier: ^16.4.7
version: 16.6.1
eslint:
specifier: ^9.39.1
version: 9.39.1(jiti@1.21.7)
version: 9.39.1(jiti@2.6.1)
eslint-config-universe:
specifier: ^12.0.1
version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3)
version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3)
prettier:
specifier: ^3.2.5
version: 3.6.2
@ -1551,7 +1551,7 @@ importers:
devDependencies:
'@nestjs/cli':
specifier: ^10.4.9
version: 10.4.9(esbuild@0.19.12)
version: 10.4.9(esbuild@0.27.0)
'@nestjs/schematics':
specifier: ^10.2.3
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
@ -1566,7 +1566,7 @@ importers:
version: 0.5.21
ts-loader:
specifier: ^9.5.1
version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12))
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0))
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
@ -1606,6 +1606,12 @@ importers:
expo-font:
specifier: ~14.0.11
version: 14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-image-manipulator:
specifier: ^14.0.8
version: 14.0.8(expo@54.0.33)
expo-image-picker:
specifier: ^17.0.10
version: 17.0.10(expo@54.0.33)
expo-linking:
specifier: ~8.0.11
version: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@ -16773,11 +16779,21 @@ packages:
peerDependencies:
expo: '*'
expo-image-manipulator@14.0.8:
resolution: {integrity: sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==}
peerDependencies:
expo: '*'
expo-image-picker@16.0.6:
resolution: {integrity: sha512-HN4xZirFjsFDIsWFb12AZh19fRzuvZjj2ll17cGr19VNRP06S/VPQU3Tdccn5vwUzQhOBlLu704CnNm278boiQ==}
peerDependencies:
expo: '*'
expo-image-picker@17.0.10:
resolution: {integrity: sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==}
peerDependencies:
expo: '*'
expo-image-picker@17.0.8:
resolution: {integrity: sha512-489ByhVs2XPoAu9zodivAKLv7hG4S/FOe8hO/C2U6jVxmRjpAKakKNjMml0IwWjf1+c/RYBqm1XxKaZ+vq/fDQ==}
peerDependencies:
@ -27296,11 +27312,6 @@ snapshots:
eslint: 8.57.1
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@1.21.7))':
dependencies:
eslint: 9.39.1(jiti@1.21.7)
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))':
dependencies:
eslint: 9.39.1(jiti@2.6.1)
@ -27988,6 +27999,7 @@ snapshots:
- graphql
- supports-color
- utf-8-validate
optional: true
'@expo/code-signing-certificates@0.0.5':
dependencies:
@ -28203,6 +28215,7 @@ snapshots:
optionalDependencies:
react: 19.1.0
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
optional: true
'@expo/env@0.4.2':
dependencies:
@ -28422,7 +28435,7 @@ snapshots:
postcss: 8.4.49
resolve-from: 5.0.0
optionalDependencies:
expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
transitivePeerDependencies:
- bufferutil
- supports-color
@ -28884,7 +28897,7 @@ snapshots:
'@expo/json-file': 10.0.8
'@react-native/normalize-colors': 0.81.5
debug: 4.4.3
expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
resolve-from: 5.0.0
semver: 7.7.3
xml2js: 0.6.0
@ -29010,6 +29023,7 @@ snapshots:
expo-font: 14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
optional: true
'@expo/vector-icons@15.0.3(expo-font@14.0.9(expo@54.0.13)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)':
dependencies:
@ -30320,32 +30334,6 @@ snapshots:
- uglify-js
- webpack-cli
'@nestjs/cli@10.4.9(esbuild@0.19.12)':
dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
'@angular-devkit/schematics': 17.3.11(chokidar@3.6.0)
'@angular-devkit/schematics-cli': 17.3.11(chokidar@3.6.0)
'@nestjs/schematics': 10.2.3(chokidar@3.6.0)(typescript@5.7.2)
chalk: 4.1.2
chokidar: 3.6.0
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.19.12))
glob: 10.4.5
inquirer: 8.2.6
node-emoji: 1.11.0
ora: 5.4.1
tree-kill: 1.2.2
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.7.2
webpack: 5.97.1(esbuild@0.19.12)
webpack-node-externals: 3.0.0
transitivePeerDependencies:
- esbuild
- uglify-js
- webpack-cli
'@nestjs/cli@10.4.9(esbuild@0.27.0)':
dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
@ -35008,16 +34996,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/scope-manager': 6.21.0
'@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 6.21.0
debug: 4.4.3
eslint: 9.39.1(jiti@1.21.7)
eslint: 9.39.1(jiti@2.6.1)
graphemer: 1.4.0
ignore: 5.3.2
natural-compare: 1.4.0
@ -35066,15 +35054,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/scope-manager': 7.18.0
'@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 7.18.0
eslint: 9.39.1(jiti@1.21.7)
eslint: 9.39.1(jiti@2.6.1)
graphemer: 1.4.0
ignore: 5.3.2
natural-compare: 1.4.0
@ -35166,14 +35154,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@typescript-eslint/scope-manager': 6.21.0
'@typescript-eslint/types': 6.21.0
'@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 6.21.0
debug: 4.4.3
eslint: 9.39.1(jiti@1.21.7)
eslint: 9.39.1(jiti@2.6.1)
optionalDependencies:
typescript: 5.3.3
transitivePeerDependencies:
@ -35205,14 +35193,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@typescript-eslint/scope-manager': 7.18.0
'@typescript-eslint/types': 7.18.0
'@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 7.18.0
debug: 4.4.3
eslint: 9.39.1(jiti@1.21.7)
eslint: 9.39.1(jiti@2.6.1)
optionalDependencies:
typescript: 5.3.3
transitivePeerDependencies:
@ -35338,12 +35326,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3)
'@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
debug: 4.4.3
eslint: 9.39.1(jiti@1.21.7)
eslint: 9.39.1(jiti@2.6.1)
ts-api-utils: 1.4.3(typescript@5.3.3)
optionalDependencies:
typescript: 5.3.3
@ -35374,12 +35362,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3)
'@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
debug: 4.4.3
eslint: 9.39.1(jiti@1.21.7)
eslint: 9.39.1(jiti@2.6.1)
ts-api-utils: 1.4.3(typescript@5.3.3)
optionalDependencies:
typescript: 5.3.3
@ -35561,15 +35549,15 @@ snapshots:
- supports-color
- typescript
'@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
'@types/json-schema': 7.0.15
'@types/semver': 7.7.1
'@typescript-eslint/scope-manager': 6.21.0
'@typescript-eslint/types': 6.21.0
'@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3)
eslint: 9.39.1(jiti@1.21.7)
eslint: 9.39.1(jiti@2.6.1)
semver: 7.7.3
transitivePeerDependencies:
- supports-color
@ -35600,13 +35588,13 @@ snapshots:
- supports-color
- typescript
'@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
'@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
'@typescript-eslint/scope-manager': 7.18.0
'@typescript-eslint/types': 7.18.0
'@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3)
eslint: 9.39.1(jiti@1.21.7)
eslint: 9.39.1(jiti@2.6.1)
transitivePeerDependencies:
- supports-color
- typescript
@ -36957,7 +36945,7 @@ snapshots:
resolve-from: 5.0.0
optionalDependencies:
'@babel/runtime': 7.28.4
expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
transitivePeerDependencies:
- '@babel/core'
- supports-color
@ -39060,10 +39048,6 @@ snapshots:
dependencies:
eslint: 8.57.1
eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)):
dependencies:
eslint: 9.39.1(jiti@1.21.7)
eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
@ -39092,17 +39076,17 @@ snapshots:
- supports-color
- typescript
eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3):
eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3):
dependencies:
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
eslint: 9.39.1(jiti@1.21.7)
eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)
eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@1.21.7))
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
eslint: 9.39.1(jiti@2.6.1)
eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)
eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@2.6.1))
optionalDependencies:
prettier: 3.6.2
transitivePeerDependencies:
@ -39165,12 +39149,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7)):
eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
eslint: 9.39.1(jiti@1.21.7)
'@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
transitivePeerDependencies:
- supports-color
@ -39227,12 +39211,6 @@ snapshots:
eslint-utils: 2.1.0
regexpp: 3.2.0
eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@1.21.7)):
dependencies:
eslint: 9.39.1(jiti@1.21.7)
eslint-utils: 2.1.0
regexpp: 3.2.0
eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@2.6.1)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
@ -39286,7 +39264,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7)):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@ -39295,9 +39273,9 @@ snapshots:
array.prototype.flatmap: 1.3.3
debug: 3.2.7
doctrine: 2.1.0
eslint: 9.39.1(jiti@1.21.7)
eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7))
eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@ -39309,7 +39287,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
'@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@ -39412,16 +39390,6 @@ snapshots:
resolve: 1.22.11
semver: 6.3.1
eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@1.21.7)):
dependencies:
eslint: 9.39.1(jiti@1.21.7)
eslint-plugin-es: 3.0.1(eslint@9.39.1(jiti@1.21.7))
eslint-utils: 2.1.0
ignore: 5.3.2
minimatch: 3.1.2
resolve: 1.22.11
semver: 6.3.1
eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@2.6.1)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
@ -39452,16 +39420,6 @@ snapshots:
'@types/eslint': 9.6.1
eslint-config-prettier: 8.10.2(eslint@8.57.1)
eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2):
dependencies:
eslint: 9.39.1(jiti@1.21.7)
prettier: 3.6.2
prettier-linter-helpers: 1.0.0
synckit: 0.11.11
optionalDependencies:
'@types/eslint': 9.6.1
eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
@ -39486,10 +39444,6 @@ snapshots:
dependencies:
eslint: 8.57.1
eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@1.21.7)):
dependencies:
eslint: 9.39.1(jiti@1.21.7)
eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@2.6.1)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
@ -39520,28 +39474,6 @@ snapshots:
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@1.21.7)):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
array.prototype.flatmap: 1.3.3
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.2.1
eslint: 9.39.1(jiti@1.21.7)
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
minimatch: 3.1.2
object.entries: 1.1.9
object.fromentries: 2.0.8
object.values: 1.2.1
prop-types: 15.8.1
resolve: 2.0.0-next.5
semver: 6.3.1
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)):
dependencies:
array-includes: 3.1.9
@ -39668,47 +39600,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint@9.39.1(jiti@1.21.7):
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.1
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.1
'@eslint/js': 9.39.1
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
espree: 10.4.0
esquery: 1.6.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0
find-up: 5.0.0
glob-parent: 6.0.2
ignore: 5.3.2
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
minimatch: 3.1.2
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
jiti: 1.21.7
transitivePeerDependencies:
- supports-color
eslint@9.39.1(jiti@2.6.1):
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
@ -40012,6 +39903,7 @@ snapshots:
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
transitivePeerDependencies:
- supports-color
optional: true
expo-blur@14.0.3(expo@52.0.47)(react-native@0.76.3(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(encoding@0.1.13)(react@18.3.1))(react@18.3.1):
dependencies:
@ -40159,6 +40051,7 @@ snapshots:
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
transitivePeerDependencies:
- supports-color
optional: true
expo-dev-client@5.0.20(expo@52.0.47):
dependencies:
@ -40343,6 +40236,7 @@ snapshots:
dependencies:
expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
optional: true
expo-font@13.0.4(expo@52.0.47)(react@18.3.1):
dependencies:
@ -40412,6 +40306,7 @@ snapshots:
fontfaceobserver: 2.3.0
react: 19.1.0
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
optional: true
expo-font@14.0.9(expo@54.0.13)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0):
dependencies:
@ -40436,11 +40331,25 @@ snapshots:
dependencies:
expo: 54.0.13(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
expo-image-loader@6.0.0(expo@54.0.33):
dependencies:
expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-image-manipulator@14.0.8(expo@54.0.33):
dependencies:
expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-image-loader: 6.0.0(expo@54.0.33)
expo-image-picker@16.0.6(expo@52.0.47):
dependencies:
expo: 52.0.47(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@expo/metro-runtime@6.1.2)(encoding@0.1.13)(react-native-webview@13.12.2(react-native@0.76.3(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.3(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
expo-image-loader: 5.0.0(expo@52.0.47)
expo-image-picker@17.0.10(expo@54.0.33):
dependencies:
expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-image-loader: 6.0.0(expo@54.0.33)
expo-image-picker@17.0.8(expo@54.0.13):
dependencies:
expo: 54.0.13(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
@ -40485,7 +40394,7 @@ snapshots:
expo-keep-awake@15.0.8(expo@54.0.33)(react@19.1.0):
dependencies:
expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
expo: 54.0.33(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react: 19.1.0
expo-linear-gradient@15.0.7(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0):
@ -40742,6 +40651,7 @@ snapshots:
invariant: 2.2.4
react: 19.1.0
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
optional: true
expo-router@4.0.21(65am5mbkmp63mwl4oejo7rwcjy):
dependencies:
@ -41764,6 +41674,7 @@ snapshots:
- graphql
- supports-color
- utf-8-validate
optional: true
exponential-backoff@3.1.3: {}
@ -42148,23 +42059,6 @@ snapshots:
forever-agent@0.6.1: {}
fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.19.12)):
dependencies:
'@babel/code-frame': 7.27.1
chalk: 4.1.2
chokidar: 3.6.0
cosmiconfig: 8.3.6(typescript@5.7.2)
deepmerge: 4.3.1
fs-extra: 10.1.0
memfs: 3.5.3
minimatch: 3.1.2
node-abort-controller: 3.1.1
schema-utils: 3.3.0
semver: 7.7.3
tapable: 2.3.0
typescript: 5.7.2
webpack: 5.97.1(esbuild@0.19.12)
fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.27.0)):
dependencies:
'@babel/code-frame': 7.27.1
@ -50890,17 +50784,6 @@ snapshots:
ansi-escapes: 4.3.2
supports-hyperlinks: 2.3.0
terser-webpack-plugin@5.3.14(esbuild@0.19.12)(webpack@5.97.1(esbuild@0.19.12)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
serialize-javascript: 6.0.2
terser: 5.44.1
webpack: 5.97.1(esbuild@0.19.12)
optionalDependencies:
esbuild: 0.19.12
terser-webpack-plugin@5.3.14(esbuild@0.27.0)(webpack@5.100.2(esbuild@0.27.0)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@ -51176,16 +51059,6 @@ snapshots:
typescript: 5.9.3
webpack: 5.100.2
ts-loader@9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)):
dependencies:
chalk: 4.1.2
enhanced-resolve: 5.18.3
micromatch: 4.0.8
semver: 7.7.3
source-map: 0.7.6
typescript: 5.9.3
webpack: 5.97.1(esbuild@0.19.12)
ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
@ -52518,36 +52391,6 @@ snapshots:
- esbuild
- uglify-js
webpack@5.97.1(esbuild@0.19.12):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.15.0
browserslist: 4.28.0
chrome-trace-event: 1.0.4
enhanced-resolve: 5.18.3
es-module-lexer: 1.7.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
loader-runner: 4.3.1
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 3.3.0
tapable: 2.3.0
terser-webpack-plugin: 5.3.14(esbuild@0.19.12)(webpack@5.97.1(esbuild@0.19.12))
watchpack: 2.4.4
webpack-sources: 3.3.3
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
webpack@5.97.1(esbuild@0.27.0):
dependencies:
'@types/eslint-scope': 3.7.7