# 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 1. [Kom i gang](#kom-i-gang) 2. [Autentisering](#autentisering) 3. [API Client oppsett](#api-client-oppsett) 4. [TypeScript Types](#typescript-types) 5. [Produktsøk](#produktsøk) 6. [Strekkodeskanning](#strekkodeskanning) 7. [Butikklokasjoner](#butikklokasjoner) 8. [Handleliste-funksjonalitet](#handleliste-funksjonalitet) 9. [State Management](#state-management) 10. [Feilhåndtering og Offline Support](#feilhåndtering-og-offline-support) 11. [Praktiske eksempler](#praktiske-eksempler) ## Kom i gang ### 1. Registrer deg for API-nøkkel Først må du registrere deg for å få en API-nøkkel: 1. Gå til [https://kassal.app/api](https://kassal.app/api) 2. Opprett en konto eller logg inn 3. Generer en ny API-nøkkel fra dashboardet 4. Lagre nøkkelen trygt ### 2. Opprett nytt Expo prosjekt ```bash # 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 ```bash # 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 ```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 ```typescript // 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 { await SecureStore.setItemAsync(API_KEY_STORAGE_KEY, apiKey); }, async getApiKey(): Promise { return await SecureStore.getItemAsync(API_KEY_STORAGE_KEY); }, async deleteApiKey(): Promise { await SecureStore.deleteItemAsync(API_KEY_STORAGE_KEY); }, async hasApiKey(): Promise { const key = await this.getApiKey(); return key !== null; } }; ``` ## API Client oppsett ### Axios konfigurasjon med interceptors ```typescript // 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 ```typescript // 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 { 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 ```typescript // 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> { const { data } = await apiClient.get('/products', { params }); return data; }, async getProductById(id: number): Promise { const { data } = await apiClient.get(`/products/id/${id}`); return data.data; }, async getProductByEan(ean: string): Promise { const { data } = await apiClient.get(`/products/ean/${ean}`); return data.data; }, async getProductByUrl(url: string): Promise { const { data } = await apiClient.get('/products/find-by-url/single', { params: { url } }); return data.data; }, async comparePricesByUrl(url: string): Promise { const { data } = await apiClient.get('/products/find-by-url/compare', { params: { url } }); return data.data; } }; ``` ### Product Search Screen ```tsx // 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 }) => ( ), []); const renderFooter = () => { if (!isFetchingNextPage) return null; return ( ); }; const renderEmpty = () => { if (isLoading) { return ( Søker... ); } if (searchQuery.length > 2 && products.length === 0) { return ( Ingen produkter funnet Prøv et annet søkeord ); } return ( Søk etter produkter Minimum 3 tegn ); }; return ( {searchQuery.length > 0 && ( setSearchQuery('')} > ✕ )} item.id.toString()} contentContainerStyle={styles.listContent} ListEmptyComponent={renderEmpty} ListFooterComponent={renderFooter} onEndReached={() => { if (hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }} onEndReachedThreshold={0.5} refreshControl={ } /> ); }; 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 ```tsx // 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([]); 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 ( Ber om kameratilgang... ); } if (!permission.granted) { return ( Ingen tilgang til kamera Trykk her for å gi tilgang til kamera ); } return ( Hold kameraet over strekkoden {loading && ( Søker etter produkt... )} {scanned && !loading && ( Skann på nytt )} { setShowComparison(false); resetScanner(); }} /> ); }; 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 ```tsx // 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(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 } }) => ( ); if (loadingLocation) { return ( Henter din posisjon... ); } if (!location) { return ( Kunne ikke hente posisjon setLoadingLocation(true)} > Prøv igjen ); } return ( Butikker innen {radius} km {[2, 5, 10, 20].map(r => ( setRadius(r)} > {r} km ))} {isLoading ? ( Søker etter butikker... ) : ( item.id} contentContainerStyle={styles.listContent} ListEmptyComponent={ Ingen butikker funnet Prøv å øke søkeradiusen } /> )} ); }; 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 ```typescript // 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 { const { data } = await apiClient.get('/shopping-lists'); return data.data; }, async createShoppingList(name: string): Promise { const { data } = await apiClient.post('/shopping-lists', { name }); return data.data; }, async addItemToList(listId: string, productId: number, quantity: number = 1): Promise { 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 { await apiClient.patch(`/shopping-lists/${listId}/items/${itemId}`, { quantity }); }, async removeItemFromList(listId: string, itemId: string): Promise { await apiClient.delete(`/shopping-lists/${listId}/items/${itemId}`); }, async deleteShoppingList(listId: string): Promise { await apiClient.delete(`/shopping-lists/${listId}`); }, // Lokal lagring for offline support async saveListsLocally(lists: ShoppingList[]): Promise { await AsyncStorage.setItem(SHOPPING_LISTS_KEY, JSON.stringify(lists)); }, async getLocalLists(): Promise { const data = await AsyncStorage.getItem(SHOPPING_LISTS_KEY); return data ? JSON.parse(data) : []; }, async clearLocalLists(): Promise { 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 ```tsx // 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(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 }) => ( {item.product?.name || 'Ukjent produkt'} {item.product?.store.name} {item.product?.current_price ? `${(item.product.current_price * item.quantity).toFixed(2)} kr` : 'Pris ikke tilgjengelig'} { if (item.quantity > 1 && selectedList) { updateQuantityMutation.mutate({ listId: selectedList.id, itemId: item.id, quantity: item.quantity - 1, }); } }} > {item.quantity} { if (selectedList) { updateQuantityMutation.mutate({ listId: selectedList.id, itemId: item.id, quantity: item.quantity + 1, }); } }} > { if (selectedList) { removeItemMutation.mutate({ listId: selectedList.id, itemId: item.id, }); } }} > ); if (!selectedList) { return ( Mine handlelister setShowNewListInput(!showNewListInput)} > {showNewListInput && ( Opprett )} item.id} renderItem={({ item }) => ( setSelectedList(item)} onLongPress={() => handleDeleteList(item)} > {item.name} {item.items.length} produkter • {calculateTotalPrice(item.items).toFixed(2)} kr )} ListEmptyComponent={ Ingen handlelister enda Trykk + for å opprette en ny } /> ); } const cheapestStore = shoppingListService.findCheapestStore(selectedList.items); const storePrices = shoppingListService.calculateOptimalStore(selectedList.items); return ( setSelectedList(null)}> {selectedList.name} handleDeleteList(selectedList)}> {cheapestStore && ( Billigste butikk {cheapestStore} Total: {storePrices[cheapestStore].toFixed(2)} kr )} item.id} renderItem={renderListItem} contentContainerStyle={styles.listContent} ListEmptyComponent={ Handlelisten er tom Søk etter produkter for å legge til } /> Total: {calculateTotalPrice(selectedList.items).toFixed(2)} kr ); }; 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 ```tsx // 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 ( ({ 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 ; }, tabBarActiveTintColor: '#DC2626', tabBarInactiveTintColor: '#6B7280', headerStyle: { backgroundColor: '#DC2626', }, headerTintColor: '#fff', headerTitleStyle: { fontWeight: '600', }, })} > ); } ``` ## 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](https://kassal.app/api). ### Nyttige ressurser - [Kassalapp API Dokumentasjon](https://kassal.app/api) - [Expo Dokumentasjon](https://docs.expo.dev) - [React Native Dokumentasjon](https://reactnative.dev) - [React Query Dokumentasjon](https://tanstack.com/query) ### 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.