feat: add kcal app
This commit is contained in:
@@ -0,0 +1,607 @@
|
||||
import { computed, onMounted, shallowRef } from 'vue';
|
||||
import { useRef } from 'vue-jsx-vapor';
|
||||
import { useMutation, useQuery } from 'vue-sync-engine';
|
||||
import { useCloseWatcher } from '@robonen/vue';
|
||||
import { groupBy } from '@robonen/stdlib';
|
||||
import { focus } from '@robonen/platform/browsers';
|
||||
import { FoodEntity, addEntryMutation, foodsQuery, upsertFoodMutation } from '../data/defs';
|
||||
import { useEntities } from '../data/composables';
|
||||
import { fetchOffByBarcode } from '../data/off';
|
||||
import type { OffProduct } from '../data/off';
|
||||
import { defaultAmount, portionNutrients } from '../domain/calc';
|
||||
import { fmtG, fmtKcal } from '../domain/format';
|
||||
import { MEALS, MEAL_LABELS } from '../domain/types';
|
||||
import type { Entry, Food, Meal } from '../domain/types';
|
||||
import { addSheet, selectedDate } from '../ui/state';
|
||||
import { IconBarcode, IconChevronLeft, IconClose, IconSearch } from '../ui/icons';
|
||||
import BarcodeScanner, { isBarcodeScanSupported } from '../components/BarcodeScanner';
|
||||
|
||||
type Step = 'pick' | 'amount' | 'quick' | 'food';
|
||||
|
||||
const GRAM_PRESETS = [50, 100, 150, 200, 300];
|
||||
|
||||
export default function AddSheet() {
|
||||
const step = shallowRef<Step>('pick');
|
||||
const query = shallowRef('');
|
||||
const meal = shallowRef<Meal>(addSheet.meal);
|
||||
const searchEl = useRef();
|
||||
|
||||
const foodsQ = useQuery(foodsQuery, () => undefined);
|
||||
const foods = useEntities(FoodEntity, () => foodsQ.data.value?.ids);
|
||||
|
||||
const addEntry = useMutation(addEntryMutation);
|
||||
const upsertFood = useMutation(upsertFoodMutation);
|
||||
|
||||
const close = () => (addSheet.open = false);
|
||||
useCloseWatcher().onClose(close);
|
||||
onMounted(() => focus(searchEl.value as HTMLElement | null));
|
||||
|
||||
// ── выбор продукта ────────────────────────────────────────────────────────
|
||||
const trimmed = computed(() => query.value.trim().toLowerCase());
|
||||
const filtered = computed(() =>
|
||||
trimmed.value === ''
|
||||
? foods.value
|
||||
: foods.value.filter(food => food.name.toLowerCase().includes(trimmed.value)),
|
||||
);
|
||||
const recents = computed(() =>
|
||||
[...foods.value]
|
||||
.filter(food => food.usedCount > 0)
|
||||
.sort((a, b) => b.lastUsedAt - a.lastUsedAt)
|
||||
.slice(0, 8),
|
||||
);
|
||||
const groups = computed(() => {
|
||||
const grouped = groupBy(filtered.value, food => food.category);
|
||||
return Object.entries(grouped).sort((a, b) => a[0].localeCompare(b[0], 'ru'));
|
||||
});
|
||||
|
||||
// ── порция ────────────────────────────────────────────────────────────────
|
||||
const selectedFood = shallowRef<Food | null>(null);
|
||||
const amountG = shallowRef(100);
|
||||
|
||||
const pickFood = (food: Food) => {
|
||||
selectedFood.value = food;
|
||||
amountG.value = defaultAmount(food);
|
||||
step.value = 'amount';
|
||||
};
|
||||
|
||||
const preview = computed(() =>
|
||||
selectedFood.value ? portionNutrients(selectedFood.value, amountG.value) : null,
|
||||
);
|
||||
|
||||
const submitAmount = () => {
|
||||
const food = selectedFood.value;
|
||||
if (!food || amountG.value <= 0) return;
|
||||
const entry: Entry = {
|
||||
id: crypto.randomUUID(),
|
||||
date: selectedDate.value,
|
||||
meal: meal.value,
|
||||
foodId: food.id,
|
||||
name: food.name,
|
||||
amountG: amountG.value,
|
||||
...portionNutrients(food, amountG.value),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
addEntry.mutate({ entry });
|
||||
close();
|
||||
};
|
||||
|
||||
// ── быстрая запись ────────────────────────────────────────────────────────
|
||||
const quickName = shallowRef('');
|
||||
const quickKcal = shallowRef(0);
|
||||
const quickProtein = shallowRef(0);
|
||||
const quickFat = shallowRef(0);
|
||||
const quickCarbs = shallowRef(0);
|
||||
|
||||
const submitQuick = () => {
|
||||
if (quickKcal.value <= 0) return;
|
||||
const entry: Entry = {
|
||||
id: crypto.randomUUID(),
|
||||
date: selectedDate.value,
|
||||
meal: meal.value,
|
||||
name: quickName.value.trim() || 'Быстрая запись',
|
||||
kcal: Math.round(quickKcal.value),
|
||||
protein: quickProtein.value,
|
||||
fat: quickFat.value,
|
||||
carbs: quickCarbs.value,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
addEntry.mutate({ entry });
|
||||
close();
|
||||
};
|
||||
|
||||
// ── новый продукт ─────────────────────────────────────────────────────────
|
||||
const foodName = shallowRef('');
|
||||
const foodCategory = shallowRef('Моё');
|
||||
const foodKcal = shallowRef(0);
|
||||
const foodProtein = shallowRef(0);
|
||||
const foodFat = shallowRef(0);
|
||||
const foodCarbs = shallowRef(0);
|
||||
const foodPiece = shallowRef(0);
|
||||
const foodBarcode = shallowRef('');
|
||||
const categories = computed(() => [...new Set(foods.value.map(food => food.category))].sort((a, b) => a.localeCompare(b, 'ru')));
|
||||
|
||||
const submitFood = async () => {
|
||||
if (foodName.value.trim() === '' || foodKcal.value <= 0) return;
|
||||
const food: Food = {
|
||||
id: crypto.randomUUID(),
|
||||
name: foodName.value.trim(),
|
||||
category: foodCategory.value.trim() || 'Моё',
|
||||
kcal: foodKcal.value,
|
||||
protein: foodProtein.value,
|
||||
fat: foodFat.value,
|
||||
carbs: foodCarbs.value,
|
||||
...(foodPiece.value > 0 ? { pieceGrams: foodPiece.value } : {}),
|
||||
...(foodBarcode.value !== '' ? { barcode: foodBarcode.value } : {}),
|
||||
usedCount: 0,
|
||||
lastUsedAt: 0,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await upsertFood.mutateAsync({ food });
|
||||
pickFood(food);
|
||||
};
|
||||
|
||||
// ── база упаковок (Open Food Facts, только штрихкоды) ─────────────────────
|
||||
const offBusy = shallowRef(false);
|
||||
const offError = shallowRef('');
|
||||
const scannerOpen = shallowRef(false);
|
||||
const barcodeInput = shallowRef('');
|
||||
const scanSupported = isBarcodeScanSupported();
|
||||
|
||||
/** Продукт из базы: уже сканировали раньше — сразу к порции, иначе на проверку формы. */
|
||||
const applyOffProduct = (product: OffProduct) => {
|
||||
const existing = foods.value.find(food => food.barcode === product.code);
|
||||
if (existing) {
|
||||
pickFood(existing);
|
||||
return;
|
||||
}
|
||||
foodName.value = product.brand && !product.name.toLowerCase().includes(product.brand.toLowerCase())
|
||||
? `${product.name} (${product.brand})`
|
||||
: product.name;
|
||||
foodCategory.value = 'Упакованное';
|
||||
foodKcal.value = product.kcal;
|
||||
foodProtein.value = product.protein;
|
||||
foodFat.value = product.fat;
|
||||
foodCarbs.value = product.carbs;
|
||||
foodPiece.value = product.servingGrams ?? 0;
|
||||
foodBarcode.value = product.code;
|
||||
step.value = 'food';
|
||||
};
|
||||
|
||||
const lookupBarcode = async (code: string) => {
|
||||
const digits = code.trim();
|
||||
if (digits === '' || offBusy.value) return;
|
||||
scannerOpen.value = false;
|
||||
offBusy.value = true;
|
||||
offError.value = '';
|
||||
try {
|
||||
const product = await fetchOffByBarcode(digits);
|
||||
if (product) {
|
||||
barcodeInput.value = '';
|
||||
applyOffProduct(product);
|
||||
}
|
||||
else {
|
||||
offError.value = `Штрихкод ${digits} не найден в базе — заведите продукт вручную с упаковки.`;
|
||||
}
|
||||
}
|
||||
catch (cause) {
|
||||
offError.value = cause instanceof Error ? cause.message : 'База недоступна';
|
||||
}
|
||||
finally {
|
||||
offBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const numeric = (raw: string): number => {
|
||||
const value = Number(raw.replace(',', '.'));
|
||||
return Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
};
|
||||
|
||||
const fieldClass = 'w-full rounded-xl border hairline bg-raised/70 px-3.5 py-2.5 text-[15px] text-ink outline-none transition focus:border-ember/50 placeholder:text-ink-faint';
|
||||
const chipClass = (active: boolean) =>
|
||||
`rounded-full border px-3 py-1.5 text-[13px] transition ${active
|
||||
? 'border-ember/60 bg-ember/15 text-ember-bright'
|
||||
: 'border-white/10 text-ink-soft hover:border-white/20 hover:text-ink'}`;
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 z-50 flex items-end justify-center">
|
||||
<div class="animate-fade-in absolute inset-0 bg-black/65 backdrop-blur-[2px]" onClick={close} />
|
||||
|
||||
<div class="animate-sheet-up relative flex max-h-[90dvh] w-full max-w-105 flex-col rounded-t-3xl border border-b-0 hairline bg-[#191511] shadow-[0_-24px_80px_rgba(0,0,0,0.5)]">
|
||||
{/* Шапка */}
|
||||
<div class="flex items-center gap-2 px-5 pt-4 pb-3">
|
||||
{step.value !== 'pick' && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Назад"
|
||||
class="grid size-9 place-items-center rounded-full text-ink-soft transition hover:bg-white/6"
|
||||
onClick={() => (step.value = 'pick')}
|
||||
>
|
||||
<IconChevronLeft class="size-5" />
|
||||
</button>
|
||||
)}
|
||||
<h2 class="text-display flex-1 text-lg font-medium">
|
||||
{step.value === 'pick' && `${MEAL_LABELS[meal.value]} · добавить`}
|
||||
{step.value === 'amount' && (selectedFood.value?.name ?? '')}
|
||||
{step.value === 'quick' && 'Быстрая запись'}
|
||||
{step.value === 'food' && 'Новый продукт'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Закрыть"
|
||||
class="grid size-9 place-items-center rounded-full text-ink-soft transition hover:bg-white/6"
|
||||
onClick={close}
|
||||
>
|
||||
<IconClose class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Приём пищи */}
|
||||
<div class="flex gap-1.5 px-5 pb-3">
|
||||
{MEALS.map(m => (
|
||||
<button type="button" class={chipClass(meal.value === m)} onClick={() => (meal.value = m)}>
|
||||
{MEAL_LABELS[m]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-5 pb-8">
|
||||
{/* ── Шаг: выбор ── */}
|
||||
{step.value === 'pick' && (
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="relative">
|
||||
<IconSearch class="absolute top-1/2 left-3.5 size-4.5 -translate-y-1/2 text-ink-faint" />
|
||||
<input
|
||||
ref={searchEl}
|
||||
type="search"
|
||||
placeholder="Найти продукт…"
|
||||
value={query.value}
|
||||
onInput={event => (query.value = event.currentTarget.value)}
|
||||
class={`${fieldClass} pl-10`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-xl border border-dashed border-white/15 px-3 py-2.5 text-[13px] text-ink-soft transition hover:border-ember/40 hover:text-ember-bright"
|
||||
onClick={() => (step.value = 'quick')}
|
||||
>
|
||||
Только калории
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-xl border border-dashed border-white/15 px-3 py-2.5 text-[13px] text-ink-soft transition hover:border-ember/40 hover:text-ember-bright"
|
||||
onClick={() => (step.value = 'food')}
|
||||
>
|
||||
Новый продукт
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{trimmed.value === '' && recents.value.length > 0 && (
|
||||
<div>
|
||||
<h3 class="mb-2 text-[11px] font-medium tracking-[0.14em] text-ink-faint uppercase">Недавние</h3>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{recents.value.map(food => (
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-white/10 bg-raised/60 px-3 py-1.5 text-[13px] text-ink transition hover:border-ember/50 hover:text-ember-bright"
|
||||
onClick={() => pickFood(food)}
|
||||
>
|
||||
{food.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.value.map(([category, list]) => (
|
||||
<div>
|
||||
<h3 class="mb-1.5 text-[11px] font-medium tracking-[0.14em] text-ink-faint uppercase">{category}</h3>
|
||||
<div class="overflow-hidden rounded-2xl border hairline bg-surface/50">
|
||||
{list.map(food => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 border-b hairline px-4 py-2.5 text-left transition last:border-b-0 hover:bg-white/4"
|
||||
onClick={() => pickFood(food)}
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-[14px]">{food.name}</span>
|
||||
<span class="shrink-0 text-[12px] text-ink-faint tnum">
|
||||
{fmtKcal(food.kcal)}
|
||||
{' '}
|
||||
/ 100 г
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filtered.value.length === 0 && (
|
||||
<p class="py-2 text-center text-[13px] text-ink-faint">
|
||||
В каталоге не нашлось — отсканируйте штрихкод упаковки ниже
|
||||
или создайте «Новый продукт».
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── База упаковок: только штрихкоды (Open Food Facts) ── */}
|
||||
<div class="flex flex-col gap-2 border-t hairline pt-4">
|
||||
<h3 class="text-[11px] font-medium tracking-[0.14em] text-ink-faint uppercase">Штрихкод упаковки</h3>
|
||||
|
||||
{scanSupported && !scannerOpen.value && (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center gap-2 rounded-xl border border-white/12 px-3 py-2.5 text-[13px] text-ink-soft transition hover:border-ember/40 hover:text-ember-bright"
|
||||
onClick={() => (scannerOpen.value = true)}
|
||||
>
|
||||
<IconBarcode class="size-5" />
|
||||
Сканировать камерой
|
||||
</button>
|
||||
)}
|
||||
|
||||
{scannerOpen.value && (
|
||||
<BarcodeScanner
|
||||
onDetected={code => void lookupBarcode(code)}
|
||||
onCancel={() => (scannerOpen.value = false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="Цифры под штрихкодом…"
|
||||
value={barcodeInput.value}
|
||||
onInput={event => (barcodeInput.value = event.currentTarget.value.replaceAll(/\D/g, ''))}
|
||||
onKeydown={(event) => {
|
||||
if (event.key === 'Enter') void lookupBarcode(barcodeInput.value);
|
||||
}}
|
||||
class={`${fieldClass} tnum`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-xl border border-white/12 px-4 text-[13px] text-ink-soft transition not-disabled:hover:border-ember/40 not-disabled:hover:text-ember-bright disabled:opacity-40"
|
||||
disabled={barcodeInput.value.length < 8 || offBusy.value}
|
||||
onClick={() => void lookupBarcode(barcodeInput.value)}
|
||||
>
|
||||
{offBusy.value ? 'Ищем…' : 'Найти'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{offError.value !== '' && (
|
||||
<p class="rounded-xl border border-over/25 bg-over/8 px-3.5 py-2.5 text-[12px] leading-relaxed text-over-bright">
|
||||
{offError.value}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p class="text-[11px] leading-relaxed text-ink-faint/80">
|
||||
КБЖУ подтянутся из Open Food Facts — открытой базы упаковок.
|
||||
Её заполняют люди, поэтому цифры стоит сверить с этикеткой.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Шаг: порция ── */}
|
||||
{step.value === 'amount' && selectedFood.value && (
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="rounded-2xl border hairline bg-surface/60 px-5 py-4 text-center">
|
||||
<div class="text-display text-[44px] leading-none font-light">{preview.value ? fmtKcal(preview.value.kcal) : 0}</div>
|
||||
<div class="mt-1 text-[12px] text-ink-faint">ккал в порции</div>
|
||||
<div class="mt-3 flex justify-center gap-4 text-[12px] text-ink-soft tnum">
|
||||
<span>
|
||||
<span class="text-protein">Б</span>
|
||||
{' '}
|
||||
{fmtG(preview.value?.protein ?? 0)}
|
||||
</span>
|
||||
<span>
|
||||
<span class="text-fat">Ж</span>
|
||||
{' '}
|
||||
{fmtG(preview.value?.fat ?? 0)}
|
||||
</span>
|
||||
<span>
|
||||
<span class="text-carbs">У</span>
|
||||
{' '}
|
||||
{fmtG(preview.value?.carbs ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Порция, граммы</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="1"
|
||||
value={amountG.value}
|
||||
onInput={event => (amountG.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} text-center text-lg tnum`}
|
||||
/>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{GRAM_PRESETS.map(grams => (
|
||||
<button type="button" class={chipClass(amountG.value === grams)} onClick={() => (amountG.value = grams)}>
|
||||
{grams}
|
||||
{' '}
|
||||
г
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedFood.value.pieceGrams && (
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{[1, 2, 3].map(count => (
|
||||
<button
|
||||
type="button"
|
||||
class={chipClass(amountG.value === count * (selectedFood.value?.pieceGrams ?? 0))}
|
||||
onClick={() => (amountG.value = count * (selectedFood.value?.pieceGrams ?? 0))}
|
||||
>
|
||||
{`${count} шт · ${count * (selectedFood.value?.pieceGrams ?? 0)} г`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl bg-ember py-3.5 text-[15px] font-medium text-[#1a1006] transition hover:bg-ember-bright disabled:opacity-40"
|
||||
disabled={amountG.value <= 0}
|
||||
onClick={submitAmount}
|
||||
>
|
||||
Добавить в
|
||||
{' '}
|
||||
{MEAL_LABELS[meal.value].toLowerCase()}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Шаг: быстрая запись ── */}
|
||||
{step.value === 'quick' && (
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-[13px] leading-relaxed text-ink-faint">
|
||||
Когда некогда взвешивать — запишите оценку калорий, чтобы день остался честным.
|
||||
Б/Ж/У можно не указывать.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название (необязательно)"
|
||||
value={quickName.value}
|
||||
onInput={event => (quickName.value = event.currentTarget.value)}
|
||||
class={fieldClass}
|
||||
/>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Калории</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
placeholder="350"
|
||||
value={quickKcal.value || ''}
|
||||
onInput={event => (quickKcal.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} text-center text-lg tnum`}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
['Белки', quickProtein],
|
||||
['Жиры', quickFat],
|
||||
['Углеводы', quickCarbs],
|
||||
] as const).map(([label, model]) => (
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">
|
||||
{label}
|
||||
, г
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
value={model.value || ''}
|
||||
onInput={event => (model.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} text-center tnum`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl bg-ember py-3.5 text-[15px] font-medium text-[#1a1006] transition hover:bg-ember-bright disabled:opacity-40"
|
||||
disabled={quickKcal.value <= 0}
|
||||
onClick={submitQuick}
|
||||
>
|
||||
Записать
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Шаг: новый продукт ── */}
|
||||
{step.value === 'food' && (
|
||||
<div class="flex flex-col gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название продукта"
|
||||
value={foodName.value}
|
||||
onInput={event => (foodName.value = event.currentTarget.value)}
|
||||
class={fieldClass}
|
||||
/>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Категория</label>
|
||||
<input
|
||||
type="text"
|
||||
list="food-categories"
|
||||
value={foodCategory.value}
|
||||
onInput={event => (foodCategory.value = event.currentTarget.value)}
|
||||
class={fieldClass}
|
||||
/>
|
||||
<datalist id="food-categories">
|
||||
{categories.value.map(category => (
|
||||
<option value={category} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
{foodBarcode.value !== ''
|
||||
? (
|
||||
<p class="rounded-xl border border-ember/25 bg-ember/8 px-3.5 py-2.5 text-[12px] leading-relaxed text-ember-bright/90">
|
||||
Значения подставлены из Open Food Facts — сверьте с этикеткой и поправьте, если расходятся.
|
||||
</p>
|
||||
)
|
||||
: <p class="text-[12px] text-ink-faint">Значения указываются на 100 г продукта.</p>}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Ккал / 100 г</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
value={foodKcal.value || ''}
|
||||
onInput={event => (foodKcal.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} tnum`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Вес 1 шт, г (если есть)</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
value={foodPiece.value || ''}
|
||||
onInput={event => (foodPiece.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} tnum`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
['Белки', foodProtein],
|
||||
['Жиры', foodFat],
|
||||
['Углеводы', foodCarbs],
|
||||
] as const).map(([label, model]) => (
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">
|
||||
{label}
|
||||
, г
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
value={model.value || ''}
|
||||
onInput={event => (model.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} text-center tnum`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl bg-ember py-3.5 text-[15px] font-medium text-[#1a1006] transition hover:bg-ember-bright disabled:opacity-40"
|
||||
disabled={foodName.value.trim() === '' || foodKcal.value <= 0}
|
||||
onClick={submitFood}
|
||||
>
|
||||
Сохранить и выбрать порцию
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user