Kassalapp API med Expo/React Native - Komplett Guide
Innledning
Kassalapp API gir deg tilgang til prisinformasjon fra norske dagligvarebutikker. Denne guiden viser deg hvordan du integrerer API-et i Expo og React Native applikasjoner, med støtte for strekkodeskanning, prissammenligning og avanserte funksjoner.
Innhold
- Kom i gang
- Autentisering
- API Client oppsett
- TypeScript Types
- Produktsøk
- Strekkodeskanning
- Butikklokasjoner
- Handleliste-funksjonalitet
- State Management
- Feilhåndtering og Offline Support
- Praktiske eksempler
Kom i gang
1. Registrer deg for API-nøkkel
Først må du registrere deg for å få en API-nøkkel:
- Gå til https://kassal.app/api
- Opprett en konto eller logg inn
- Generer en ny API-nøkkel fra dashboardet
- Lagre nøkkelen trygt
2. Opprett nytt Expo prosjekt
# Opprett nytt prosjekt med Expo
npx create-expo-app kassalapp-client --template
# Naviger til prosjektmappen
cd kassalapp-client
# Start utviklingsserver
npx expo start
3. Installer nødvendige pakker
# Core dependencies
npx expo install axios @tanstack/react-query expo-secure-store expo-location expo-camera
# Storage and network
npx expo install @react-native-async-storage/async-storage @react-native-community/netinfo
# UI og utilities
npx expo install react-native-safe-area-context react-native-screens @react-navigation/native @react-navigation/bottom-tabs @react-navigation/stack
# Development dependencies
npm install --save-dev @types/react @types/react-native typescript
4. Konfigurer app.json
{
"expo": {
"name": "Kassalapp Client",
"slug": "kassalapp-client",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSCameraUsageDescription": "Appen bruker kamera for å skanne strekkoder på produkter",
"NSLocationWhenInUseUsageDescription": "Appen bruker din posisjon for å finne butikker i nærheten"
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"permissions": [
"CAMERA",
"ACCESS_FINE_LOCATION",
"ACCESS_COARSE_LOCATION"
]
},
"plugins": [
[
"expo-camera",
{
"cameraPermission": "Tillat $(PRODUCT_NAME) å bruke kamera for strekkodeskanning"
}
]
]
}
}
Autentisering
Sikker lagring av API-nøkkel
// src/services/secureStorage.ts
import * as SecureStore from 'expo-secure-store';
const API_KEY_STORAGE_KEY = 'kassalapp_api_key';
export const SecureStorage = {
async saveApiKey(apiKey: string): Promise<void> {
await SecureStore.setItemAsync(API_KEY_STORAGE_KEY, apiKey);
},
async getApiKey(): Promise<string | null> {
return await SecureStore.getItemAsync(API_KEY_STORAGE_KEY);
},
async deleteApiKey(): Promise<void> {
await SecureStore.deleteItemAsync(API_KEY_STORAGE_KEY);
},
async hasApiKey(): Promise<boolean> {
const key = await this.getApiKey();
return key !== null;
}
};
API Client oppsett
Axios konfigurasjon med interceptors
// src/services/apiClient.ts
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
import { SecureStorage } from './secureStorage';
import NetInfo from '@react-native-community/netinfo';
const BASE_URL = 'https://kassal.app/api/v1';
const TIMEOUT = 10000;
class ApiClient {
private axiosInstance: AxiosInstance;
private isRefreshing = false;
private failedQueue: Array<{
resolve: (value: any) => void;
reject: (error: any) => void;
}> = [];
constructor() {
this.axiosInstance = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
});
this.setupInterceptors();
}
private setupInterceptors(): void {
// Request interceptor for auth
this.axiosInstance.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
// Check network connectivity
const netInfo = await NetInfo.fetch();
if (!netInfo.isConnected) {
throw new Error('Ingen internettforbindelse');
}
// Add auth token
const apiKey = await SecureStorage.getApiKey();
if (apiKey) {
config.headers.Authorization = `Bearer ${apiKey}`;
}
// Log request in dev mode
if (__DEV__) {
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`);
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for error handling
this.axiosInstance.interceptors.response.use(
(response) => {
if (__DEV__) {
console.log(`✅ ${response.config.method?.toUpperCase()} ${response.config.url} - ${response.status}`);
}
return response;
},
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (__DEV__) {
console.log(`❌ ${originalRequest?.method?.toUpperCase()} ${originalRequest?.url} - ${error.response?.status}`);
}
// Handle different error scenarios
if (error.response?.status === 401 && !originalRequest._retry) {
if (this.isRefreshing) {
return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject });
});
}
originalRequest._retry = true;
this.isRefreshing = true;
// Handle unauthorized - clear API key
await SecureStorage.deleteApiKey();
// Process failed queue
this.processQueue(error, null);
this.isRefreshing = false;
throw new Error('API-nøkkel er ugyldig. Vennligst logg inn på nytt.');
}
if (error.response?.status === 429) {
// Rate limiting
const retryAfter = error.response.headers['retry-after'] || 60;
throw new Error(`For mange forespørsler. Vent ${retryAfter} sekunder.`);
}
if (error.response?.status === 404) {
throw new Error('Ressursen ble ikke funnet');
}
if (!error.response) {
throw new Error('Nettverksfeil. Sjekk internettforbindelsen.');
}
return Promise.reject(error);
}
);
}
private processQueue(error: any, token: string | null = null): void {
this.failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
this.failedQueue = [];
}
public getInstance(): AxiosInstance {
return this.axiosInstance;
}
}
export const apiClient = new ApiClient().getInstance();
TypeScript Types
Type definisjoner for API responses
// src/types/api.types.ts
export interface Store {
id: number;
name: string;
url: string;
logo: string;
}
export interface Product {
id: number;
name: string;
brand: string | null;
vendor: string | null;
description: string | null;
ingredients: string | null;
url: string;
image: string | null;
store: Store;
current_price: number | null;
current_unit_price: number | null;
weight: number | null;
weight_unit: string | null;
ean: string | null;
created_at: string;
updated_at: string;
price_history?: PriceHistory[];
category?: Category;
allergens?: Allergen[];
nutritional_contents?: NutritionalContent[];
labels?: Label[];
}
export interface PriceHistory {
price: number;
date: string;
}
export interface Category {
id: number;
name: string;
depth: number;
}
export interface Allergen {
code: string;
display_name: string;
contains: 'YES' | 'NO' | 'MAY_CONTAIN';
}
export interface NutritionalContent {
display_name: string;
amount: number;
unit: string;
code: string;
}
export interface Label {
name: string;
display_name: string;
description: string;
organization: string;
}
export interface PhysicalStore {
id: string;
group: string;
name: string;
address: string;
phone: string | null;
email: string | null;
fax: string | null;
position: {
lat: number;
lng: number;
};
opening_hours: OpeningHours;
logo: string;
website: string;
detail_url: string | null;
store: Store;
}
export interface OpeningHours {
[day: string]: {
open: string;
close: string;
closed: boolean;
};
}
export interface ShoppingList {
id: string;
name: string;
items: ShoppingListItem[];
created_at: string;
updated_at: string;
}
export interface ShoppingListItem {
id: string;
product_id: number;
product?: Product;
quantity: number;
checked: boolean;
added_at: string;
}
export interface PaginatedResponse<T> {
data: T[];
links: {
first: string;
last: string;
prev: string | null;
next: string | null;
};
meta: {
current_page: number;
from: number;
last_page: number;
per_page: number;
to: number;
total: number;
};
}
Produktsøk
Product Service med React Query
// src/services/productService.ts
import { apiClient } from './apiClient';
import { Product, PaginatedResponse } from '../types/api.types';
export interface ProductSearchParams {
search?: string;
category?: string;
category_id?: number;
brand?: string;
vendor?: string;
price_min?: number;
price_max?: number;
sort?: 'price_asc' | 'price_desc' | 'name_asc' | 'name_desc' | 'date_asc' | 'date_desc';
page?: number;
size?: number;
unique?: boolean;
exclude_without_ean?: boolean;
}
export const productService = {
async searchProducts(params: ProductSearchParams): Promise<PaginatedResponse<Product>> {
const { data } = await apiClient.get('/products', { params });
return data;
},
async getProductById(id: number): Promise<Product> {
const { data } = await apiClient.get(`/products/id/${id}`);
return data.data;
},
async getProductByEan(ean: string): Promise<Product[]> {
const { data } = await apiClient.get(`/products/ean/${ean}`);
return data.data;
},
async getProductByUrl(url: string): Promise<Product> {
const { data } = await apiClient.get('/products/find-by-url/single', {
params: { url }
});
return data.data;
},
async comparePricesByUrl(url: string): Promise<Product[]> {
const { data } = await apiClient.get('/products/find-by-url/compare', {
params: { url }
});
return data.data;
}
};
Product Search Screen
// src/screens/ProductSearchScreen.tsx
import React, { useState, useCallback } from 'react';
import {
View,
Text,
TextInput,
FlatList,
StyleSheet,
ActivityIndicator,
TouchableOpacity,
Image,
RefreshControl,
} from 'react-native';
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { useDebouncedValue } from '../hooks/useDebouncedValue';
import { productService, ProductSearchParams } from '../services/productService';
import { Product } from '../types/api.types';
import { ProductListItem } from '../components/ProductListItem';
export const ProductSearchScreen: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebouncedValue(searchQuery, 500);
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
refetch,
} = useInfiniteQuery({
queryKey: ['products', debouncedSearch],
queryFn: ({ pageParam = 1 }) =>
productService.searchProducts({
search: debouncedSearch,
page: pageParam,
size: 20,
}),
getNextPageParam: (lastPage) => {
if (lastPage.meta.current_page < lastPage.meta.last_page) {
return lastPage.meta.current_page + 1;
}
return undefined;
},
enabled: debouncedSearch.length > 2,
});
const products = data?.pages.flatMap(page => page.data) ?? [];
const renderProduct = useCallback(({ item }: { item: Product }) => (
<ProductListItem product={item} />
), []);
const renderFooter = () => {
if (!isFetchingNextPage) return null;
return (
<View style={styles.footerLoader}>
<ActivityIndicator size="small" color="#DC2626" />
</View>
);
};
const renderEmpty = () => {
if (isLoading) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#DC2626" />
<Text style={styles.loadingText}>Søker...</Text>
</View>
);
}
if (searchQuery.length > 2 && products.length === 0) {
return (
<View style={styles.centerContainer}>
<Text style={styles.emptyText}>Ingen produkter funnet</Text>
<Text style={styles.emptySubtext}>Prøv et annet søkeord</Text>
</View>
);
}
return (
<View style={styles.centerContainer}>
<Text style={styles.emptyText}>Søk etter produkter</Text>
<Text style={styles.emptySubtext}>Minimum 3 tegn</Text>
</View>
);
};
return (
<View style={styles.container}>
<View style={styles.searchContainer}>
<TextInput
style={styles.searchInput}
placeholder="Søk etter produkter..."
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
/>
{searchQuery.length > 0 && (
<TouchableOpacity
style={styles.clearButton}
onPress={() => setSearchQuery('')}
>
<Text style={styles.clearButtonText}>✕</Text>
</TouchableOpacity>
)}
</View>
<FlatList
data={products}
renderItem={renderProduct}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={styles.listContent}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
refreshControl={
<RefreshControl
refreshing={isLoading && !isFetchingNextPage}
onRefresh={refetch}
colors={['#DC2626']}
/>
}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F9FAFB',
},
searchContainer: {
backgroundColor: 'white',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
flexDirection: 'row',
alignItems: 'center',
},
searchInput: {
flex: 1,
height: 40,
borderWidth: 1,
borderColor: '#D1D5DB',
borderRadius: 8,
paddingHorizontal: 12,
fontSize: 16,
},
clearButton: {
marginLeft: 8,
padding: 8,
},
clearButtonText: {
fontSize: 20,
color: '#6B7280',
},
listContent: {
flexGrow: 1,
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
loadingText: {
marginTop: 12,
fontSize: 16,
color: '#6B7280',
},
emptyText: {
fontSize: 18,
color: '#374151',
fontWeight: '600',
},
emptySubtext: {
marginTop: 4,
fontSize: 14,
color: '#6B7280',
},
footerLoader: {
paddingVertical: 20,
alignItems: 'center',
},
});
Strekkodeskanning
Barcode Scanner implementasjon
// src/screens/BarcodeScannerScreen.tsx
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
Modal,
ActivityIndicator,
} from 'react-native';
import { CameraView, CameraType, useCameraPermissions, BarcodeScanningResult } from 'expo-camera';
import { productService } from '../services/productService';
import { Product } from '../types/api.types';
import { ProductComparisonModal } from '../components/ProductComparisonModal';
export const BarcodeScannerScreen: React.FC = () => {
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false);
const [loading, setLoading] = useState(false);
const [products, setProducts] = useState<Product[]>([]);
const [showComparison, setShowComparison] = useState(false);
useEffect(() => {
if (!permission?.granted) {
requestPermission();
}
}, [permission]);
const handleBarCodeScanned = async ({ data }: BarcodeScanningResult) => {
if (scanned || loading) return;
setScanned(true);
setLoading(true);
// Validate EAN format
if (!/^(\d{8}|\d{13})$/.test(data)) {
Alert.alert(
'Ugyldig strekkode',
'Strekkoden må være 8 eller 13 siffer',
[{ text: 'OK', onPress: () => resetScanner() }]
);
return;
}
try {
const fetchedProducts = await productService.getProductByEan(data);
if (fetchedProducts.length > 0) {
setProducts(fetchedProducts);
setShowComparison(true);
} else {
Alert.alert(
'Produkt ikke funnet',
`Ingen produkter funnet for strekkode: ${data}`,
[{ text: 'OK', onPress: () => resetScanner() }]
);
}
} catch (error) {
Alert.alert(
'Feil',
'Kunne ikke hente produktinformasjon',
[{ text: 'OK', onPress: () => resetScanner() }]
);
} finally {
setLoading(false);
}
};
const resetScanner = () => {
setScanned(false);
setLoading(false);
setProducts([]);
};
if (!permission) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#DC2626" />
<Text style={styles.permissionText}>Ber om kameratilgang...</Text>
</View>
);
}
if (!permission.granted) {
return (
<View style={styles.centerContainer}>
<Text style={styles.permissionText}>Ingen tilgang til kamera</Text>
<TouchableOpacity onPress={requestPermission} style={styles.permissionButton}>
<Text style={styles.permissionSubtext}>
Trykk her for å gi tilgang til kamera
</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<CameraView
style={StyleSheet.absoluteFillObject}
barcodeScannerSettings={{
barcodeTypes: ["ean13", "ean8"],
}}
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
/>
<View style={styles.overlay}>
<View style={styles.scanArea}>
<View style={[styles.corner, styles.topLeft]} />
<View style={[styles.corner, styles.topRight]} />
<View style={[styles.corner, styles.bottomLeft]} />
<View style={[styles.corner, styles.bottomRight]} />
</View>
<Text style={styles.instructionText}>
Hold kameraet over strekkoden
</Text>
{loading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="white" />
<Text style={styles.loadingText}>Søker etter produkt...</Text>
</View>
)}
</View>
{scanned && !loading && (
<TouchableOpacity
style={styles.scanButton}
onPress={resetScanner}
>
<Text style={styles.scanButtonText}>Skann på nytt</Text>
</TouchableOpacity>
)}
<ProductComparisonModal
visible={showComparison}
products={products}
onClose={() => {
setShowComparison(false);
resetScanner();
}}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'black',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'white',
padding: 20,
},
permissionText: {
fontSize: 18,
fontWeight: '600',
color: '#374151',
marginTop: 16,
},
permissionSubtext: {
fontSize: 14,
color: '#6B7280',
marginTop: 8,
textAlign: 'center',
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
},
scanArea: {
width: 250,
height: 250,
borderWidth: 2,
borderColor: 'transparent',
position: 'relative',
},
corner: {
position: 'absolute',
width: 40,
height: 40,
borderColor: 'white',
},
topLeft: {
top: 0,
left: 0,
borderTopWidth: 3,
borderLeftWidth: 3,
},
topRight: {
top: 0,
right: 0,
borderTopWidth: 3,
borderRightWidth: 3,
},
bottomLeft: {
bottom: 0,
left: 0,
borderBottomWidth: 3,
borderLeftWidth: 3,
},
bottomRight: {
bottom: 0,
right: 0,
borderBottomWidth: 3,
borderRightWidth: 3,
},
instructionText: {
color: 'white',
fontSize: 16,
marginTop: 32,
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
loadingContainer: {
position: 'absolute',
bottom: 100,
backgroundColor: 'rgba(0,0,0,0.8)',
paddingHorizontal: 24,
paddingVertical: 16,
borderRadius: 8,
alignItems: 'center',
},
loadingText: {
color: 'white',
marginTop: 8,
},
scanButton: {
position: 'absolute',
bottom: 50,
alignSelf: 'center',
backgroundColor: '#DC2626',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
scanButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
});
Butikklokasjoner
Store Locator med Expo Location
// src/screens/StoreLocatorScreen.tsx
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
ActivityIndicator,
Alert,
} from 'react-native';
import * as Location from 'expo-location';
import { useQuery } from '@tanstack/react-query';
import { storeService } from '../services/storeService';
import { PhysicalStore } from '../types/api.types';
import { StoreListItem } from '../components/StoreListItem';
export const StoreLocatorScreen: React.FC = () => {
const [location, setLocation] = useState<Location.LocationObject | null>(null);
const [loadingLocation, setLoadingLocation] = useState(true);
const [radius, setRadius] = useState(5); // km
useEffect(() => {
(async () => {
setLoadingLocation(true);
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
'Posisjonstilgang nektet',
'Gi appen tilgang til posisjon for å finne butikker i nærheten'
);
setLoadingLocation(false);
return;
}
try {
const currentLocation = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
setLocation(currentLocation);
} catch (error) {
Alert.alert(
'Kunne ikke hente posisjon',
'Sjekk at posisjonstjenester er aktivert'
);
} finally {
setLoadingLocation(false);
}
})();
}, []);
const { data: stores, isLoading, refetch } = useQuery({
queryKey: ['stores', location?.coords.latitude, location?.coords.longitude, radius],
queryFn: () =>
storeService.getNearbyStores({
lat: location!.coords.latitude,
lng: location!.coords.longitude,
km: radius,
}),
enabled: !!location,
});
const calculateDistance = (store: PhysicalStore): number => {
if (!location) return 0;
const R = 6371; // Earth's radius in km
const dLat = toRad(store.position.lat - location.coords.latitude);
const dLon = toRad(store.position.lng - location.coords.longitude);
const lat1 = toRad(location.coords.latitude);
const lat2 = toRad(store.position.lat);
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.sin(dLon/2) * Math.sin(dLon/2) * Math.cos(lat1) * Math.cos(lat2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
};
const toRad = (value: number): number => {
return value * Math.PI / 180;
};
const sortedStores = stores?.data
.map(store => ({
...store,
distance: calculateDistance(store)
}))
.sort((a, b) => a.distance - b.distance) ?? [];
const renderStore = ({ item }: { item: PhysicalStore & { distance: number } }) => (
<StoreListItem store={item} distance={item.distance} />
);
if (loadingLocation) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#DC2626" />
<Text style={styles.loadingText}>Henter din posisjon...</Text>
</View>
);
}
if (!location) {
return (
<View style={styles.centerContainer}>
<Text style={styles.errorText}>Kunne ikke hente posisjon</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => setLoadingLocation(true)}
>
<Text style={styles.retryButtonText}>Prøv igjen</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerText}>Butikker innen {radius} km</Text>
<View style={styles.radiusSelector}>
{[2, 5, 10, 20].map(r => (
<TouchableOpacity
key={r}
style={[
styles.radiusButton,
radius === r && styles.radiusButtonActive
]}
onPress={() => setRadius(r)}
>
<Text
style={[
styles.radiusButtonText,
radius === r && styles.radiusButtonTextActive
]}
>
{r} km
</Text>
</TouchableOpacity>
))}
</View>
</View>
{isLoading ? (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#DC2626" />
<Text style={styles.loadingText}>Søker etter butikker...</Text>
</View>
) : (
<FlatList
data={sortedStores}
renderItem={renderStore}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>Ingen butikker funnet</Text>
<Text style={styles.emptySubtext}>Prøv å øke søkeradiusen</Text>
</View>
}
/>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F9FAFB',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
header: {
backgroundColor: 'white',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
},
headerText: {
fontSize: 18,
fontWeight: '600',
color: '#374151',
marginBottom: 12,
},
radiusSelector: {
flexDirection: 'row',
gap: 8,
},
radiusButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
borderWidth: 1,
borderColor: '#D1D5DB',
},
radiusButtonActive: {
backgroundColor: '#DC2626',
borderColor: '#DC2626',
},
radiusButtonText: {
color: '#6B7280',
fontSize: 14,
fontWeight: '500',
},
radiusButtonTextActive: {
color: 'white',
},
loadingText: {
marginTop: 12,
fontSize: 16,
color: '#6B7280',
},
errorText: {
fontSize: 18,
color: '#EF4444',
fontWeight: '600',
marginBottom: 16,
},
retryButton: {
backgroundColor: '#DC2626',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
retryButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
listContent: {
flexGrow: 1,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 40,
},
emptyText: {
fontSize: 18,
color: '#374151',
fontWeight: '600',
},
emptySubtext: {
marginTop: 4,
fontSize: 14,
color: '#6B7280',
},
});
Handleliste-funksjonalitet
Shopping List Management
// src/services/shoppingListService.ts
import { apiClient } from './apiClient';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { ShoppingList, ShoppingListItem } from '../types/api.types';
const SHOPPING_LISTS_KEY = '@shopping_lists';
export const shoppingListService = {
// API-baserte metoder for autentiserte brukere
async getShoppingLists(): Promise<ShoppingList[]> {
const { data } = await apiClient.get('/shopping-lists');
return data.data;
},
async createShoppingList(name: string): Promise<ShoppingList> {
const { data } = await apiClient.post('/shopping-lists', { name });
return data.data;
},
async addItemToList(listId: string, productId: number, quantity: number = 1): Promise<ShoppingListItem> {
const { data } = await apiClient.post(`/shopping-lists/${listId}/items`, {
product_id: productId,
quantity,
});
return data.data;
},
async updateItemQuantity(listId: string, itemId: string, quantity: number): Promise<void> {
await apiClient.patch(`/shopping-lists/${listId}/items/${itemId}`, { quantity });
},
async removeItemFromList(listId: string, itemId: string): Promise<void> {
await apiClient.delete(`/shopping-lists/${listId}/items/${itemId}`);
},
async deleteShoppingList(listId: string): Promise<void> {
await apiClient.delete(`/shopping-lists/${listId}`);
},
// Lokal lagring for offline support
async saveListsLocally(lists: ShoppingList[]): Promise<void> {
await AsyncStorage.setItem(SHOPPING_LISTS_KEY, JSON.stringify(lists));
},
async getLocalLists(): Promise<ShoppingList[]> {
const data = await AsyncStorage.getItem(SHOPPING_LISTS_KEY);
return data ? JSON.parse(data) : [];
},
async clearLocalLists(): Promise<void> {
await AsyncStorage.removeItem(SHOPPING_LISTS_KEY);
},
// Pris-optimalisering
calculateOptimalStore(items: ShoppingListItem[]): { [storeName: string]: number } {
const storePrices: { [storeName: string]: number } = {};
items.forEach(item => {
if (item.product && item.product.current_price) {
const storeName = item.product.store.name;
const totalPrice = item.product.current_price * item.quantity;
storePrices[storeName] = (storePrices[storeName] || 0) + totalPrice;
}
});
return storePrices;
},
findCheapestStore(items: ShoppingListItem[]): string | null {
const storePrices = this.calculateOptimalStore(items);
let cheapestStore = null;
let lowestPrice = Infinity;
Object.entries(storePrices).forEach(([store, price]) => {
if (price < lowestPrice) {
lowestPrice = price;
cheapestStore = store;
}
});
return cheapestStore;
}
};
Shopping List Screen
// src/screens/ShoppingListScreen.tsx
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
TextInput,
Alert,
} from 'react-native';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { shoppingListService } from '../services/shoppingListService';
import { ShoppingList, ShoppingListItem } from '../types/api.types';
import { Ionicons } from '@expo/vector-icons';
export const ShoppingListScreen: React.FC = () => {
const queryClient = useQueryClient();
const [selectedList, setSelectedList] = useState<ShoppingList | null>(null);
const [newListName, setNewListName] = useState('');
const [showNewListInput, setShowNewListInput] = useState(false);
const { data: lists, isLoading } = useQuery({
queryKey: ['shoppingLists'],
queryFn: shoppingListService.getShoppingLists,
});
const createListMutation = useMutation({
mutationFn: shoppingListService.createShoppingList,
onSuccess: (newList) => {
queryClient.invalidateQueries({ queryKey: ['shoppingLists'] });
setSelectedList(newList);
setNewListName('');
setShowNewListInput(false);
},
onError: () => {
Alert.alert('Feil', 'Kunne ikke opprette handleliste');
}
});
const deleteListMutation = useMutation({
mutationFn: shoppingListService.deleteShoppingList,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shoppingLists'] });
setSelectedList(null);
},
onError: () => {
Alert.alert('Feil', 'Kunne ikke slette handleliste');
}
});
const updateQuantityMutation = useMutation({
mutationFn: ({ listId, itemId, quantity }: any) =>
shoppingListService.updateItemQuantity(listId, itemId, quantity),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shoppingLists'] });
}
});
const removeItemMutation = useMutation({
mutationFn: ({ listId, itemId }: any) =>
shoppingListService.removeItemFromList(listId, itemId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shoppingLists'] });
}
});
const handleCreateList = () => {
if (newListName.trim()) {
createListMutation.mutate(newListName.trim());
}
};
const handleDeleteList = (list: ShoppingList) => {
Alert.alert(
'Slett handleliste',
`Er du sikker på at du vil slette "${list.name}"?`,
[
{ text: 'Avbryt', style: 'cancel' },
{
text: 'Slett',
style: 'destructive',
onPress: () => deleteListMutation.mutate(list.id),
},
]
);
};
const calculateTotalPrice = (items: ShoppingListItem[]): number => {
return items.reduce((total, item) => {
const price = item.product?.current_price || 0;
return total + (price * item.quantity);
}, 0);
};
const renderListItem = ({ item }: { item: ShoppingListItem }) => (
<View style={styles.listItem}>
<View style={styles.itemInfo}>
<Text style={styles.itemName}>{item.product?.name || 'Ukjent produkt'}</Text>
<Text style={styles.itemStore}>{item.product?.store.name}</Text>
<Text style={styles.itemPrice}>
{item.product?.current_price
? `${(item.product.current_price * item.quantity).toFixed(2)} kr`
: 'Pris ikke tilgjengelig'}
</Text>
</View>
<View style={styles.quantityControls}>
<TouchableOpacity
style={styles.quantityButton}
onPress={() => {
if (item.quantity > 1 && selectedList) {
updateQuantityMutation.mutate({
listId: selectedList.id,
itemId: item.id,
quantity: item.quantity - 1,
});
}
}}
>
<Ionicons name="remove" size={20} color="#6B7280" />
</TouchableOpacity>
<Text style={styles.quantityText}>{item.quantity}</Text>
<TouchableOpacity
style={styles.quantityButton}
onPress={() => {
if (selectedList) {
updateQuantityMutation.mutate({
listId: selectedList.id,
itemId: item.id,
quantity: item.quantity + 1,
});
}
}}
>
<Ionicons name="add" size={20} color="#6B7280" />
</TouchableOpacity>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => {
if (selectedList) {
removeItemMutation.mutate({
listId: selectedList.id,
itemId: item.id,
});
}
}}
>
<Ionicons name="trash-outline" size={20} color="#EF4444" />
</TouchableOpacity>
</View>
</View>
);
if (!selectedList) {
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Mine handlelister</Text>
<TouchableOpacity
style={styles.addButton}
onPress={() => setShowNewListInput(!showNewListInput)}
>
<Ionicons name="add-circle" size={32} color="#DC2626" />
</TouchableOpacity>
</View>
{showNewListInput && (
<View style={styles.newListInput}>
<TextInput
style={styles.input}
placeholder="Navn på handleliste"
value={newListName}
onChangeText={setNewListName}
onSubmitEditing={handleCreateList}
/>
<TouchableOpacity
style={styles.createButton}
onPress={handleCreateList}
>
<Text style={styles.createButtonText}>Opprett</Text>
</TouchableOpacity>
</View>
)}
<FlatList
data={lists}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.listCard}
onPress={() => setSelectedList(item)}
onLongPress={() => handleDeleteList(item)}
>
<View>
<Text style={styles.listName}>{item.name}</Text>
<Text style={styles.listInfo}>
{item.items.length} produkter • {calculateTotalPrice(item.items).toFixed(2)} kr
</Text>
</View>
<Ionicons name="chevron-forward" size={24} color="#6B7280" />
</TouchableOpacity>
)}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>Ingen handlelister enda</Text>
<Text style={styles.emptySubtext}>Trykk + for å opprette en ny</Text>
</View>
}
/>
</View>
);
}
const cheapestStore = shoppingListService.findCheapestStore(selectedList.items);
const storePrices = shoppingListService.calculateOptimalStore(selectedList.items);
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => setSelectedList(null)}>
<Ionicons name="arrow-back" size={24} color="#374151" />
</TouchableOpacity>
<Text style={styles.headerTitle}>{selectedList.name}</Text>
<TouchableOpacity onPress={() => handleDeleteList(selectedList)}>
<Ionicons name="trash-outline" size={24} color="#EF4444" />
</TouchableOpacity>
</View>
{cheapestStore && (
<View style={styles.optimizationCard}>
<Text style={styles.optimizationTitle}>Billigste butikk</Text>
<Text style={styles.optimizationStore}>{cheapestStore}</Text>
<Text style={styles.optimizationPrice}>
Total: {storePrices[cheapestStore].toFixed(2)} kr
</Text>
</View>
)}
<FlatList
data={selectedList.items}
keyExtractor={(item) => item.id}
renderItem={renderListItem}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>Handlelisten er tom</Text>
<Text style={styles.emptySubtext}>Søk etter produkter for å legge til</Text>
</View>
}
/>
<View style={styles.footer}>
<Text style={styles.totalText}>
Total: {calculateTotalPrice(selectedList.items).toFixed(2)} kr
</Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F9FAFB',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: 'white',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: '#374151',
},
addButton: {
padding: 4,
},
newListInput: {
flexDirection: 'row',
padding: 16,
backgroundColor: 'white',
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
},
input: {
flex: 1,
height: 40,
borderWidth: 1,
borderColor: '#D1D5DB',
borderRadius: 8,
paddingHorizontal: 12,
marginRight: 8,
},
createButton: {
backgroundColor: '#DC2626',
paddingHorizontal: 16,
justifyContent: 'center',
borderRadius: 8,
},
createButtonText: {
color: 'white',
fontWeight: '600',
},
listCard: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: 'white',
padding: 16,
marginHorizontal: 16,
marginVertical: 4,
borderRadius: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
listName: {
fontSize: 16,
fontWeight: '600',
color: '#374151',
},
listInfo: {
fontSize: 14,
color: '#6B7280',
marginTop: 4,
},
listItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: 'white',
padding: 16,
marginHorizontal: 16,
marginVertical: 4,
borderRadius: 8,
},
itemInfo: {
flex: 1,
},
itemName: {
fontSize: 16,
fontWeight: '500',
color: '#374151',
},
itemStore: {
fontSize: 14,
color: '#6B7280',
marginTop: 2,
},
itemPrice: {
fontSize: 14,
fontWeight: '600',
color: '#059669',
marginTop: 2,
},
quantityControls: {
flexDirection: 'row',
alignItems: 'center',
},
quantityButton: {
padding: 4,
},
quantityText: {
marginHorizontal: 12,
fontSize: 16,
fontWeight: '500',
},
deleteButton: {
marginLeft: 12,
padding: 4,
},
optimizationCard: {
backgroundColor: '#10B981',
padding: 16,
margin: 16,
borderRadius: 8,
},
optimizationTitle: {
color: 'white',
fontSize: 14,
opacity: 0.9,
},
optimizationStore: {
color: 'white',
fontSize: 18,
fontWeight: '600',
marginTop: 4,
},
optimizationPrice: {
color: 'white',
fontSize: 16,
marginTop: 4,
},
listContent: {
flexGrow: 1,
paddingBottom: 80,
},
footer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'white',
padding: 16,
borderTopWidth: 1,
borderTopColor: '#E5E7EB',
},
totalText: {
fontSize: 18,
fontWeight: '600',
color: '#374151',
textAlign: 'center',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 40,
},
emptyText: {
fontSize: 18,
color: '#374151',
fontWeight: '600',
},
emptySubtext: {
marginTop: 4,
fontSize: 14,
color: '#6B7280',
},
});
State Management
React Query Setup
// src/App.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
// Import screens
import { ProductSearchScreen } from './screens/ProductSearchScreen';
import { BarcodeScannerScreen } from './screens/BarcodeScannerScreen';
import { StoreLocatorScreen } from './screens/StoreLocatorScreen';
import { ShoppingListScreen } from './screens/ShoppingListScreen';
import { ProfileScreen } from './screens/ProfileScreen';
const Tab = createBottomTabNavigator();
// Configure React Query
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
retry: 2,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<SafeAreaProvider>
<NavigationContainer>
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused, color, size }) => {
let iconName: keyof typeof Ionicons.glyphMap;
switch (route.name) {
case 'Search':
iconName = focused ? 'search' : 'search-outline';
break;
case 'Scanner':
iconName = focused ? 'barcode' : 'barcode-outline';
break;
case 'Stores':
iconName = focused ? 'location' : 'location-outline';
break;
case 'Lists':
iconName = focused ? 'list' : 'list-outline';
break;
case 'Profile':
iconName = focused ? 'person' : 'person-outline';
break;
default:
iconName = 'help-outline';
}
return <Ionicons name={iconName} size={size} color={color} />;
},
tabBarActiveTintColor: '#DC2626',
tabBarInactiveTintColor: '#6B7280',
headerStyle: {
backgroundColor: '#DC2626',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: '600',
},
})}
>
<Tab.Screen name="Search" component={ProductSearchScreen} options={{ title: 'Søk' }} />
<Tab.Screen name="Scanner" component={BarcodeScannerScreen} options={{ title: 'Skann' }} />
<Tab.Screen name="Stores" component={StoreLocatorScreen} options={{ title: 'Butikker' }} />
<Tab.Screen name="Lists" component={ShoppingListScreen} options={{ title: 'Lister' }} />
<Tab.Screen name="Profile" component={ProfileScreen} options={{ title: 'Profil' }} />
</Tab.Navigator>
</NavigationContainer>
</SafeAreaProvider>
</QueryClientProvider>
);
}
Avslutning
Denne guiden har vist deg hvordan du integrerer Kassalapp API i Expo og React Native applikasjoner. Med eksemplene og kodesnuttene kan du nå:
- Søke etter produkter og sammenligne priser
- Implementere strekkodeskanning med Expo BarCodeScanner
- Finne nærmeste butikker med Expo Location
- Bygge smarte handlelister med pris-optimalisering
- Håndtere state med React Query og TypeScript
For mer informasjon og oppdatert dokumentasjon, besøk https://kassal.app/api.
Nyttige ressurser
Lisens og bruksvilkår
Husk å følge Kassalapp sine bruksvilkår og respekter rate limits. For kommersiell bruk eller høyere rate limits, kontakt Kassalapp for enterprise-løsninger.