← スキル一覧に戻る

react-native-vet
by Ai-Whisperers
Multi-tenant veterinary clinic management platform - Next.js 15, Supabase, TypeScript
⭐ 0🍴 0📅 2026年1月24日
SKILL.md
name: react-native-vet description: React Native patterns for veterinary mobile apps including push notifications, offline-first data, QR scanning, camera integration, and mobile payments. Use when planning or building the Vete mobile app.
React Native Veterinary App Patterns
Overview
This skill covers React Native development patterns for building a mobile companion app for the Vete veterinary platform, with focus on pet owner features.
1. Project Structure
vete-mobile/
├── app/ # Expo Router screens
│ ├── (auth)/ # Auth screens (login, register)
│ │ ├── login.tsx
│ │ ├── register.tsx
│ │ └── forgot-password.tsx
│ ├── (tabs)/ # Main tab navigation
│ │ ├── _layout.tsx
│ │ ├── home.tsx
│ │ ├── pets.tsx
│ │ ├── appointments.tsx
│ │ ├── store.tsx
│ │ └── profile.tsx
│ ├── pet/[id]/ # Pet details
│ │ ├── index.tsx
│ │ ├── vaccines.tsx
│ │ ├── records.tsx
│ │ └── qr-tag.tsx
│ ├── appointment/[id].tsx
│ ├── booking/ # Booking flow
│ │ ├── service.tsx
│ │ ├── date.tsx
│ │ ├── time.tsx
│ │ └── confirm.tsx
│ └── _layout.tsx
├── components/
│ ├── ui/ # Reusable UI components
│ ├── pets/ # Pet-related components
│ ├── appointments/ # Appointment components
│ └── common/ # Shared components
├── lib/
│ ├── api/ # API client
│ ├── hooks/ # Custom hooks
│ ├── store/ # Zustand stores
│ ├── utils/ # Utilities
│ └── notifications/ # Push notification handling
├── assets/ # Images, fonts
└── app.json # Expo config
2. Push Notifications
Expo Notifications Setup
// lib/notifications/setup.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import { supabase } from '@/lib/api/supabase';
// Configure notification behavior
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
export async function registerForPushNotifications(): Promise<string | null> {
if (!Device.isDevice) {
console.log('Push notifications require a physical device');
return null;
}
// Check permissions
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('Push notification permission denied');
return null;
}
// Get Expo push token
const token = await Notifications.getExpoPushTokenAsync({
projectId: process.env.EXPO_PROJECT_ID,
});
// Android channel setup
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('appointments', {
name: 'Citas',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#FF6B6B',
});
await Notifications.setNotificationChannelAsync('vaccines', {
name: 'Vacunas',
importance: Notifications.AndroidImportance.HIGH,
});
await Notifications.setNotificationChannelAsync('promotions', {
name: 'Promociones',
importance: Notifications.AndroidImportance.DEFAULT,
});
}
return token.data;
}
export async function savePushToken(userId: string, token: string): Promise<void> {
await supabase.from('push_tokens').upsert({
user_id: userId,
token,
platform: Platform.OS,
updated_at: new Date().toISOString(),
});
}
Notification Handlers
// lib/notifications/handlers.ts
import * as Notifications from 'expo-notifications';
import { router } from 'expo-router';
export function setupNotificationListeners() {
// Handle notification received while app is foregrounded
const foregroundSubscription = Notifications.addNotificationReceivedListener(
(notification) => {
console.log('Notification received:', notification);
}
);
// Handle notification tap
const responseSubscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
const data = response.notification.request.content.data;
// Navigate based on notification type
switch (data.type) {
case 'appointment_reminder':
router.push(`/appointment/${data.appointmentId}`);
break;
case 'vaccine_due':
router.push(`/pet/${data.petId}/vaccines`);
break;
case 'prescription_ready':
router.push(`/pet/${data.petId}/records`);
break;
case 'order_update':
router.push(`/orders/${data.orderId}`);
break;
default:
router.push('/home');
}
}
);
return () => {
foregroundSubscription.remove();
responseSubscription.remove();
};
}
Notification Types
// lib/notifications/types.ts
export interface VetNotification {
type: NotificationType;
title: string;
body: string;
data: NotificationData;
}
export type NotificationType =
| 'appointment_reminder'
| 'appointment_confirmed'
| 'appointment_cancelled'
| 'vaccine_due'
| 'vaccine_overdue'
| 'prescription_ready'
| 'lab_results_ready'
| 'order_shipped'
| 'order_delivered'
| 'promotion';
export interface NotificationData {
type: NotificationType;
appointmentId?: string;
petId?: string;
orderId?: string;
clinicId?: string;
}
// Notification templates (Spanish)
export const NOTIFICATION_TEMPLATES = {
appointment_reminder_24h: {
title: '⏰ Recordatorio de cita',
body: '{{petName}} tiene cita mañana a las {{time}}',
},
appointment_reminder_1h: {
title: '🔔 Cita en 1 hora',
body: '{{petName}} tiene cita a las {{time}} en {{clinicName}}',
},
vaccine_due: {
title: '💉 Vacuna pendiente',
body: '{{petName}} necesita su vacuna de {{vaccineName}}',
},
prescription_ready: {
title: '📋 Receta lista',
body: 'La receta de {{petName}} está lista para retirar',
},
lab_results: {
title: '🔬 Resultados disponibles',
body: 'Los resultados de {{petName}} ya están disponibles',
},
};
3. Offline-First Data
MMKV Storage Setup
// lib/storage/mmkv.ts
import { MMKV } from 'react-native-mmkv';
export const storage = new MMKV({
id: 'vete-app-storage',
encryptionKey: 'your-encryption-key', // For sensitive data
});
// Type-safe storage helpers
export const mmkvStorage = {
getString: (key: string) => storage.getString(key),
setString: (key: string, value: string) => storage.set(key, value),
getObject: <T>(key: string): T | null => {
const value = storage.getString(key);
return value ? JSON.parse(value) : null;
},
setObject: <T>(key: string, value: T) => {
storage.set(key, JSON.stringify(value));
},
delete: (key: string) => storage.delete(key),
clearAll: () => storage.clearAll(),
};
Offline Pets Store
// lib/store/pets-store.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { mmkvStorage } from '@/lib/storage/mmkv';
import { Pet, Vaccine, MedicalRecord } from '@/lib/types';
interface PetsState {
pets: Pet[];
vaccines: Record<string, Vaccine[]>; // Keyed by petId
records: Record<string, MedicalRecord[]>;
lastSynced: string | null;
// Actions
setPets: (pets: Pet[]) => void;
addPet: (pet: Pet) => void;
updatePet: (petId: string, updates: Partial<Pet>) => void;
setVaccines: (petId: string, vaccines: Vaccine[]) => void;
setRecords: (petId: string, records: MedicalRecord[]) => void;
setLastSynced: (date: string) => void;
}
export const usePetsStore = create<PetsState>()(
persist(
(set) => ({
pets: [],
vaccines: {},
records: {},
lastSynced: null,
setPets: (pets) => set({ pets }),
addPet: (pet) => set((state) => ({ pets: [...state.pets, pet] })),
updatePet: (petId, updates) =>
set((state) => ({
pets: state.pets.map((p) =>
p.id === petId ? { ...p, ...updates } : p
),
})),
setVaccines: (petId, vaccines) =>
set((state) => ({
vaccines: { ...state.vaccines, [petId]: vaccines },
})),
setRecords: (petId, records) =>
set((state) => ({
records: { ...state.records, [petId]: records },
})),
setLastSynced: (date) => set({ lastSynced: date }),
}),
{
name: 'pets-storage',
storage: createJSONStorage(() => ({
getItem: (name) => mmkvStorage.getString(name) ?? null,
setItem: (name, value) => mmkvStorage.setString(name, value),
removeItem: (name) => mmkvStorage.delete(name),
})),
}
)
);
Sync Service
// lib/sync/sync-service.ts
import NetInfo from '@react-native-community/netinfo';
import { supabase } from '@/lib/api/supabase';
import { usePetsStore } from '@/lib/store/pets-store';
import { useAppointmentsStore } from '@/lib/store/appointments-store';
class SyncService {
private isSyncing = false;
async syncAll(): Promise<void> {
if (this.isSyncing) return;
const netInfo = await NetInfo.fetch();
if (!netInfo.isConnected) {
console.log('Offline - skipping sync');
return;
}
this.isSyncing = true;
try {
await Promise.all([
this.syncPets(),
this.syncAppointments(),
this.syncVaccines(),
]);
usePetsStore.getState().setLastSynced(new Date().toISOString());
} catch (error) {
console.error('Sync failed:', error);
} finally {
this.isSyncing = false;
}
}
private async syncPets(): Promise<void> {
const { data: pets, error } = await supabase
.from('pets')
.select('*')
.order('name');
if (!error && pets) {
usePetsStore.getState().setPets(pets);
}
}
private async syncAppointments(): Promise<void> {
const { data: appointments, error } = await supabase
.from('appointments')
.select(`
*,
services (name, duration_minutes),
pets (name, species)
`)
.gte('start_time', new Date().toISOString())
.order('start_time');
if (!error && appointments) {
useAppointmentsStore.getState().setAppointments(appointments);
}
}
private async syncVaccines(): Promise<void> {
const pets = usePetsStore.getState().pets;
for (const pet of pets) {
const { data: vaccines, error } = await supabase
.from('vaccines')
.select('*')
.eq('pet_id', pet.id)
.order('administered_date', { ascending: false });
if (!error && vaccines) {
usePetsStore.getState().setVaccines(pet.id, vaccines);
}
}
}
}
export const syncService = new SyncService();
4. QR Code Scanning
QR Scanner Component
// components/qr-scanner.tsx
import { useState, useEffect } from 'react';
import { View, Text, StyleSheet, Alert } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { router } from 'expo-router';
interface QRScannerProps {
onScan: (data: string) => void;
onClose: () => void;
}
export function QRScanner({ onScan, onClose }: QRScannerProps) {
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false);
useEffect(() => {
if (!permission?.granted) {
requestPermission();
}
}, []);
const handleBarCodeScanned = ({ data }: { data: string }) => {
if (scanned) return;
setScanned(true);
// Parse QR data
try {
// Expected format: vete://pet/{petId} or vete://tag/{tagCode}
const url = new URL(data);
if (url.protocol === 'vete:') {
const [, type, id] = url.pathname.split('/');
switch (type) {
case 'pet':
router.push(`/pet/${id}`);
break;
case 'tag':
handleTagScan(id);
break;
default:
Alert.alert('Código no reconocido');
}
} else {
onScan(data);
}
} catch {
// Not a URL, treat as plain text
onScan(data);
}
};
if (!permission?.granted) {
return (
<View style={styles.container}>
<Text style={styles.message}>
Necesitamos acceso a la cámara para escanear códigos QR
</Text>
</View>
);
}
return (
<View style={styles.container}>
<CameraView
style={styles.camera}
facing="back"
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ['qr'],
}}
/>
{/* Overlay with scan frame */}
<View style={styles.overlay}>
<View style={styles.scanFrame} />
<Text style={styles.hint}>Apuntá al código QR</Text>
</View>
</View>
);
}
async function handleTagScan(tagCode: string) {
// Look up tag and navigate to pet
const { data: tag, error } = await supabase
.from('qr_tags')
.select('pet_id')
.eq('code', tagCode)
.eq('is_active', true)
.single();
if (tag?.pet_id) {
router.push(`/pet/${tag.pet_id}`);
} else {
Alert.alert('Tag no encontrado', 'Este tag no está asociado a ninguna mascota');
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
camera: {
flex: 1,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
},
scanFrame: {
width: 250,
height: 250,
borderWidth: 2,
borderColor: '#fff',
borderRadius: 16,
backgroundColor: 'transparent',
},
hint: {
color: '#fff',
fontSize: 16,
marginTop: 20,
},
message: {
textAlign: 'center',
padding: 20,
},
});
5. Camera Integration (Pet Photos)
Photo Capture Hook
// lib/hooks/use-pet-photo.ts
import { useState } from 'react';
import * as ImagePicker from 'expo-image-picker';
import * as ImageManipulator from 'expo-image-manipulator';
import { supabase } from '@/lib/api/supabase';
interface UsePetPhotoOptions {
petId: string;
onSuccess?: (url: string) => void;
}
export function usePetPhoto({ petId, onSuccess }: UsePetPhotoOptions) {
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const takePhoto = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
setError('Se necesita permiso para acceder a la cámara');
return;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled) {
await uploadPhoto(result.assets[0].uri);
}
};
const pickFromLibrary = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
setError('Se necesita permiso para acceder a la galería');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled) {
await uploadPhoto(result.assets[0].uri);
}
};
const uploadPhoto = async (uri: string) => {
setIsUploading(true);
setError(null);
try {
// Resize and compress image
const manipulated = await ImageManipulator.manipulateAsync(
uri,
[{ resize: { width: 500, height: 500 } }],
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG }
);
// Convert to blob
const response = await fetch(manipulated.uri);
const blob = await response.blob();
// Upload to Supabase Storage
const filename = `${petId}/${Date.now()}.jpg`;
const { data, error: uploadError } = await supabase.storage
.from('pet-photos')
.upload(filename, blob, {
contentType: 'image/jpeg',
upsert: true,
});
if (uploadError) throw uploadError;
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('pet-photos')
.getPublicUrl(filename);
// Update pet record
await supabase
.from('pets')
.update({ photo_url: publicUrl })
.eq('id', petId);
onSuccess?.(publicUrl);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al subir la foto');
} finally {
setIsUploading(false);
}
};
return {
takePhoto,
pickFromLibrary,
isUploading,
error,
};
}
6. Mobile Payments (Paraguay)
Bancard Mobile SDK Integration
// lib/payments/bancard-mobile.ts
import { NativeModules, Platform } from 'react-native';
const { BancardModule } = NativeModules;
interface BancardPaymentRequest {
amount: number;
orderId: string;
description: string;
}
interface BancardPaymentResult {
success: boolean;
transactionId?: string;
authorizationCode?: string;
error?: string;
}
export async function processBancardPayment(
request: BancardPaymentRequest
): Promise<BancardPaymentResult> {
if (Platform.OS !== 'android' && Platform.OS !== 'ios') {
throw new Error('Bancard only available on mobile');
}
try {
// Call native Bancard SDK
const result = await BancardModule.processPayment({
publicKey: process.env.BANCARD_PUBLIC_KEY,
amount: request.amount.toFixed(2),
currency: 'PYG',
shopProcessId: request.orderId,
description: request.description,
});
return {
success: result.status === 'approved',
transactionId: result.transactionId,
authorizationCode: result.authorizationCode,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Error de pago',
};
}
}
Payment Screen
// app/checkout/payment.tsx
import { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import { processBancardPayment } from '@/lib/payments/bancard-mobile';
import { formatGuarani } from '@/lib/utils/currency';
export default function PaymentScreen() {
const { orderId, total } = useLocalSearchParams<{ orderId: string; total: string }>();
const [isProcessing, setIsProcessing] = useState(false);
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const handlePayment = async () => {
if (!selectedMethod) {
Alert.alert('Seleccioná un método de pago');
return;
}
setIsProcessing(true);
try {
if (selectedMethod === 'card') {
const result = await processBancardPayment({
amount: parseFloat(total),
orderId,
description: `Pedido #${orderId}`,
});
if (result.success) {
router.replace(`/checkout/success?orderId=${orderId}`);
} else {
Alert.alert('Pago rechazado', result.error);
}
} else if (selectedMethod === 'cash') {
// Mark order as pending cash payment
await updateOrderPaymentMethod(orderId, 'cash');
router.replace(`/checkout/success?orderId=${orderId}&method=cash`);
}
} catch (error) {
Alert.alert('Error', 'No se pudo procesar el pago');
} finally {
setIsProcessing(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>Método de Pago</Text>
<Text style={styles.total}>Total: {formatGuarani(parseFloat(total))}</Text>
{/* Payment methods */}
<TouchableOpacity
style={[styles.method, selectedMethod === 'card' && styles.methodSelected]}
onPress={() => setSelectedMethod('card')}
>
<Text style={styles.methodIcon}>💳</Text>
<View>
<Text style={styles.methodTitle}>Tarjeta</Text>
<Text style={styles.methodDesc}>Débito o crédito (Bancard)</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
style={[styles.method, selectedMethod === 'cash' && styles.methodSelected]}
onPress={() => setSelectedMethod('cash')}
>
<Text style={styles.methodIcon}>💵</Text>
<View>
<Text style={styles.methodTitle}>Efectivo</Text>
<Text style={styles.methodDesc}>Pagar al retirar</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
style={[styles.payButton, isProcessing && styles.payButtonDisabled]}
onPress={handlePayment}
disabled={isProcessing}
>
<Text style={styles.payButtonText}>
{isProcessing ? 'Procesando...' : 'Pagar'}
</Text>
</TouchableOpacity>
</View>
);
}
7. App Configuration
app.json (Expo)
{
"expo": {
"name": "Vete",
"slug": "vete-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "app.vete.mobile",
"infoPlist": {
"NSCameraUsageDescription": "Vete necesita acceso a la cámara para tomar fotos de tu mascota y escanear códigos QR.",
"NSPhotoLibraryUsageDescription": "Vete necesita acceso a tu galería para seleccionar fotos de tu mascota."
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "app.vete.mobile",
"permissions": [
"android.permission.CAMERA",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.VIBRATE"
],
"googleServicesFile": "./google-services.json"
},
"plugins": [
"expo-router",
[
"expo-camera",
{
"cameraPermission": "Permitir a Vete acceder a tu cámara."
}
],
[
"expo-image-picker",
{
"photosPermission": "Permitir a Vete acceder a tus fotos."
}
],
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#FF6B6B"
}
]
],
"extra": {
"eas": {
"projectId": "your-project-id"
}
}
}
}
Reference: Expo documentation, React Native best practices
スコア
総合スコア
50/100
リポジトリの品質指標に基づく評価
✓SKILL.md
SKILL.mdファイルが含まれている
+20
○LICENSE
ライセンスが設定されている
0/10
○説明文
100文字以上の説明がある
0/10
○人気
GitHub Stars 100以上
0/15
○最近の活動
3ヶ月以内に更新がある
0/10
○フォーク
10回以上フォークされている
0/5
✓Issue管理
オープンIssueが50未満
+5
✓言語
プログラミング言語が設定されている
+5
○タグ
1つ以上のタグが設定されている
0/5
レビュー
💬
レビュー機能は近日公開予定です