Detailed Package Specifications
Comprehensive technical specifications for reptidex’s 2 shared packages, including internal modules, APIs, and implementation details.@reptidex/ui Package
Design System Module
Purpose: Design tokens, themes, and foundational design system elementsModule:
@reptidex/ui/design-system
Size: ~20KB gzipped
Dependencies: Tailwind CSS, Class Variance Authority
Exports: Forest & Stone tokens, themes, utilitiesCopy
export const forestStoneTokens = {
colors: {
deepPine: {
50: '#f7f8f6',
100: '#e8ebe4',
500: '#3B4031', // Deep Pine - primary text/headings
600: '#2d3125',
700: '#1f221a',
900: '#0f100e'
},
mossGreen: {
50: '#f4f6f1',
100: '#e3e8dc',
500: '#8A9A5B', // Moss Green - primary buttons/links
600: '#6f7d4a',
700: '#555f39',
900: '#2a2f1c'
},
warmSand: {
50: '#fdfcfa',
100: '#f7f4ee',
500: '#D9CBA3', // Warm Sand - secondary surfaces
600: '#c9b588',
700: '#b39f6d',
900: '#5a4f36'
},
softClay: {
50: '#fdf8f5',
100: '#f6ebe1',
500: '#C68642', // Soft Clay - accents/CTAs
600: '#a66d35',
700: '#865428',
900: '#432a14'
},
offWhite: {
50: '#ffffff',
100: '#F6F3EE', // Off-White - base background
200: '#f0ebe3',
500: '#e8e1d6'
}
},
spacing: {
xs: '0.25rem', // 4px
sm: '0.5rem', // 8px
md: '1rem', // 16px
lg: '1.5rem', // 24px
xl: '2rem', // 32px
'2xl': '3rem', // 48px
'3xl': '4rem' // 64px
},
typography: {
fontFamily: {
heading: ['Merriweather', 'serif'],
body: ['Roboto', 'sans-serif'],
accent: ['Playfair Display', 'serif'],
mono: ['JetBrains Mono', 'Consolas', 'monospace']
},
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.5rem' }],
lg: ['1.125rem', { lineHeight: '1.75rem' }],
xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem' }]
}
},
borderRadius: {
none: '0',
sm: '8px', // Small elements
md: '16px', // Standard components
lg: '24px', // Large cards
full: '50%' // Circular elements
},
shadows: {
sm: '0 2px 8px rgba(59, 64, 49, 0.06)', // Subtle lift
md: '0 6px 24px rgba(59, 64, 49, 0.08)', // Standard cards
lg: '0 12px 40px rgba(59, 64, 49, 0.12)', // Modals, overlays
xl: '0 24px 64px rgba(59, 64, 49, 0.16)' // Maximum elevation
}
};
Copy
import { forestStoneTokens } from './tokens';
export const forestStoneTheme = {
colors: forestStoneTokens.colors,
typography: forestStoneTokens.typography,
spacing: forestStoneTokens.spacing,
borderRadius: forestStoneTokens.borderRadius,
shadows: forestStoneTokens.shadows
};
// Tailwind CSS configuration
export const tailwindConfig = {
theme: {
extend: {
colors: {
'deep-pine': forestStoneTokens.colors.deepPine,
'moss-green': forestStoneTokens.colors.mossGreen,
'warm-sand': forestStoneTokens.colors.warmSand,
'soft-clay': forestStoneTokens.colors.softClay,
'off-white': forestStoneTokens.colors.offWhite
},
fontFamily: {
heading: forestStoneTokens.typography.fontFamily.heading,
body: forestStoneTokens.typography.fontFamily.body,
accent: forestStoneTokens.typography.fontFamily.accent
},
borderRadius: forestStoneTokens.borderRadius,
boxShadow: forestStoneTokens.shadows
}
}
};
export const darkTheme = createTheme({
...lightTheme,
palette: {
...lightTheme.palette,
mode: 'dark',
background: {
default: designTokens.colors.neutral[900],
paper: designTokens.colors.neutral[800]
}
}
});
Component Library Module
Purpose: Core UI components for all reptidex applicationsModule:
@reptidex/ui/components
Size: ~100KB gzipped
Dependencies: Radix UI, Class Variance Authority, Tailwind CSS
Components: 35+ Forest & Stone componentsLayout Components
- Container, Grid, Stack, Flex
- Section, Sidebar, Header
- ResponsiveLayout, MobileDrawer
Data Display
- AnimalCard, AnimalGrid, AnimalTable
- PedigreeTree, GeneticChart
- MorphBadge, StatusBadge
Forms & Inputs
- FormField, FormSection, FormWizard
- AnimalForm, BreedingForm
- ImageUpload, DatePicker
Navigation
- NavBar, Breadcrumbs, Tabs
- Pagination, SortControls
- SearchBox, FilterPanel
Copy
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../utils';
import { Animal, Morph } from '@reptidex/core';
interface AnimalCardProps {
animal: Animal;
variant?: 'compact' | 'detailed' | 'grid';
showActions?: boolean;
showPedigree?: boolean;
onSelect?: (animal: Animal) => void;
onEdit?: (animal: Animal) => void;
}
const animalCardVariants = cva(
'relative overflow-hidden rounded-md border border-warm-sand-200 bg-white shadow-sm transition-all duration-200',
{
variants: {
variant: {
compact: 'w-full max-w-sm',
detailed: 'w-full',
grid: 'aspect-square w-full'
},
interactive: {
true: 'cursor-pointer hover:-translate-y-1 hover:shadow-md',
false: 'cursor-default'
}
},
defaultVariants: {
variant: 'detailed',
interactive: false
}
}
);
export const AnimalCard: React.FC<AnimalCardProps> = ({
animal,
variant = 'detailed',
showActions = true,
showPedigree = false,
onSelect,
onEdit
}) => {
const handleCardClick = () => {
if (onSelect) onSelect(animal);
};
return (
<div
className={cn(animalCardVariants({
variant,
interactive: !!onSelect
}))}
onClick={handleCardClick}
>
{/* Animal Image */}
<Box sx={{ position: 'relative', height: variant === 'compact' ? 120 : 200 }}>
<Image
src={animal.primaryImage?.url || '/placeholder-snake.jpg'}
alt={`${animal.name} - ${animal.species.commonName}`}
fill
style={{ objectFit: 'cover' }}
/>
{animal.status !== 'available' && (
<Chip
label={animal.status.toUpperCase()}
size="small"
color="warning"
sx={{ position: 'absolute', top: 8, right: 8 }}
/>
)}
</Box>
<CardContent>
<Typography variant="h6" component="h3" gutterBottom>
{animal.name}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
{animal.species.commonName} • {animal.sex} • {animal.age} old
</Typography>
{/* Morph Badges */}
<Box sx={{ mt: 1, mb: 1 }}>
{animal.morphs.slice(0, variant === 'compact' ? 2 : 4).map((morph) => (
<MorphBadge
key={morph.id}
morph={morph}
size="small"
sx={{ mr: 0.5, mb: 0.5 }}
/>
))}
{animal.morphs.length > (variant === 'compact' ? 2 : 4) && (
<Chip
label={`+${animal.morphs.length - (variant === 'compact' ? 2 : 4)} more`}
size="small"
variant="outlined"
/>
)}
</Box>
{variant === 'detailed' && (
<>
<Typography variant="body2" gutterBottom>
<strong>Lineage:</strong> {animal.lineage?.completeness || 0}% complete
</Typography>
{animal.price && (
<Typography variant="h6" color="primary" sx={{ mt: 1 }}>
${animal.price.toLocaleString()}
</Typography>
)}
</>
)}
{showPedigree && animal.pedigree && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Pedigree Preview:
</Typography>
<PedigreePreview pedigree={animal.pedigree} maxGenerations={2} />
</Box>
)}
</CardContent>
{showActions && (
<CardActions>
<Button size="small" onClick={() => onSelect?.(animal)}>
View Details
</Button>
{onEdit && (
<Button size="small" onClick={() => onEdit(animal)}>
Edit
</Button>
)}
</CardActions>
)}
</Card>
);
};
Copy
interface MorphBadgeProps {
morph: Morph;
size?: 'small' | 'medium';
showInheritance?: boolean;
}
const morphBadgeVariants = cva(
'inline-flex items-center rounded-sm font-medium text-white',
{
variants: {
size: {
small: 'px-2 py-1 text-xs',
medium: 'px-3 py-1.5 text-sm'
},
inheritance: {
recessive: 'bg-blue-600',
codominant: 'bg-amber-600',
dominant: 'bg-moss-green-600',
'incomplete-dominant': 'bg-purple-600',
unknown: 'bg-gray-500'
}
},
defaultVariants: {
size: 'medium',
inheritance: 'unknown'
}
}
);
export const MorphBadge: React.FC<MorphBadgeProps> = ({
morph,
size = 'medium',
showInheritance = false
}) => {
return (
<span
className={cn(morphBadgeVariants({
size,
inheritance: morph.inheritance as any
}))}
>
{showInheritance ? `${morph.name} (${morph.inheritance})` : morph.name}
</span>
);
};
Charts & Visualization Module
Purpose: Data visualization components for analytics and reportingModule:
@reptidex/ui/charts
Size: ~40KB gzipped
Dependencies: Recharts, Tailwind CSS
Chart Types: Line, Bar, Pie, Scatter, Heatmap with Forest & Stone stylingCopy
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { BreedingRecord } from '@reptidex/core';
interface BreedingAnalyticsProps {
data: BreedingRecord[];
dateRange: { start: Date; end: Date };
groupBy: 'month' | 'quarter' | 'year';
metrics: ('success_rate' | 'hatch_rate' | 'fertility_rate')[];
}
export const BreedingAnalyticsChart: React.FC<BreedingAnalyticsProps> = ({
data,
dateRange,
groupBy,
metrics
}) => {
const chartData = useMemo(() => {
return aggregateBreedingData(data, groupBy, dateRange);
}, [data, groupBy, dateRange]);
const metricConfig = {
success_rate: { color: '#2e7d32', name: 'Success Rate' },
hatch_rate: { color: '#1976d2', name: 'Hatch Rate' },
fertility_rate: { color: '#ed6c02', name: 'Fertility Rate' }
};
return (
<Box sx={{ width: '100%', height: 400 }}>
<ResponsiveContainer>
<LineChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="period"
tickFormatter={(value) => formatPeriod(value, groupBy)}
/>
<YAxis
tickFormatter={(value) => `${value}%`}
domain={[0, 100]}
/>
<Tooltip content={<CustomTooltip />} />
{metrics.map((metric) => (
<Line
key={metric}
type="monotone"
dataKey={metric}
stroke={metricConfig[metric].color}
strokeWidth={2}
name={metricConfig[metric].name}
dot={{ fill: metricConfig[metric].color }}
/>
))}
</LineChart>
</ResponsiveContainer>
</Box>
);
};
const CustomTooltip = ({ active, payload, label }) => {
if (!active || !payload?.length) return null;
return (
<Paper sx={{ p: 2, border: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2" gutterBottom>
{label}
</Typography>
{payload.map((entry, index) => (
<Typography key={index} variant="body2" sx={{ color: entry.color }}>
{entry.name}: {entry.value}%
</Typography>
))}
</Paper>
);
};
@reptidex/core Package
API Client Module
Purpose: Type-safe API clients for all reptidex servicesModule:
Size: ~35KB gzipped
Dependencies: Axios, React Query, Zod
Services: 6 backend service clients
@reptidex/core/apiSize: ~35KB gzipped
Dependencies: Axios, React Query, Zod
Services: 6 backend service clients
Copy
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { authStore } from '../stores/auth';
export class BaseAPIClient {
protected client: AxiosInstance;
constructor(config: APIClientConfig) {
this.client = axios.create({
baseURL: config.baseURL,
timeout: config.timeout || 10000,
headers: {
'Content-Type': 'application/json'
}
});
this.setupInterceptors();
}
private setupInterceptors(): void {
// Request interceptor for authentication
this.client.interceptors.request.use(async (config) => {
const token = authStore.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor for error handling
this.client.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
const refreshed = await authStore.refreshToken();
if (refreshed) {
return this.client.request(error.config);
}
authStore.logout();
}
return Promise.reject(error);
}
);
}
protected async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.get(url, config);
return response.data;
}
protected async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.post(url, data, config);
return response.data;
}
protected async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.put(url, data, config);
return response.data;
}
protected async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.delete(url, config);
return response.data;
}
}
Copy
import { BaseAPIClient } from './base';
import { Animal, CreateAnimalRequest, UpdateAnimalRequest, SearchFilters } from '../types';
export class AnimalsAPIClient extends BaseAPIClient {
constructor() {
super({
baseURL: process.env.NEXT_PUBLIC_REPTI_ANIMAL_API_URL || 'http://localhost:8002'
});
}
async getAnimals(filters: SearchFilters = {}): Promise<PaginatedResponse<Animal>> {
const params = new URLSearchParams();
if (filters.species) params.append('species', filters.species);
if (filters.morphs?.length) params.append('morphs', filters.morphs.join(','));
if (filters.sex) params.append('sex', filters.sex);
if (filters.priceRange) {
params.append('price_min', filters.priceRange[0].toString());
params.append('price_max', filters.priceRange[1].toString());
}
if (filters.page) params.append('page', filters.page.toString());
if (filters.limit) params.append('limit', filters.limit.toString());
return this.get<PaginatedResponse<Animal>>(`/animals?${params.toString()}`);
}
async getAnimal(id: string): Promise<Animal> {
return this.get<Animal>(`/animals/${id}`);
}
async createAnimal(animalData: CreateAnimalRequest): Promise<Animal> {
return this.post<Animal>('/animals', animalData);
}
async updateAnimal(id: string, animalData: UpdateAnimalRequest): Promise<Animal> {
return this.put<Animal>(`/animals/${id}`, animalData);
}
async deleteAnimal(id: string): Promise<void> {
return this.delete(`/animals/${id}`);
}
async getPedigree(animalId: string, generations: number = 4): Promise<Pedigree> {
return this.get<Pedigree>(`/animals/${animalId}/pedigree?generations=${generations}`);
}
async getOffspring(animalId: string): Promise<Animal[]> {
return this.get<Animal[]>(`/animals/${animalId}/offspring`);
}
}
export const animalsAPI = new AnimalsAPIClient();
Copy
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { animalsAPI } from '../api/animals';
import { SearchFilters, CreateAnimalRequest } from '../types';
export function useAnimals(filters: SearchFilters = {}) {
return useQuery({
queryKey: ['animals', filters],
queryFn: () => animalsAPI.getAnimals(filters),
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
keepPreviousData: true
});
}
export function useAnimal(id: string) {
return useQuery({
queryKey: ['animals', id],
queryFn: () => animalsAPI.getAnimal(id),
enabled: !!id,
staleTime: 10 * 60 * 1000 // 10 minutes
});
}
export function useCreateAnimal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (animalData: CreateAnimalRequest) => animalsAPI.createAnimal(animalData),
onSuccess: (newAnimal) => {
// Invalidate and refetch animals list
queryClient.invalidateQueries({ queryKey: ['animals'] });
// Add to cache
queryClient.setQueryData(['animals', newAnimal.id], newAnimal);
}
});
}
export function usePedigree(animalId: string, generations: number = 4) {
return useQuery({
queryKey: ['pedigree', animalId, generations],
queryFn: () => animalsAPI.getPedigree(animalId, generations),
enabled: !!animalId,
staleTime: 30 * 60 * 1000 // 30 minutes - pedigrees don't change often
});
}
Business Logic Module
Purpose: reptidex-specific business logic and calculationsModule:
Size: ~25KB gzipped
Dependencies: date-fns, lodash
Features: Genetic calculations, breeding logic, pricing algorithms
@reptidex/core/businessSize: ~25KB gzipped
Dependencies: date-fns, lodash
Features: Genetic calculations, breeding logic, pricing algorithms
Copy
interface GeneticTraits {
[gene: string]: 'normal' | 'het' | 'homo' | 'super';
}
interface GeneticProbability {
phenotype: string;
genotype: GeneticTraits;
probability: number;
}
export function calculateGeneticProbability(
sire: GeneticTraits,
dam: GeneticTraits,
targetMorphs?: string[]
): GeneticProbability[] {
const allGenes = new Set([...Object.keys(sire), ...Object.keys(dam)]);
const results: GeneticProbability[] = [];
// Generate all possible combinations
const combinations = generateGeneticCombinations(sire, dam, Array.from(allGenes));
// Calculate probabilities for each combination
const probabilityMap = new Map<string, number>();
combinations.forEach(combination => {
const phenotype = determinePhenotype(combination.genotype);
const key = JSON.stringify(combination.genotype);
probabilityMap.set(key, (probabilityMap.get(key) || 0) + combination.probability);
});
// Convert to result format
probabilityMap.forEach((probability, genotypeKey) => {
const genotype = JSON.parse(genotypeKey) as GeneticTraits;
const phenotype = determinePhenotype(genotype);
results.push({
phenotype,
genotype,
probability: Math.round(probability * 100)
});
});
// Filter by target morphs if specified
if (targetMorphs?.length) {
return results.filter(result =>
targetMorphs.some(morph => result.phenotype.includes(morph))
);
}
return results.sort((a, b) => b.probability - a.probability);
}
function generateGeneticCombinations(
sire: GeneticTraits,
dam: GeneticTraits,
genes: string[]
): { genotype: GeneticTraits; probability: number }[] {
// Implementation of Punnett square logic for multiple genes
const combinations: { genotype: GeneticTraits; probability: number }[] = [];
// For each gene, calculate possible allele combinations
genes.forEach(gene => {
const sireAlleles = getAlleles(sire[gene] || 'normal');
const damAlleles = getAlleles(dam[gene] || 'normal');
// Generate all possible offspring genotypes for this gene
sireAlleles.forEach(sireAllele => {
damAlleles.forEach(damAllele => {
const offspringGenotype = combineAlleles(sireAllele, damAllele);
// Add to combinations with appropriate probability
});
});
});
return combinations;
}
function determinePhenotype(genotype: GeneticTraits): string {
const expressedTraits: string[] = [];
Object.entries(genotype).forEach(([gene, expression]) => {
switch (expression) {
case 'homo':
case 'super':
expressedTraits.push(gene);
break;
case 'het':
// Some genes express in heterozygous form (codominant)
if (isCodeminant(gene)) {
expressedTraits.push(`${gene} het`);
}
break;
}
});
return expressedTraits.length > 0 ? expressedTraits.join(' ') : 'normal';
}
Copy
interface BreedingPair {
sire: Animal;
dam: Animal;
expectedPairingDate: Date;
notes?: string;
}
interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
export function validateBreedingPair(pair: BreedingPair): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// Age validation
if (pair.sire.age < 12) { // months
errors.push('Male must be at least 12 months old');
}
if (pair.dam.age < 18) { // months
errors.push('Female must be at least 18 months old');
}
// Weight validation (species-specific)
const minWeights = getMinBreedingWeights(pair.sire.species.id);
if (pair.sire.weight < minWeights.male) {
errors.push(`Male weight (${pair.sire.weight}g) below minimum (${minWeights.male}g)`);
}
if (pair.dam.weight < minWeights.female) {
errors.push(`Female weight (${pair.dam.weight}g) below minimum (${minWeights.female}g)`);
}
// Health status validation
if (pair.sire.healthStatus !== 'healthy') {
errors.push('Male must be in healthy condition');
}
if (pair.dam.healthStatus !== 'healthy') {
errors.push('Female must be in healthy condition');
}
// Genetic compatibility warnings
const geneticAnalysis = analyzeGeneticCompatibility(pair.sire, pair.dam);
if (geneticAnalysis.inbreedingCoefficient > 0.125) {
warnings.push('High inbreeding coefficient detected');
}
if (geneticAnalysis.lethalCombinations.length > 0) {
errors.push(`Potentially lethal combinations: ${geneticAnalysis.lethalCombinations.join(', ')}`);
}
// Seasonal breeding considerations
const breedingSeason = getOptimalBreedingSeason(pair.sire.species.id);
const pairingMonth = pair.expectedPairingDate.getMonth();
if (!breedingSeason.includes(pairingMonth)) {
warnings.push('Pairing outside optimal breeding season');
}
return {
valid: errors.length === 0,
errors,
warnings
};
}
Authentication & Authorization Module
Purpose: User authentication, session management, and access controlModule:
Size: ~15KB gzipped
Dependencies: JWT decode, Zustand
Features: JWT handling, RBAC, protected routes
@reptidex/core/authSize: ~15KB gzipped
Dependencies: JWT decode, Zustand
Features: JWT handling, RBAC, protected routes
Copy
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { jwtDecode } from 'jwt-decode';
interface User {
id: string;
email: string;
firstName: string;
lastName: string;
roles: string[];
organizationId?: string;
subscription?: {
tier: 'free' | 'breeder' | 'premium';
status: 'active' | 'cancelled' | 'past_due';
};
}
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
refreshTokens: () => Promise<boolean>;
updateUser: (updates: Partial<User>) => void;
hasRole: (role: string) => boolean;
hasPermission: (permission: string) => boolean;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
login: async (email: string, password: string) => {
set({ isLoading: true });
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error('Login failed');
}
const { accessToken, refreshToken, user } = await response.json();
set({
user,
accessToken,
refreshToken,
isAuthenticated: true,
isLoading: false
});
} catch (error) {
set({ isLoading: false });
throw error;
}
},
logout: () => {
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false
});
},
refreshTokens: async () => {
const { refreshToken } = get();
if (!refreshToken) return false;
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${refreshToken}`
}
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const { accessToken, refreshToken: newRefreshToken } = await response.json();
set({
accessToken,
refreshToken: newRefreshToken
});
return true;
} catch (error) {
get().logout();
return false;
}
},
updateUser: (updates: Partial<User>) => {
set(state => ({
user: state.user ? { ...state.user, ...updates } : null
}));
},
hasRole: (role: string) => {
const { user } = get();
return user?.roles.includes(role) || false;
},
hasPermission: (permission: string) => {
const { user } = get();
if (!user) return false;
// Role-based permissions mapping
const rolePermissions: Record<string, string[]> = {
admin: ['*'], // All permissions
breeder: [
'animals:read',
'animals:write',
'breeding:read',
'breeding:write',
'marketplace:read',
'marketplace:write'
],
user: ['animals:read', 'marketplace:read']
};
return user.roles.some(role =>
rolePermissions[role]?.includes('*') ||
rolePermissions[role]?.includes(permission)
);
}
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated
})
}
)
);
Copy
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { useAuthStore } from '../stores/auth';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredRoles?: string[];
requiredPermissions?: string[];
fallbackUrl?: string;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
requiredRoles = [],
requiredPermissions = [],
fallbackUrl = '/login'
}) => {
const router = useRouter();
const { isAuthenticated, hasRole, hasPermission, isLoading } = useAuthStore();
useEffect(() => {
if (isLoading) return;
if (!isAuthenticated) {
router.push(`${fallbackUrl}?redirect=${encodeURIComponent(router.asPath)}`);
return;
}
// Check role requirements
if (requiredRoles.length > 0 && !requiredRoles.some(role => hasRole(role))) {
router.push('/unauthorized');
return;
}
// Check permission requirements
if (requiredPermissions.length > 0 && !requiredPermissions.some(permission => hasPermission(permission))) {
router.push('/unauthorized');
return;
}
}, [isAuthenticated, isLoading, hasRole, hasPermission, router, requiredRoles, requiredPermissions, fallbackUrl]);
if (isLoading) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
return null;
}
return <>{children}</>;
};
// Hook for easier usage
export const useAuth = () => {
const store = useAuthStore();
return {
user: store.user,
isAuthenticated: store.isAuthenticated,
isLoading: store.isLoading,
login: store.login,
logout: store.logout,
hasRole: store.hasRole,
hasPermission: store.hasPermission
};
};
Testing & Quality Assurance
Unit Testing
Copy
// @reptidex/ui component testing
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@mui/material/styles';
import { AnimalCard } from '../components/AnimalCard';
import { lightTheme } from '../design-system/themes';
import { mockAnimal } from '../../__mocks__/animal';
const renderWithTheme = (component: React.ReactElement) => {
return render(
<ThemeProvider theme={lightTheme}>
{component}
</ThemeProvider>
);
};
describe('AnimalCard', () => {
it('renders animal information correctly', () => {
renderWithTheme(<AnimalCard animal={mockAnimal} />);
expect(screen.getByText(mockAnimal.name)).toBeInTheDocument();
expect(screen.getByText(mockAnimal.species.commonName)).toBeInTheDocument();
expect(screen.getByRole('img')).toHaveAttribute('alt', expect.stringContaining(mockAnimal.name));
});
it('displays morph badges', () => {
const animalWithMorphs = {
...mockAnimal,
morphs: [
{ id: '1', name: 'Pied', inheritance: 'recessive' },
{ id: '2', name: 'Banana', inheritance: 'codominant' }
]
};
renderWithTheme(<AnimalCard animal={animalWithMorphs} />);
expect(screen.getByText('Pied')).toBeInTheDocument();
expect(screen.getByText('Banana')).toBeInTheDocument();
});
it('calls onSelect when card is clicked', () => {
const onSelect = jest.fn();
renderWithTheme(<AnimalCard animal={mockAnimal} onSelect={onSelect} />);
fireEvent.click(screen.getByRole('article'));
expect(onSelect).toHaveBeenCalledWith(mockAnimal);
});
});
Copy
// @reptidex/core business logic testing
import { calculateGeneticProbability } from '../business/genetics';
describe('calculateGeneticProbability', () => {
it('calculates simple recessive cross correctly', () => {
const sire = { pied: 'het' };
const dam = { pied: 'het' };
const result = calculateGeneticProbability(sire, dam);
expect(result).toEqual([
{ phenotype: 'normal', genotype: { pied: 'normal' }, probability: 75 },
{ phenotype: 'pied', genotype: { pied: 'homo' }, probability: 25 }
]);
});
it('calculates codominant cross correctly', () => {
const sire = { banana: 'het' };
const dam = { banana: 'normal' };
const result = calculateGeneticProbability(sire, dam);
expect(result).toEqual([
{ phenotype: 'normal', genotype: { banana: 'normal' }, probability: 50 },
{ phenotype: 'banana het', genotype: { banana: 'het' }, probability: 50 }
]);
});
});
This detailed specification provides comprehensive documentation for reptidex’s simplified 2-package architecture with Forest & Stone design system, covering all major modules, APIs, and implementation patterns needed for effective development.

