mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(settings): allow users to disable achievements and gamification
Introduce user-specific settings to toggle achievements. Avoid evaluation and awarding of achievements on the backend when disabled. Render a toggle in the Account Settings interface on the frontend. Closes #614
This commit is contained in:
@@ -76,6 +76,7 @@ const isBookDockActive = computed(() => route.name === 'book-dock')
|
||||
const isAchievementsActive = computed(() => route.name === 'achievements')
|
||||
const isStatisticsActive = computed(() => route.name === 'statistics')
|
||||
const isAnnotationsActive = computed(() => route.name === 'annotations')
|
||||
const achievementsEnabled = computed(() => user.value?.settings?.achievementPreferences?.enabled !== false)
|
||||
|
||||
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
|
||||
const canChangePassword = computed(
|
||||
@@ -624,7 +625,7 @@ function formatBadgeStyle(fmt: string) {
|
||||
<BarChart3 :size="15" class="mr-2 text-muted-foreground" />
|
||||
Statistics
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="navigateToAchievements">
|
||||
<DropdownMenuItem v-if="achievementsEnabled" @click="navigateToAchievements">
|
||||
<Trophy :size="15" class="mr-2 text-muted-foreground" />
|
||||
Achievements
|
||||
</DropdownMenuItem>
|
||||
@@ -761,7 +762,7 @@ function formatBadgeStyle(fmt: string) {
|
||||
</Tooltip>
|
||||
|
||||
<!-- Achievements button -->
|
||||
<Tooltip>
|
||||
<Tooltip v-if="achievementsEnabled">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
|
||||
loadingMore: null as unknown as Ref<boolean>,
|
||||
settled: null as unknown as Ref<boolean>,
|
||||
hasMore: null as unknown as Ref<boolean>,
|
||||
user: null as unknown as Ref<{ provisioningMethod: string; settings?: { achievementPreferences?: { enabled?: boolean } } }>,
|
||||
loadMore: vi.fn<() => Promise<void>>(),
|
||||
clearGlobalSearch: vi.fn<() => void>(),
|
||||
}))
|
||||
@@ -39,10 +40,9 @@ vi.mock('@/features/book/composables/useGlobalSearch', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/composables/useAuth', async () => {
|
||||
const { ref: vueRef } = await import('vue')
|
||||
return {
|
||||
useAuth: () => ({
|
||||
user: vueRef({ provisioningMethod: 'password' }),
|
||||
user: mocks.user,
|
||||
logout: vi.fn<() => void>(),
|
||||
}),
|
||||
}
|
||||
@@ -195,6 +195,7 @@ describe('AppHeader global search', () => {
|
||||
mocks.loadingMore = ref(false)
|
||||
mocks.settled = ref(true)
|
||||
mocks.hasMore = ref(true)
|
||||
mocks.user = ref({ provisioningMethod: 'password', settings: {} })
|
||||
})
|
||||
|
||||
it('does not leave the dropdown for a separate search route on Enter when no result is selected', async () => {
|
||||
@@ -276,4 +277,12 @@ describe('AppHeader global search', () => {
|
||||
expect(wrapper.text()).not.toContain('Prey 1')
|
||||
expect(wrapper.text()).toContain('Prey 60')
|
||||
})
|
||||
|
||||
it('hides achievement entry points when achievements are disabled', () => {
|
||||
mocks.user = ref({ provisioningMethod: 'password', settings: { achievementPreferences: { enabled: false } } })
|
||||
|
||||
const wrapper = mountHeader()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Achievements')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import type { OidcProviderPublic, UserSettings } from '@bookorbit/types'
|
||||
import { ChevronDown, ChevronUp, Clock, KeyRound, Link, LinkIcon, MapPin, Save, Trash2, Upload } from '@lucide/vue'
|
||||
import { ChevronDown, ChevronUp, Clock, KeyRound, Link, LinkIcon, MapPin, Save, Trash2, Trophy, Upload } from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { api } from '@/lib/api'
|
||||
@@ -36,6 +36,8 @@ const formName = ref('')
|
||||
const formTimezone = ref('UTC')
|
||||
const savingTimezone = ref(false)
|
||||
const timezoneChanged = computed(() => formTimezone.value !== ((user.value?.settings as UserSettings | undefined)?.timezone ?? 'UTC'))
|
||||
const savingAchievements = ref(false)
|
||||
const achievementsEnabled = computed(() => (user.value?.settings as UserSettings | undefined)?.achievementPreferences?.enabled !== false)
|
||||
|
||||
const timezones = (() => {
|
||||
try {
|
||||
@@ -178,6 +180,33 @@ async function saveTimezone() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAchievementsToggle() {
|
||||
if (shouldBlockAccountEdit() || !user.value || savingAchievements.value) return
|
||||
|
||||
const enabled = !achievementsEnabled.value
|
||||
savingAchievements.value = true
|
||||
try {
|
||||
const res = await api('/api/v1/users/me/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: { achievementPreferences: { enabled } } }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => null)) as { message?: string | string[] } | null
|
||||
const message = Array.isArray(payload?.message)
|
||||
? (payload.message[0] ?? 'Failed to update achievement preference')
|
||||
: (payload?.message ?? 'Failed to update achievement preference')
|
||||
toast.error(message)
|
||||
return
|
||||
}
|
||||
|
||||
await me()
|
||||
toast.success(`Achievements ${enabled ? 'enabled' : 'disabled'}`)
|
||||
} finally {
|
||||
savingAchievements.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([formName], () => {
|
||||
profileState.value = 'idle'
|
||||
if (profileError.value) profileError.value = null
|
||||
@@ -430,6 +459,34 @@ function closeUnlinkDialog() {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-border bg-card p-4 md:p-5 shadow-xs">
|
||||
<div class="flex items-center justify-between gap-4 py-1.5">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<Trophy :size="16" class="text-muted-foreground shrink-0" />
|
||||
<p class="text-sm font-semibold text-foreground">Enable achievements</p>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Track achievement progress and show achievement-related interface. Manage achievement notifications separately in Notifications.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="achievementsEnabled"
|
||||
:disabled="savingAchievements || accountEditBlocked"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="achievementsEnabled ? 'bg-primary' : 'bg-muted-foreground/30'"
|
||||
@click="handleAchievementsToggle"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none block h-4 w-4 rounded-full bg-white shadow-xs ring-0 transition-transform"
|
||||
:class="achievementsEnabled ? 'translate-x-4' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-border bg-card p-4 md:p-5 space-y-4 shadow-xs">
|
||||
<button class="md:hidden w-full flex items-center justify-between gap-2 text-left" @click="avatarCardOpen = !avatarCardOpen">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
import { registerAuthGuard } from './auth.guard'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchSetupStatus: vi.fn<() => Promise<boolean>>(),
|
||||
openChangePassword: vi.fn<(required: boolean) => void>(),
|
||||
user: null as unknown as Ref<{
|
||||
isDefaultPassword: boolean
|
||||
provisioningMethod: string
|
||||
settings: { achievementPreferences: { enabled: boolean } }
|
||||
}>,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/composables/useAuth', () => ({
|
||||
useAuth: () => ({ user: mocks.user }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useChangePasswordDialog', () => ({
|
||||
useChangePasswordDialog: () => ({ open: mocks.openChangePassword }),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/composables/useSetupStatus', () => ({
|
||||
useSetupStatus: () => ({ fetchSetupStatus: mocks.fetchSetupStatus }),
|
||||
}))
|
||||
|
||||
describe('registerAuthGuard', () => {
|
||||
type TestRoute = {
|
||||
name: string
|
||||
path: string
|
||||
fullPath: string
|
||||
meta: Record<string, unknown>
|
||||
}
|
||||
let guard: ((to: TestRoute) => Promise<unknown>) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.fetchSetupStatus.mockReset()
|
||||
mocks.fetchSetupStatus.mockResolvedValue(false)
|
||||
mocks.openChangePassword.mockReset()
|
||||
mocks.user = ref({
|
||||
isDefaultPassword: false,
|
||||
provisioningMethod: 'local',
|
||||
settings: { achievementPreferences: { enabled: false } },
|
||||
})
|
||||
registerAuthGuard({ beforeEach: vi.fn((callback: (to: TestRoute) => Promise<unknown>) => (guard = callback)) } as never)
|
||||
})
|
||||
|
||||
it('redirects disabled achievement routes to Account profile settings', async () => {
|
||||
if (!guard) throw new Error('Expected guard to be registered')
|
||||
|
||||
await expect(guard({ name: 'achievements', path: '/achievements', fullPath: '/achievements', meta: {} })).resolves.toEqual({
|
||||
name: 'settings-account',
|
||||
query: { tab: 'profile' },
|
||||
})
|
||||
})
|
||||
|
||||
it('allows achievement routes when achievements are enabled', async () => {
|
||||
mocks.user.value.settings.achievementPreferences.enabled = true
|
||||
if (!guard) throw new Error('Expected guard to be registered')
|
||||
|
||||
await expect(guard({ name: 'achievements', path: '/achievements', fullPath: '/achievements', meta: {} })).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -35,6 +35,10 @@ export function registerAuthGuard(router: Router): void {
|
||||
if (to.path !== '/') return { path: '/' }
|
||||
}
|
||||
|
||||
if (to.name === 'achievements' && user.value.settings.achievementPreferences?.enabled === false) {
|
||||
return { name: 'settings-account', query: { tab: 'profile' } }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user