feat(products): compute price tiers from objective price/use

This commit is contained in:
Piotr Oleszczyk 2026-03-04 14:47:18 +01:00
parent c5ea38880c
commit 83ba4cc5c0
13 changed files with 664 additions and 48 deletions

View file

@ -108,7 +108,8 @@ export interface ProductParseResponse {
texture?: string;
absorption_speed?: string;
leave_on?: boolean;
price_tier?: string;
price_amount?: number;
price_currency?: string;
size_ml?: number;
full_weight_g?: number;
empty_weight_g?: number;

View file

@ -20,7 +20,6 @@
];
const textures = ['watery', 'gel', 'emulsion', 'cream', 'oil', 'balm', 'foam', 'fluid'];
const absorptionSpeeds = ['very_fast', 'fast', 'moderate', 'slow', 'very_slow'];
const priceTiers = ['budget', 'mid', 'premium', 'luxury'];
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
const skinConcerns = [
'acne', 'rosacea', 'hyperpigmentation', 'aging', 'dehydration',
@ -71,13 +70,6 @@
very_slow: m["productForm_absorptionVerySlow"]()
});
const priceTierLabels = $derived<Record<string, string>>({
budget: m["productForm_priceBudget"](),
mid: m["productForm_priceMid"](),
premium: m["productForm_pricePremium"](),
luxury: m["productForm_priceLuxury"]()
});
const skinTypeLabels = $derived<Record<string, string>>({
dry: m["productForm_skinTypeDry"](),
oily: m["productForm_skinTypeOily"](),
@ -210,7 +202,8 @@
if (r.recommended_time) recommendedTime = r.recommended_time;
if (r.texture) texture = r.texture;
if (r.absorption_speed) absorptionSpeed = r.absorption_speed;
if (r.price_tier) priceTier = r.price_tier;
if (r.price_amount != null) priceAmount = String(r.price_amount);
if (r.price_currency) priceCurrency = r.price_currency;
if (r.leave_on != null) leaveOn = String(r.leave_on);
if (r.size_ml != null) sizeMl = String(r.size_ml);
if (r.full_weight_g != null) fullWeightG = String(r.full_weight_g);
@ -260,7 +253,8 @@
let leaveOn = $state(untrack(() => (product?.leave_on != null ? String(product.leave_on) : 'true')));
let texture = $state(untrack(() => product?.texture ?? ''));
let absorptionSpeed = $state(untrack(() => product?.absorption_speed ?? ''));
let priceTier = $state(untrack(() => product?.price_tier ?? ''));
let priceAmount = $state(untrack(() => (product?.price_amount != null ? String(product.price_amount) : '')));
let priceCurrency = $state(untrack(() => product?.price_currency ?? 'PLN'));
let fragranceFree = $state(
untrack(() => (product?.fragrance_free != null ? String(product.fragrance_free) : ''))
);
@ -776,16 +770,13 @@
<CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div class="space-y-2">
<Label>{m["productForm_priceTier"]()}</Label>
<input type="hidden" name="price_tier" value={priceTier} />
<Select type="single" value={priceTier} onValueChange={(v) => (priceTier = v)}>
<SelectTrigger>{priceTier ? priceTierLabels[priceTier] : m["productForm_selectTier"]()}</SelectTrigger>
<SelectContent>
{#each priceTiers as p}
<SelectItem value={p}>{priceTierLabels[p]}</SelectItem>
{/each}
</SelectContent>
</Select>
<Label for="price_amount">Price</Label>
<Input id="price_amount" name="price_amount" type="number" min="0" step="0.01" placeholder="e.g. 79.99" bind:value={priceAmount} />
</div>
<div class="space-y-2">
<Label for="price_currency">Currency</Label>
<Input id="price_currency" name="price_currency" maxlength={3} placeholder="PLN" bind:value={priceCurrency} />
</div>
<div class="space-y-2">

View file

@ -42,6 +42,7 @@ export type MedicationKind =
export type OverallSkinState = "excellent" | "good" | "fair" | "poor";
export type PartOfDay = "am" | "pm";
export type PriceTier = "budget" | "mid" | "premium" | "luxury";
export type PriceTierSource = "category" | "fallback" | "insufficient_data";
export type ProductCategory =
| "cleanser"
| "toner"
@ -147,7 +148,11 @@ export interface Product {
texture?: TextureType;
absorption_speed?: AbsorptionSpeed;
leave_on: boolean;
price_amount?: number;
price_currency?: string;
price_tier?: PriceTier;
price_per_use_pln?: number;
price_tier_source?: PriceTierSource;
size_ml?: number;
full_weight_g?: number;
empty_weight_g?: number;

View file

@ -55,6 +55,31 @@
})());
const totalCount = $derived(groupedProducts.reduce((s, [, arr]) => s + arr.length, 0));
function formatPricePerUse(value?: number): string {
if (value == null) return '-';
return `${value.toFixed(2)} PLN/use`;
}
function formatTier(value?: string): string {
if (!value) return 'n/a';
return value;
}
function getPricePerUse(product: Product): number | undefined {
return (product as Product & { price_per_use_pln?: number }).price_per_use_pln;
}
function getTierSource(product: Product): string | undefined {
return (product as Product & { price_tier_source?: string }).price_tier_source;
}
function sourceLabel(product: Product): string {
const source = getTierSource(product);
if (source === 'fallback') return 'fallback';
if (source === 'insufficient_data') return 'insufficient';
return 'category';
}
</script>
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
@ -92,26 +117,27 @@
<TableHead>{m["products_colBrand"]()}</TableHead>
<TableHead>{m["products_colTargets"]()}</TableHead>
<TableHead>{m["products_colTime"]()}</TableHead>
<TableHead>Pricing</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#if totalCount === 0}
<TableRow>
<TableCell colspan={4} class="text-center text-muted-foreground py-8">
<TableCell colspan={5} class="text-center text-muted-foreground py-8">
{m["products_noProducts"]()}
</TableCell>
</TableRow>
{:else}
{#each groupedProducts as [category, products] (category)}
<TableRow class="bg-muted/30 hover:bg-muted/30">
<TableCell colspan={4} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide">
<TableCell colspan={5} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide">
{category.replace(/_/g, ' ')}
</TableCell>
</TableRow>
{#each products as product (product.id)}
<TableRow class="cursor-pointer hover:bg-muted/50">
<TableCell>
<a href="/products/{product.id}" class="font-medium hover:underline">
<a href={resolve(`/products/${product.id}`)} class="font-medium hover:underline">
{product.name}
</a>
</TableCell>
@ -127,6 +153,13 @@
</div>
</TableCell>
<TableCell class="uppercase text-sm">{product.recommended_time}</TableCell>
<TableCell>
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">{formatPricePerUse(getPricePerUse(product))}</span>
<Badge variant="outline" class="uppercase text-[10px]">{formatTier(product.price_tier)}</Badge>
<Badge variant="secondary" class="text-[10px]">{sourceLabel(product)}</Badge>
</div>
</TableCell>
</TableRow>
{/each}
{/each}
@ -145,14 +178,19 @@
{category.replace(/_/g, ' ')}
</div>
{#each products as product (product.id)}
<a
href="/products/{product.id}"
class="block rounded-lg border border-border p-4 hover:bg-muted/50"
>
<a
href={resolve(`/products/${product.id}`)}
class="block rounded-lg border border-border p-4 hover:bg-muted/50"
>
<div class="flex items-start justify-between gap-2">
<div>
<p class="font-medium">{product.name}</p>
<p class="text-sm text-muted-foreground">{product.brand}</p>
<div class="mt-1 flex items-center gap-2 text-xs">
<span class="text-muted-foreground">{formatPricePerUse(getPricePerUse(product))}</span>
<Badge variant="outline" class="uppercase text-[10px]">{formatTier(product.price_tier)}</Badge>
<Badge variant="secondary" class="text-[10px]">{sourceLabel(product)}</Badge>
</div>
</div>
<span class="shrink-0 text-xs uppercase text-muted-foreground">{product.recommended_time}</span>
</div>

View file

@ -119,17 +119,19 @@ export const actions: Actions = {
};
// Optional strings
for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes']) {
for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes', 'price_currency']) {
const v = parseOptionalString(form.get(field) as string | null);
body[field] = v ?? null;
}
// Optional enum selects (null if empty = clearing the value)
for (const field of ['texture', 'absorption_speed', 'price_tier']) {
for (const field of ['texture', 'absorption_speed']) {
const v = form.get(field) as string | null;
body[field] = v || null;
}
body.price_amount = parseOptionalFloat(form.get('price_amount') as string | null) ?? null;
// Optional numbers
body.size_ml = parseOptionalFloat(form.get('size_ml') as string | null) ?? null;
body.full_weight_g = parseOptionalFloat(form.get('full_weight_g') as string | null) ?? null;

View file

@ -16,6 +16,43 @@
let showInventoryForm = $state(false);
let editingInventoryId = $state<string | null>(null);
function formatAmount(amount?: number, currency?: string): string {
if (amount == null || !currency) return '-';
try {
return new Intl.NumberFormat('pl-PL', {
style: 'currency',
currency: currency.toUpperCase(),
maximumFractionDigits: 2
}).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency.toUpperCase()}`;
}
}
function formatPricePerUse(value?: number): string {
if (value == null) return '-';
return `${value.toFixed(2)} PLN/use`;
}
function getPriceAmount(): number | undefined {
return (product as { price_amount?: number }).price_amount;
}
function getPriceCurrency(): string | undefined {
return (product as { price_currency?: string }).price_currency;
}
function getPricePerUse(): number | undefined {
return (product as { price_per_use_pln?: number }).price_per_use_pln;
}
function getTierSource(): string {
const source = (product as { price_tier_source?: string }).price_tier_source;
if (source === 'fallback') return 'fallback';
if (source === 'insufficient_data') return 'insufficient_data';
return 'category';
}
</script>
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
@ -33,6 +70,26 @@
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m.common_saved()}</div>
{/if}
<Card>
<CardContent class="pt-4">
<div class="grid grid-cols-1 gap-3 text-sm sm:grid-cols-3">
<div>
<p class="text-muted-foreground">Price</p>
<p class="font-medium">{formatAmount(getPriceAmount(), getPriceCurrency())}</p>
</div>
<div>
<p class="text-muted-foreground">Price per use</p>
<p class="font-medium">{formatPricePerUse(getPricePerUse())}</p>
</div>
<div>
<p class="text-muted-foreground">Price tier</p>
<p class="font-medium uppercase">{product.price_tier ?? 'n/a'}</p>
<p class="text-xs text-muted-foreground">source: {getTierSource()}</p>
</div>
</div>
</CardContent>
</Card>
<!-- Edit form -->
<form method="POST" action="?/update" use:enhance class="space-y-6">
<ProductForm {product} />

View file

@ -107,17 +107,20 @@ export const actions: Actions = {
};
// Optional strings
for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes']) {
for (const field of ['line_name', 'url', 'sku', 'barcode', 'usage_notes', 'personal_tolerance_notes', 'price_currency']) {
const v = parseOptionalString(form.get(field) as string | null);
if (v !== undefined) payload[field] = v;
}
// Optional enum selects
for (const field of ['texture', 'absorption_speed', 'price_tier']) {
for (const field of ['texture', 'absorption_speed']) {
const v = form.get(field) as string | null;
if (v) payload[field] = v;
}
const price_amount = parseOptionalFloat(form.get('price_amount') as string | null);
if (price_amount !== undefined) payload.price_amount = price_amount;
// Optional numbers
const size_ml = parseOptionalFloat(form.get('size_ml') as string | null);
if (size_ml !== undefined) payload.size_ml = size_ml;