feat(auth): add user referral system with invite codes
Generate unique referral codes for users Allow new users to register with a referral code Display referral stats and invite link in user profile
This commit is contained in:
@@ -10,6 +10,7 @@ export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
|
||||
export const iconChevronDown: AppIcon = 'mdi:chevron-down';
|
||||
export const iconClose: AppIcon = 'mdi:close';
|
||||
export const iconComment: AppIcon = 'mdi:comment-outline';
|
||||
export const iconCopy: AppIcon = 'mdi:content-copy';
|
||||
export const iconDelete: AppIcon = 'mdi:trash-can-outline';
|
||||
export const iconDish: AppIcon = 'mdi:silverware-fork-knife';
|
||||
export const iconDragHandle: AppIcon = 'mdi:drag';
|
||||
@@ -32,6 +33,7 @@ export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
|
||||
export const iconPokemon: AppIcon = 'mdi:pokeball';
|
||||
export const iconProfile: AppIcon = 'mdi:account-circle-outline';
|
||||
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
|
||||
export const iconReferral: AppIcon = 'mdi:account-multiple-plus-outline';
|
||||
export const iconRegister: AppIcon = 'mdi:account-plus-outline';
|
||||
export const iconReply: AppIcon = 'mdi:reply-outline';
|
||||
export const iconReactionFun: AppIcon = 'mdi:party-popper';
|
||||
|
||||
@@ -316,6 +316,12 @@ export interface AuthUser {
|
||||
emailVerified: boolean;
|
||||
}
|
||||
|
||||
export interface ReferralSummary {
|
||||
code: string;
|
||||
url: string;
|
||||
verifiedReferralCount: number;
|
||||
}
|
||||
|
||||
export interface UserProfilePayload {
|
||||
displayName: string;
|
||||
}
|
||||
@@ -328,6 +334,7 @@ export interface LoginPayload {
|
||||
|
||||
export interface RegisterPayload extends LoginPayload {
|
||||
displayName: string;
|
||||
referralCode?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
@@ -637,6 +644,7 @@ export const api = {
|
||||
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
|
||||
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
||||
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
|
||||
referral: () => getJson<{ referral: ReferralSummary }>('/api/auth/referral'),
|
||||
logout: () => postEmpty('/api/auth/logout'),
|
||||
options: () => getJson<Options>('/api/options'),
|
||||
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
||||
|
||||
@@ -4220,6 +4220,12 @@ button:disabled,
|
||||
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
|
||||
}
|
||||
|
||||
.auth-field-note {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.profile-page {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -4247,6 +4253,10 @@ button:disabled,
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.profile-card--referral {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.profile-identity {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
@@ -4317,6 +4327,55 @@ button:disabled,
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.profile-referral {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.profile-referral__metric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
min-height: 58px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.profile-referral__metric span {
|
||||
color: var(--muted);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.profile-referral__metric strong {
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-family: var(--font-display);
|
||||
font-size: 34px;
|
||||
font-weight: 950;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.profile-code-input {
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.profile-referral-link-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-referral-link-row .ui-button {
|
||||
min-height: 44px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
||||
@@ -4647,6 +4706,10 @@ button:disabled,
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-card--referral {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.system-wording-sidebar {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
@@ -4761,6 +4824,14 @@ button:disabled,
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-referral-link-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-referral-link-row .ui-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.life-toolbar__actions,
|
||||
.life-toolbar .ui-button {
|
||||
width: 100%;
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import { iconMail } from '../icons';
|
||||
import { api } from '../services/api';
|
||||
|
||||
const route = useRoute();
|
||||
const email = ref('');
|
||||
const displayName = ref('');
|
||||
const password = ref('');
|
||||
const referralCode = ref(typeof route.query.ref === 'string' ? route.query.ref.trim().toUpperCase() : '');
|
||||
const busy = ref(false);
|
||||
const message = ref('');
|
||||
const errorMessage = ref('');
|
||||
@@ -24,7 +27,8 @@ async function submitRegister() {
|
||||
const response = await api.register({
|
||||
email: email.value,
|
||||
displayName: displayName.value,
|
||||
password: password.value
|
||||
password: password.value,
|
||||
referralCode: referralCode.value
|
||||
});
|
||||
message.value = response.message;
|
||||
} catch (error) {
|
||||
@@ -65,6 +69,19 @@ async function submitRegister() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="register-referral-code">{{ t('auth.referralCode') }}</label>
|
||||
<input
|
||||
id="register-referral-code"
|
||||
v-model="referralCode"
|
||||
autocomplete="off"
|
||||
inputmode="text"
|
||||
maxlength="16"
|
||||
:placeholder="t('auth.referralCodePlaceholder')"
|
||||
/>
|
||||
<small class="auth-field-note">{{ t('auth.referralCodeHint') }}</small>
|
||||
</div>
|
||||
|
||||
<StatusMessage v-if="message" variant="success">{{ message }}</StatusMessage>
|
||||
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
||||
|
||||
|
||||
@@ -6,16 +6,19 @@ import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusBadge from '../components/StatusBadge.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import { iconProfile, iconSave } from '../icons';
|
||||
import { api, notifyAuthChange, type AuthUser } from '../services/api';
|
||||
import { iconCopy, iconProfile, iconReferral, iconSave } from '../icons';
|
||||
import { api, notifyAuthChange, type AuthUser, type ReferralSummary } from '../services/api';
|
||||
|
||||
const { t } = useI18n();
|
||||
const user = ref<AuthUser | null>(null);
|
||||
const referral = ref<ReferralSummary | null>(null);
|
||||
const displayName = ref('');
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const message = ref('');
|
||||
const errorMessage = ref('');
|
||||
const referralMessage = ref('');
|
||||
const referralErrorMessage = ref('');
|
||||
|
||||
const trimmedDisplayName = computed(() => displayName.value.trim());
|
||||
const hasChanges = computed(() => {
|
||||
@@ -32,11 +35,21 @@ async function loadProfile() {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
errorMessage.value = '';
|
||||
referralMessage.value = '';
|
||||
referralErrorMessage.value = '';
|
||||
referral.value = null;
|
||||
|
||||
try {
|
||||
const response = await api.me();
|
||||
user.value = response.user;
|
||||
displayName.value = response.user.displayName;
|
||||
|
||||
try {
|
||||
const referralResponse = await api.referral();
|
||||
referral.value = referralResponse.referral;
|
||||
} catch {
|
||||
referralErrorMessage.value = t('pages.profile.referralLoadFailed');
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||
} finally {
|
||||
@@ -67,6 +80,40 @@ async function saveProfile() {
|
||||
}
|
||||
}
|
||||
|
||||
function writeClipboard(value: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
return navigator.clipboard.writeText(value);
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = value;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.append(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand('copy');
|
||||
textarea.remove();
|
||||
|
||||
return copied ? Promise.resolve() : Promise.reject(new Error('Clipboard unavailable'));
|
||||
}
|
||||
|
||||
async function copyReferralLink() {
|
||||
if (!referral.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
referralMessage.value = '';
|
||||
referralErrorMessage.value = '';
|
||||
|
||||
try {
|
||||
await writeClipboard(referral.value.url);
|
||||
referralMessage.value = t('pages.profile.referralCopied');
|
||||
} catch {
|
||||
referralErrorMessage.value = t('pages.profile.referralCopyFailed');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadProfile();
|
||||
});
|
||||
@@ -102,6 +149,14 @@ onMounted(() => {
|
||||
<Skeleton variant="box" width="120px" height="42px" />
|
||||
</div>
|
||||
</section>
|
||||
<section class="profile-card profile-card--referral" aria-hidden="true">
|
||||
<Skeleton width="180px" height="28px" />
|
||||
<div class="auth-form">
|
||||
<Skeleton variant="box" height="58px" />
|
||||
<Skeleton variant="box" height="44px" />
|
||||
<Skeleton variant="box" width="120px" height="42px" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else-if="user" class="profile-layout">
|
||||
@@ -153,6 +208,42 @@ onMounted(() => {
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="profile-card profile-card--referral" :aria-label="t('pages.profile.referralTitle')">
|
||||
<div class="profile-card__header">
|
||||
<Icon :icon="iconReferral" class="profile-card__icon" aria-hidden="true" />
|
||||
<h2>{{ t('pages.profile.referralTitle') }}</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="referral" class="profile-referral">
|
||||
<div class="profile-referral__metric">
|
||||
<span>{{ t('pages.profile.verifiedReferralCount') }}</span>
|
||||
<strong>{{ referral.verifiedReferralCount }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="profile-referral-code">{{ t('pages.profile.referralCode') }}</label>
|
||||
<input id="profile-referral-code" class="profile-readonly-input profile-code-input" :value="referral.code" readonly />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="profile-referral-url">{{ t('pages.profile.referralUrl') }}</label>
|
||||
<div class="profile-referral-link-row">
|
||||
<input id="profile-referral-url" class="profile-readonly-input" :value="referral.url" readonly />
|
||||
<button class="ui-button ui-button--blue" type="button" @click="copyReferralLink">
|
||||
<Icon :icon="iconCopy" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.profile.copyReferralLink') }}
|
||||
</button>
|
||||
</div>
|
||||
<small class="profile-field-note">{{ t('pages.profile.referralHint') }}</small>
|
||||
</div>
|
||||
|
||||
<StatusMessage v-if="referralMessage" variant="success">{{ referralMessage }}</StatusMessage>
|
||||
<StatusMessage v-if="referralErrorMessage" variant="danger">{{ referralErrorMessage }}</StatusMessage>
|
||||
</div>
|
||||
|
||||
<StatusMessage v-else-if="referralErrorMessage" variant="danger" :duration="0">{{ referralErrorMessage }}</StatusMessage>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<StatusMessage v-else-if="errorMessage" variant="danger" :duration="0">{{ errorMessage }}</StatusMessage>
|
||||
|
||||
Reference in New Issue
Block a user