feat(products): add shopping suggestions feature

- Add POST /api/products/suggest endpoint that analyzes skin condition
  and inventory to suggest product types (e.g., 'Salicylic Acid 2% Masque')
- Add MCP tool get_shopping_suggestions() for MCP clients
- Add 'Suggest' button to Products page in frontend
- Add /products/suggest page with suggestion cards
- Include product type, key ingredients, target concerns, why_needed,
  recommended_time, and frequency in suggestions
- Fix stock logic: sealed products now count as available inventory
- Add legend to clarify ✓ (in stock) vs ✗ (not in stock) markers
This commit is contained in:
Piotr Oleszczyk 2026-03-02 22:38:08 +01:00
parent 389ca5ffdc
commit 40f9a353bb
8 changed files with 583 additions and 2 deletions

View file

@ -33,6 +33,17 @@
"products_title": "Products",
"products_count": "{count} products",
"products_addNew": "+ Add product",
"products_suggest": "Suggest",
"products_suggestTitle": "Shopping suggestions",
"products_suggestSubtitle": "What to buy?",
"products_suggestDescription": "Based on your skin condition and products you own, I'll suggest product types that could complement your routine.",
"products_suggestGenerating": "Analyzing...",
"products_suggestBtn": "Generate suggestions",
"products_suggestResults": "Suggestions",
"products_suggestTime": "Time",
"products_suggestFrequency": "Frequency",
"products_suggestRegenerate": "Regenerate",
"products_suggestNoResults": "No suggestions.",
"products_noProducts": "No products found.",
"products_filterAll": "All",
"products_filterOwned": "Owned",

View file

@ -33,6 +33,17 @@
"products_title": "Produkty",
"products_count": "{count} produktów",
"products_addNew": "+ Dodaj produkt",
"products_suggest": "Sugeruj",
"products_suggestTitle": "Sugestie zakupowe",
"products_suggestSubtitle": "Co warto kupić?",
"products_suggestDescription": "Na podstawie Twojego stanu skóry i posiadanych produktów zasugeruję typy produktów, które mogłyby uzupełnić Twoją rutynę.",
"products_suggestGenerating": "Analizuję...",
"products_suggestBtn": "Generuj sugestie",
"products_suggestResults": "Propozycje",
"products_suggestTime": "Pora",
"products_suggestFrequency": "Częstotliwość",
"products_suggestRegenerate": "Wygeneruj ponownie",
"products_suggestNoResults": "Brak propozycji.",
"products_noProducts": "Nie znaleziono produktów.",
"products_filterAll": "Wszystkie",
"products_filterOwned": "Posiadane",

View file

@ -216,6 +216,23 @@ export interface BatchSuggestion {
overall_reasoning: string;
}
// ─── Shopping suggestion types ───────────────────────────────────────────────
export interface ProductSuggestion {
category: string;
product_type: string;
key_ingredients: string[];
target_concerns: string[];
why_needed: string;
recommended_time: string;
frequency: string;
}
export interface ShoppingSuggestionResponse {
suggestions: ProductSuggestion[];
reasoning: string;
}
// ─── Health types ────────────────────────────────────────────────────────────
export interface MedicationUsage {

View file

@ -63,7 +63,10 @@
<h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2>
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
</div>
<Button href="/products/new">{m["products_addNew"]()}</Button>
<div class="flex gap-2">
<Button href="/products/suggest" variant="outline">{m["products_suggest"]()}</Button>
<Button href="/products/new">{m["products_addNew"]()}</Button>
</div>
</div>
<div class="flex flex-wrap gap-1">

View file

@ -0,0 +1,26 @@
import type { ActionData } from './$types';
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
suggest: async ({ fetch }) => {
try {
const res = await fetch('/api/products/suggest', {
method: 'POST',
});
if (!res.ok) {
const err = await res.text();
return fail(res.status, { error: err || 'Failed to get suggestions' });
}
const data = await res.json();
return {
suggestions: data.suggestions,
reasoning: data.reasoning,
};
} catch (e) {
return fail(500, { error: String(e) });
}
},
} satisfies Actions;

View file

@ -0,0 +1,126 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ActionData, PageData } from './$types';
import type { ProductSuggestion } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
let { data, form }: { data: PageData; form: ActionData } = $props();
let suggestions = $state<ProductSuggestion[] | null>(null);
let reasoning = $state('');
let loading = $state(false);
let errorMsg = $state<string | null>(null);
function enhanceForm() {
loading = true;
errorMsg = null;
return async ({ result, update }: { result: { type: string; data?: Record<string, unknown> }; update: (opts?: { reset?: boolean }) => Promise<void> }) => {
loading = false;
if (result.type === 'success' && result.data?.suggestions) {
suggestions = result.data.suggestions as ProductSuggestion[];
reasoning = result.data.reasoning as string;
errorMsg = null;
} else if (result.type === 'failure') {
errorMsg = (result.data?.error as string) ?? m["suggest_errorDefault"]();
}
await update({ reset: false });
};
}
</script>
<svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["products_suggestTitle"]()}</h2>
</div>
{#if errorMsg}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMsg}</div>
{/if}
<Card>
<CardHeader><CardTitle class="text-base">{m["products_suggestSubtitle"]()}</CardTitle></CardHeader>
<CardContent>
<form method="POST" action="?/suggest" use:enhance={enhanceForm} class="space-y-4">
<p class="text-sm text-muted-foreground">
{m["products_suggestDescription"]()}
</p>
<Button type="submit" disabled={loading} class="w-full">
{#if loading}
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
{m["products_suggestGenerating"]()}
{:else}
{m["products_suggestBtn"]()}
{/if}
</Button>
</form>
</CardContent>
</Card>
{#if suggestions && suggestions.length > 0}
{#if reasoning}
<Card class="border-muted bg-muted/30">
<CardContent class="pt-4">
<p class="text-sm text-muted-foreground italic">{reasoning}</p>
</CardContent>
</Card>
{/if}
<div class="space-y-4">
<h3 class="text-lg font-semibold">{m["products_suggestResults"]()}</h3>
{#each suggestions as s (s.product_type)}
<Card>
<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>
{#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>
{/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>
{/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>
</div>
</div>
</CardContent>
</Card>
{/each}
</div>
<form method="POST" action="?/suggest" use:enhance={enhanceForm}>
<Button variant="outline" type="submit" disabled={loading}>
{m["products_suggestRegenerate"]()}
</Button>
</form>
{:else if suggestions && suggestions.length === 0}
<Card>
<CardContent class="py-8 text-center text-muted-foreground">
{m["products_suggestNoResults"]()}
</CardContent>
</Card>
{/if}
</div>