Allow manual prices for products

This commit is contained in:
2026-07-01 11:08:53 +02:00
parent c730d36cfe
commit 6cf8906798
3 changed files with 109 additions and 4 deletions
+1
View File
@@ -21,6 +21,7 @@ Die App richtet sich an Privatpersonen, die im Supermarkt schnell bessere Kaufen
- Kurze Begründung zur Bewertung und mögliche Alternativhinweise
- Manuelle Produktsuche, falls der Barcode nicht erkannt wird
- Lokale Einkaufsliste mit Menge, Preis, Abhaken und Löschen
- Manuelle Preiseingabe, wenn Open Food Facts keinen Artikelpreis liefert
- Lokaler Produktcache und Offline-Modus für gespeicherte Produkte
- Produktdaten per Barcode aus der Open-Food-Facts-API
- Später geplant: erweiterte Profile, Budgettracking, Nachhaltigkeitsfilter und Rezeptvorschläge
+8 -1
View File
@@ -49,7 +49,14 @@ export default function AlternativesScreen({ product, preferences, productCache,
<AlternativeProductCard
item={item}
key={item.product.id}
onChoose={() => onChoose(item.product)}
onChoose={() => {
if (typeof item.product.price === 'number') {
onChoose(item.product);
return;
}
onOpenProduct(item.product);
}}
onOpen={() => onOpenProduct(item.product)}
/>
))}
+100 -3
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { StyleSheet, Text, TextInput, View } from 'react-native';
import PrimaryButton from '../components/PrimaryButton';
import SecondaryButton from '../components/SecondaryButton';
@@ -15,10 +15,27 @@ import { formatPrice } from '../utils/formatPrice';
export default function ProductDetailScreen({ product, preferences, onBack, onAddToList, onShowAlternatives }) {
const [savedMessage, setSavedMessage] = useState('');
const [manualPrice, setManualPrice] = useState('');
const [priceError, setPriceError] = useState('');
const rating = useMemo(() => calculateProductRating(product, preferences), [product, preferences]);
const hasProductPrice = typeof product.price === 'number';
const currency = product.currency || preferences.currency;
function addToList() {
onAddToList(product);
const parsedManualPrice = parseManualPrice(manualPrice);
if (!hasProductPrice && parsedManualPrice === undefined) {
setSavedMessage('');
setPriceError('Bitte gib einen Preis ein, bevor du das Produkt hinzufügst.');
return;
}
onAddToList({
...product,
currency,
price: hasProductPrice ? product.price : parsedManualPrice
});
setPriceError('');
setSavedMessage('Produkt wurde zur Einkaufsliste hinzugefügt.');
}
@@ -32,7 +49,9 @@ export default function ProductDetailScreen({ product, preferences, onBack, onAd
<View style={styles.copy}>
<Text style={styles.brand}>{product.brand || 'Ohne Marke'}</Text>
<Text style={styles.name}>{product.name}</Text>
<Text style={styles.meta}>{product.category} · {formatPrice(product.price, product.currency)}</Text>
<Text style={styles.meta}>
{product.category || 'Produkt'} · {formatPrice(product.price, currency)}
</Text>
</View>
<ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} />
</View>
@@ -41,6 +60,30 @@ export default function ProductDetailScreen({ product, preferences, onBack, onAd
</Text>
</View>
{!hasProductPrice ? (
<View style={styles.pricePanel}>
<Text style={styles.priceTitle}>Preis ergänzen</Text>
<Text style={styles.priceCopy}>
Open Food Facts liefert keine Preise. Gib den Ladenpreis ein, damit die Einkaufsliste und Budgetprüfung stimmen.
</Text>
<View style={styles.priceInputRow}>
<Text style={styles.currency}>{currency}</Text>
<TextInput
keyboardType="decimal-pad"
onChangeText={(value) => {
setManualPrice(value);
setPriceError('');
}}
placeholder="z. B. 3.50"
placeholderTextColor={colors.muted}
style={styles.priceInput}
value={manualPrice}
/>
</View>
{priceError ? <Text style={styles.errorText}>{priceError}</Text> : null}
</View>
) : null}
<SectionTitle>Bewertungskriterien</SectionTitle>
{Object.entries(rating.categoryScores).map(([category, categoryRating]) => (
<RatingCategoryRow category={category} key={category} rating={categoryRating} />
@@ -62,6 +105,17 @@ export default function ProductDetailScreen({ product, preferences, onBack, onAd
);
}
function parseManualPrice(value) {
const normalizedValue = value.trim().replace(',', '.');
const parsedValue = Number(normalizedValue);
if (!normalizedValue || !Number.isFinite(parsedValue) || parsedValue <= 0) {
return undefined;
}
return Math.round(parsedValue * 100) / 100;
}
const styles = StyleSheet.create({
productPanel: {
backgroundColor: colors.surface,
@@ -102,6 +156,49 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '700'
},
pricePanel: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderRadius: 8,
borderWidth: 1,
gap: 10,
padding: 16
},
priceTitle: {
color: colors.text,
fontSize: 17,
fontWeight: '800'
},
priceCopy: {
color: colors.muted,
fontSize: 14,
lineHeight: 20
},
priceInputRow: {
alignItems: 'center',
flexDirection: 'row',
gap: 10
},
currency: {
color: colors.muted,
fontSize: 16,
fontWeight: '800'
},
priceInput: {
borderColor: colors.border,
borderRadius: 8,
borderWidth: 1,
color: colors.text,
flex: 1,
fontSize: 16,
paddingHorizontal: 12,
paddingVertical: 10
},
errorText: {
color: colors.danger,
fontSize: 13,
fontWeight: '800'
},
dataPanel: {
backgroundColor: colors.surface,
borderColor: colors.border,