From ad5b42db06ae163cbca4702e201afd59f48b09bd Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 11 Jun 2026 18:11:45 +0330 Subject: [PATCH] =?UTF-8?q?feat(profile):=20"set=20your=20city"=20gamifica?= =?UTF-8?q?tion=20box=20=E2=86=92=20one-time=20500-coin=20reward?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New searchable city picker (src/lib/iran-cities.ts, ~60 Iranian cities, fa/en search) shown as a gold reward card at the top of the profile Basic tab. - First time a non-empty city is set, the player earns 500 coins (CITY_REWARD), granted server-authoritatively. Collapses to a compact summary afterwards with a "change city" option (no re-reward). - Frontend: UserProfile.city + cityRewardClaimed; mock-service grants on first set; session/service updateProfile accept `city`; celebratory toast + sfx. - Backend (.NET): ProfileDto.City/CityRewardClaimed (JSON blob → no migration); ProfileService.Update grants +500 once and writes a "city" ledger entry. - i18n: city.* keys (fa + en). Co-Authored-By: Claude Opus 4.8 --- .../src/Hokm.Server/Profiles/ProfileModels.cs | 2 + .../Hokm.Server/Profiles/ProfileService.cs | 20 ++- src/components/screens/ProfileScreen.tsx | 160 +++++++++++++++++- src/lib/i18n.tsx | 18 ++ src/lib/iran-cities.ts | 89 ++++++++++ src/lib/online/gamification.ts | 5 + src/lib/online/mock-service.ts | 9 +- src/lib/online/service.ts | 2 +- src/lib/online/types.ts | 2 + src/lib/session-store.ts | 2 +- 10 files changed, 304 insertions(+), 5 deletions(-) create mode 100644 src/lib/iran-cities.ts diff --git a/server/src/Hokm.Server/Profiles/ProfileModels.cs b/server/src/Hokm.Server/Profiles/ProfileModels.cs index d901e98..68a5049 100644 --- a/server/src/Hokm.Server/Profiles/ProfileModels.cs +++ b/server/src/Hokm.Server/Profiles/ProfileModels.cs @@ -59,6 +59,8 @@ public class ProfileDto // social public string Gender { get; set; } = ""; // "" | male | female | other + public string? City { get; set; } // selected city id (see client IRAN_CITIES) + public bool CityRewardClaimed { get; set; } // one-time "set your city" reward granted public SocialLinksDto Socials { get; set; } = new(); public string SocialsVisibility { get; set; } = "public"; // public | friends | hidden diff --git a/server/src/Hokm.Server/Profiles/ProfileService.cs b/server/src/Hokm.Server/Profiles/ProfileService.cs index 67e9179..fab7b18 100644 --- a/server/src/Hokm.Server/Profiles/ProfileService.cs +++ b/server/src/Hokm.Server/Profiles/ProfileService.cs @@ -69,9 +69,27 @@ public class ProfileService if (patch.TryGetProperty("socialsVisibility", out var sv) && sv.ValueKind == JsonValueKind.String) p.SocialsVisibility = sv.GetString()!; if (patch.TryGetProperty("socials", out var so) && so.ValueKind == JsonValueKind.Object) p.Socials = JsonSerializer.Deserialize(so.GetRawText(), JsonOpts.Default) ?? p.Socials; - return await Save(p); + // One-time "set your city" reward: first non-empty city → +500 coins. + var cityRewarded = false; + if (patch.TryGetProperty("city", out var ci) && ci.ValueKind == JsonValueKind.String) + { + var city = ci.GetString(); + p.City = city; + if (!string.IsNullOrWhiteSpace(city) && !p.CityRewardClaimed) + { + p.CityRewardClaimed = true; + p.Coins += CityReward; + cityRewarded = true; + } + } + await Save(p); + if (cityRewarded) await Ledger(uid, "city", CityReward, "profile-city"); + return p; } + /// One-time coin reward for setting your city (mirrors client CITY_REWARD). + public const int CityReward = 500; + public async Task UpgradePlan(string uid) { var p = await GetOrCreate(uid, null); diff --git a/src/components/screens/ProfileScreen.tsx b/src/components/screens/ProfileScreen.tsx index 913cf3d..9091c2f 100644 --- a/src/components/screens/ProfileScreen.tsx +++ b/src/components/screens/ProfileScreen.tsx @@ -1,7 +1,7 @@ "use client"; import { motion } from "framer-motion"; -import { Check, ChevronLeft, Crown, Eye, EyeOff, Lock, LogOut, Pencil, Star, Upload, Users, Volume2 } from "lucide-react"; +import { Check, ChevronLeft, Crown, Eye, EyeOff, Gift, Lock, LogOut, MapPin, Pencil, Search, Star, Upload, Users, Volume2 } from "lucide-react"; import { useRef, useState } from "react"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { RankBadge } from "@/components/online/RankBadge"; @@ -16,12 +16,16 @@ import { ACHIEVEMENTS, CARD_BACKS, CARD_FRONTS, + CITY_REWARD, TITLES, achievementProgress, ownedAvatarIds, ownedCardBackIds, ownedCardFrontIds, } from "@/lib/online/gamification"; +import { IRAN_CITIES, cityById, searchCities, type City } from "@/lib/iran-cities"; +import { pushNotification } from "@/lib/notification-store"; +import { sound } from "@/lib/sound"; /** Level required before a player can upload a custom profile photo. */ const PHOTO_UPLOAD_MIN_LEVEL = 25; @@ -95,6 +99,11 @@ export function ProfileScreen() { {/* ===== Basic: player card + stats/achievements ===== */} {tab === "basic" && (
+ {/* one-time "set your city" reward */} +
+ +
+ {/* player card */} s.profile); + const updateProfile = useSessionStore((s) => s.updateProfile); + + const claimed = !!profile?.cityRewardClaimed; + const currentCity = cityById(profile?.city); + const [editing, setEditing] = useState(!claimed); + const [query, setQuery] = useState(""); + const [pickedId, setPickedId] = useState(profile?.city ?? null); + const [saving, setSaving] = useState(false); + + const results = query.trim() ? searchCities(query, 40) : IRAN_CITIES.slice(0, 40); + const picked = cityById(pickedId ?? undefined); + const cityName = (c?: City) => (c ? (locale === "fa" ? c.fa : c.en) : ""); + + const choose = (c: City) => { + setPickedId(c.id); + setQuery(cityName(c)); + }; + + const save = async () => { + if (!pickedId || saving) return; + const firstClaim = !claimed; + setSaving(true); + try { + await updateProfile({ city: pickedId }); + } finally { + setSaving(false); + } + setEditing(false); + if (firstClaim) { + sound.play("award"); + pushNotification({ + kind: "system", + titleFa: `+${CITY_REWARD.toLocaleString("fa")} سکه دریافت شد! 🎉`, + titleEn: `+${CITY_REWARD} coins earned! 🎉`, + bodyFa: "شهر شما ثبت شد.", + bodyEn: "Your city has been saved.", + icon: "📍", + }); + } + }; + + // ---- Claimed + collapsed: compact summary ---- + if (claimed && !editing) { + return ( +
+ + + +
+
{cityName(currentCity) || t("city.unknown")}
+
{t("city.claimed")}
+
+ +
+ ); + } + + // ---- Editing / unclaimed: reward prompt + searchable picker ---- + return ( + + {!claimed && ( +
+ + + +
+
{t("city.rewardTitle")}
+
+ {t("city.rewardSub").replace("{n}", CITY_REWARD.toLocaleString(locale === "fa" ? "fa" : "en"))} +
+
+
+ )} + + {/* searchable city combobox */} +
+ + { + setQuery(e.target.value); + setPickedId(null); + }} + placeholder={t("city.search")} + className="w-full rounded-xl bg-navy-900/70 gold-border ltr:pl-9 rtl:pr-9 px-3 py-2.5 text-sm text-cream placeholder:text-cream/30 outline-none focus:ring-2 focus:ring-gold-500/40" + /> +
+ + {/* results — only while there's no committed pick yet */} + {!picked && ( +
+ {results.length === 0 && ( +
{t("city.none")}
+ )} + {results.map((c) => ( + + ))} +
+ )} + + {/* confirm / claim */} + {picked && ( + + )} +
+ ); +} + function SoundSettings() { const { t } = useI18n(); const { sfx, toggleSfx } = useSoundStore(); diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 2305799..3b20978 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -152,6 +152,15 @@ const fa: Dict = { "chat.placeholder": "پیام بنویسید…", "chat.send": "ارسال", "chat.emoji": "ایموجی", + "city.rewardTitle": "شهرت را انتخاب کن!", + "city.rewardSub": "{n} سکه هدیه بگیر", + "city.search": "جستجوی شهر…", + "city.none": "شهری پیدا نشد", + "city.claim": "ثبت و دریافت {n} سکه", + "city.saveCity": "ذخیره: {city}", + "city.claimed": "جایزهٔ شهر دریافت شد ✓", + "city.change": "تغییر", + "city.unknown": "نامشخص", "chat.empty": "گفتگو را شروع کنید", "friends.message": "پیام", @@ -503,6 +512,15 @@ const en: Dict = { "chat.placeholder": "Type a message…", "chat.send": "Send", "chat.emoji": "Emoji", + "city.rewardTitle": "Set your city!", + "city.rewardSub": "Earn {n} coins", + "city.search": "Search city…", + "city.none": "No city found", + "city.claim": "Save & get {n} coins", + "city.saveCity": "Save: {city}", + "city.claimed": "City reward claimed ✓", + "city.change": "Change", + "city.unknown": "Unknown", "chat.empty": "Start the conversation", "friends.message": "Message", diff --git a/src/lib/iran-cities.ts b/src/lib/iran-cities.ts new file mode 100644 index 0000000..0976ee0 --- /dev/null +++ b/src/lib/iran-cities.ts @@ -0,0 +1,89 @@ +// Major Iranian cities for the profile "set your city" picker. +// `id` is a stable slug; `fa`/`en` are display names (search matches either). + +export interface City { + id: string; + fa: string; + en: string; +} + +export const IRAN_CITIES: City[] = [ + { id: "tehran", fa: "تهران", en: "Tehran" }, + { id: "mashhad", fa: "مشهد", en: "Mashhad" }, + { id: "isfahan", fa: "اصفهان", en: "Isfahan" }, + { id: "karaj", fa: "کرج", en: "Karaj" }, + { id: "shiraz", fa: "شیراز", en: "Shiraz" }, + { id: "tabriz", fa: "تبریز", en: "Tabriz" }, + { id: "qom", fa: "قم", en: "Qom" }, + { id: "ahvaz", fa: "اهواز", en: "Ahvaz" }, + { id: "kermanshah", fa: "کرمانشاه", en: "Kermanshah" }, + { id: "urmia", fa: "ارومیه", en: "Urmia" }, + { id: "rasht", fa: "رشت", en: "Rasht" }, + { id: "zahedan", fa: "زاهدان", en: "Zahedan" }, + { id: "hamedan", fa: "همدان", en: "Hamedan" }, + { id: "kerman", fa: "کرمان", en: "Kerman" }, + { id: "yazd", fa: "یزد", en: "Yazd" }, + { id: "ardabil", fa: "اردبیل", en: "Ardabil" }, + { id: "bandar-abbas", fa: "بندرعباس", en: "Bandar Abbas" }, + { id: "arak", fa: "اراک", en: "Arak" }, + { id: "eslamshahr", fa: "اسلام‌شهر", en: "Eslamshahr" }, + { id: "zanjan", fa: "زنجان", en: "Zanjan" }, + { id: "sanandaj", fa: "سنندج", en: "Sanandaj" }, + { id: "qazvin", fa: "قزوین", en: "Qazvin" }, + { id: "khorramabad", fa: "خرم‌آباد", en: "Khorramabad" }, + { id: "gorgan", fa: "گرگان", en: "Gorgan" }, + { id: "sari", fa: "ساری", en: "Sari" }, + { id: "shahriar", fa: "شهریار", en: "Shahriar" }, + { id: "dezful", fa: "دزفول", en: "Dezful" }, + { id: "kashan", fa: "کاشان", en: "Kashan" }, + { id: "bojnurd", fa: "بجنورد", en: "Bojnurd" }, + { id: "birjand", fa: "بیرجند", en: "Birjand" }, + { id: "bushehr", fa: "بوشهر", en: "Bushehr" }, + { id: "qaemshahr", fa: "قائم‌شهر", en: "Qaemshahr" }, + { id: "babol", fa: "بابل", en: "Babol" }, + { id: "amol", fa: "آمل", en: "Amol" }, + { id: "khoy", fa: "خوی", en: "Khoy" }, + { id: "sabzevar", fa: "سبزوار", en: "Sabzevar" }, + { id: "neyshabur", fa: "نیشابور", en: "Neyshabur" }, + { id: "najafabad", fa: "نجف‌آباد", en: "Najafabad" }, + { id: "varamin", fa: "ورامین", en: "Varamin" }, + { id: "bandar-anzali", fa: "بندر انزلی", en: "Bandar-e Anzali" }, + { id: "yasuj", fa: "یاسوج", en: "Yasuj" }, + { id: "ilam", fa: "ایلام", en: "Ilam" }, + { id: "marvdasht", fa: "مرودشت", en: "Marvdasht" }, + { id: "saveh", fa: "ساوه", en: "Saveh" }, + { id: "bukan", fa: "بوکان", en: "Bukan" }, + { id: "shahrekord", fa: "شهرکرد", en: "Shahrekord" }, + { id: "maragheh", fa: "مراغه", en: "Maragheh" }, + { id: "semnan", fa: "سمنان", en: "Semnan" }, + { id: "gonbad-kavus", fa: "گنبد کاووس", en: "Gonbad-e Kavus" }, + { id: "saqqez", fa: "سقز", en: "Saqqez" }, + { id: "bandar-mahshahr", fa: "بندر ماهشهر", en: "Bandar-e Mahshahr" }, + { id: "abadan", fa: "آبادان", en: "Abadan" }, + { id: "khorramshahr", fa: "خرمشهر", en: "Khorramshahr" }, + { id: "borujerd", fa: "بروجرد", en: "Borujerd" }, + { id: "miandoab", fa: "میاندوآب", en: "Miandoab" }, + { id: "marand", fa: "مرند", en: "Marand" }, + { id: "torbat-heydarieh", fa: "تربت حیدریه", en: "Torbat-e Heydarieh" }, + { id: "jiroft", fa: "جیرفت", en: "Jiroft" }, + { id: "rafsanjan", fa: "رفسنجان", en: "Rafsanjan" }, + { id: "sirjan", fa: "سیرجان", en: "Sirjan" }, + { id: "kish", fa: "کیش", en: "Kish" }, + { id: "qeshm", fa: "قشم", en: "Qeshm" }, + { id: "other", fa: "سایر شهرها", en: "Other" }, +]; + +const BY_ID: Record = Object.fromEntries(IRAN_CITIES.map((c) => [c.id, c])); + +export function cityById(id?: string | null): City | undefined { + return id ? BY_ID[id] : undefined; +} + +/** Case/diacritic-insensitive filter on either Persian or English name. */ +export function searchCities(query: string, limit = 30): City[] { + const q = query.trim().toLowerCase(); + if (!q) return IRAN_CITIES.slice(0, limit); + return IRAN_CITIES.filter( + (c) => c.fa.includes(q) || c.en.toLowerCase().includes(q) || c.id.includes(q), + ).slice(0, limit); +} diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts index 6c2bc06..d2465b7 100644 --- a/src/lib/online/gamification.ts +++ b/src/lib/online/gamification.ts @@ -803,3 +803,8 @@ export const DAILY_REWARDS = [100, 150, 200, 300, 400, 600, 1500]; export function dailyRewardFor(day: number): number { return DAILY_REWARDS[Math.min(day, DAILY_REWARDS.length) - 1] ?? 100; } + +/* ----------------------- Profile-completion reward ----------------------- */ + +/** One-time coin reward for setting your city on the profile. */ +export const CITY_REWARD = 500; diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 2ab0965..03b8f28 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -6,6 +6,7 @@ import { ACHIEVEMENTS, CARD_BACKS, CARD_FRONTS, + CITY_REWARD, REACTION_PACKS, STICKER_PACKS, TITLES, @@ -362,7 +363,13 @@ export class MockOnlineService implements OnlineService { async updateProfile(patch: Parameters[0]) { const p = await this.getProfile(); - this.profile = { ...p, ...patch }; + const next = { ...p, ...patch }; + // One-time reward: first time the player sets a (non-empty) city → +500 coins. + if (patch.city && patch.city.trim() && !p.cityRewardClaimed) { + next.coins = p.coins + CITY_REWARD; + next.cityRewardClaimed = true; + } + this.profile = next; this.saveProfile(); return this.profile; } diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts index 4b9e25e..6f33009 100644 --- a/src/lib/online/service.ts +++ b/src/lib/online/service.ts @@ -56,7 +56,7 @@ export interface OnlineService { Pick< UserProfile, | "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack" - | "gender" | "socials" | "socialsVisibility" + | "gender" | "city" | "socials" | "socialsVisibility" > > ): Promise; diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index 300d181..2abd81e 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -83,6 +83,8 @@ export interface UserProfile { // social gender?: Gender; + city?: string; // selected city id (see IRAN_CITIES) + cityRewardClaimed?: boolean; // true once the one-time "set your city" reward was granted socials?: SocialLinks; socialsVisibility?: SocialVisibility; // default "public" diff --git a/src/lib/session-store.ts b/src/lib/session-store.ts index 1269c9f..ba628a2 100644 --- a/src/lib/session-store.ts +++ b/src/lib/session-store.ts @@ -26,7 +26,7 @@ interface SessionStore { Pick< UserProfile, | "displayName" | "avatar" | "avatarImage" | "title" | "cardFront" | "cardBack" - | "gender" | "socials" | "socialsVisibility" + | "gender" | "city" | "socials" | "socialsVisibility" > > ) => Promise;