feat(products): improve shopping suggestion decision support

This commit is contained in:
Piotr Oleszczyk 2026-03-08 22:30:30 +01:00
parent 5d9d18bd05
commit bb5d402c15
8 changed files with 352 additions and 142 deletions

View file

@ -58,6 +58,15 @@
"products_suggestResults": "Suggestions",
"products_suggestTime": "Time",
"products_suggestFrequency": "Frequency",
"products_suggestPriorityHigh": "High priority",
"products_suggestPriorityMedium": "Medium priority",
"products_suggestPriorityLow": "Low priority",
"products_suggestBuyNow": "Buy now because",
"products_suggestRoutineFit": "How it fits your routine",
"products_suggestBudgetSkip": "If you're cutting the budget",
"products_suggestKeyIngredients": "Key ingredients",
"products_suggestTargets": "Targets",
"products_suggestCautions": "Cautions",
"products_suggestRegenerate": "Regenerate",
"products_suggestNoResults": "No suggestions.",
"products_noProducts": "No products found.",

View file

@ -60,6 +60,15 @@
"products_suggestResults": "Propozycje",
"products_suggestTime": "Pora",
"products_suggestFrequency": "Częstotliwość",
"products_suggestPriorityHigh": "Wysoki priorytet",
"products_suggestPriorityMedium": "Średni priorytet",
"products_suggestPriorityLow": "Niski priorytet",
"products_suggestBuyNow": "Kup teraz, bo",
"products_suggestRoutineFit": "Jak wpisuje się w rutynę",
"products_suggestBudgetSkip": "Jeśli tniesz budżet",
"products_suggestKeyIngredients": "Kluczowe składniki",
"products_suggestTargets": "Cele",
"products_suggestCautions": "Uwagi",
"products_suggestRegenerate": "Wygeneruj ponownie",
"products_suggestNoResults": "Brak propozycji.",
"products_noProducts": "Nie znaleziono produktów.",

View file

@ -284,14 +284,21 @@ export interface BatchSuggestion {
// ─── Shopping suggestion types ───────────────────────────────────────────────
export type ShoppingPriority = 'high' | 'medium' | 'low';
export interface ProductSuggestion {
category: string;
product_type: string;
priority: ShoppingPriority;
key_ingredients: string[];
target_concerns: string[];
why_needed: string;
recommended_time: string;
frequency: string;
short_reason: string;
reason_to_buy_now: string;
reason_not_needed_if_budget_tight?: string;
fit_with_current_routine: string;
usage_cautions: string[];
}
export interface ShoppingSuggestionResponse {

View file

@ -17,6 +17,9 @@ export const actions: Actions = {
return {
suggestions: data.suggestions,
reasoning: data.reasoning,
validation_warnings: data.validation_warnings,
auto_fixes_applied: data.auto_fixes_applied,
response_metadata: data.response_metadata,
};
} catch (e) {
return fail(500, { error: String(e) });

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ProductSuggestion, ResponseMetadata } from '$lib/types';
import type { ProductSuggestion, ResponseMetadata, ShoppingPriority } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
@ -22,6 +22,28 @@
let autoFixes = $state<string[] | undefined>(undefined);
let responseMetadata = $state<ResponseMetadata | undefined>(undefined);
function priorityLabel(priority: ShoppingPriority) {
switch (priority) {
case 'high':
return m.products_suggestPriorityHigh();
case 'medium':
return m.products_suggestPriorityMedium();
default:
return m.products_suggestPriorityLow();
}
}
function priorityClasses(priority: ShoppingPriority) {
switch (priority) {
case 'high':
return 'border-transparent bg-[color:var(--page-accent)] text-white';
case 'medium':
return 'border-[color:var(--page-accent)]/20 bg-[var(--page-accent-soft)] text-foreground';
default:
return 'border-border bg-secondary text-secondary-foreground';
}
}
function enhanceForm() {
loading = true;
errorMsg = null;
@ -104,37 +126,93 @@
<div class="space-y-4 reveal-3">
<h3 class="text-lg font-semibold">{m["products_suggestResults"]()}</h3>
{#each suggestions as s (s.product_type)}
<Card>
<Card class="border-border/80 bg-card/95 shadow-sm">
<CardContent class="pt-4">
<div class="space-y-3">
<div class="flex items-start justify-between gap-2">
<h4 class="font-medium">{s.product_type}</h4>
<Badge variant="secondary" class="shrink-0">{s.category}</Badge>
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<Badge class={priorityClasses(s.priority)}>{priorityLabel(s.priority)}</Badge>
<Badge variant="secondary" class="shrink-0">{s.category}</Badge>
</div>
<h4 class="font-medium tracking-[0.01em]">{s.product_type}</h4>
<p class="text-sm text-foreground/80">{s.short_reason}</p>
</div>
<div class="flex gap-4 text-xs text-muted-foreground sm:justify-end">
<span>{m["products_suggestTime"]()}: {s.recommended_time.toUpperCase()}</span>
<span>{m["products_suggestFrequency"]()}: {s.frequency}</span>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{m.products_suggestBuyNow()}
</p>
<p class="mt-2 text-sm leading-6 text-foreground/85">{s.reason_to_buy_now}</p>
</div>
<div class="rounded-xl border border-border/70 bg-muted/20 p-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{m.products_suggestRoutineFit()}
</p>
<p class="mt-2 text-sm leading-6 text-foreground/85">{s.fit_with_current_routine}</p>
</div>
</div>
{#if s.reason_not_needed_if_budget_tight}
<div class="rounded-xl border border-dashed border-border/80 bg-background p-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{m.products_suggestBudgetSkip()}
</p>
<p class="mt-2 text-sm leading-6 text-foreground/80">
{s.reason_not_needed_if_budget_tight}
</p>
</div>
{/if}
{#if s.key_ingredients.length > 0}
<div class="flex flex-wrap gap-1">
{#each s.key_ingredients as ing (ing)}
<Badge variant="outline" class="text-xs">{ing}</Badge>
{/each}
<div class="space-y-2">
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{m.products_suggestKeyIngredients()}
</p>
<div class="flex flex-wrap gap-1.5">
{#each s.key_ingredients as ing (ing)}
<Badge variant="outline" class="text-[11px] normal-case tracking-normal">{ing}</Badge>
{/each}
</div>
</div>
{/if}
{#if s.target_concerns.length > 0}
<div class="flex flex-wrap gap-1">
{#each s.target_concerns as concern (concern)}
<Badge class="text-xs">{concern.replace(/_/g, ' ')}</Badge>
{/each}
<div class="space-y-2">
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{m.products_suggestTargets()}
</p>
<div class="flex flex-wrap gap-1.5">
{#each s.target_concerns as concern (concern)}
<Badge class="text-[11px] normal-case tracking-normal">{concern.replace(/_/g, ' ')}</Badge>
{/each}
</div>
</div>
{/if}
<p class="text-sm text-muted-foreground">{s.why_needed}</p>
<div class="flex gap-4 text-xs text-muted-foreground">
<span>{m["products_suggestTime"]()}: {s.recommended_time.toUpperCase()}</span>
<span>{m["products_suggestFrequency"]()}: {s.frequency}</span>
{#if s.usage_cautions.length > 0}
<div class="space-y-2">
<p class="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{m.products_suggestCautions()}
</p>
<div class="flex flex-wrap gap-1.5">
{#each s.usage_cautions as caution (caution)}
<Badge variant="outline" class="border-amber-200 bg-amber-50 text-[11px] normal-case tracking-normal text-amber-900">
{caution}
</Badge>
{/each}
</div>
</div>
{/if}
</div>
</div>
</CardContent>
</Card>
{/each}