first commit
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { apiClient } from '../services/api/client';
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
mobile: string;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export interface ChancesState {
|
||||
totalChances: number;
|
||||
usedChances: number;
|
||||
remainingChances: number;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('userAuth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('user_access_token'));
|
||||
const user = ref<UserProfile | null>(
|
||||
localStorage.getItem('user_info') ? JSON.parse(localStorage.getItem('user_info')!) : null,
|
||||
);
|
||||
const chances = ref<ChancesState>({
|
||||
totalChances: 0,
|
||||
usedChances: 0,
|
||||
remainingChances: 0,
|
||||
});
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value);
|
||||
|
||||
async function requestOtp(mobile: string) {
|
||||
const res = await apiClient.post('/auth/send-otp', { mobile });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function verifyOtp(mobile: string, code: string, campaignId?: string) {
|
||||
const res = await apiClient.post('/auth/verify-otp', { mobile, code });
|
||||
const authData = res.data;
|
||||
|
||||
token.value = authData.accessToken;
|
||||
user.value = authData.user;
|
||||
|
||||
localStorage.setItem('user_access_token', authData.accessToken);
|
||||
localStorage.setItem('user_info', JSON.stringify(authData.user));
|
||||
|
||||
if (campaignId) {
|
||||
await loadChances(campaignId);
|
||||
}
|
||||
return authData;
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
if (!token.value) return null;
|
||||
try {
|
||||
const res = await apiClient.get('/me');
|
||||
user.value = res.data;
|
||||
localStorage.setItem('user_info', JSON.stringify(res.data));
|
||||
return res.data;
|
||||
} catch {
|
||||
logout();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChances(campaignId: string) {
|
||||
if (!token.value || !campaignId) return;
|
||||
try {
|
||||
const res = await apiClient.get('/me/chances', {
|
||||
params: { campaignId },
|
||||
});
|
||||
chances.value = res.data;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function decrementChance() {
|
||||
if (chances.value.remainingChances > 0) {
|
||||
chances.value.remainingChances--;
|
||||
chances.value.usedChances++;
|
||||
}
|
||||
}
|
||||
|
||||
function setRemainingChances(count: number) {
|
||||
chances.value.remainingChances = count;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
chances.value = { totalChances: 0, usedChances: 0, remainingChances: 0 };
|
||||
localStorage.removeItem('user_access_token');
|
||||
localStorage.removeItem('user_info');
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
chances,
|
||||
isAuthenticated,
|
||||
requestOtp,
|
||||
verifyOtp,
|
||||
loadProfile,
|
||||
loadChances,
|
||||
decrementChance,
|
||||
setRemainingChances,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import confetti from 'canvas-confetti';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { useSoundEffects } from '../composables/useSoundEffects';
|
||||
import { apiClient } from '../services/api/client';
|
||||
import { useAuthStore } from './auth.store';
|
||||
|
||||
export interface PrizeResult {
|
||||
id: string;
|
||||
title: string;
|
||||
segment: number;
|
||||
code?: string | null;
|
||||
type: string;
|
||||
image?: string | null;
|
||||
}
|
||||
|
||||
export const useSpinStore = defineStore('spin', () => {
|
||||
const authStore = useAuthStore();
|
||||
const { playWin, playNoPrize } = useSoundEffects();
|
||||
|
||||
const isRequesting = ref(false);
|
||||
const isWheelSpinning = ref(false);
|
||||
const targetSegmentIndex = ref<number | null>(null);
|
||||
const spinResult = ref<any>(null);
|
||||
const showResultModal = ref(false);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const pendingIdempotencyKey = ref<string | null>(null);
|
||||
|
||||
function generateIdempotencyKey(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
async function executeSpin(campaignId: string, segments: any[]) {
|
||||
if (isRequesting.value || isWheelSpinning.value) return;
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
errorMessage.value = 'لطفاً ابتدا وارد حساب کاربری خود شوید.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (authStore.chances.remainingChances <= 0) {
|
||||
errorMessage.value = 'شما شانس کافی برای چرخاندن گردونه ندارید!';
|
||||
return;
|
||||
}
|
||||
|
||||
isRequesting.value = true;
|
||||
errorMessage.value = null;
|
||||
|
||||
if (!pendingIdempotencyKey.value) {
|
||||
pendingIdempotencyKey.value = generateIdempotencyKey();
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiClient.post(`/campaigns/${campaignId}/spin`, {
|
||||
idempotencyKey: pendingIdempotencyKey.value,
|
||||
});
|
||||
|
||||
const data = res.data;
|
||||
spinResult.value = data;
|
||||
|
||||
// Update remaining chances from server
|
||||
if (typeof data.remainingChances === 'number') {
|
||||
authStore.setRemainingChances(data.remainingChances);
|
||||
} else {
|
||||
authStore.decrementChance();
|
||||
}
|
||||
|
||||
// Find the segment index in the client segments list
|
||||
let segIdx = 0;
|
||||
if (data.prize) {
|
||||
// Priority 1: Match strictly by prize id first
|
||||
let found = segments.findIndex((s) => s.id === data.prize.id);
|
||||
|
||||
// Priority 2: Fallback match by exact title
|
||||
if (found === -1 && data.prize.title) {
|
||||
found = segments.findIndex((s) => s.title === data.prize.title);
|
||||
}
|
||||
|
||||
// Priority 3: Fallback match by sortOrder / segment
|
||||
if (found === -1 && data.prize.segment !== undefined) {
|
||||
found = segments.findIndex((s) => s.sortOrder === data.prize.segment);
|
||||
}
|
||||
|
||||
segIdx = found !== -1 ? found : 0;
|
||||
}
|
||||
|
||||
targetSegmentIndex.value = segIdx;
|
||||
isWheelSpinning.value = true;
|
||||
|
||||
// Clear the idempotency key once successfully acknowledged by server
|
||||
pendingIdempotencyKey.value = null;
|
||||
} catch (err: any) {
|
||||
let msg =
|
||||
err.response?.data?.message ||
|
||||
(Array.isArray(err.response?.data?.message)
|
||||
? err.response?.data?.message.join(' - ')
|
||||
: 'خطا در برقراری ارتباط با سرور قرعهکشی');
|
||||
if (typeof msg === 'string') {
|
||||
if (msg === 'CAMPAIGN_DRAFT') {
|
||||
msg = 'این کمپین در وضعیت پیشنویس (DRAFT) قرار دارد و هنوز فعال نشده است.';
|
||||
} else if (msg === 'CAMPAIGN_PAUSED') {
|
||||
msg = 'این کمپین موقتاً متوقف شده است.';
|
||||
} else if (msg === 'CAMPAIGN_NOT_STARTED') {
|
||||
msg = 'زمان شروع این کمپین هنوز فرا نرسیده است.';
|
||||
} else if (msg === 'CAMPAIGN_FINISHED') {
|
||||
msg = 'مهلت شرکت در این کمپین به پایان رسیده است.';
|
||||
}
|
||||
}
|
||||
errorMessage.value = msg;
|
||||
} finally {
|
||||
isRequesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onWheelAnimationFinished() {
|
||||
isWheelSpinning.value = false;
|
||||
targetSegmentIndex.value = null;
|
||||
|
||||
const prize = spinResult.value?.prize;
|
||||
const isNoPrize = !prize || prize.type === 'NO_PRIZE' || prize.title?.trim() === 'پوچ' || prize.title?.includes('پوچ');
|
||||
|
||||
if (!isNoPrize) {
|
||||
playWin();
|
||||
// Confetti burst
|
||||
confetti({
|
||||
particleCount: 80,
|
||||
spread: 70,
|
||||
origin: { y: 0.6 },
|
||||
});
|
||||
} else {
|
||||
// Play famous iconic Sad Trombone losing sound
|
||||
playNoPrize();
|
||||
}
|
||||
|
||||
showResultModal.value = true;
|
||||
}
|
||||
|
||||
function closeResultModal() {
|
||||
showResultModal.value = false;
|
||||
spinResult.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
isRequesting,
|
||||
isWheelSpinning,
|
||||
targetSegmentIndex,
|
||||
spinResult,
|
||||
showResultModal,
|
||||
errorMessage,
|
||||
executeSpin,
|
||||
onWheelAnimationFinished,
|
||||
closeResultModal,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user