Add gamification, micro-animations, and a mini-game for empty states
Applies a few Hook Model (trigger/action/reward/investment) patterns on top of the existing swipe/match loop: - Daily streak: a flame badge in the home header, backed by the new checkin endpoint — an external trigger to return daily. - Profile completeness meter + badge collection on the profile page (client-computed from existing data) — the investment loop nudging users to fill out bio/photos/answers. - Bio editor on the profile page, shown on swipe cards and candidate detail so profiles read richer. - Catch the Hearts: a small tap mini-game (falling hearts, bonus items, 20s timer, persisted high score) shown instead of a dead end when the discover feed runs out, so there's still a reason to stay in the app. - Micro-animations: heart-burst on like, confetti on match, tap feedback on all buttons (active:scale-95). Fixed a React "purity" lint violation along the way (Math.random() can't be called inside useMemo under this project's rules — moved to useState lazy initializers, which run once on mount as intended).
This commit is contained in:
@@ -46,6 +46,8 @@ export interface UserProfile {
|
||||
approval_status: ApprovalStatus;
|
||||
rejection_reason?: string;
|
||||
is_admin: boolean;
|
||||
bio?: string;
|
||||
streak_count: number;
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
@@ -125,6 +127,7 @@ export interface DiscoverCandidate {
|
||||
id: number;
|
||||
first_name: string;
|
||||
age: number;
|
||||
bio?: string;
|
||||
photos: string[];
|
||||
distance_km?: number;
|
||||
interests?: string[];
|
||||
@@ -139,6 +142,7 @@ export interface CandidateDetail {
|
||||
id: number;
|
||||
first_name: string;
|
||||
age: number;
|
||||
bio?: string;
|
||||
photos: string[];
|
||||
distance_km?: number;
|
||||
height_cm?: number;
|
||||
@@ -420,6 +424,22 @@ export function updateLocation(latitude: number, longitude: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export function updateBio(bio: string) {
|
||||
return authRequest<null>("/profile/bio", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ bio }),
|
||||
});
|
||||
}
|
||||
|
||||
export interface CheckInResult {
|
||||
streak_count: number;
|
||||
is_new_today: boolean;
|
||||
}
|
||||
|
||||
export function checkIn() {
|
||||
return authRequest<CheckInResult>("/profile/checkin", { method: "POST" });
|
||||
}
|
||||
|
||||
// ---------------- دوستیابی ----------------
|
||||
|
||||
export function getDiscoverFeed(params?: {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
verifyOtp as apiVerifyOtp,
|
||||
getOnboardingStatus,
|
||||
getProfileMe,
|
||||
checkIn as apiCheckIn,
|
||||
} from "./api";
|
||||
|
||||
type AuthStatus = "loading" | "guest" | "authenticated";
|
||||
@@ -23,6 +24,7 @@ interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: UserProfile | null;
|
||||
onboardingStatus: OnboardingStatus | null;
|
||||
streak: number;
|
||||
login: (phone: string, otp: string) => Promise<UserProfile>;
|
||||
logout: () => void;
|
||||
refreshOnboardingStatus: () => Promise<OnboardingStatus | null>;
|
||||
@@ -37,6 +39,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [onboardingStatus, setOnboardingStatus] = useState<OnboardingStatus | null>(
|
||||
null
|
||||
);
|
||||
const [streak, setStreak] = useState(0);
|
||||
|
||||
const bootstrap = useCallback(async () => {
|
||||
const access = tokenStore.getAccess();
|
||||
@@ -48,7 +51,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const profile = await getProfileMe();
|
||||
setUser(profile.user);
|
||||
setOnboardingStatus(profile.onboarding_status);
|
||||
setStreak(profile.user.streak_count);
|
||||
setStatus("authenticated");
|
||||
// یک بار در روز برای هر کاربر: استریک روزانه (گیمیفیکیشن) رو ثبت میکنه.
|
||||
// fire-and-forget؛ اگه شکست بخوره چیزی توی تجربهی کاربر خراب نمیشه.
|
||||
apiCheckIn()
|
||||
.then((r) => setStreak(r.streak_count))
|
||||
.catch(() => {});
|
||||
} catch {
|
||||
tokenStore.clear();
|
||||
setStatus("guest");
|
||||
@@ -64,10 +73,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const data = await apiVerifyOtp(phone, otp);
|
||||
tokenStore.set(data.tokens.access_token, data.tokens.refresh_token);
|
||||
setUser(data.user);
|
||||
setStreak(data.user.streak_count);
|
||||
setStatus("authenticated");
|
||||
// وضعیت کامل مراحل رو جدا میگیریم چون verify-otp فقط current_step/is_onboarded میده
|
||||
const fullStatus = await getOnboardingStatus();
|
||||
setOnboardingStatus(fullStatus);
|
||||
apiCheckIn()
|
||||
.then((r) => setStreak(r.streak_count))
|
||||
.catch(() => {});
|
||||
return data.user;
|
||||
}, []);
|
||||
|
||||
@@ -75,6 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
tokenStore.clear();
|
||||
setUser(null);
|
||||
setOnboardingStatus(null);
|
||||
setStreak(0);
|
||||
setStatus("guest");
|
||||
}, []);
|
||||
|
||||
@@ -92,6 +106,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const profile = await getProfileMe();
|
||||
setUser(profile.user);
|
||||
setOnboardingStatus(profile.onboarding_status);
|
||||
setStreak(profile.user.streak_count);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -100,6 +115,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
status,
|
||||
user,
|
||||
onboardingStatus,
|
||||
streak,
|
||||
login,
|
||||
logout,
|
||||
refreshOnboardingStatus,
|
||||
|
||||
Reference in New Issue
Block a user