Files
MyIP/frontend/components/User.vue
T
Claude 96525d2fc7 Chore: trim the branch — leftovers, redundancy, comments
A pass over every file the branch touched, after the iOS animation hunt
left tries and reverts behind it. Removed what nothing reaches: the
view-transition rule for a transition no code starts, an unreachable
guard in the transition hook, a no-op KeepAlive key branch, a sign-in
guard the menu already enforces, usage-dialog wrappers that only
forwarded to the store, a duplicated modifier-click check and card
filter (isPlainClick / listedTools), a test that hand-rolled
listedTools, a drawer-era fixture parameter. auth-flow.js folded into
auth-redirect.js. Comments cut to what the code cannot say itself —
file headers of a few lines, the measured iOS reasons, non-obvious
orderings — and stale ones fixed (Nav under KeepAlive, Home.vue
reusing DEFAULT_META, "from Nav", enabledCards). AGENTS.md sections
tightened to the code as it is now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
2026-10-05 16:50:16 +00:00

281 lines
13 KiB
Vue

<template>
<!-- User account dialog: Benefits + Usage merged behind Tabs. Signed-in
visitors land on the Usage tab (their own numbers are what they came
for); signed-out visitors land on Benefits. -->
<Dialog :open="isOpen" @update:open="isOpen = $event">
<DialogContent :title="t('user.Benefits.Title')" class="max-w-2xl">
<DialogHeader :icon="HeartHandshake" :title="t('user.Benefits.Title')" />
<Tabs v-model="activeTab">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="benefits" class="cursor-pointer">{{ t('user.Benefits.Benefit') }}</TabsTrigger>
<TabsTrigger value="usage" class="cursor-pointer">{{ t('user.Usage.Title') }}</TabsTrigger>
</TabsList>
<!-- Benefits tab: one card per tier, stacked single-column on
every viewport. Each tier's first item is "everything in
the previous tier" so the lists stay short; the sponsor
CTA is a button under its card, not a feature row. -->
<TabsContent value="benefits" class="space-y-3 pt-3">
<p class="text-sm text-muted-foreground leading-relaxed">
{{ t('user.Benefits.Note1') }} {{ t('user.Benefits.Note2') }}
</p>
<section v-for="tier in benefitTiers" :key="tier.key"
class="flex flex-col rounded-lg border bg-card p-3"
:class="tier.current && 'border-success/60'">
<header class="flex items-center gap-1.5 mb-2">
<component :is="tier.icon" class="size-4 text-muted-foreground shrink-0" />
<h4 class="text-sm font-semibold m-0 truncate">
{{ t(`user.Benefits.Tiers.${tier.key}.Name`) }}
</h4>
<Badge v-if="tier.current" variant="success"
class="ml-auto text-[10px] px-1.5 py-0 h-4 shrink-0">
{{ t('user.Benefits.Current') }}
</Badge>
</header>
<ul class="space-y-1.5 list-none p-0 m-0">
<li v-for="n in tier.count" :key="n"
class="flex items-start gap-1.5 text-xs leading-relaxed">
<!-- Item1 of the higher tiers = "everything in the
previous tier" — rendered muted with a plus so
real additions stand out. -->
<component :is="tier.key !== 'Visitor' && n === 1 ? Plus : Check"
class="size-3.5 shrink-0 mt-0.5"
:class="tier.key !== 'Visitor' && n === 1 ? 'text-muted-foreground' : 'text-success'" />
<span :class="tier.key !== 'Visitor' && n === 1 ? 'text-muted-foreground' : ''">
{{ t(`user.Benefits.Tiers.${tier.key}.Item${n}`) }}
</span>
</li>
</ul>
<Button v-if="tier.key === 'Sponsor' && !isSponsor" as-child size="sm" variant="action"
class="mt-3 w-full">
<a href="https://github.com/sponsors/jason5ng32" target="_blank" rel="noopener">
<Heart class="size-3.5" />
{{ t('user.QuotaSponsorCta') }}
</a>
</Button>
</section>
</TabsContent>
<!-- Usage tab: share of the monthly quota used per advanced
feature — percentages only, the backend never sends raw
counts. Refetched on dialog open so the numbers are fresh. -->
<TabsContent value="usage" class="space-y-4 pt-3">
<div v-if="!isSignedIn"
class="flex items-start gap-2 p-3 rounded-md border border-info/30 bg-info/10 text-sm text-info">
<Info class="size-4 mt-0.5 shrink-0" />
<span>{{ t('user.SignInToUse') }}</span>
</div>
<template v-else-if="usageRows.length">
<p class="text-sm text-muted-foreground leading-relaxed">{{ t('user.Usage.Note') }}</p>
<ul class="rounded-lg border bg-card divide-y list-none">
<li v-for="row in usageRows" :key="row.key" class="p-3 space-y-1.5">
<div class="flex items-baseline justify-between gap-2 text-sm">
<span>{{ t(row.labelKey) }}</span>
<span class="font-mono tabular-nums text-muted-foreground">
{{ t('user.Usage.PercentUsed', { percent: row.percent }) }}
</span>
</div>
<Progress :model-value="row.percent" class="h-1.5"
:indicator-class="dotClass(row.tone)" />
</li>
</ul>
<p class="text-xs text-muted-foreground leading-relaxed">
{{ t('user.Usage.SponsorNote') }}
<a href="https://github.com/sponsors/jason5ng32" target="_blank" rel="noopener"
class="underline underline-offset-2 hover:text-foreground">{{ t('user.QuotaSponsorCta')
}}</a>
</p>
</template>
<div v-else class="text-sm text-muted-foreground flex items-center justify-center gap-2 py-10">
<Spinner /> {{ t('user.Fields.Fetching') }}
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events.js';
import { authenticatedFetch } from '@/utils/authenticated-fetch';
import { useStatusTone } from '@/composables/use-status-tone.js';
import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Spinner } from '@/components/ui/spinner';
import { Check, Heart, HeartHandshake, Info, Plus, UserRound, UserRoundCheck } from '@lucide/vue';
const { t } = useI18n();
const store = useMainStore();
const route = useRoute();
const { dotClass } = useStatusTone();
// `matched` is empty until the first navigation resolves the route.
const isOffHome = computed(() => route.matched.length > 0 && route.name !== 'home');
const isSignedIn = computed(() => store.isSignedIn);
const remoteUserInfo = computed(() => store.remoteUserInfo);
const remoteUserInfoFetched = computed(() => store.remoteUserInfoFetched);
const triggerUserBenefits = computed(() => store.triggerUserBenefits);
const triggerRemoteUserInfo = computed(() => store.triggerRemoteUserInfo);
const triggerUpdateAchievements = computed(() => store.triggerUpdateAchievements);
const achievementToUpdate = computed(() => store.achievementToUpdate);
const isUpdateAchievementsSuccess = ref(false);
const isOpen = ref(false);
const activeTab = ref('benefits');
// Three user tiers shown on the Benefits tab. `count` = number of ItemN keys
// in the locale pack; `current` badges the visitor's own tier (sponsor status
// comes from the backend's quota snapshot).
const isSponsor = computed(() => remoteUserInfo.value?.quota?.isSponsor === true);
const benefitTiers = computed(() => [
{ key: 'Visitor', icon: UserRound, count: 6, current: !isSignedIn.value },
{ key: 'SignedIn', icon: UserRoundCheck, count: 7, current: isSignedIn.value && !isSponsor.value },
{ key: 'Sponsor', icon: HeartHandshake, count: 3, current: isSignedIn.value && isSponsor.value },
]);
const openUserBenefits = () => {
// Signed-in visitors open straight onto their usage numbers.
activeTab.value = isSignedIn.value ? 'usage' : 'benefits';
isOpen.value = true;
store.triggerUserBenefits = false;
if (isSignedIn.value) refreshUserInfo();
trackEvent('Nav', 'NavClick', 'UserBenefits');
};
// Feature key → display label. Tool features reuse their page titles; the
// ipinfo advanced-data feature has no tool page, so it gets its own key.
const USAGE_LABEL_KEYS = {
ipinfo: 'user.Usage.IpinfoFeature',
invisibility_test: 'invisibilitytest.Title',
dns_leak_test: 'enhanceddnsleaktest.Title',
persona_check: 'personacheck.Title',
ip_hitlist: 'ipblocklist.Title',
};
// Rows for the usage dialog. Empty until the (re)fetched user info lands or
// when the backend predates the quota field — the template then shows the
// fetching placeholder instead of a broken table.
const usageRows = computed(() => {
const features = remoteUserInfo.value?.quota?.features;
if (!features) return [];
return Object.entries(features).map(([key, { percent, exhausted }]) => {
const tone = exhausted ? 'fail' : percent >= 80 ? 'ok-slow' : 'ok-fast';
return { key, labelKey: USAGE_LABEL_KEYS[key] || key, percent, tone };
});
});
// Refetch on every open (bypassing the one-shot page-load guard) so the
// numbers reflect what the visitor just consumed in this session.
const refreshUserInfo = async () => {
if (!isSignedIn.value) return;
try {
store.remoteUserInfo = await authenticatedFetch(`/api/getuserinfo`);
} catch (error) {
console.error('Error refreshing user info:', error);
}
};
// Fetch user info. `remoteUserInfoFetched` flips only on success, so a failed
// round stays refetchable from Nav / Achievements on their next open.
const getUserInfo = async () => {
if (remoteUserInfoFetched.value || !isSignedIn.value) return;
try {
store.remoteUserInfo = await authenticatedFetch(`/api/getuserinfo`);
store.remoteUserInfoFetched = true;
initUserAchievements();
} catch (error) {
console.error('Error fetching user info:', error);
}
};
// Initialize user achievements
const initUserAchievements = () => {
if (!remoteUserInfo.value) return;
// A brand-new account may come back with no achievements payload yet.
const { achievements, functionUses } = remoteUserInfo.value;
Object.entries(achievements ?? {}).forEach(([key, value]) => {
if (store.userAchievements[key]) {
store.userAchievements[key].achieved = value.achieved;
store.userAchievements[key].achievedTime = value.achievedTime;
}
});
// Snapshot applied — release the achievement engine's evaluation gate
// before emitting anything, so the event below already sees synced state.
store.userAchievementsSynced = true;
// Unlock rules (IAmHuman / MakingBigNews) live in data/achievement-rules.js.
emitAppEvent('user:info-loaded', { totalFunctionUses: functionUses?.total ?? 0 });
};
// Update local achievement status
const updateLocalAchievementStatus = (achievementName) => {
store.userAchievements[achievementName].achieved = true;
store.userAchievements[achievementName].achievedTime = Date.now();
};
// Update user achievement
const updateUserAchievement = async (achievementName) => {
isUpdateAchievementsSuccess.value = false;
updateLocalAchievementStatus(achievementName);
const message = t('user.Achievements.CongratsMessage') + t('user.Achievements.NewAchievementIs') + t('user.Achievements.Type.' + achievementName + '.Title');
store.setAlert(true, 'text-success', message, t('user.Achievements.Congrats'), 5000);
try {
await authenticatedFetch(`/api/updateuserachievement`, 'PUT', { achievement: achievementName });
isUpdateAchievementsSuccess.value = true;
} catch (error) {
console.error('Error updating user achievement', error);
}
};
// User info loads once signed in and, on Home, after its tests (allHasLoaded);
// other pages never run them. getUserInfo self-guards against double fetches.
watch(() => isSignedIn.value && (store.allHasLoaded || isOffHome.value), (ready) => {
if (ready) getUserInfo();
}, { immediate: true });
watch(() => triggerUserBenefits.value, (newVal) => {
if (newVal) openUserBenefits();
});
// A page change closes the dialog.
watch(() => route.path, () => {
isOpen.value = false;
});
// One-shot trigger: cleared here so the next request from UserMenu / Achievements
// is a fresh false → true edge rather than a no-op write.
watch(() => triggerRemoteUserInfo.value, (newVal) => {
if (!newVal) return;
store.triggerRemoteUserInfo = false;
getUserInfo();
});
watch(() => triggerUpdateAchievements.value, (newVal) => {
if (newVal) {
updateUserAchievement(achievementToUpdate.value);
store.triggerUpdateAchievements = false;
}
});
</script>