Benard John
feat: implement full-stack architecture with database models, authentication services, and web dashboard components
37b5223 | <template> | |
| <n-modal | |
| v-model:show="authStore.showAuthModal" | |
| :mask-closable="authStore.authModalStep !== 'otp'" | |
| :close-on-esc="authStore.authModalStep !== 'otp'" | |
| preset="card" | |
| class="auth-modal" | |
| :bordered="false" | |
| role="dialog" | |
| aria-labelledby="auth-title" | |
| > | |
| <template #header> | |
| <div class="modal-header"> | |
| <div class="header-mark" aria-hidden="true"> | |
| <span class="mark-black" /> | |
| <span class="mark-fimbr" /> | |
| <span class="mark-red" /> | |
| <span class="mark-fimbr" /> | |
| <span class="mark-green" /> | |
| </div> | |
| <h2 id="auth-title" class="modal-title"> | |
| {{ stepTitle }} | |
| </h2> | |
| <p class="modal-subtitle">{{ stepSubtitle }}</p> | |
| </div> | |
| </template> | |
| <div class="step-tracker" :data-step="authStore.authModalStep"> | |
| <div class="step" :class="{ active: authStore.authModalStep === 'email' }"> | |
| <span class="dot" /> | |
| <span class="label">Email</span> | |
| </div> | |
| <div class="connector" /> | |
| <div class="step" :class="{ active: authStore.authModalStep === 'otp' }"> | |
| <span class="dot" /> | |
| <span class="label">Code</span> | |
| </div> | |
| <div class="connector" /> | |
| <div class="step" :class="{ active: authStore.authModalStep === 'success' }"> | |
| <span class="dot" /> | |
| <span class="label">Done</span> | |
| </div> | |
| </div> | |
| <!-- STEP 1: Email --> | |
| <div v-if="authStore.authModalStep === 'email'" class="step-pane"> | |
| <p class="pitch"> | |
| <span class="pitch-num">25×</span> more daily queries | |
| <span class="pitch-sep">·</span> | |
| History & bookmarks | |
| <span class="pitch-sep">·</span> | |
| No password needed | |
| </p> | |
| <n-form-item label="Email address" :show-feedback="false" class="email-field"> | |
| <n-input | |
| v-model:value="email" | |
| placeholder="you@example.com" | |
| size="large" | |
| :disabled="authStore.isLoading" | |
| @keyup.enter="sendOTP" | |
| /> | |
| </n-form-item> | |
| <div class="lang-toggle" role="group" aria-label="OTP language"> | |
| <button | |
| type="button" | |
| :class="['lang-btn', { active: lang === 'en' }]" | |
| @click="lang = 'en'" | |
| > | |
| EN | |
| </button> | |
| <button | |
| type="button" | |
| :class="['lang-btn', { active: lang === 'sw' }]" | |
| @click="lang = 'sw'" | |
| > | |
| SW | |
| </button> | |
| </div> | |
| <n-alert | |
| v-if="authStore.lastError" | |
| type="error" | |
| :show-icon="false" | |
| class="alert" | |
| > | |
| {{ authStore.lastError }} | |
| </n-alert> | |
| <n-button | |
| type="primary" | |
| size="large" | |
| block | |
| :loading="authStore.isLoading" | |
| :disabled="!email.includes('@')" | |
| @click="sendOTP" | |
| > | |
| Send verification code | |
| <template #icon> | |
| <n-icon><mail-outline /></n-icon> | |
| </template> | |
| </n-button> | |
| <p class="footnote"> | |
| Article 35 of the Constitution of Kenya guarantees your right to access | |
| public information. We use your email <em>only</em> for sign-in. | |
| </p> | |
| </div> | |
| <!-- STEP 2: OTP --> | |
| <div v-else-if="authStore.authModalStep === 'otp'" class="step-pane"> | |
| <p class="pitch"> | |
| We sent a 6-digit code to <strong>{{ maskedEmail }}</strong> | |
| </p> | |
| <n-input | |
| v-model:value="otp" | |
| placeholder="123 456" | |
| size="large" | |
| maxlength="6" | |
| class="otp-field" | |
| :input-props="{ inputmode: 'numeric', pattern: '[0-9]*', autocomplete: 'one-time-code' }" | |
| @keyup.enter="verify" | |
| /> | |
| <n-alert | |
| v-if="authStore.lastError" | |
| type="error" | |
| :show-icon="false" | |
| class="alert" | |
| > | |
| {{ authStore.lastError }} | |
| </n-alert> | |
| <n-button | |
| type="primary" | |
| size="large" | |
| block | |
| :loading="authStore.isLoading" | |
| :disabled="otp.length < 4" | |
| @click="verify" | |
| > | |
| Verify & sign in | |
| </n-button> | |
| <div class="otp-secondary"> | |
| <n-button text size="small" @click="authStore.authModalStep = 'email'"> | |
| ← Change email | |
| </n-button> | |
| <n-button text size="small" :disabled="resendCooldown > 0" @click="sendOTP"> | |
| {{ resendCooldown > 0 ? `Resend in ${resendCooldown}s` : 'Resend code' }} | |
| </n-button> | |
| </div> | |
| </div> | |
| <!-- STEP 3: Success --> | |
| <div v-else-if="authStore.authModalStep === 'success'" class="step-pane success-pane"> | |
| <div class="success-orb"> | |
| <n-icon size="56" color="white"><checkmark-circle-outline /></n-icon> | |
| </div> | |
| <h3 class="success-title">You're in.</h3> | |
| <p class="success-sub"> | |
| You now have <strong>500 queries/day</strong>, full history, and | |
| the ability to generate API keys. | |
| </p> | |
| <n-button | |
| type="primary" | |
| size="large" | |
| block | |
| @click="authStore.closeAuthModal()" | |
| > | |
| Start exploring | |
| </n-button> | |
| </div> | |
| </n-modal> | |
| </template> | |
| <script setup lang="ts"> | |
| import { ref, computed, onUnmounted } from 'vue' | |
| import { useAuthStore } from '@/stores/auth' | |
| import { MailOutline, CheckmarkCircleOutline } from '@vicons/ionicons5' | |
| const authStore = useAuthStore() | |
| const email = ref(authStore.pendingEmail || '') | |
| const otp = ref('') | |
| const lang = ref<'en' | 'sw'>('en') | |
| const resendCooldown = ref(0) | |
| let timer: number | null = null | |
| function startCooldown() { | |
| resendCooldown.value = 30 | |
| if (timer) clearInterval(timer) | |
| timer = window.setInterval(() => { | |
| resendCooldown.value = Math.max(0, resendCooldown.value - 1) | |
| if (resendCooldown.value === 0 && timer) { | |
| clearInterval(timer) | |
| timer = null | |
| } | |
| }, 1000) | |
| } | |
| onUnmounted(() => { | |
| if (timer) clearInterval(timer) | |
| }) | |
| const stepTitle = computed(() => { | |
| switch (authStore.authModalStep) { | |
| case 'email': return 'Unlock more access' | |
| case 'otp': return 'Check your inbox' | |
| case 'success': return 'Welcome to Ukweli' | |
| } | |
| return '' | |
| }) | |
| const stepSubtitle = computed(() => { | |
| switch (authStore.authModalStep) { | |
| case 'email': | |
| return 'One email. Six digits. Twenty-five times more queries.' | |
| case 'otp': | |
| return 'Enter the verification code we just sent you.' | |
| case 'success': | |
| return 'You are now a registered citizen of Ukweli.' | |
| } | |
| return '' | |
| }) | |
| const maskedEmail = computed(() => { | |
| const [user, domain] = email.value.split('@') | |
| if (!user || !domain) return email.value | |
| if (user.length <= 2) return `${user[0]}***@${domain}` | |
| return `${user[0]}${user[1]}***@${domain}` | |
| }) | |
| async function sendOTP() { | |
| try { | |
| await authStore.register(email.value, lang.value) | |
| startCooldown() | |
| } catch { | |
| // error already set on the store | |
| } | |
| } | |
| async function verify() { | |
| try { | |
| await authStore.verifyOTP(email.value, otp.value) | |
| } catch { | |
| // error already set on the store | |
| } | |
| } | |
| </script> | |
| <style scoped lang="scss"> | |
| .auth-modal { | |
| max-width: 460px; | |
| :deep(.n-card) { | |
| background: var(--bg-card); | |
| border-radius: var(--radius-xl); | |
| overflow: hidden; | |
| } | |
| :deep(.n-card__content) { | |
| padding: 8px 28px 28px; | |
| } | |
| } | |
| .modal-header { | |
| text-align: left; | |
| } | |
| .header-mark { | |
| display: flex; | |
| width: 56px; | |
| height: 8px; | |
| border-radius: 4px; | |
| overflow: hidden; | |
| margin-bottom: 14px; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.15); | |
| > span { | |
| flex: 1; | |
| } | |
| .mark-black { background: var(--ke-black); } | |
| .mark-fimbr { background: var(--ke-white); flex: 0 0 4px; } | |
| .mark-red { background: var(--ke-red); } | |
| .mark-green { background: var(--ke-green); } | |
| } | |
| .modal-title { | |
| font-family: var(--font-display); | |
| font-size: 24px; | |
| line-height: 1.2; | |
| margin: 0 0 6px; | |
| color: var(--text-primary); | |
| } | |
| .modal-subtitle { | |
| font-size: 13px; | |
| color: var(--text-secondary); | |
| margin: 0; | |
| } | |
| // -------- Step tracker -------- | |
| .step-tracker { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin: 4px 0 20px; | |
| padding: 12px 0; | |
| border-top: 1px solid var(--border); | |
| border-bottom: 1px solid var(--border); | |
| .step { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| color: var(--text-muted); | |
| transition: color 0.3s var(--ease-out); | |
| .dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: var(--border-strong); | |
| transition: all 0.3s var(--ease-spring); | |
| } | |
| &.active { | |
| color: var(--primary); | |
| .dot { | |
| background: var(--primary); | |
| transform: scale(1.3); | |
| box-shadow: 0 0 0 4px var(--primary-soft); | |
| } | |
| } | |
| } | |
| .connector { | |
| flex: 1; | |
| height: 1px; | |
| background: var(--border); | |
| } | |
| } | |
| // -------- Steps -------- | |
| .step-pane { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 14px; | |
| animation: slideUp 0.4s var(--ease-out); | |
| } | |
| .pitch { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: baseline; | |
| gap: 6px; | |
| font-size: 13px; | |
| color: var(--text-secondary); | |
| margin: 0; | |
| .pitch-num { | |
| color: var(--accent); | |
| font-weight: 800; | |
| font-size: 16px; | |
| font-family: var(--font-display); | |
| } | |
| .pitch-sep { | |
| color: var(--text-muted); | |
| } | |
| } | |
| .email-field, .otp-field { | |
| margin: 0; | |
| } | |
| .otp-field { | |
| :deep(input) { | |
| font-family: var(--font-mono); | |
| font-size: 24px; | |
| letter-spacing: 0.5em; | |
| text-align: center; | |
| font-weight: 600; | |
| } | |
| } | |
| .lang-toggle { | |
| display: inline-flex; | |
| align-self: flex-start; | |
| background: var(--bg-elevated); | |
| border-radius: 999px; | |
| padding: 3px; | |
| border: 1px solid var(--border); | |
| .lang-btn { | |
| background: transparent; | |
| border: none; | |
| padding: 4px 12px; | |
| border-radius: 999px; | |
| font-size: 11px; | |
| font-weight: 700; | |
| color: var(--text-muted); | |
| cursor: pointer; | |
| transition: all 0.2s var(--ease-out); | |
| letter-spacing: 0.06em; | |
| &.active { | |
| background: var(--bg-card); | |
| color: var(--text-primary); | |
| box-shadow: var(--shadow-sm); | |
| } | |
| &:hover:not(.active) { color: var(--text-secondary); } | |
| } | |
| } | |
| .alert { | |
| border-radius: var(--radius-md); | |
| } | |
| .footnote { | |
| font-size: 11px; | |
| color: var(--text-muted); | |
| line-height: 1.5; | |
| margin-top: 4px; | |
| em { color: var(--text-secondary); font-style: normal; font-weight: 600; } | |
| } | |
| .otp-secondary { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-top: 4px; | |
| } | |
| // -------- Success -------- | |
| .success-pane { | |
| align-items: center; | |
| text-align: center; | |
| padding: 8px 0 4px; | |
| } | |
| .success-orb { | |
| width: 96px; | |
| height: 96px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin-bottom: 16px; | |
| box-shadow: 0 12px 32px rgba(0, 102, 0, 0.30); | |
| animation: pulseGlow 2.4s var(--ease-out) infinite; | |
| } | |
| .success-title { | |
| font-family: var(--font-display); | |
| font-size: 28px; | |
| margin-bottom: 8px; | |
| } | |
| .success-sub { | |
| font-size: 14px; | |
| color: var(--text-secondary); | |
| margin-bottom: 20px; | |
| line-height: 1.6; | |
| strong { color: var(--accent); font-family: var(--font-display); } | |
| } | |
| </style> | |