diff --git a/src/common/utils/call-event.ts b/src/common/utils/call-event.ts index d5da9496..506d9654 100644 --- a/src/common/utils/call-event.ts +++ b/src/common/utils/call-event.ts @@ -47,6 +47,8 @@ export interface EventName { openProfile: null openMarketModal: null font_change: FontFamily + close_all_modals: null + openWizardModal: null } export function callEvent(eventName: K, data?: EventName[K]) { diff --git a/src/components/chip.component.tsx b/src/components/chip.component.tsx new file mode 100644 index 00000000..1502076c --- /dev/null +++ b/src/components/chip.component.tsx @@ -0,0 +1,22 @@ +interface ChipProps { + selected: boolean + onClick: () => void + children: React.ReactNode + className?: string +} + +export const Chip: React.FC = ({ + selected, + onClick, + children, + className = '', +}) => { + return ( + + ) +} diff --git a/src/components/welcome-wizard.tsx b/src/components/welcome-wizard.tsx new file mode 100644 index 00000000..ac92d358 --- /dev/null +++ b/src/components/welcome-wizard.tsx @@ -0,0 +1,365 @@ +import { useState } from 'react' +import Modal from '@/components/modal' +import { Button } from '@/components/button/button' +import { + useGetOccupations, + useGetInterests, +} from '@/services/hooks/profile/getProfileMeta.hook' +import { LuChevronLeft } from 'react-icons/lu' +import { TextInput } from '@/components/text-input' +import { sleep } from '@/common/utils/timeout' +import { Chip } from '@/components/chip.component' +import { ItemSelector } from './item-selector' +import { useSetupWizard } from '@/services/hooks/auth/authService.hook' +import { showToast } from '@/common/toast' +import { safeAwait } from '@/services/api' +import Analytics from '@/analytics' + +export enum ReferralSource { + Social = 'social', + Youtube = 'youtube', + Friends = 'friends', + SearchOther = 'search_other', +} + +interface WelcomeWizardProps { + isOpen: boolean + onClose: () => void +} + +const StepWrapper = ({ children }: { children: React.ReactNode }) => { + return
{children}
+} + +const StepImage = ({ src, alt }: { src: string; alt: string }) => { + return ( +
+ {alt} +
+ ) +} +export const WelcomeWizard = ({ isOpen, onClose }: WelcomeWizardProps) => { + const [currentStep, setCurrentStep] = useState(1) + const [fetching, setFetching] = useState(false) + const [selectedOccupation, setSelectedOccupation] = useState(null) + const [selectedInterests, setSelectedInterests] = useState([]) + const [selectedReferralSource, setSelectedReferralSource] = + useState(null) + const [referralCode, setReferralCode] = useState('') + const totalSteps = 5 + + const { mutateAsync, isPending } = useSetupWizard() + + const { data: occupations, isLoading: occupationsLoading } = + useGetOccupations(fetching) + const { data: interests, isLoading: interestsLoading } = useGetInterests(fetching) + + const nextStep = () => { + Analytics.event(`welcome_wizard_step_${currentStep}_completed`) + if (currentStep < totalSteps) setCurrentStep(currentStep + 1) + else onClose() + } + + const prevStep = () => { + if (currentStep > 1) setCurrentStep(currentStep - 1) + Analytics.event('welcome_wizard_step_back_clicked') + } + + useEffect(() => { + const load = async () => { + await sleep(300) + setFetching(true) + } + load() + Analytics.event('welcome_wizard_opened') + }, []) + + const save = async () => { + if ( + !selectedOccupation || + selectedInterests.length === 0 || + !selectedReferralSource + ) { + showToast('لطفاً تمام مراحل را تکمیل کنید.', 'error') + return + } + + const [err, _] = await safeAwait( + mutateAsync({ + occupationId: selectedOccupation, + interestsIds: selectedInterests, + referralSource: selectedReferralSource, + referralCode: referralCode || undefined, + }) + ) + if (err) { + showToast('خطا در ثبت اطلاعات. لطفاً دوباره تلاش کنید.', 'error') + Analytics.event('welcome_wizard_completion_failed') + return + } + + setCurrentStep(currentStep + 1) + Analytics.event('welcome_wizard_completed') + } + + const renderStepContent = () => { + switch (currentStep) { + case 1: + return ( + +
+
+

+ خوش اومدی! +

+

+ خیلی خوشحالیم که اینجایی. بیا با هم پروفایلت رو کامل + کنیم تا تجربه بهتری داشته باشی. +

+
+ +
+ +
+ ) + + case 2: + return ( + +
+
+
+

+ چه کاره‌ای؟ +

+

+ حرفه‌ات رو انتخاب کن +

+
+
+ {occupationsLoading ? ( +
+ در حال بارگذاری... +
+ ) : ( + occupations?.map((job) => { + const isSelected = + selectedOccupation === job.id + return ( + + setSelectedOccupation(job.id) + } + > + {job.title} + + ) + }) + )} +
+
+
+ +
+
+ +
+ ) + + case 3: + return ( + +
+
+
+

+ به چی علاقه داری؟ +

+

+ هر تعداد که دوست داری انتخاب کن +

+
+
+ {interestsLoading ? ( +
+ در حال بارگذاری... +
+ ) : ( + interests?.map((item) => { + const isSelected = selectedInterests.includes( + item.id + ) + return ( + { + if (isSelected) + setSelectedInterests( + selectedInterests.filter( + (id) => id !== item.id + ) + ) + else + setSelectedInterests([ + ...selectedInterests, + item.id, + ]) + }} + > + {item.title} + + ) + }) + )} +
+
+
+ +
+
+ +
+ ) + + case 4: + return ( + +
+
+

+ مرحله ۴: از کجا شنیدی؟ +

+

+ لطفاً بگو از کجا با ویجتیفای آشنا شدی. +

+
+ +
+
+ {[ + { + value: ReferralSource.Social, + label: 'شبکه‌های اجتماعی', + }, + { + value: ReferralSource.Youtube, + label: 'یوتیوب', + }, + { + value: ReferralSource.Friends, + label: 'دوستان', + }, + { + value: ReferralSource.SearchOther, + label: 'جستجو یا سایر', + }, + ].map((option) => ( + + setSelectedReferralSource(option.value) + } + /> + ))} +
+ + {selectedReferralSource === ReferralSource.Friends && ( +
+ +
+ )} +
+ + +
+ +
+ ) + + case 5: + return ( + +
+
+

+ همه چیز آماده‌ست! 🚀 +

+

+ تنظیمات پروفایلت با موفقیت انجام شد. حالا می‌تونی از + تمام امکانات استفاده کنی. +

+
+ +
+ +
+ ) + } + } + + return ( + +
+ {currentStep > 1 && currentStep < totalSteps && ( + + )} + {renderStepContent()} +
+
+ ) +} diff --git a/src/layouts/navbar/friends-list/friends.tsx b/src/layouts/navbar/friends-list/friends.tsx index d0f6816a..703c889a 100644 --- a/src/layouts/navbar/friends-list/friends.tsx +++ b/src/layouts/navbar/friends-list/friends.tsx @@ -5,7 +5,6 @@ import { AuthRequiredModal } from '@/components/auth/AuthRequiredModal' import { ClickableTooltip } from '@/components/clickableTooltip' import { useAuth } from '@/context/auth.context' import { useGetFriends } from '@/services/hooks/friends/friendService.hook' -import { UserAccountModal } from '../../setting/tabs/account/user-account.modal' import { FriendItem } from './friend.item' import { HiUserGroup } from 'react-icons/hi2' @@ -73,12 +72,11 @@ export function FriendsList() { const { isAuthenticated } = useAuth() const [firstAuth, setFirstAuth] = useState(false) - const [showSettingsModal, setShowSettingsModal] = useState(false) const [activeProfileId, setActiveProfileId] = useState(null) const [isOpen, setIsOpen] = useState(false) const triggerRef = useRef(null) - const { data: friendsData, refetch: refetchFriends } = useGetFriends({ + const { data: friendsData } = useGetFriends({ status: 'ACCEPTED', enabled: isAuthenticated, }) @@ -91,13 +89,8 @@ export function FriendsList() { setFirstAuth(true) return } - setShowSettingsModal(true) } - const handleSettingsModalClose = () => { - setShowSettingsModal(false) - refetchFriends() - } const handleAuthModalClose = () => setFirstAuth(false) if (!isAuthenticated) { @@ -128,11 +121,6 @@ export function FriendsList() { closeOnClickOutside={true} /> - - { if (user?.avatar) { @@ -32,6 +33,7 @@ const getTooltipContent = (user: any) => { export function ProfileNav() { const { user, isAuthenticated } = useAuth() const [showSettingsModal, setShowSettingsModal] = useState(false) + const [openedWizard, setOpenedWizard] = useState(false) const handleProfileClick = () => { setShowSettingsModal(true) @@ -51,8 +53,19 @@ export function ProfileNav() { handleProfileClick() }) + const eventClose = listenEvent('close_all_modals', () => { + modalCloseHandler() + }) + + const openWizardEvent = listenEvent('openWizardModal', () => { + modalCloseHandler() + setOpenedWizard(true) + }) + return () => { event() + eventClose() + openWizardEvent() } }, []) @@ -85,6 +98,13 @@ export function ProfileNav() { selectedTab="profile" onClose={modalCloseHandler} /> + + {openedWizard && ( + setOpenedWizard(false)} + /> + )} ) } diff --git a/src/layouts/setting/tabs/about-us/about-us.tsx b/src/layouts/setting/tabs/about-us/about-us.tsx index 6c689701..ad2ed6fe 100644 --- a/src/layouts/setting/tabs/about-us/about-us.tsx +++ b/src/layouts/setting/tabs/about-us/about-us.tsx @@ -44,7 +44,7 @@ export function AboutUsTab() { 'mb-1 text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-700 to-indigo-600' } > - ویجتی‌فای + ویجتیفای
- ویجتی‌فای یک افزونه متن‌باز برای مرورگر شماست که صفحه جدید را با + ویجتیفای یک افزونه متن‌باز برای مرورگر شماست که صفحه جدید را با ابزارهای کاربردی و سبک زیبا به محیطی کارآمد و شخصی‌سازی شده تبدیل می‌کند.

diff --git a/src/layouts/setting/tabs/account/auth-form/auth-otp.tsx b/src/layouts/setting/tabs/account/auth-form/auth-otp.tsx index 390caedb..554f12b7 100644 --- a/src/layouts/setting/tabs/account/auth-form/auth-otp.tsx +++ b/src/layouts/setting/tabs/account/auth-form/auth-otp.tsx @@ -8,6 +8,8 @@ import { useAuth } from '@/context/auth.context' import { isEmpty, isEmail, isLessThan } from '@/utils/validators' import InputTextError from './components/input-text-error' import OtpInput from './components/otp-input' +import { callEvent } from '@/common/utils/call-event' +import { sleep } from '@/common/utils/timeout' type AuthOtpProps = { step: 'enter-email' | 'enter-otp' @@ -79,6 +81,11 @@ const AuthOtp: React.FC = ({ step, setStep }) => { try { setError({ email: null, otp: null, api: null }) const response = await verifyOtp({ email, code: otp }) + if (response.isNewUser) { + callEvent('openWizardModal') + await sleep(500) + } + login(response.data) } catch (err: any) { const content = translateError(err) diff --git a/src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx b/src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx index 0290df16..71726c0d 100644 --- a/src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx +++ b/src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx @@ -10,6 +10,8 @@ import type { AxiosError } from 'axios' import { showToast } from '@/common/toast' import { translateError } from '@/utils/translate-error' import Analytics from '@/analytics' +import { callEvent } from '@/common/utils/call-event' +import { sleep } from '@/common/utils/timeout' export default function LoginGoogleButton() { const { login } = useAuth() @@ -67,6 +69,11 @@ export default function LoginGoogleButton() { return showToast(translateError(err) as string, 'error') } + if (response.isNewUser) { + callEvent('openWizardModal') + await sleep(300) + } + login(response.data) } } finally { diff --git a/src/layouts/setting/tabs/account/components/profile-display.tsx b/src/layouts/setting/tabs/account/components/profile-display.tsx index dcd24291..96715363 100644 --- a/src/layouts/setting/tabs/account/components/profile-display.tsx +++ b/src/layouts/setting/tabs/account/components/profile-display.tsx @@ -14,9 +14,10 @@ interface ProfileDisplayProps { } const getGenderInfo = (gender: 'MALE' | 'FEMALE' | 'OTHER' | null | undefined) => { - if (gender === 'MALE') return { label: 'مذکر', icon: } - if (gender === 'FEMALE') return { label: 'مؤنث', icon: } - return { label: 'نامشخص', icon: } + if (gender === 'MALE') return { label: 'آقا هستم', icon: } + if (gender === 'FEMALE') + return { label: 'خانم هستم', icon: } + return { label: 'بماند', icon: } } const formatJalaliDate = (dateString: string | null | undefined): string => { diff --git a/src/services/hooks/auth/authService.hook.ts b/src/services/hooks/auth/authService.hook.ts index 088038fc..32fde1a6 100644 --- a/src/services/hooks/auth/authService.hook.ts +++ b/src/services/hooks/auth/authService.hook.ts @@ -36,6 +36,17 @@ interface OtpVerifyPayload { email: string code: string } + +interface WizardPayload { + occupationId: string + + interestsIds: string[] + + referralSource: ReferralSource + + referralCode?: string +} + async function signIn(credentials: LoginCredentials): Promise { const client = await getMainClient() const response = await client.post('/auth/signin', credentials) @@ -106,6 +117,12 @@ async function verifyOtp(payload: OtpVerifyPayload): Promise { return response.data } +async function setupWizard(data: WizardPayload): Promise { + const client = await getMainClient() + const response = await client.post('/users/@me/complete-wizard', data) + return response.data +} + export function useSignIn() { return useMutation({ mutationFn: (credentials: LoginCredentials) => signIn(credentials), @@ -147,3 +164,9 @@ export function useVerifyOtp() { mutationFn: (payload: OtpVerifyPayload) => verifyOtp(payload), }) } + +export function useSetupWizard() { + return useMutation({ + mutationFn: (data: WizardPayload) => setupWizard(data), + }) +} diff --git a/src/utils/translate-error.ts b/src/utils/translate-error.ts index 5b0e692d..523c0790 100644 --- a/src/utils/translate-error.ts +++ b/src/utils/translate-error.ts @@ -81,6 +81,10 @@ const errorTranslations: Record = { TODO_NOT_FOUND: 'وظیفه مورد نظر یافت نشد', INVALID_OTP_CODE: 'کد تایید نامعتبر است', USE_EMAIL_FOR_OTP: 'لطفا از ایمیل برای دریافت کد تایید استفاده کنید', + + INVALID_OCCUPATION_ID: 'شغل نامعتبری انتخاب کردی!', + ONE_OR_MORE_INVALID_INTEREST_IDS: + 'یک یا چندتا از علاقه‌مندی‌هایی که انتخاب کردی نامعتبر هستن!', } const validationTranslations: Record = {