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:
parent
389ca5ffdc
commit
40f9a353bb
8 changed files with 583 additions and 2 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
26
frontend/src/routes/products/suggest/+page.server.ts
Normal file
26
frontend/src/routes/products/suggest/+page.server.ts
Normal 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;
|
||||
126
frontend/src/routes/products/suggest/+page.svelte
Normal file
126
frontend/src/routes/products/suggest/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue