Merge pull request #3 from HiamQuan/fix/template_login

fix: FE base login
This commit is contained in:
Nguyễn Anh Quân 2025-10-22 14:41:35 +07:00 committed by GitHub
commit dcb36ab411
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1679 additions and 101 deletions

View File

@ -31,3 +31,7 @@ h2 {
margin-bottom: 1rem; margin-bottom: 1rem;
text-align: center; text-align: center;
} }
.highlight {
color: var(--primary-color);
}

View File

@ -17,6 +17,7 @@
--text-muted: #8b95a5; --text-muted: #8b95a5;
--text-light: #ffffff; --text-light: #ffffff;
// Background Colors // Background Colors
--bg-gradient-start: #F0F8FF; --bg-gradient-start: #F0F8FF;
--bg-gradient-end: #E6F2FF; --bg-gradient-end: #E6F2FF;

View File

@ -0,0 +1,436 @@
<script setup lang="ts">
import { ref, defineEmits, onMounted } from 'vue'
import { ButtonCommon } from '@/components'
import { useSeedStore } from '@/stores'
const emit = defineEmits<{
next: []
back: []
}>()
const seedStore = useSeedStore()
const seedWords = ref<string[]>([])
const currentQuestionIndex = ref(0)
const selectedAnswer = ref('')
const isCorrect = ref(false)
const showResult = ref(false)
const generateQuiz = (): {
position: number
correctWord: string
options: string[]
} | null => {
if (seedWords.value.length === 0) return null
const randomPosition = Math.floor(Math.random() * 12) + 1
currentQuestionIndex.value = randomPosition - 1
const correctWord = seedWords.value[randomPosition - 1]
const options = [correctWord]
const BIP39_WORDS = [
'abandon',
'ability',
'able',
'about',
'above',
'absent',
'absorb',
'abstract',
'absurd',
'abuse',
'access',
'accident',
'account',
'accuse',
'achieve',
'acid',
'acoustic',
'acquire',
'across',
'act',
'action',
'actor',
'actress',
'actual',
'adapt',
'add',
'addict',
'address',
'adjust',
'admit',
'adult',
'advance',
'advice',
'aerobic',
'affair',
'afford',
'afraid',
'again',
'age',
'agent',
'agree',
'ahead',
'aim',
'air',
'airport',
'aisle',
'alarm',
'album',
'alcohol',
'alert',
'alien',
'all',
'alley',
'allow',
'almost',
'alone',
'alpha',
'already',
'also',
'alter',
'always',
'amateur',
'amazing',
'among',
'amount',
'amused',
'analyst',
'anchor',
'ancient',
'anger',
'angle',
'angry',
'animal',
'ankle',
'announce',
'annual',
'another',
'answer',
'antenna',
'antique',
'anxiety',
'any',
'apart',
'apology',
'appear',
'apple',
'approve',
'april',
'arch',
'arctic',
'area',
'arena',
'argue',
'arm',
'armed',
'armor',
'army',
'around',
'arrange',
'arrest',
]
while (options.length < 4) {
const randomWord = BIP39_WORDS[Math.floor(Math.random() * BIP39_WORDS.length)]
if (!options.includes(randomWord)) {
options.push(randomWord)
}
}
options.sort(() => Math.random() - 0.5)
return {
position: randomPosition,
correctWord,
options,
}
}
const quizData = ref<{
position: number
correctWord: string
options: string[]
} | null>(null)
const handleAnswerSelect = (answer: string) => {
selectedAnswer.value = answer
isCorrect.value = answer === quizData.value?.correctWord
showResult.value = true
}
const handleNext = () => {
if (isCorrect.value) {
emit('next')
} else {
showResult.value = false
selectedAnswer.value = ''
const newQuiz = generateQuiz()
if (newQuiz) {
quizData.value = newQuiz
}
}
}
onMounted(() => {
const words = seedStore.getSeedWords()
if (words.length > 0) {
seedWords.value = words
const newQuiz = generateQuiz()
if (newQuiz) {
quizData.value = newQuiz
}
} else {
const sampleWords = [
'abandon',
'ability',
'able',
'about',
'above',
'absent',
'absorb',
'abstract',
'absurd',
'abuse',
'access',
'accident',
]
seedWords.value = sampleWords
const newQuiz = generateQuiz()
if (newQuiz) {
quizData.value = newQuiz
}
}
})
</script>
<template>
<div class="confirm-container">
<div class="confirm-card">
<div class="confirm-header">
<h1 class="confirm-title">Recovery Seed</h1>
</div>
<div class="confirm-content">
<div class="progress-indicators">
<div class="progress-circle"></div>
<div class="progress-circle active"></div>
<div class="progress-circle"></div>
</div>
<div class="instruction-text">
<p>
Make sure you wrote the phrase down correctly by answering this quick
checkup.
</p>
</div>
<div class="quiz-section">
<h2 class="quiz-question">What is the {{ quizData?.position }}th word?</h2>
<div class="answer-options">
<button
v-for="(option, index) in quizData?.options"
:key="index"
class="answer-button"
:class="{
selected: selectedAnswer === option,
correct: showResult && option === quizData?.correctWord,
incorrect:
showResult &&
selectedAnswer === option &&
option !== quizData?.correctWord,
}"
@click="handleAnswerSelect(option)"
:disabled="showResult"
>
{{ option }}
</button>
</div>
<div v-if="showResult" class="result-message">
<p v-if="isCorrect" class="success-message"> Correct! You can proceed.</p>
<p v-else class="error-message"> Incorrect. Please try again.</p>
</div>
</div>
<div class="confirm-actions">
<ButtonCommon
v-if="showResult && isCorrect"
type="primary"
size="large"
@click="handleNext"
>
CONTINUE
</ButtonCommon>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.confirm-container {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
background: var(--bg-light);
min-height: 100vh;
}
.confirm-card {
@include card-base;
max-width: 500px;
width: 100%;
border: 2px solid var(--primary-color);
}
.confirm-header {
text-align: center;
margin-bottom: var(--spacing-2xl);
padding-bottom: var(--spacing-xl);
border-bottom: 1px solid var(--border-color);
.confirm-title {
font-size: var(--font-2xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0;
}
}
.confirm-content {
.progress-indicators {
display: flex;
justify-content: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-2xl);
.progress-circle {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--border-light);
transition: background-color 0.3s ease;
&.active {
background: var(--primary-color);
}
}
}
.instruction-text {
text-align: center;
margin-bottom: var(--spacing-2xl);
p {
font-size: var(--font-sm);
color: var(--text-secondary);
line-height: var(--leading-normal);
margin: 0;
}
}
.quiz-section {
margin-bottom: var(--spacing-2xl);
.quiz-question {
font-size: var(--font-lg);
font-weight: var(--font-bold);
color: var(--text-primary);
text-align: center;
margin-bottom: var(--spacing-xl);
}
.answer-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
.answer-button {
padding: var(--spacing-md);
border: 2px solid var(--border-light);
background: var(--bg-white);
border-radius: var(--radius-md);
font-size: var(--font-sm);
font-weight: var(--font-medium);
color: var(--text-primary);
cursor: pointer;
transition: all 0.3s ease;
&:hover:not(:disabled) {
border-color: var(--primary-color);
background: var(--primary-light);
}
&.selected {
border-color: var(--primary-color);
background: var(--primary-light);
}
&.correct {
border-color: var(--success-color);
background: var(--success-light);
color: var(--success-color);
}
&.incorrect {
border-color: var(--error-color);
background: var(--error-light);
color: var(--error-color);
}
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
}
}
.result-message {
text-align: center;
.success-message {
color: var(--success-color);
font-weight: var(--font-medium);
margin: 0;
}
.error-message {
color: var(--error-color);
font-weight: var(--font-medium);
margin: 0;
}
}
}
.confirm-actions {
display: flex;
justify-content: space-between;
gap: var(--spacing-md);
}
}
@media (max-width: 640px) {
.confirm-container {
padding: var(--spacing-md);
}
.confirm-card {
max-width: 100%;
}
.quiz-section {
.answer-options {
grid-template-columns: 1fr;
}
}
.confirm-actions {
flex-direction: column;
}
}
</style>

View File

@ -1,13 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, defineEmits } from 'vue'
import { ButtonCommon, FormCommon } from '@/components' import { ButtonCommon, FormCommon } from '@/components'
const emit = defineEmits<{
navigateToOpenWallet: [event: Event]
navigateToRecoverySeed: []
}>()
const password = ref('') const password = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const passwordError = ref('') const passwordError = ref('')
const confirmPasswordError = ref('') const confirmPasswordError = ref('')
// Password strength calculation
const passwordStrength = computed(() => { const passwordStrength = computed(() => {
if (!password.value) return { level: 0, text: '', color: '' } if (!password.value) return { level: 0, text: '', color: '' }
@ -42,15 +46,9 @@ const canProceed = computed(() => {
) )
}) })
const handleCancel = () => { const handleIHaveWallet = (e: Event) => {
password.value = '' e.preventDefault()
confirmPassword.value = '' emit('navigateToOpenWallet', e)
passwordError.value = ''
confirmPasswordError.value = ''
}
const handleIHaveWallet = () => {
console.log('Navigate to open wallet')
} }
const handleNext = () => { const handleNext = () => {
@ -63,14 +61,13 @@ const handleNext = () => {
} }
return return
} }
console.log('Proceed to next step') emit('navigateToRecoverySeed')
} }
</script> </script>
<template> <template>
<div class="auth-container"> <div class="auth-container">
<div class="auth-card"> <div class="auth-card">
<!-- Header -->
<div class="auth-card-header"> <div class="auth-card-header">
<div class="logo-container"> <div class="logo-container">
<div class="logo-circle"> <div class="logo-circle">
@ -79,7 +76,6 @@ const handleNext = () => {
viewBox="0 0 100 100" viewBox="0 0 100 100"
class="neptune-logo" class="neptune-logo"
> >
<!-- Neptune planet with ring -->
<defs> <defs>
<linearGradient <linearGradient
id="neptuneGradient" id="neptuneGradient"
@ -113,10 +109,8 @@ const handleNext = () => {
</linearGradient> </linearGradient>
</defs> </defs>
<!-- Planet -->
<circle cx="50" cy="50" r="28" fill="url(#neptuneGradient)" /> <circle cx="50" cy="50" r="28" fill="url(#neptuneGradient)" />
<!-- Surface details -->
<ellipse <ellipse
cx="50" cx="50"
cy="45" cy="45"
@ -126,7 +120,6 @@ const handleNext = () => {
/> />
<ellipse cx="50" cy="55" rx="20" ry="5" fill="rgba(0, 0, 0, 0.1)" /> <ellipse cx="50" cy="55" rx="20" ry="5" fill="rgba(0, 0, 0, 0.1)" />
<!-- Ring -->
<ellipse <ellipse
cx="50" cx="50"
cy="50" cy="50"
@ -138,7 +131,6 @@ const handleNext = () => {
opacity="0.8" opacity="0.8"
/> />
<!-- Highlight -->
<circle cx="42" cy="42" r="6" fill="rgba(255, 255, 255, 0.4)" /> <circle cx="42" cy="42" r="6" fill="rgba(255, 255, 255, 0.4)" />
</svg> </svg>
</div> </div>
@ -152,7 +144,6 @@ const handleNext = () => {
</div> </div>
<div class="auth-card-content"> <div class="auth-card-content">
<!-- Password Input -->
<div class="form-group"> <div class="form-group">
<FormCommon <FormCommon
v-model="password" v-model="password"
@ -165,7 +156,6 @@ const handleNext = () => {
@input="passwordError = ''" @input="passwordError = ''"
/> />
<!-- Password Strength Indicator -->
<div v-if="password" class="password-strength"> <div v-if="password" class="password-strength">
<div class="strength-bar"> <div class="strength-bar">
<div <div
@ -182,7 +172,6 @@ const handleNext = () => {
</div> </div>
</div> </div>
<!-- Confirm Password Input -->
<div class="form-group"> <div class="form-group">
<FormCommon <FormCommon
v-model="confirmPassword" v-model="confirmPassword"
@ -195,7 +184,6 @@ const handleNext = () => {
@input="confirmPasswordError = ''" @input="confirmPasswordError = ''"
/> />
<!-- Password Match Indicator -->
<div <div
v-if="confirmPassword" v-if="confirmPassword"
class="password-match" class="password-match"
@ -206,12 +194,10 @@ const handleNext = () => {
</div> </div>
</div> </div>
<!-- Helper Text -->
<p class="helper-text"> <p class="helper-text">
Password must be at least 8 characters with uppercase, lowercase, and numbers. Password must be at least 8 characters with uppercase, lowercase, and numbers.
</p> </p>
<!-- Action Buttons -->
<div class="auth-button-group"> <div class="auth-button-group">
<ButtonCommon <ButtonCommon
type="primary" type="primary"
@ -227,8 +213,6 @@ const handleNext = () => {
<button class="link-button" @click="handleIHaveWallet"> <button class="link-button" @click="handleIHaveWallet">
Already have a wallet? Already have a wallet?
</button> </button>
<span class="separator"></span>
<button class="link-button" @click="handleCancel">Cancel</button>
</div> </div>
</div> </div>
</div> </div>
@ -238,7 +222,6 @@ const handleNext = () => {
<style lang="scss" scoped> <style lang="scss" scoped>
.auth-container { .auth-container {
min-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -330,7 +313,6 @@ const handleNext = () => {
} }
} }
// Password Strength Indicator
.password-strength { .password-strength {
margin-top: var(--spacing-sm); margin-top: var(--spacing-sm);
display: flex; display: flex;
@ -358,7 +340,6 @@ const handleNext = () => {
} }
} }
// Password Match Indicator
.password-match { .password-match {
margin-top: var(--spacing-sm); margin-top: var(--spacing-sm);
font-size: var(--font-xs); font-size: var(--font-xs);
@ -372,7 +353,6 @@ const handleNext = () => {
} }
} }
// Helper Text
.helper-text { .helper-text {
font-size: var(--font-xs); font-size: var(--font-xs);
color: var(--text-muted); color: var(--text-muted);
@ -380,7 +360,6 @@ const handleNext = () => {
line-height: var(--leading-normal); line-height: var(--leading-normal);
} }
// Action Buttons
.auth-button-group { .auth-button-group {
margin-top: var(--spacing-2xl); margin-top: var(--spacing-2xl);
@ -413,7 +392,6 @@ const handleNext = () => {
} }
} }
// Responsive Design
@media (max-width: 640px) { @media (max-width: 640px) {
.auth-container { .auth-container {
padding: var(--spacing-md); padding: var(--spacing-md);

View File

@ -1,12 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ButtonCommon } from '@/components' import { ButtonCommon } from '@/components'
const emit = defineEmits<{
onboardingComplete: []
}>()
const goToNewWallet = () => { const goToNewWallet = () => {
window.open('https://kaspa-ng.org', '_blank') window.open('https://kaspa-ng.org', '_blank')
} }
const goToLegacyWallet = () => { const goToLegacyWallet = () => {
window.open('https://wallet.kaspanet.io', '_blank') // Emit event to parent to show tab interface
emit('onboardingComplete')
} }
</script> </script>
@ -40,13 +45,13 @@ const goToLegacyWallet = () => {
<style lang="scss" scoped> <style lang="scss" scoped>
.welcome-page { .welcome-page {
min-height: 100vh; min-height: 100vh;
background-color: var(--vt-c-white); background-color: var(--bg-light);
position: relative; position: relative;
.welcome-card { .welcome-card {
background-color: var(--vt-c-white); background-color: var(--bg-white);
box-shadow: 0 0 15px var(--vt-c-badge-caption-shadow); box-shadow: var(--shadow-md);
border-radius: 10px; border-radius: var(--radius-md);
width: 100%; width: 100%;
max-width: 800px; max-width: 800px;
flex-direction: column; flex-direction: column;
@ -59,4 +64,15 @@ const goToLegacyWallet = () => {
} }
} }
} }
p {
margin: 1rem;
font-size: var(--font-sm);
}
.note {
margin-top: 1rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
</style> </style>

View File

@ -1,44 +1,172 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ButtonCommon, FormCommon } from '@/components' import { ButtonCommon, FormCommon } from '@/components'
const router = useRouter()
const password = ref('') const password = ref('')
const passwordError = ref('')
const isLoading = ref(false)
const handleOpenWallet = () => {} const handleOpenWallet = async () => {
if (!password.value) {
passwordError.value = 'Please enter your password'
return
}
const handleNewWallet = () => {} isLoading.value = true
passwordError.value = ''
try {
await new Promise((resolve) => setTimeout(resolve, 1500))
router.push('/')
} catch (error) {
passwordError.value = 'Invalid password. Please try again.'
} finally {
isLoading.value = false
}
}
const emit = defineEmits<{
navigateToCreate: []
}>()
const navigateToNewWallet = () => {
emit('navigateToCreate')
}
</script> </script>
<template> <template>
<div class="auth-container"> <div class="auth-container">
<div class="auth-card"> <div class="auth-card">
<div class="auth-card-header">
<h2>Open Wallet</h2>
</div>
<div class="auth-card-content"> <div class="auth-card-content">
<div class="wallet-icon"> <div class="wallet-icon">
<div class="icon-circle"></div> <div class="logo-container">
<div class="logo-circle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
class="neptune-logo"
>
<defs>
<linearGradient
id="neptuneGradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop
offset="0%"
style="stop-color: #007fcf; stop-opacity: 1"
/>
<stop
offset="100%"
style="stop-color: #0066a6; stop-opacity: 1"
/>
</linearGradient>
<linearGradient
id="ringGradient"
x1="0%"
y1="0%"
x2="100%"
y2="0%"
>
<stop
offset="0%"
style="stop-color: #007fcf; stop-opacity: 0.3"
/>
<stop
offset="50%"
style="stop-color: #007fcf; stop-opacity: 0.6"
/>
<stop
offset="100%"
style="stop-color: #007fcf; stop-opacity: 0.3"
/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="28" fill="url(#neptuneGradient)" />
<ellipse
cx="50"
cy="45"
rx="22"
ry="6"
fill="rgba(255, 255, 255, 0.1)"
/>
<ellipse cx="50" cy="55" rx="20" ry="5" fill="rgba(0, 0, 0, 0.1)" />
<ellipse
cx="50"
cy="50"
rx="42"
ry="12"
fill="none"
stroke="url(#ringGradient)"
stroke-width="4"
opacity="0.8"
/>
<circle cx="42" cy="42" r="6" fill="rgba(255, 255, 255, 0.4)" />
</svg>
</div>
<div class="logo-text">
<span class="coin-name">Neptune</span>
<span class="coin-symbol">NPTUN</span>
</div>
</div>
</div> </div>
<div class="password-section"> <div class="form-group">
<FormCommon <FormCommon
v-model="password" v-model="password"
type="password" type="password"
label="Unlock the wallet with your password:" label="Enter your password"
placeholder="Enter your password" placeholder="Password"
show-password-toggle show-password-toggle
required required
:error="passwordError"
:disabled="isLoading"
@input="passwordError = ''"
@keyup.enter="handleOpenWallet"
/> />
</div> </div>
<div class="auth-button-group"> <div class="security-notice">
<ButtonCommon class="auth-btn secondary" @click="handleNewWallet"> <div class="notice-icon">
NEW WALLET <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
</ButtonCommon> <path
d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
stroke="currentColor"
stroke-width="2"
/>
<path
d="M9 12l2 2 4-4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<span>Your password is encrypted and stored locally</span>
</div>
<ButtonCommon class="auth-btn primary" @click="handleOpenWallet"> <div class="auth-button-group">
OPEN WALLET <ButtonCommon
type="primary"
size="large"
block
:disabled="!password || isLoading"
:loading="isLoading"
@click="handleOpenWallet"
>
{{ isLoading ? 'Opening...' : 'Open Wallet' }}
</ButtonCommon>
<ButtonCommon type="default" size="large" block @click="navigateToNewWallet">
New Wallet
</ButtonCommon> </ButtonCommon>
</div> </div>
</div> </div>
@ -46,4 +174,173 @@ const handleNewWallet = () => {}
</div> </div>
</template> </template>
<style lang="scss" scoped></style> <style lang="scss" scoped>
.auth-container {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
background: var(--bg-light);
}
.auth-card {
@include card-base;
max-width: 420px;
width: 100%;
@media (max-width: 640px) {
max-width: 100%;
}
}
.auth-card-content {
.form-group {
margin-bottom: var(--spacing-xl);
}
}
.logo-container {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
.logo-circle {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-light), var(--bg-white));
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-sm);
box-shadow: 0 2px 8px rgba(0, 127, 207, 0.15);
.neptune-logo {
width: 100%;
height: 100%;
}
}
.logo-text {
display: flex;
flex-direction: column;
align-items: flex-start;
.coin-name {
font-size: var(--font-lg);
font-weight: var(--font-bold);
color: var(--text-primary);
line-height: 1;
margin-bottom: 2px;
}
.coin-symbol {
font-size: var(--font-xs);
font-weight: var(--font-medium);
color: var(--primary-color);
background: var(--primary-light);
padding: 2px 8px;
border-radius: var(--radius-sm);
}
}
}
.security-notice {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md);
background: var(--bg-light);
border-radius: var(--radius-md);
border: 1px solid var(--border-light);
margin-bottom: var(--spacing-xl);
.notice-icon {
color: var(--success-color);
flex-shrink: 0;
}
span {
font-size: var(--font-xs);
color: var(--text-secondary);
line-height: var(--leading-normal);
}
}
.auth-button-group {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
// Help Links
.help-links {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-sm);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border-color);
.link-button {
background: none;
border: none;
color: var(--primary-color);
font-size: var(--font-sm);
cursor: pointer;
transition: color 0.2s ease;
padding: 0;
&:hover:not(:disabled) {
color: var(--primary-hover);
text-decoration: underline;
}
&:disabled {
color: var(--text-muted);
cursor: not-allowed;
}
}
.separator {
color: var(--text-muted);
font-size: var(--font-sm);
}
}
// Responsive Design
@media (max-width: 640px) {
.auth-container {
padding: var(--spacing-md);
}
.auth-card-header {
.logo-container {
.logo-circle {
width: 40px;
height: 40px;
}
.logo-text {
.coin-name {
font-size: var(--font-md);
}
}
}
.auth-title {
font-size: var(--font-xl);
}
}
.wallet-icon {
.icon-circle {
width: 64px;
height: 64px;
}
}
}
</style>

View File

@ -0,0 +1,207 @@
<script setup lang="ts">
import { ref, defineEmits, onMounted } from 'vue'
import { ButtonCommon } from '@/components'
import { generateSeedPhrase } from '@/utils'
import { useSeedStore } from '@/stores'
const emit = defineEmits<{
next: []
back: []
}>()
const seedStore = useSeedStore()
const seedWords = ref<string[]>([])
onMounted(() => {
const words = generateSeedPhrase()
seedWords.value = words
seedStore.setSeedWords(words)
})
const handleNext = () => {
emit('next')
}
const handleBack = () => {
emit('back')
}
</script>
<template>
<div class="recovery-container">
<div class="recovery-card">
<div class="recovery-header">
<h1 class="recovery-title">Recovery Seed</h1>
</div>
<div class="recovery-content">
<div class="instruction-text">
<p>
Your wallet is accessible by a seed phrase. The seed phrase is an ordered
12-word secret phrase.
</p>
<p>
Make sure no one is looking, as anyone with your seed phrase can access your
wallet your funds. Write it down and keep it safe.
</p>
</div>
<div class="seed-words-container">
<div class="seed-words-grid">
<div v-for="(word, index) in seedWords" :key="index" class="seed-word-item">
<span class="word-number">{{ index + 1 }}</span>
<span class="word-text">{{ word }}</span>
</div>
</div>
</div>
<div class="cool-fact">
<p>
Cool fact: there are more 12-word phrase combinations than nanoseconds since
the big bang!
</p>
</div>
<div class="recovery-actions">
<ButtonCommon type="default" size="large" @click="handleBack">
BACK
</ButtonCommon>
<ButtonCommon type="primary" size="large" @click="handleNext">
NEXT
</ButtonCommon>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.recovery-container {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
background: var(--bg-light);
min-height: 100vh;
}
.recovery-card {
@include card-base;
max-width: 500px;
width: 100%;
border: 2px solid var(--primary-color);
}
.recovery-header {
text-align: center;
margin-bottom: var(--spacing-2xl);
padding-bottom: var(--spacing-xl);
border-bottom: 1px solid var(--border-color);
.recovery-title {
font-size: var(--font-2xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0;
}
}
.recovery-content {
.instruction-text {
margin-bottom: var(--spacing-2xl);
p {
font-size: var(--font-sm);
color: var(--text-secondary);
line-height: var(--leading-normal);
margin-bottom: var(--spacing-md);
&:last-child {
margin-bottom: 0;
}
}
}
.seed-words-container {
margin-bottom: var(--spacing-2xl);
padding: var(--spacing-lg);
background: var(--bg-hover);
border-radius: var(--radius-md);
border: 1px solid var(--border-light);
.seed-words-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-md);
.seed-word-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--bg-white);
border-radius: var(--radius-sm);
border: 1px solid var(--border-light);
.word-number {
font-size: var(--font-xs);
font-weight: var(--font-bold);
color: var(--text-muted);
min-width: 20px;
}
.word-text {
font-size: var(--font-sm);
font-weight: var(--font-medium);
color: var(--text-primary);
}
}
}
}
.cool-fact {
margin-bottom: var(--spacing-2xl);
text-align: center;
p {
font-size: var(--font-xs);
color: var(--text-muted);
font-style: italic;
margin: 0;
}
}
.recovery-actions {
display: flex;
justify-content: space-between;
gap: var(--spacing-md);
}
}
// Responsive Design
@media (max-width: 640px) {
.recovery-container {
padding: var(--spacing-md);
}
.recovery-card {
max-width: 100%;
}
.seed-words-container {
.seed-words-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-sm);
.seed-word-item {
padding: var(--spacing-xs);
.word-number {
min-width: 16px;
}
}
}
}
}
</style>

View File

@ -46,7 +46,7 @@ const handleClick = () => {
transition: var(--transition-all); transition: var(--transition-all);
border-radius: var(--btn-radius); border-radius: var(--btn-radius);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
transition: all 2s ease-in-out;
&:hover { &:hover {
background: var(--primary-hover); background: var(--primary-hover);
border-color: var(--primary-hover); border-color: var(--primary-hover);

View File

@ -89,19 +89,19 @@ const handleBlur = (e: FocusEvent) => {
.input-container { .input-container {
position: relative; position: relative;
border: 1px solid var(--vt-c-gray-6); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 6px;
transition: all 0.2s ease; transition: all 0.2s ease;
&.focused { &.focused {
border-color: var(--vt-c-main-color); border-color: var(--border-primary);
box-shadow: var(--vt-input-shadow-focus); box-shadow: var(--shadow-primary);
} }
&.error { &.error {
border-color: var(--vt-c-red-v3); border-color: var(--error-color);
background-color: var(); background-color: var();
} }
&.disabled { &.disabled {
background-color: var(--vt-c-gray-4); background-color: var(--bg-hover);
cursor: not-allowed; cursor: not-allowed;
} }
} }
@ -109,17 +109,18 @@ const handleBlur = (e: FocusEvent) => {
.form-input { .form-input {
width: 100%; width: 100%;
padding: 12px 40px 12px 12px; padding: 12px 40px 12px 12px;
border: none; border: var(--border-color);
background: transparent; background: var(--text-light);
font-size: 14px; font-size: var(--font-base);
color: var(--vt-c-black-bold); color: var(--text-primary);
outline: none; outline: none;
border-radius: var(--radius-md);
&::placeholder { &::placeholder {
color: var(--vt-c-gray-8); color: var(--text-muted);
} }
&:disabled { &:disabled {
cursor: not-allowed; cursor: not-allowed;
color: var(--vt-c-gray-8); color: var(--text-muted);
} }
} }
@ -131,10 +132,10 @@ const handleBlur = (e: FocusEvent) => {
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
color: var(--vt-c-gray-8); color: var(--text-muted);
padding: 4px; padding: 4px;
&:hover { &:hover {
color: var(--vt-c-black-bold); color: var(--text-primary);
} }
&:disabled { &:disabled {
cursor: not-allowed; cursor: not-allowed;
@ -145,7 +146,7 @@ const handleBlur = (e: FocusEvent) => {
.error-message { .error-message {
margin-top: 4px; margin-top: 4px;
font-size: 12px; font-size: 12px;
color: var(--vt-c-red-v3); color: var(--error-color);
display: flex; display: flex;
align-items: center; align-items: center;
} }

View File

@ -1,31 +1,201 @@
<script setup lang="ts"> <script setup lang="ts">
import type { IconProps } from '@/interface';
import { computed } from 'vue' import { computed } from 'vue'
import {
EyeOutlined,
EyeInvisibleOutlined,
SearchOutlined,
CloseOutlined,
RightOutlined,
LeftOutlined,
} from '@ant-design/icons-vue'
import type { IconProps } from '@/interface'
const props = withDefaults(defineProps<IconProps>(), { const props = withDefaults(defineProps<IconProps>(), {
icon: '', size: 16,
color: 'currentColor',
}) })
const iconMap: Record<string, any> = { const iconSize = computed(() => {
eye: EyeOutlined, if (typeof props.size === 'number') {
'eye-off': EyeInvisibleOutlined, return `${props.size}px`
search: SearchOutlined,
close: CloseOutlined,
'arrow-right': RightOutlined,
'arrow-left': LeftOutlined,
} }
return props.size
const IconComponent = computed(() => iconMap[props.icon]) })
</script> </script>
<template> <template>
<component :is="IconComponent" :style="{ fontSize: size, color }" :class="props.class" /> <svg
:width="iconSize"
:height="iconSize"
:class="props.class"
:style="{ color: props.color }"
viewBox="0 0 24 24"
fill="none"
>
<!-- Eye Icon -->
<path
v-if="icon === 'eye'"
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
stroke="currentColor"
stroke-width="2"
/>
<circle
v-if="icon === 'eye'"
cx="12"
cy="12"
r="3"
stroke="currentColor"
stroke-width="2"
/>
<!-- Eye Off Icon -->
<path
v-if="icon === 'eye-off'"
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
stroke="currentColor"
stroke-width="2"
/>
<line
v-if="icon === 'eye-off'"
x1="1"
y1="1"
x2="23"
y2="23"
stroke="currentColor"
stroke-width="2"
/>
<!-- Wallet Icon -->
<path
v-if="icon === 'wallet'"
d="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
stroke="currentColor"
stroke-width="2"
/>
<path
v-if="icon === 'wallet'"
d="M9 12L11 14L15 10"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Shield Icon -->
<path
v-if="icon === 'shield'"
d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
stroke="currentColor"
stroke-width="2"
/>
<path
v-if="icon === 'shield'"
d="M9 12l2 2 4-4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Lock Icon -->
<rect
v-if="icon === 'lock'"
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
stroke="currentColor"
stroke-width="2"
/>
<path
v-if="icon === 'lock'"
d="M7 11V7a5 5 0 0 1 10 0v4"
stroke="currentColor"
stroke-width="2"
/>
<!-- Key Icon -->
<path
v-if="icon === 'key'"
d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Plus Icon -->
<line
v-if="icon === 'plus'"
x1="12"
y1="5"
x2="12"
y2="19"
stroke="currentColor"
stroke-width="2"
/>
<line
v-if="icon === 'plus'"
x1="5"
y1="12"
x2="19"
y2="12"
stroke="currentColor"
stroke-width="2"
/>
<!-- Check Icon -->
<path
v-if="icon === 'check'"
d="M20 6L9 17l-5-5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- X Icon -->
<line
v-if="icon === 'x'"
x1="18"
y1="6"
x2="6"
y2="18"
stroke="currentColor"
stroke-width="2"
/>
<line
v-if="icon === 'x'"
x1="6"
y1="6"
x2="18"
y2="18"
stroke="currentColor"
stroke-width="2"
/>
<!-- Arrow Right Icon -->
<path
v-if="icon === 'arrow-right'"
d="M5 12h14m-7-7l7 7-7 7"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Copy Icon -->
<rect
v-if="icon === 'copy'"
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
stroke="currentColor"
stroke-width="2"
/>
<path
v-if="icon === 'copy'"
d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
stroke="currentColor"
stroke-width="2"
/>
</svg>
</template> </template>

View File

@ -4,6 +4,8 @@ import FormCommon from './common/FormCommon.vue'
import OnboardingComponent from './auth/OnboardingComponent.vue' import OnboardingComponent from './auth/OnboardingComponent.vue'
import OpenWalletComponent from './auth/OpenWalletComponent.vue' import OpenWalletComponent from './auth/OpenWalletComponent.vue'
import CreateWalletComponent from './auth/CreateWalletComponent.vue' import CreateWalletComponent from './auth/CreateWalletComponent.vue'
import RecoverySeedComponent from './auth/RecoverySeedComponent.vue'
import ConfirmSeedComponent from './auth/ConfirmSeedComponent.vue'
import { IconCommon } from './icon' import { IconCommon } from './icon'
export { export {
@ -13,5 +15,7 @@ export {
OnboardingComponent, OnboardingComponent,
OpenWalletComponent, OpenWalletComponent,
CreateWalletComponent, CreateWalletComponent,
RecoverySeedComponent,
ConfirmSeedComponent,
IconCommon, IconCommon,
} }

10
src/interface/icon.ts Normal file
View File

@ -0,0 +1,10 @@
export interface IconProps {
icon: string
size?: number | string
color?: string
class?: string
}
export interface HighlightProps {
class?: string
}

View File

@ -26,7 +26,19 @@ export const routes: any = [
{ {
path: '/login', path: '/login',
name: 'login', name: 'login',
component: Page.Login, component: Page.Auth,
beforeEnter: ifNotAuthenticated,
},
{
path: '/recovery-seed',
name: 'recovery-seed',
component: Page.Auth,
beforeEnter: ifNotAuthenticated,
},
{
path: '/confirm-seed',
name: 'confirm-seed',
component: Page.Auth,
beforeEnter: ifNotAuthenticated, beforeEnter: ifNotAuthenticated,
}, },
{ {

85
src/stores/authStore.ts Normal file
View File

@ -0,0 +1,85 @@
import { ref } from 'vue'
// Auth flow states
export type AuthState = 'onboarding' | 'login' | 'create' | 'recovery' | 'confirm' | 'complete'
// Auth store to manage the flow
const currentState = ref<AuthState>('onboarding')
export const useAuthStore = () => {
const getCurrentState = () => currentState.value
const setState = (state: AuthState) => {
currentState.value = state
}
const nextStep = () => {
switch (currentState.value) {
case 'onboarding':
setState('login')
break
case 'login':
// Stay in login, user chooses create or open
break
case 'create':
setState('recovery')
break
case 'recovery':
setState('confirm')
break
case 'confirm':
setState('complete')
break
case 'complete':
// Flow complete
break
}
}
const previousStep = () => {
switch (currentState.value) {
case 'onboarding':
// Can't go back from onboarding
break
case 'login':
setState('onboarding')
break
case 'create':
setState('login')
break
case 'recovery':
setState('create')
break
case 'confirm':
setState('recovery')
break
case 'complete':
setState('confirm')
break
}
}
const goToCreate = () => {
setState('create')
}
const goToLogin = () => {
setState('login')
}
const resetFlow = () => {
setState('onboarding')
localStorage.removeItem('onboarding-completed')
}
return {
currentState: currentState.value,
getCurrentState,
setState,
nextStep,
previousStep,
goToCreate,
goToLogin,
resetFlow,
}
}

View File

@ -1 +1,2 @@
export {} export * from './seedStore'
export * from './authStore'

33
src/stores/seedStore.ts Normal file
View File

@ -0,0 +1,33 @@
import { ref } from 'vue'
const seedWords = ref<string[]>([])
const isSeedGenerated = ref(false)
export const useSeedStore = () => {
const setSeedWords = (words: string[]) => {
seedWords.value = words
isSeedGenerated.value = true
}
const getSeedWords = () => {
return seedWords.value
}
const clearSeedWords = () => {
seedWords.value = []
isSeedGenerated.value = false
}
const hasSeedWords = () => {
return isSeedGenerated.value && seedWords.value.length > 0
}
return {
seedWords: seedWords.value,
isSeedGenerated: isSeedGenerated.value,
setSeedWords,
getSeedWords,
clearSeedWords,
hasSeedWords,
}
}

View File

@ -0,0 +1,128 @@
// BIP39 English wordlist (first 100 words for demo)
const BIP39_WORDS = [
'abandon',
'ability',
'able',
'about',
'above',
'absent',
'absorb',
'abstract',
'absurd',
'abuse',
'access',
'accident',
'account',
'accuse',
'achieve',
'acid',
'acoustic',
'acquire',
'across',
'act',
'action',
'actor',
'actress',
'actual',
'adapt',
'add',
'addict',
'address',
'adjust',
'admit',
'adult',
'advance',
'advice',
'aerobic',
'affair',
'afford',
'afraid',
'again',
'age',
'agent',
'agree',
'ahead',
'aim',
'air',
'airport',
'aisle',
'alarm',
'album',
'alcohol',
'alert',
'alien',
'all',
'alley',
'allow',
'almost',
'alone',
'alpha',
'already',
'also',
'alter',
'always',
'amateur',
'amazing',
'among',
'amount',
'amused',
'analyst',
'anchor',
'ancient',
'anger',
'angle',
'angry',
'animal',
'ankle',
'announce',
'annual',
'another',
'answer',
'antenna',
'antique',
'anxiety',
'any',
'apart',
'apology',
'appear',
'apple',
'approve',
'april',
'arch',
'arctic',
'area',
'arena',
'argue',
'arm',
'armed',
'armor',
'army',
'around',
'arrange',
'arrest',
]
/**
* Generate a random seed phrase with 12 words
* In a real application, you would use a proper BIP39 library
*/
export const generateSeedPhrase = (): string[] => {
const words: string[] = []
for (let i = 0; i < 12; i++) {
const randomIndex = Math.floor(Math.random() * BIP39_WORDS.length)
words.push(BIP39_WORDS[randomIndex])
}
return words
}
/**
* Validate if a seed phrase is valid (basic validation)
*/
export const validateSeedPhrase = (words: string[]): boolean => {
if (words.length !== 12) return false
// Check if all words are in the BIP39 wordlist
return words.every((word) => BIP39_WORDS.includes(word.toLowerCase()))
}

View File

@ -2,3 +2,4 @@ export * from './constants/code'
export * from './constants/constants' export * from './constants/constants'
export * from './helpers/format' export * from './helpers/format'
export * from './helpers/localStorage' export * from './helpers/localStorage'
export * from './helpers/seedPhrase'

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed } from 'vue'
import { OnboardingComponent } from '@/components'
import { useAuthStore } from '@/stores'
import { LoginTab, CreateTab, RecoveryTab, ConfirmTab } from './components'
const authStore = useAuthStore()
const currentState = computed(() => authStore.getCurrentState())
const handleOnboardingComplete = () => {
authStore.nextStep()
}
const handleGoToCreate = () => {
authStore.goToCreate()
}
const handleGoToLogin = () => {
authStore.goToLogin()
}
const handleNext = () => {
authStore.nextStep()
}
const handleBack = () => {
authStore.previousStep()
}
</script>
<template>
<div class="auth-container">
<OnboardingComponent
v-if="currentState === 'onboarding'"
@onboarding-complete="handleOnboardingComplete"
/>
<LoginTab v-else-if="currentState === 'login'" @go-to-create="handleGoToCreate" />
<CreateTab
v-else-if="currentState === 'create'"
@go-to-login="handleGoToLogin"
@next="handleNext"
/>
<RecoveryTab
v-else-if="currentState === 'recovery'"
@back="handleBack"
@next="handleNext"
/>
<ConfirmTab v-else-if="currentState === 'confirm'" @back="handleBack" @next="handleNext" />
<div v-else-if="currentState === 'complete'" class="complete-state">
<h2>Wallet Setup Complete!</h2>
<p>Your wallet has been successfully created.</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.auth-container {
min-height: 100vh;
background: var(--bg-light);
font-family: var(--font-primary);
}
.complete-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
padding: var(--spacing-xl);
h2 {
color: var(--success-color);
margin-bottom: var(--spacing-md);
}
p {
color: var(--text-secondary);
font-size: var(--font-lg);
}
}
</style>

View File

@ -1,8 +0,0 @@
<script setup lang="ts">
import { CreateWalletComponent } from '@/components'
</script>
<template>
<div></div>
<CreateWalletComponent />
</template>

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import { ConfirmSeedComponent } from '@/components'
const emit = defineEmits<{
next: []
back: []
}>()
const handleNext = () => {
emit('next')
}
const handleBack = () => {
emit('back')
}
</script>
<template>
<div class="confirm-tab">
<ConfirmSeedComponent @next="handleNext" @back="handleBack" />
</div>
</template>
<style lang="scss" scoped>
.confirm-tab {
padding: var(--spacing-lg);
}
</style>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { CreateWalletComponent } from '@/components'
const emit = defineEmits<{
goToLogin: []
next: []
}>()
const handleNavigateToOpenWallet = () => {
emit('goToLogin')
}
const handleNavigateToRecoverySeed = () => {
emit('next')
}
</script>
<template>
<div class="create-tab">
<CreateWalletComponent
@navigateToOpenWallet="handleNavigateToOpenWallet"
@navigateToRecoverySeed="handleNavigateToRecoverySeed"
/>
</div>
</template>
<style lang="scss" scoped>
.create-tab {
padding: var(--spacing-lg);
}
</style>

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import { OpenWalletComponent } from '@/components'
const emit = defineEmits<{
goToCreate: []
}>()
const handleNavigateToCreate = () => {
emit('goToCreate')
}
</script>
<template>
<div class="login-tab">
<OpenWalletComponent @navigateToCreate="handleNavigateToCreate" />
</div>
</template>
<style lang="scss" scoped>
.login-tab {
padding: var(--spacing-lg);
}
</style>

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import { RecoverySeedComponent } from '@/components'
const emit = defineEmits<{
next: []
back: []
}>()
const handleNext = () => {
emit('next')
}
const handleBack = () => {
emit('back')
}
</script>
<template>
<div class="recovery-tab">
<RecoverySeedComponent @next="handleNext" @back="handleBack" />
</div>
</template>
<style lang="scss" scoped>
.recovery-tab {
padding: var(--spacing-lg);
}
</style>

View File

@ -0,0 +1,4 @@
export { default as LoginTab } from './LoginTab.vue'
export { default as CreateTab } from './CreateTab.vue'
export { default as RecoveryTab } from './RecoveryTab.vue'
export { default as ConfirmTab } from './ConfirmTab.vue'

View File

@ -1,3 +1,3 @@
export const Home = () => import('@/views/Home/HomeView.vue') export const Home = () => import('@/views/Home/HomeView.vue')
export const NotFound = () => import('@/views/NotFound/NotFoundView.vue') export const NotFound = () => import('@/views/NotFound/NotFoundView.vue')
export const Login = () => import('@/views/Auth/LoginView.vue') export const Auth = () => import('@/views/Auth/AuthView.vue')