Ship Expo Router screens, seeded stores, and polished UX so auth, shop, checkout, prescriptions, loyalty, family, and account processes work end-to-end offline. Co-authored-by: Cursor <cursoragent@cursor.com>
229 lines
6.1 KiB
TypeScript
229 lines
6.1 KiB
TypeScript
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import { create } from "zustand";
|
|
import { createJSONStorage, persist } from "zustand/middleware";
|
|
import { DEMO_USER } from "@/lib/data/seed";
|
|
|
|
export type AuthUser = {
|
|
id: string;
|
|
name: string;
|
|
phone: string;
|
|
email?: string;
|
|
points: number;
|
|
tier: "bronze" | "silver" | "gold";
|
|
isGuest: boolean;
|
|
biometricEnabled: boolean;
|
|
avatarUri?: string | null;
|
|
referralCode?: string;
|
|
activeVoucher?: string | null;
|
|
};
|
|
|
|
export type PointsEntry = {
|
|
id: string;
|
|
at: string;
|
|
label: string;
|
|
delta: number;
|
|
balance: number;
|
|
};
|
|
|
|
type AuthState = {
|
|
user: AuthUser | null;
|
|
hasOnboarded: boolean;
|
|
ledger: PointsEntry[];
|
|
setHasOnboarded: (v: boolean) => void;
|
|
signIn: (payload: Partial<AuthUser> & { phone: string; name: string }) => void;
|
|
signOut: () => void;
|
|
setBiometric: (enabled: boolean) => void;
|
|
setAvatar: (uri: string | null) => void;
|
|
updateProfile: (
|
|
patch: Partial<Pick<AuthUser, "name" | "email" | "phone">>
|
|
) => void;
|
|
addPoints: (pts: number, label?: string) => void;
|
|
redeemPoints: (pts: number) => boolean;
|
|
setActiveVoucher: (code: string | null) => void;
|
|
ensureDemoUser: () => void;
|
|
};
|
|
|
|
function tierFor(points: number): AuthUser["tier"] {
|
|
if (points >= 1000) return "gold";
|
|
if (points >= 300) return "silver";
|
|
return "bronze";
|
|
}
|
|
|
|
const seedLedger: PointsEntry[] = [
|
|
{
|
|
id: "pts_1",
|
|
at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 20).toISOString(),
|
|
label: "Welcome bonus",
|
|
delta: 50,
|
|
balance: 50,
|
|
},
|
|
{
|
|
id: "pts_2",
|
|
at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 12).toISOString(),
|
|
label: "Order reward",
|
|
delta: 28,
|
|
balance: 78,
|
|
},
|
|
{
|
|
id: "pts_3",
|
|
at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5).toISOString(),
|
|
label: "Referral bonus",
|
|
delta: 100,
|
|
balance: 178,
|
|
},
|
|
{
|
|
id: "pts_4",
|
|
at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(),
|
|
label: "Order reward",
|
|
delta: 242,
|
|
balance: 420,
|
|
},
|
|
];
|
|
|
|
export const useAuthStore = create<AuthState>()(
|
|
persist(
|
|
(set, get) => ({
|
|
user: {
|
|
...DEMO_USER,
|
|
email: DEMO_USER.email,
|
|
referralCode: "GISHEN-KIRU",
|
|
activeVoucher: null,
|
|
},
|
|
hasOnboarded: true,
|
|
ledger: seedLedger,
|
|
setHasOnboarded: (v) => set({ hasOnboarded: v }),
|
|
signIn: ({ phone, name, email, isGuest, avatarUri }) =>
|
|
set({
|
|
user: {
|
|
id: `u_${Date.now()}`,
|
|
name,
|
|
phone,
|
|
email,
|
|
points: isGuest ? 0 : 50,
|
|
tier: "bronze",
|
|
isGuest: !!isGuest,
|
|
biometricEnabled: false,
|
|
avatarUri: avatarUri ?? null,
|
|
referralCode: `GISHEN-${name.slice(0, 4).toUpperCase() || "USER"}`,
|
|
activeVoucher: null,
|
|
},
|
|
hasOnboarded: true,
|
|
ledger: isGuest
|
|
? []
|
|
: [
|
|
{
|
|
id: `pts_${Date.now()}`,
|
|
at: new Date().toISOString(),
|
|
label: "Welcome bonus",
|
|
delta: 50,
|
|
balance: 50,
|
|
},
|
|
],
|
|
}),
|
|
signOut: () => set({ user: null }),
|
|
setBiometric: (enabled) => {
|
|
const user = get().user;
|
|
if (!user) return;
|
|
set({ user: { ...user, biometricEnabled: enabled } });
|
|
},
|
|
setAvatar: (uri) => {
|
|
const user = get().user;
|
|
if (!user) return;
|
|
set({ user: { ...user, avatarUri: uri } });
|
|
},
|
|
updateProfile: (patch) => {
|
|
const user = get().user;
|
|
if (!user) return;
|
|
set({ user: { ...user, ...patch } });
|
|
},
|
|
addPoints: (pts, label = "Order reward") => {
|
|
const user = get().user;
|
|
if (!user || user.isGuest || pts === 0) return;
|
|
const points = user.points + pts;
|
|
const entry: PointsEntry = {
|
|
id: `pts_${Date.now()}`,
|
|
at: new Date().toISOString(),
|
|
label,
|
|
delta: pts,
|
|
balance: points,
|
|
};
|
|
set({
|
|
user: { ...user, points, tier: tierFor(points) },
|
|
ledger: [entry, ...get().ledger],
|
|
});
|
|
},
|
|
redeemPoints: (pts) => {
|
|
const user = get().user;
|
|
if (!user || user.isGuest || user.points < pts) return false;
|
|
const points = user.points - pts;
|
|
const entry: PointsEntry = {
|
|
id: `pts_${Date.now()}`,
|
|
at: new Date().toISOString(),
|
|
label: `Redeemed for voucher`,
|
|
delta: -pts,
|
|
balance: points,
|
|
};
|
|
set({
|
|
user: {
|
|
...user,
|
|
points,
|
|
tier: tierFor(points),
|
|
activeVoucher: "GISHEN10",
|
|
},
|
|
ledger: [entry, ...get().ledger],
|
|
});
|
|
return true;
|
|
},
|
|
setActiveVoucher: (code) => {
|
|
const user = get().user;
|
|
if (!user) return;
|
|
set({ user: { ...user, activeVoucher: code } });
|
|
},
|
|
ensureDemoUser: () => {
|
|
if (!get().user) {
|
|
set({
|
|
user: {
|
|
...DEMO_USER,
|
|
referralCode: "GISHEN-KIRU",
|
|
activeVoucher: null,
|
|
},
|
|
hasOnboarded: true,
|
|
ledger: seedLedger,
|
|
});
|
|
}
|
|
},
|
|
}),
|
|
{
|
|
name: "gishen-auth",
|
|
storage: createJSONStorage(() => AsyncStorage),
|
|
version: 3,
|
|
migrate: (persisted: unknown) => {
|
|
const state = (persisted ?? {}) as Partial<AuthState>;
|
|
if (!state.user) {
|
|
return {
|
|
...state,
|
|
user: {
|
|
...DEMO_USER,
|
|
referralCode: "GISHEN-KIRU",
|
|
activeVoucher: null,
|
|
},
|
|
hasOnboarded: true,
|
|
ledger: seedLedger,
|
|
};
|
|
}
|
|
return {
|
|
...state,
|
|
user: {
|
|
...DEMO_USER,
|
|
...state.user,
|
|
avatarUri: state.user.avatarUri ?? null,
|
|
referralCode: state.user.referralCode ?? "GISHEN-KIRU",
|
|
activeVoucher: state.user.activeVoucher ?? null,
|
|
},
|
|
ledger: state.ledger?.length ? state.ledger : seedLedger,
|
|
};
|
|
},
|
|
}
|
|
)
|
|
);
|