Allow manual prices for products
This commit is contained in:
@@ -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
|
- Kurze Begründung zur Bewertung und mögliche Alternativhinweise
|
||||||
- Manuelle Produktsuche, falls der Barcode nicht erkannt wird
|
- Manuelle Produktsuche, falls der Barcode nicht erkannt wird
|
||||||
- Lokale Einkaufsliste mit Menge, Preis, Abhaken und Löschen
|
- 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
|
- Lokaler Produktcache und Offline-Modus für gespeicherte Produkte
|
||||||
- Produktdaten per Barcode aus der Open-Food-Facts-API
|
- Produktdaten per Barcode aus der Open-Food-Facts-API
|
||||||
- Später geplant: erweiterte Profile, Budgettracking, Nachhaltigkeitsfilter und Rezeptvorschläge
|
- Später geplant: erweiterte Profile, Budgettracking, Nachhaltigkeitsfilter und Rezeptvorschläge
|
||||||
|
|||||||
@@ -49,7 +49,14 @@ export default function AlternativesScreen({ product, preferences, productCache,
|
|||||||
<AlternativeProductCard
|
<AlternativeProductCard
|
||||||
item={item}
|
item={item}
|
||||||
key={item.product.id}
|
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)}
|
onOpen={() => onOpenProduct(item.product)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
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 PrimaryButton from '../components/PrimaryButton';
|
||||||
import SecondaryButton from '../components/SecondaryButton';
|
import SecondaryButton from '../components/SecondaryButton';
|
||||||
@@ -15,10 +15,27 @@ import { formatPrice } from '../utils/formatPrice';
|
|||||||
|
|
||||||
export default function ProductDetailScreen({ product, preferences, onBack, onAddToList, onShowAlternatives }) {
|
export default function ProductDetailScreen({ product, preferences, onBack, onAddToList, onShowAlternatives }) {
|
||||||
const [savedMessage, setSavedMessage] = useState('');
|
const [savedMessage, setSavedMessage] = useState('');
|
||||||
|
const [manualPrice, setManualPrice] = useState('');
|
||||||
|
const [priceError, setPriceError] = useState('');
|
||||||
const rating = useMemo(() => calculateProductRating(product, preferences), [product, preferences]);
|
const rating = useMemo(() => calculateProductRating(product, preferences), [product, preferences]);
|
||||||
|
const hasProductPrice = typeof product.price === 'number';
|
||||||
|
const currency = product.currency || preferences.currency;
|
||||||
|
|
||||||
function addToList() {
|
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.');
|
setSavedMessage('Produkt wurde zur Einkaufsliste hinzugefügt.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +49,9 @@ export default function ProductDetailScreen({ product, preferences, onBack, onAd
|
|||||||
<View style={styles.copy}>
|
<View style={styles.copy}>
|
||||||
<Text style={styles.brand}>{product.brand || 'Ohne Marke'}</Text>
|
<Text style={styles.brand}>{product.brand || 'Ohne Marke'}</Text>
|
||||||
<Text style={styles.name}>{product.name}</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>
|
</View>
|
||||||
<ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} />
|
<ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} />
|
||||||
</View>
|
</View>
|
||||||
@@ -41,6 +60,30 @@ export default function ProductDetailScreen({ product, preferences, onBack, onAd
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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>
|
<SectionTitle>Bewertungskriterien</SectionTitle>
|
||||||
{Object.entries(rating.categoryScores).map(([category, categoryRating]) => (
|
{Object.entries(rating.categoryScores).map(([category, categoryRating]) => (
|
||||||
<RatingCategoryRow category={category} key={category} rating={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({
|
const styles = StyleSheet.create({
|
||||||
productPanel: {
|
productPanel: {
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
@@ -102,6 +156,49 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '700'
|
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: {
|
dataPanel: {
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
|
|||||||
Reference in New Issue
Block a user