Ukweli / web /src /components /AuthModal.vue
Benard John
feat: implement full-stack architecture with database models, authentication services, and web dashboard components
37b5223
Raw
History Blame Contribute Delete
11.2 kB
<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 &amp; 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 &amp; 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>