yslootahrobotics/src/store/usePricingStore.ts
Najjar\NajjarV02 52b910ed93 feat(pricing): switch base+EDU prices to AED and unify attire at 5000 AED
- `base` (Basic body) → 77,125 AED  (Unitree G1 retail $16k + $5k markup)
- new `edu` row     → 146,900 AED  ($40k flat)
- all persona attire (kandura, vest, suit, robot-doctor, security-guard) → 5,000 AED
- `custom-color` unchanged at 3,500 AED
- PricingEngine now swaps the base line when EDU body is selected
- localStorage key bumped to `lootah-pricing-v2` to invalidate stale client caches
- Robot Body buttons now describe the variants as `Standard consumer` vs `Open-source education / research` (same chassis GLB, differs in licensing)
- New `prisma/update-prices.ts` idempotent script applies the new prices to an existing DB; seed.ts updated for fresh installs
- Pricing tests updated to match new defaults

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:40:04 +04:00

171 lines
5.4 KiB
TypeScript

import { createStore } from 'zustand/vanilla';
import { useSyncExternalStore } from 'react';
export interface PricingItem {
id: string;
label: string;
price: number;
modelPath?: string;
}
export interface PricingState {
items: PricingItem[];
isHydrated: boolean;
}
export interface PricingActions {
updatePrice: (itemId: string, newPrice: number) => void;
updateItem: (itemId: string, updates: Partial<Pick<PricingItem, 'label' | 'price' | 'modelPath'>>) => void;
addItem: (item: PricingItem) => void;
removeItem: (itemId: string) => void;
resetPrices: () => void;
hydrate: () => void;
}
export type PricingStore = PricingState & PricingActions;
// Prices in AED. USD → AED at 3.6725 (CBUAE peg).
// Basic = Unitree G1 retail ($16k) + $5k markup = $21k ≈ 77,125 AED.
// EDU = $40k flat ≈ 146,900 AED.
// All persona attire = 5,000 AED each.
const DEFAULT_ITEMS: PricingItem[] = [
{ id: 'base', label: 'G1 Robot Basic', price: 77125 },
{ id: 'edu', label: 'G1 Robot EDU', price: 146900 },
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 5000 },
{ id: 'industrial-vest', label: 'Industrial Vest', price: 5000 },
{ id: 'business-suit', label: 'Business Suit', price: 5000 },
{ id: 'custom-color', label: 'Custom Color', price: 3500 },
{ id: 'robot-doctor', label: 'Robot Doctor', price: 5000 },
{ id: 'security-guard', label: 'Security Guard', price: 5000 },
];
// Bump suffix to invalidate any cached localStorage prices from the old pricing scheme.
const STORAGE_KEY = 'lootah-pricing-v2';
function loadFromStorage(): PricingItem[] | null {
if (typeof window === 'undefined') return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
return parsed;
} catch {
return null;
}
}
function saveToStorage(items: PricingItem[]) {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
} catch {
// Storage full or unavailable
}
}
export const pricingStore = createStore<PricingStore>((set, get) => ({
items: DEFAULT_ITEMS,
isHydrated: false,
updatePrice: (itemId: string, newPrice: number) => {
set((state) => {
const updated = state.items.map((item) =>
item.id === itemId ? { ...item, price: newPrice } : item
);
saveToStorage(updated);
return { items: updated };
});
},
updateItem: (itemId: string, updates: Partial<Pick<PricingItem, 'label' | 'price' | 'modelPath'>>) => {
set((state) => {
const updated = state.items.map((item) =>
item.id === itemId ? { ...item, ...updates } : item
);
saveToStorage(updated);
return { items: updated };
});
},
resetPrices: () => {
saveToStorage(DEFAULT_ITEMS);
set({ items: [...DEFAULT_ITEMS] });
},
addItem: (item: PricingItem) => {
set((state) => {
if (state.items.some((i) => i.id === item.id)) return state;
const updated = [...state.items, item];
saveToStorage(updated);
return { items: updated };
});
},
removeItem: (itemId: string) => {
// Prevent removing the base robot price
if (itemId === 'base') return;
set((state) => {
const updated = state.items.filter((i) => i.id !== itemId);
saveToStorage(updated);
return { items: updated };
});
},
hydrate: () => {
const stored = loadFromStorage();
if (stored && stored.length > 0) {
// Use stored items directly (preserves custom labels, prices, added items).
// Re-add any default items that were never stored (fresh install gap).
const storedIds = new Set(stored.map((s) => s.id));
const missing = DEFAULT_ITEMS.filter((d) => !storedIds.has(d.id));
set({ items: [...stored, ...missing], isHydrated: true });
} else {
set({ isHydrated: true });
}
// Also fetch from server DB to get the latest pricing (async, non-blocking)
if (typeof window !== 'undefined') {
fetch('/api/admin/pricing/')
.then((r) => r.json())
.then((data) => {
const serverItems: PricingItem[] = data.items ?? [];
if (serverItems.length > 0) {
// Merge: server wins for label/price, but keep local modelPath if server doesn't have one
const localItems = get().items;
const localMap = new Map(localItems.map((l) => [l.id, l]));
const merged: PricingItem[] = serverItems.map((s) => {
const local = localMap.get(s.id);
return {
...s,
modelPath: s.modelPath || local?.modelPath,
};
});
// Also keep any local-only items not in server
for (const local of localItems) {
if (!serverItems.some((s) => s.id === local.id)) {
merged.push(local);
}
}
saveToStorage(merged);
set({ items: merged });
}
})
.catch(() => {}); // silent — use local data as fallback
}
},
}));
export const usePricingStore = <T>(selector: (state: PricingStore) => T): T => {
return useSyncExternalStore(
pricingStore.subscribe,
() => selector(pricingStore.getState()),
() => selector(pricingStore.getState())
);
};
export const getPrice = (itemId: string): number => {
const item = pricingStore.getState().items.find((i) => i.id === itemId);
return item?.price ?? 0;
};