feat(products): compute price tiers from objective price/use
This commit is contained in:
parent
c5ea38880c
commit
83ba4cc5c0
13 changed files with 664 additions and 48 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue