Bli med i Kassalapp-fellesskapet på Discord

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
  2. Autentisering
  3. API Client oppsett
  4. TypeScript Types
  5. Produktsøk
  6. Strekkodeskanning
  7. Butikklokasjoner
  8. Handleliste-funksjonalitet
  9. State Management
  10. Feilhåndtering og Offline Support
  11. 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
  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<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

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<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

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<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

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 }) => (
    <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

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<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

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<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

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<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

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<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

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 (
    <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.