Play levels 7-13 in the app and pin E2E to the local thirteen-stage backend commit. Co-authored-by: Cursor <cursoragent@cursor.com>
250 lines
14 KiB
Vue
250 lines
14 KiB
Vue
<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>
|