Files
gold_land_user/src/modules/gold-land/components/financial-goal/FinancialGoalPlayer.vue
T
safavimohadeseh78andCursor c53a67831d
Frontend CI / unit (test, type-check, build) (push) Successful in 7m21s
Frontend CI / browser E2E (frontend + pinned backend) (push) Successful in 11m38s
feat: complete the thirteen-stage learning experience and E2E journey
Play levels 7-13 in the app and pin E2E to the local thirteen-stage backend commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 19:42:04 +03:30

250 lines
14 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import PrimaryButton from '@/components/common/PrimaryButton.vue'
import { illustrations } from '@/assets/illustrations'
import { ApiClientError, ApiNetworkError, StaleSessionError } from '@/api/errors'
import { runMeMutation } from '@/api/sync'
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
import { clonePlan, initialPlan, revisedPlan } from '@/modules/gold-land/financial-goal/goalMath'
import { setRiskAnswer } from '@/modules/gold-land/risk-return/riskChoices'
import type { CompleteLevelResponse, FinancialGoalContent, LevelItem } from '@/modules/gold-land/types/goldLand.types'
const props = defineProps<{ levelId: number }>()
const emit = defineEmits<{ backToMap: [focusLevel?: number]; nextLevel: [levelId: number] }>()
type Phase = 'intro' | 'initial' | 'event' | 'questions' | 'review' | 'result'
const loading = ref(true)
const error = ref('')
const submitError = ref('')
const submitting = ref(false)
const phase = ref<Phase>('intro')
const mission = ref<FinancialGoalContent | null>(null)
const initialWeekly = ref<number | null>(null)
const initialSnapshot = ref<number | null>(null)
const exceptionWeekly = ref<number | null>(null)
const continueWeekly = ref<number | null>(null)
const answers = ref<Record<string, string>>({})
const questionIndex = ref(0)
const result = ref<CompleteLevelResponse | null>(null)
const levels = ref<LevelItem[]>([])
const plan = computed(() => mission.value?.plan)
const preview = computed(() => {
if (!plan.value || initialWeekly.value == null) return null
return initialPlan(plan.value.currentSavings, plan.value.goalPrice, initialWeekly.value, plan.value.weeklyIncome, plan.value.normalEssential)
})
const revised = computed(() => {
if (!plan.value || initialSnapshot.value == null || exceptionWeekly.value == null || continueWeekly.value == null) return null
return revisedPlan(plan.value.currentSavings, plan.value.goalPrice, initialSnapshot.value, exceptionWeekly.value, continueWeekly.value)
})
const questions = computed(() => mission.value?.questions ?? [])
const currentQuestion = computed(() => questions.value[questionIndex.value])
const feedback = computed(() => {
const value = result.value?.feedback
if (!value || !('timeline' in value)) return null
return value
})
const nextPlayable = computed(() => {
if (!result.value?.unlockedLevelId) return null
const next = levels.value.find((level) => level.id === result.value?.unlockedLevelId)
if (!next || next.status === 'locked' || next.status === 'coming_soon') return null
return next
})
function confirmInitial() {
if (initialWeekly.value == null) return
initialSnapshot.value = initialWeekly.value
exceptionWeekly.value = null
continueWeekly.value = null
phase.value = 'event'
}
function changeInitial() {
if (initialSnapshot.value == null) return
const saved = clonePlan(initialSnapshot.value)
phase.value = 'initial'
initialWeekly.value = saved
}
async function loadMission() {
loading.value = true
error.value = ''
result.value = null
phase.value = 'intro'
try {
const detail = await goldLandApi.getLevel(props.levelId)
if (detail.level.status === 'locked') {
error.value = 'این مرحله هنوز قفل است.'
return
}
if (detail.level.status === 'coming_soon') {
error.value = 'این مرحله به‌زودی باز می‌شود.'
return
}
if (detail.activity?.type !== 'financial-goal' || !detail.activity.financialGoal) {
error.value = 'محتوای این مرحله در دسترس نیست.'
return
}
await goldLandApi.startLevel(props.levelId)
mission.value = detail.activity.financialGoal
} catch (err) {
error.value = err instanceof Error ? err.message : 'خطا در دریافت چالش'
} finally {
loading.value = false
}
}
function goReview() {
if (questions.value.some((question) => !answers.value[question.id])) {
submitError.value = 'اول به هر پنج سؤال پاسخ بده.'
return
}
submitError.value = ''
phase.value = 'review'
}
async function submitGoal() {
if (submitting.value || initialSnapshot.value == null || exceptionWeekly.value == null || continueWeekly.value == null) return
submitting.value = true
submitError.value = ''
try {
const response = await runMeMutation(
() => goldLandApi.completeLevel(props.levelId, [
{
questionId: 'plan',
plan: {
initialWeekly: initialSnapshot.value!,
exceptionWeekly: exceptionWeekly.value!,
continueWeekly: continueWeekly.value!,
},
},
...questions.value.map((question) => ({ questionId: question.id, answerId: answers.value[question.id] ?? '' })),
]),
(value) => value.me,
)
result.value = response
phase.value = 'result'
levels.value = (await goldLandApi.getProgress()).levels
} catch (err) {
if (err instanceof StaleSessionError) return
submitError.value = err instanceof ApiNetworkError
? 'اتصال برقرار نشد. برنامه و پاسخ‌ها حفظ شده‌اند. دوباره بفرست.'
: err instanceof ApiClientError
? err.message
: err instanceof Error
? err.message
: 'ارسال نتیجه ناموفق بود'
} finally {
submitting.value = false
}
}
onMounted(loadMission)
</script>
<template>
<div class="goal-game">
<p v-if="loading">در حال بارگذاری چالش...</p>
<template v-else-if="error">
<p role="alert">{{ error }}</p>
<PrimaryButton variant="purple" full-width @click="loadMission">تلاش دوباره</PrimaryButton>
</template>
<template v-else-if="mission && phase === 'intro'">
<img :src="illustrations.professor" alt="" class="goal-game__guide" />
<p class="goal-game__guide-name">{{ mission.guideTitle }}</p>
<h2>{{ mission.title }}</h2>
<p>{{ mission.intro }}</p>
<ul><li v-for="rule in mission.rules" :key="rule">{{ rule }}</li></ul>
<PrimaryButton variant="gold" full-width @click="phase = 'initial'">ساخت برنامه</PrimaryButton>
</template>
<template v-else-if="mission && plan && phase === 'initial'">
<h2>برنامه اولیه</h2>
<p>هدف {{ plan.goalPrice }} سکه. موجودی {{ plan.currentSavings }} سکه. پول هفتگی {{ plan.weeklyIncome }} و نیاز ضروری {{ plan.normalEssential }} سکه.</p>
<p>پس‌انداز هفتگی را انتخاب کن. سریع‌ترین برنامه نمره اضافه ندارد.</p>
<div class="goal-game__choices">
<button v-for="rate in plan.initialRates" :key="rate" type="button" :aria-pressed="initialWeekly === rate" @click="initialWeekly = rate">{{ rate }} سکه در هفته</button>
</div>
<template v-if="preview && initialWeekly != null">
<p>مبلغ باقی‌مانده تا هدف: {{ preview.remaining }} سکه.</p>
<p>برآورد زمان: {{ preview.weeks }} هفته. مجموع پس‌انداز در هفته رسیدن: {{ preview.totalSaved }} سکه.</p>
<p>{{ preview.extra > 0 ? `مبلغ اضافه بر هدف: ${preview.extra} سکه.` : 'مبلغ اضافه بر هدف نداری.' }}</p>
<p>خرج اختیاری یا ذخیره همان هفته: {{ preview.optional }} سکه. صفر بودن آن به‌تنهایی خطا نیست.</p>
</template>
<PrimaryButton variant="gold" full-width :disabled="initialWeekly == null" @click="confirmInitial">تأیید برنامه اولیه</PrimaryButton>
</template>
<template v-else-if="mission && plan && phase === 'event'">
<h2>تغییر شرایط</h2>
<p>{{ mission.eventMessage }}</p>
<p>هفته اول با {{ initialSnapshot }} سکه پس‌انداز ثبت شده و با تغییر بعدی عوض نمی‌شود.</p>
<p>برای هفته دوم یکی از این مقدارها را انتخاب کن. پس‌انداز نکردن هم معتبر است.</p>
<div class="goal-game__choices">
<button v-for="rate in plan.exceptionRates" :key="`ex-${rate}`" type="button" :aria-pressed="exceptionWeekly === rate" @click="exceptionWeekly = rate">هفته دوم: {{ rate }} سکه</button>
</div>
<p>از هفته سوم شرایط اولیه برمی‌گردد. نرخ ادامه را خودت انتخاب کن.</p>
<div class="goal-game__choices">
<button v-for="rate in plan.continueRates" :key="`go-${rate}`" type="button" :aria-pressed="continueWeekly === rate" @click="continueWeekly = rate">ادامه: {{ rate }} سکه</button>
</div>
<template v-if="revised">
<p>موجودی پس از هفته دوم: {{ revised.balanceAfterTwo }} سکه.</p>
<p>هفته‌های باقی‌مانده: {{ revised.remainingWeeks }}. کل زمان: {{ revised.totalWeeks }} هفته.</p>
<p>موجودی نهایی برآوردی: {{ revised.finalBalance }} سکه. {{ revised.extra > 0 ? `${revised.extra} سکه اضافه بر هدف.` : 'بدون مبلغ اضافه.' }}</p>
<p>خط زمان: هفته ۱ با برنامه اولیه، هفته ۲ با نیاز بیشتر، بعد نرخ ادامه تا رسیدن به هدف.</p>
</template>
<PrimaryButton variant="purple" full-width @click="changeInitial">تغییر برنامه اولیه</PrimaryButton>
<PrimaryButton variant="gold" full-width :disabled="exceptionWeekly == null || continueWeekly == null" @click="phase = 'questions'">رفتن به سؤال‌ها</PrimaryButton>
</template>
<template v-else-if="mission && phase === 'questions' && currentQuestion">
<p>سؤال {{ questionIndex + 1 }} از {{ questions.length }}</p>
<fieldset>
<legend>{{ currentQuestion.prompt }}</legend>
<label v-for="option in currentQuestion.options" :key="option.id">
<input type="radio" :name="currentQuestion.id" :value="option.id" :checked="answers[currentQuestion.id] === option.id" @change="answers = setRiskAnswer(answers, currentQuestion.id, option.id)" />
<span>{{ option.label }}</span>
</label>
</fieldset>
<div class="goal-game__nav">
<PrimaryButton variant="purple" :disabled="questionIndex === 0" @click="questionIndex -= 1">قبلی</PrimaryButton>
<PrimaryButton v-if="questionIndex < questions.length - 1" variant="gold" :disabled="!answers[currentQuestion.id]" @click="questionIndex += 1">بعدی</PrimaryButton>
<PrimaryButton v-else variant="gold" :disabled="!answers[currentQuestion.id]" @click="goReview">مرور پاسخ‌ها</PrimaryButton>
</div>
<p v-if="submitError" role="alert">{{ submitError }}</p>
</template>
<template v-else-if="mission && phase === 'review'">
<h2>مرور</h2>
<p>برنامه اولیه {{ initialSnapshot }}، هفته دوم {{ exceptionWeekly }}، ادامه {{ continueWeekly }}. نمره از این عددها نمی‌آید.</p>
<article v-for="(question, index) in questions" :key="question.id">
<p>{{ question.prompt }}</p>
<p>انتخاب تو: {{ question.options.find((option) => option.id === answers[question.id])?.label }}</p>
<button type="button" @click="questionIndex = index; phase = 'questions'">تغییر این پاسخ</button>
</article>
<PrimaryButton variant="gold" full-width :disabled="submitting" @click="submitGoal">{{ submitting ? 'در حال ارسال...' : 'ثبت نتیجه' }}</PrimaryButton>
<p v-if="submitError" role="alert">{{ submitError }}</p>
<PrimaryButton v-if="submitError" variant="purple" full-width :disabled="submitting" @click="submitGoal">ارسال دوباره</PrimaryButton>
</template>
<template v-else-if="result && feedback">
<h2>نتیجه</h2>
<p>امتیاز {{ result.score }} از ۱۰۰. ستاره: {{ result.stars }} از ۳.</p>
<p v-if="result.reward.xp > 0 || result.reward.coins > 0">پاداش: {{ result.reward.xp }} تجربه و {{ result.reward.coins }} سکه.</p>
<p v-else>این بار پاداش تازه‌ای اضافه نشد.</p>
<p>برنامه اولیه {{ feedback.timeline.initialWeeks }} هفته. بعد از هفته دوم {{ feedback.timeline.balanceAfterTwo }} سکه، {{ feedback.timeline.remainingWeeks }} هفته دیگر، کل {{ feedback.timeline.totalWeeks }} هفته و موجودی نهایی {{ feedback.timeline.finalBalance }} سکه.</p>
<ul>
<li v-for="item in feedback.questions" :key="item.id">{{ item.earned ? 'درست' : 'قابل بهتر شدن' }}: {{ item.explanation }}</li>
</ul>
<p>{{ feedback.summary }}</p>
<PrimaryButton variant="gold" full-width @click="emit('backToMap', result.unlockedLevelId ?? props.levelId)">بازگشت به نقشه</PrimaryButton>
<PrimaryButton v-if="nextPlayable" variant="purple" full-width @click="emit('nextLevel', nextPlayable.id)">رفتن به مرحله بعد</PrimaryButton>
</template>
</div>
</template>
<style scoped>
.goal-game { display: flex; flex-direction: column; gap: 12px; padding-bottom: 24px; }
.goal-game p, .goal-game li, .goal-game legend { line-height: 1.8; }
.goal-game__guide { width: 96px; height: 96px; object-fit: contain; align-self: center; }
.goal-game__guide-name { text-align: center; font-weight: 800; margin: 0; }
.goal-game__choices, .goal-game__nav { display: flex; flex-wrap: wrap; gap: 8px; }
.goal-game button, label { min-height: 44px; }
label, article { border: 1px solid rgba(118, 75, 190, 0.35); border-radius: var(--radius-lg); padding: 12px; }
label { display: flex; gap: 8px; }
</style>