- `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>
171 lines
5.4 KiB
TypeScript
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;
|
|
};
|