This commit is contained in:
Rynare 2025-04-23 14:47:11 +07:00
parent 62903bf565
commit e60e47d28e
42 changed files with 998 additions and 118 deletions

View File

@ -8,6 +8,9 @@ export default defineAppConfig({
}
},
width: 'w-screen max-w-sm',
},
modal: {
container: 'flex min-h-full items-center justify-center text-center',
}
}
})

49
components/auth/auth.vue Normal file
View File

@ -0,0 +1,49 @@
<template>
<div class="relative rounded-lg overflow-hidden p-4 ">
<slot></slot>
<div class="p-4">
<div class="flex flex-col items-center space-y-3 mb-4">
<NuxtImg src="/assets/icons/logo.png" height="44" width="44" type="webp" />
<h2 class="text-2xl font-bold mt-[0_!important]">
<span class="text-blue-700">Stok</span>
<span class="text-yellow-300">In</span>
</h2>
</div>
<div v-if="authSectionIsLoading"
class="absolute top-0 left-0 right-0 bottom-0 z-30 flex backdrop-brightness-75">
<div class="flex flex-col w-1/2 m-auto items-center justify-center">
<OverlayLoadingPadlock class="scale-50" />
<p>Please Wait...</p>
</div>
</div>
<div v-if="authSection === 'login'">
<AuthLogin @is-loading="e => authSectionIsLoading = e"
@section:forgot-password="authSection = 'forgot-password'" />
<div class="mt-4 flex justify-center items-center">
<span>
Tidak punya akun?
</span>
<NuxtUiButton label="Daftar" variant="link" @click="authSection = 'register'" />
</div>
</div>
<div v-else-if="authSection === 'register'">
<AuthRegister @is-loading="e => authSectionIsLoading = e" />
<div class="mt-4 flex justify-center items-center">
<span>
Sudah punya akun?
</span>
<NuxtUiButton label="Masuk" variant="link" @click="authSection = 'login'" />
</div>
</div>
<div v-else-if="authSection === 'forgot-password'">
<AuthForgotPassword @is-loading="e => authSectionIsLoading = e" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { AuthForgotPassword } from '#components';
const authSection = useState<'login' | 'register' | 'forgot-password'>('auth-section', () => 'login')
const authSectionIsLoading = ref<boolean>(false)
</script>

View File

@ -0,0 +1,71 @@
<template>
<div>
<p class="text-sm text-gray-500 text-center mb-4">
Enter your email to receive password reset instructions
</p>
<NuxtUiForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit" v-if="!isEmailSent">
<div class="flex gap-y-1 mt-4 divide-x-2 justify-end">
<NuxtUiButton label="Login" variant="link" @click="authSection = 'login'" />
<NuxtUiButton label="Register" variant="link" @click="authSection = 'register'" />
</div>
<NuxtUiFormGroup label="Email" name="email" :error="invalidCredentials ? 'Email not registered' : ''">
<NuxtUiInput v-model="state.email" icon="i-heroicons-envelope" />
</NuxtUiFormGroup>
<NuxtUiButton :loading="isSubmitting" type="submit">
Submit
</NuxtUiButton>
</NuxtUiForm>
<div v-else>
<NuxtUiCard class="p-4 text-center space-y-2">
<NuxtUiIcon name="lucide:check-circle" class="text-green-600 w-6 h-6 mx-auto" />
<h3 class="text-lg font-semibold">Email Sent</h3>
<p class="text-sm text-gray-500">
We've sent you an email with a link to reset your password.
Please check your inbox and follow the instructions provided.
</p>
</NuxtUiCard>
<div class="flex gap-2 mt-2">
<NuxtUiButton to="/auth/forgot-password/success">true</NuxtUiButton>
<NuxtUiButton to="/auth/forgot-password/invalid">false</NuxtUiButton>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { z } from 'zod';
import type { FormSubmitEvent } from '#ui/types'
const authSection = useState<'login' | 'register' | 'forgot-password'>('auth-section', () => 'login')
const isSubmitting = ref<boolean>(false)
const invalidCredentials = ref<boolean>(false)
const isEmailSent = ref<boolean>(false)
const schema = z.object({
email: z.string().email('Invalid email')
})
type Schema = z.output<typeof schema>
const state = reactive({
email: undefined,
})
async function onSubmit(event: FormSubmitEvent<Schema>) {
isSubmitting.value = true
isEmailSent.value = false
setTimeout(() => {
const { email } = event.data
if (email === 'fahim@gmail.com') {
isSubmitting.value = false
invalidCredentials.value = false
isEmailSent.value = true
return
}
invalidCredentials.value = true
isSubmitting.value = false
}, 3000);
}
</script>

68
components/auth/login.vue Normal file
View File

@ -0,0 +1,68 @@
<script setup lang="ts">
import { z } from 'zod'
import type { FormSubmitEvent } from '#ui/types'
const route = useRoute();
const emit = defineEmits(['is-loading', 'section:forgot-password'])
const invalidCredentials = ref<boolean>(false)
const isSubmitting = ref<boolean>(false)
watch(isSubmitting, newVal => emit('is-loading', newVal))
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Must be at least 8 characters')
})
type Schema = z.output<typeof schema>
const state = reactive({
email: undefined,
password: undefined
})
async function onSubmit(event: FormSubmitEvent<Schema>) {
isSubmitting.value = true
setTimeout(() => {
const { email, password } = event.data
if (email === 'fahim@gmail.com' && password === '99999999') {
isSubmitting.value = false
invalidCredentials.value = false
console.log('✅ Data User:', event.data)
return
}
invalidCredentials.value = true
isSubmitting.value = false
}, 3000);
}
</script>
<template>
<p class="text-sm text-gray-500 text-center mb-4">
Login to access your stock predictions dashboard
</p>
<NuxtUiForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<NuxtUiFormGroup label="Email" name="email" :error="invalidCredentials ? 'Invalid Email or Password' : ''">
<NuxtUiInput v-model="state.email" icon="i-heroicons-envelope" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Password" name="password"
:error="invalidCredentials ? 'Invalid Email or Password' : ''">
<template #hint>
<NuxtUiButton type="button" @click="emit('section:forgot-password')" color="blue" variant="link"
class="pe-0">
Forgot Password?
</NuxtUiButton>
</template>
<NuxtUiInput v-model="state.password" type="password" icon="i-heroicons-lock-closed" id="auth-pw" />
</NuxtUiFormGroup>
<NuxtUiButton type="submit" :loading="isSubmitting">
Submit
</NuxtUiButton>
</NuxtUiForm>
</template>

View File

@ -0,0 +1,70 @@
<template>
<p class="text-sm text-gray-500 text-center mb-4">
Create your account to get started with predictive financial insights
</p>
<NuxtUiForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<NuxtUiFormGroup label="Email" name="email" :error="!!usedEmail ? 'Email telah digunakan.' : ''">
<NuxtUiInput v-model="state.email" icon="i-heroicons-envelope" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Password" name="password">
<NuxtUiInput v-model="state.password" type="password" icon="i-heroicons-lock-closed" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Re-Enter Password" name="password_confirmation">
<NuxtUiInput v-model="state.password_confirmation" type="password" icon="i-heroicons-lock-closed" />
</NuxtUiFormGroup>
<NuxtUiButton type="submit" :loading="isSubmitting">
Submit
</NuxtUiButton>
</NuxtUiForm>
</template>
<script setup lang="ts">
import { z } from 'zod'
import type { FormSubmitEvent } from '#ui/types'
const emit = defineEmits(['is-loading'])
const usedEmail = ref<string | null>(null)
const isSubmitting = ref<boolean>(false)
watch(isSubmitting, newVal => emit('is-loading', newVal))
const baseSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Must be at least 8 characters'),
password_confirmation: z.string().min(8, 'Must be at least 8 characters'),
})
const schema = baseSchema
.refine((data) => data.password === data.password_confirmation, {
message: "Passwords don't match",
path: ['password_confirmation'],
})
type Schema = z.output<typeof schema>
const state = reactive<Schema>({
email: '',
password: '',
password_confirmation: ''
})
async function onSubmit(event: FormSubmitEvent<Schema>) {
isSubmitting.value = true
setTimeout(() => {
if (event.data.email === 'fahim@gmail.com') {
usedEmail.value = 'fahim@gmail.com'
isSubmitting.value = false
return
}
usedEmail.value = null
console.log('✅ Data User:', event.data)
isSubmitting.value = false
}, 3000);
}
</script>

View File

@ -4,16 +4,9 @@
<footer class="border-t bg-background">
<div class=" flex flex-col items-center justify-between gap-4 py-10 md:h-24 md:flex-row md:py-0">
<div class="flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="text-primary-foreground">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
</svg>
</div>
<NuxtImg src="assets/icons/logo.png" format="webp" height="24" width="auto" />
<p class="text-center text-sm leading-loose text-muted-foreground md:text-left">
© 2025 SmartLog. All rights reserved.
© 2025 StokIn. All rights reserved.
</p>
</div>
<div class="flex gap-4">

View File

@ -4,17 +4,9 @@
class="fixed left-0 right-0 top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<NuxtUiContainer>
<div class="flex h-16 items-center">
<div class="flex items-center gap-2">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="text-primary-foreground">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
</svg>
</div>
<span class="text-xl font-bold">SmartLog</span>
</div>
<NuxtLink href="/" class="flex items-center gap-2">
<NuxtImg src="/assets/icons/logo-text.png" width="auto" height="44" format="webp" />
</NuxtLink>
<nav class="ml-auto flex items-center gap-4 sm:gap-6">
<div v-if="window.width.value <= 640">
@ -29,17 +21,13 @@
<!-- Navigasi -->
<div class="mt-10 space-y-6">
<NuxtLink to="/#features" class="text-lg block hover:text-green-500"
@click="isOpen = false">
Features
</NuxtLink>
<NuxtLink to="/#how-it-works" class="text-lg block hover:text-green-500"
@click="isOpen = false">
How It Works
</NuxtLink>
<NuxtLink to="/#pricing" class="text-lg block hover:text-green-500"
<NuxtLink to="/#features" class="text-lg block hover:text-green-500"
@click="isOpen = false">
Pricing
Features
</NuxtLink>
<NuxtLink to="/demo" class="text-lg block text-green-500 font-semibold"
@click="isOpen = false">
@ -49,37 +37,60 @@
<!-- Tombol "Get Started" -->
<div class="mt-auto flex justify-start">
<NuxtUiButton label="Log In" color="green" />
<NuxtUiButton color="green" @click="() => {
if (route.path.startsWith('/auth/forgot-password')) {
navigateTo('/auth')
} else {
authModalIsOpen = true
isOpen = false
}
}">
Log In
</NuxtUiButton>
</div>
</div>
</NuxtUiSlideover>
</div>
<div v-else class="ml-auto flex items-center gap-4 sm:gap-6">
<NuxtLink href="/#features" class="text-sm font-medium transition-colors hover:text-primary">
Features
</NuxtLink>
<NuxtLink href="/#how-it-works"
class="text-sm font-medium transition-colors hover:text-primary">How
It
Works</NuxtLink>
<NuxtLink href="/#pricing" class="text-sm font-medium transition-colors hover:text-primary">
Pricing
<NuxtLink href="/#features" class="text-sm font-medium transition-colors hover:text-primary">
Features
</NuxtLink>
<NuxtLink href="/demo"
class="text-sm font-medium text-primary transition-colors hover:text-primary/80">
Demo</NuxtLink>
<NuxtUiButton color="green">
Get Started
<NuxtUiButton color="green" @click="() => {
if (route.path.startsWith('/auth/forgot-password')) {
navigateTo('/auth')
} else {
authModalIsOpen = true
}
}">
Log In
</NuxtUiButton>
</div>
</nav>
</div>
<NuxtUiModal v-model="authModalIsOpen" prevent-close :ui="{
width: 'w-full max-w-[400px]',
}">
<Auth @vue:mounted="authSection = 'login'">
<div class="flex justify-end">
<NuxtUiButton icon="i-heroicons-x-mark-16-solid" color="red" @click="authModalIsOpen = false" />
</div>
</Auth>
</NuxtUiModal>
</NuxtUiContainer>
</header>
</template>
<script lang="ts" setup>
import { useWindowSize } from '@vueuse/core';
const window = useWindowSize()
const isOpen = ref(false)
const authModalIsOpen = ref<boolean>(false)
const route = useRoute();
const authSection = useState<'login' | 'register' | 'forgot-password'>('auth-section', () => 'login')
</script>

View File

@ -1,6 +1,6 @@
<template>
<!-- CTA Section -->
<section class="container py-20">
<section class=" py-20">
<div class="mx-auto max-w-4xl rounded-lg p-8 md:p-12">
<div class="flex flex-col items-center justify-between gap-4 text-center md:flex-row md:text-left">
<div class="space-y-2">
@ -11,7 +11,7 @@
</p>
</div>
<NuxtUiButton color="green"
class="h-10 items-center justify-center rounded-md px-8 text-sm font-medium shadow ">
class="h-10 items-center justify-center rounded-md px-8 text-sm font-medium shadow " to="/demo">
<span>
Try Demo
</span>

View File

@ -1,7 +1,7 @@
<template>
<!-- Features Section -->
<section id="features" class="container py-20">
<section id="features" class=" py-20">
<div class="mx-auto flex max-w-[58rem] flex-col items-center space-y-4 text-center">
<h2 class="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">Powerful Prediction Features
</h2>

View File

@ -1,54 +1,32 @@
<template>
<!-- Hero Section -->
<section class="container py-20 md:py-32">
<div class="grid gap-10 md:grid-cols-2 md:gap-16">
<div class="flex flex-col justify-center space-y-6 sm:pb-32">
<div class="space-y-2">
<h1 class="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl">
Predict the Future with ARIMA
</h1>
<p class="text-lg text-muted-foreground md:text-xl">
Harness the power of ARIMA to make accurate predictions and informed decisions.
</p>
</div>
<div class="flex flex-col gap-2 min-[400px]:flex-row">
<NuxtUiButton
class="inline-flex h-10 items-center justify-center rounded-md px-8 text-sm font-medium shadow"
to="/demo">
Try Demo
</NuxtUiButton>
<NuxtUiButton variant="outline" color="white"
class="inline-flex h-10 items-center justify-center rounded-md px-8 text-sm font-medium shadow">
Learn More
</NuxtUiButton>
</div>
<section class="mt-20 pb-20 md:mt-32 md:pb-32 mx-auto min-h-screen flex gap-3 flex-col sm:flex-row">
<div class="flex flex-col justify-center space-y-6 sm:w-[60%] py-10">
<div class="space-y-2">
<h1 class="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl">
Predict the Future with ARIMA
</h1>
<p class="text-lg text-muted-foreground md:text-xl">
Harness the power of ARIMA to make accurate predictions and informed decisions.
</p>
</div>
<div class="flex items-center justify-center">
<div
class="relative h-[350px] w-[350px] rounded-full bg-gradient-to-r from-primary/20 to-primary/40 p-4">
<div class="absolute inset-0 flex items-center justify-center">
<div class="h-[250px] w-[250px] rounded-full bg-background p-4 shadow-lg">
<div
class="flex h-full w-full items-center justify-center rounded-full bg-gradient-to-br from-primary/10 to-primary/30">
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round"
stroke-linejoin="round" class="text-primary">
<path d="M12 2v4"></path>
<path d="M12 18v4"></path>
<path d="m4.93 4.93 2.83 2.83"></path>
<path d="m16.24 16.24 2.83 2.83"></path>
<path d="M2 12h4"></path>
<path d="M18 12h4"></path>
<path d="m4.93 19.07 2.83-2.83"></path>
<path d="m16.24 7.76 2.83-2.83"></path>
<circle cx="12" cy="12" r="5"></circle>
</svg>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2 min-[400px]:flex-row">
<NuxtUiButton
class="inline-flex h-10 items-center justify-center rounded-md px-8 text-sm font-medium shadow"
to="/demo">
Try Demo
</NuxtUiButton>
<NuxtUiButton variant="outline" color="white"
class="inline-flex h-10 items-center justify-center rounded-md px-8 text-sm font-medium shadow"
to="/#how-it-works">
Learn More
</NuxtUiButton>
</div>
</div>
<Placeholder class="aspect-video flex-grow bg-gray-500" />
</section>
</template>
</template>
<style>
:root {
scroll-behavior: smooth;
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<!-- How It Works Section -->
<section id="how-it-works" class="container py-20 bg-muted/50 rounded-lg my-10">
<section id="how-it-works" class=" py-20 bg-muted/50 rounded-lg my-10">
<div class="mx-auto flex max-w-[58rem] flex-col items-center space-y-4 text-center">
<h2 class="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">How It Works</h2>
<p class="max-w-[85%] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed">

View File

@ -0,0 +1,64 @@
<template>
<span class="loader"></span>
</template>
<style scoped>
.loader {
position: relative;
width: 100px;
height: 130px;
background: #fff;
border-radius: 4px;
}
.loader:before {
content: '';
position: absolute;
width: 54px;
height: 25px;
left: 50%;
top: 0;
background-image:
radial-gradient(ellipse at center, #0000 24%, #de3500 25%, #de3500 64%, #0000 65%),
linear-gradient(to bottom, #0000 34%, #de3500 35%);
background-size: 12px 12px, 100% auto;
background-repeat: no-repeat;
background-position: center top;
transform: translate(-50%, -65%);
box-shadow: 0 -3px rgba(0, 0, 0, 0.25) inset;
}
.loader:after {
content: '';
position: absolute;
left: 50%;
top: 20%;
transform: translateX(-50%);
width: 66%;
height: 60%;
background: linear-gradient(to bottom, #f79577 30%, #0000 31%);
background-size: 100% 16px;
animation: writeDown 2s ease-out infinite;
}
@keyframes writeDown {
0% {
height: 0%;
opacity: 0;
}
20% {
height: 0%;
opacity: 1;
}
80% {
height: 65%;
opacity: 1;
}
100% {
height: 65%;
opacity: 0;
}
}
</style>

View File

@ -0,0 +1,62 @@
<template>
<span class="loader"></span>
</template>
<style scoped>
.loader {
width: 200px;
height: 140px;
background: #979794;
box-sizing: border-box;
position: relative;
border-radius: 8px;
perspective: 1000px;
}
.loader:before {
content: '';
position: absolute;
left: 10px;
right: 10px;
top: 10px;
bottom: 10px;
border-radius: 8px;
background: #f5f5f5 no-repeat;
background-size: 60px 10px;
background-image: linear-gradient(#ddd 100px, transparent 0),
linear-gradient(#ddd 100px, transparent 0),
linear-gradient(#ddd 100px, transparent 0),
linear-gradient(#ddd 100px, transparent 0),
linear-gradient(#ddd 100px, transparent 0),
linear-gradient(#ddd 100px, transparent 0);
background-position: 15px 30px, 15px 60px, 15px 90px,
105px 30px, 105px 60px, 105px 90px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.25);
}
.loader:after {
content: '';
position: absolute;
width: calc(50% - 10px);
right: 10px;
top: 10px;
bottom: 10px;
border-radius: 8px;
background: #fff no-repeat;
background-size: 60px 10px;
background-image: linear-gradient(#ddd 100px, transparent 0),
linear-gradient(#ddd 100px, transparent 0),
linear-gradient(#ddd 100px, transparent 0);
background-position: 50% 30px, 50% 60px, 50% 90px;
transform: rotateY(0deg);
transform-origin: left center;
animation: paging 1s linear infinite;
}
@keyframes paging {
to {
transform: rotateY(-180deg);
}
}
</style>

View File

@ -0,0 +1,28 @@
<template>
<span class="loader"></span>
</template>
<style scoped>
.loader {
width: 48px;
height: 48px;
display: inline-block;
position: relative;
background: #FFF;
box-sizing: border-box;
animation: flipX 1s linear infinite;
}
@keyframes flipX {
0% {
transform: perspective(200px) rotateX(0deg) rotateY(0deg);
}
50% {
transform: perspective(200px) rotateX(-180deg) rotateY(0deg);
}
100% {
transform: perspective(200px) rotateX(-180deg) rotateY(-180deg);
}
}
</style>

View File

@ -0,0 +1,55 @@
<template>
<span class="loader"></span>
</template>
<style scoped>
.loader {
width: 64px;
height: 44px;
position: relative;
border: 5px solid #fff;
border-radius: 8px;
}
.loader::before {
content: '';
position: absolute;
border: 5px solid #fff;
width: 32px;
height: 28px;
border-radius: 50% 50% 0 0;
left: 50%;
top: 0;
transform: translate(-50%, -100%)
}
.loader::after {
content: '';
position: absolute;
transform: translate(-50%, -50%);
left: 50%;
top: 50%;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #fff;
box-shadow: 16px 0 #fff, -16px 0 #fff;
animation: flash 0.5s ease-out infinite alternate;
}
@keyframes flash {
0% {
background-color: rgba(255, 255, 255, 0.25);
box-shadow: 16px 0 rgba(255, 255, 255, 0.25), -16px 0 rgba(255, 255, 255, 1);
}
50% {
background-color: rgba(255, 255, 255, 1);
box-shadow: 16px 0 rgba(255, 255, 255, 0.25), -16px 0 rgba(255, 255, 255, 0.25);
}
100% {
background-color: rgba(255, 255, 255, 0.25);
box-shadow: 16px 0 rgba(255, 255, 255, 1), -16px 0 rgba(255, 255, 255, 0.25);
}
}
</style>

View File

@ -0,0 +1,41 @@
<template>
<span class="loader"></span>
</template>
<style scoped>
.loader {
width: 48px;
height: 48px;
display: inline-block;
position: relative;
}
.loader::after,
.loader::before {
content: '';
box-sizing: border-box;
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid #FFF;
position: absolute;
left: 0;
top: 0;
animation: animloader 2s linear infinite;
}
.loader::after {
animation-delay: 1s;
}
@keyframes animloader {
0% {
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0;
}
}
</style>

View File

@ -0,0 +1,44 @@
<template>
<span class="loader">{{ props?.loaderText || 'Loading' }}</span>
</template>
<script lang="ts" setup>
const props = defineProps<{
loaderText?: string | number
}>()
</script>
<style scoped>
.loader {
color: #FFF;
display: inline-block;
position: relative;
font-size: 48px;
font-family: Arial, Helvetica, sans-serif;
box-sizing: border-box;
}
.loader::after {
content: '';
width: 5px;
height: 5px;
background: currentColor;
position: absolute;
bottom: 10px;
right: -5px;
box-sizing: border-box;
animation: animloader 1s linear infinite;
}
@keyframes animloader {
0% {
box-shadow: 10px 0 rgba(255, 255, 255, 0), 20px 0 rgba(255, 255, 255, 0);
}
50% {
box-shadow: 10px 0 white, 20px 0 rgba(255, 255, 255, 0);
}
100% {
box-shadow: 10px 0 white, 20px 0 white;
}
}
</style>

View File

@ -0,0 +1,61 @@
<template>
<span class="loader"></span>
</template>
<style scoped>
.loader {
position: relative;
border-style: solid;
box-sizing: border-box;
border-width: 40px 60px 30px 60px;
border-color: #3760C9 #96DDFC #96DDFC #36BBF7;
animation: envFloating 1s ease-in infinite alternate;
}
.loader:after {
content: "";
position: absolute;
right: 62px;
top: -40px;
height: 70px;
width: 50px;
background-image:
linear-gradient(#fff 45px, transparent 0),
linear-gradient(#fff 45px, transparent 0),
linear-gradient(#fff 45px, transparent 0);
background-repeat: no-repeat;
background-size: 30px 4px;
background-position: 0px 11px, 8px 35px, 0px 60px;
animation: envDropping 0.75s linear infinite;
}
@keyframes envFloating {
0% {
transform: translate(-2px, -5px)
}
100% {
transform: translate(0, 5px)
}
}
@keyframes envDropping {
0% {
background-position: 100px 11px, 115px 35px, 105px 60px;
opacity: 1;
}
50% {
background-position: 0px 11px, 20px 35px, 5px 60px;
}
60% {
background-position: -30px 11px, 0px 35px, -10px 60px;
}
75%,
100% {
background-position: -30px 11px, -30px 35px, -30px 60px;
opacity: 0;
}
}
</style>

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'rounded-xl border bg-card text-card-foreground shadow',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</p>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex items-center p-6 pt-0', props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<h3
:class="
cn('font-semibold leading-none tracking-tight', props.class)
"
>
<slot />
</h3>
</template>

View File

@ -0,0 +1,6 @@
export { default as Card } from './Card.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardFooter } from './CardFooter.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { Label, type LabelProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<Label
v-bind="delegatedProps"
:class="
cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

@ -0,0 +1 @@
export { default as Label } from './Label.vue'

View File

@ -1,3 +1,3 @@
<template>
<slot />
<slot></slot>
</template>

View File

@ -2,9 +2,17 @@
<NuxtUiContainer
class="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 flex flex-col">
<LandingHeader />
<div class="grow">
<div class="grow mt-[64px] flex flex-col">
<slot />
</div>
<LandingFooter />
</NuxtUiContainer>
</template>
<script lang="ts" setup>
const colorMode = useColorMode()
onMounted(() => {
if (colorMode.preference !== 'light') {
colorMode.preference = 'light'
}
})
</script>

View File

@ -0,0 +1,42 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
const token = to.params.token;
const forgotPasswordTokenStatus = useState<'unset' | 'invalid' | 'valid'>('forgot-password-token-status', () => 'unset')
if (!token) {
return navigateTo('/login');
}
try {
const isValid = await verifyToken(token as string);
if (!isValid) {
forgotPasswordTokenStatus.value = 'invalid'
} else {
forgotPasswordTokenStatus.value = 'valid'
}
} catch (error) {
forgotPasswordTokenStatus.value = 'invalid'
}
async function verifyToken(token: string) {
return new Promise((resolve) => setTimeout(() => {
if (token === 'success') {
return resolve(true)
} else {
return resolve(false)
}
}, 2000))
// const response = await fetch('https://your-backend.com/api/verify-token', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify({ token }),
// });
// const data = await response.json();
// return data.isValid;
}
});

View File

@ -1,5 +1,15 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
nitro: {
routeRules: {
'/_nuxt/**': {
headers: {
'cache-control': 'public, max-age=31536000, immutable',
'content-encoding': 'br'
}
}
}
},
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
modules: ['@nuxt/image', '@nuxt/ui', 'shadcn-nuxt'],
@ -16,5 +26,9 @@ export default defineNuxtConfig({
* @default "./components/ui"
*/
componentDir: './components/ui'
},
image: {
format: ['webp'],
quality: 80,
}
})

20
package-lock.json generated
View File

@ -14,12 +14,13 @@
"clsx": "^2.1.1",
"lucide-vue-next": "^0.485.0",
"nuxt": "^3.16.1",
"reka-ui": "^2.1.1",
"reka-ui": "^2.2.0",
"shadcn-nuxt": "^1.0.3",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
"vue-router": "^4.5.0",
"zod": "^3.24.2"
}
},
"node_modules/@alloc/quick-lru": {
@ -8904,9 +8905,9 @@
}
},
"node_modules/reka-ui": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.1.1.tgz",
"integrity": "sha512-awvpQ041LPXAvf2uRVFwedsyz9SwsuoWlRql1fg4XimUCxEI2GOfHo6FIdL44dSPb/eG/gWbdGhoGHLlbX5gPA==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.2.0.tgz",
"integrity": "sha512-eeRrLI4LwJ6dkdwks6KFNKGs0+beqZlHO3JMHen7THDTh+yJ5Z0KNwONmOhhV/0hZC2uJCEExgG60QPzGstkQg==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.13",
@ -11544,6 +11545,15 @@
"engines": {
"node": ">= 14"
}
},
"node_modules/zod": {
"version": "3.24.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@ -17,11 +17,12 @@
"clsx": "^2.1.1",
"lucide-vue-next": "^0.485.0",
"nuxt": "^3.16.1",
"reka-ui": "^2.1.1",
"reka-ui": "^2.2.0",
"shadcn-nuxt": "^1.0.3",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
"vue-router": "^4.5.0",
"zod": "^3.24.2"
}
}

View File

@ -1,5 +1,5 @@
<template>
<NuxtLayout name="auth">
<NuxtLayout name="default">
<div></div>
</NuxtLayout>
</template>

View File

@ -1,5 +0,0 @@
<template>
<NuxtLayout name="auth">
<div></div>
</NuxtLayout>
</template>

View File

@ -1,5 +0,0 @@
<template>
<NuxtLayout name="auth">
<div></div>
</NuxtLayout>
</template>

View File

@ -0,0 +1,89 @@
<script setup lang="ts">
import { z } from 'zod'
import type { FormSubmitEvent } from '#ui/types'
definePageMeta({
middleware: ['forgot-password-confirmation'],
});
const forgotPasswordTokenStatus = useState<'unset' | 'invalid' | 'valid'>('forgot-password-token-status', () => 'unset')
const authSection = useState<'login' | 'register' | 'forgot-password'>('auth-section', () => 'login')
const isSubmitting = ref<boolean>(false)
const baseSchema = z.object({
newPassword: z.string().min(8, 'Must be at least 8 characters'),
newPassword2: z.string().min(8, 'Must be at least 8 characters')
})
const schema = baseSchema
.refine((data) => data.newPassword === data.newPassword2, {
message: "Passwords don't match",
path: ['newPassword2'],
})
type Schema = z.output<typeof schema>
const state = reactive({
newPassword: undefined,
newPassword2: undefined
})
async function onSubmit(event: FormSubmitEvent<Schema>) {
isSubmitting.value = true
setTimeout(() => {
isSubmitting.value = false
}, 3000);
}
</script>
<template>
<NuxtLayout name="default">
<div class="my-auto p-5">
<div class="grid grid-cols-1 max-w-sm mx-auto">
<div class="pb-3 flex gap-3">
<NuxtUiButton to="/auth/forgot-password/success">true</NuxtUiButton>
<NuxtUiButton to="/auth/forgot-password/invalid">false</NuxtUiButton>
</div>
<div class="w-full">
<div v-if="forgotPasswordTokenStatus === 'valid'">
<NuxtUiForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<NuxtUiFormGroup label="New Password" name="password">
<NuxtUiInput v-model="state.newPassword" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="New Password Confirmation" name="password_confirmation">
<NuxtUiInput v-model="state.newPassword2" type="password" />
</NuxtUiFormGroup>
<NuxtUiButton type="submit" :loading="isSubmitting">
Submit
</NuxtUiButton>
</NuxtUiForm>
</div>
<div v-else>
<NuxtUiCard class="max-w-md mx-auto text-center">
<template #header>
<h2 class="text-xl font-semibold text-red-600">Invalid or Expired Token</h2>
</template>
<p class="text-gray-700">
The password reset link you used is either invalid, expired, or has already been used.
</p>
<p class="mt-2 text-gray-600">
Please request a new reset link to continue.
</p>
<template #footer>
<NuxtUiButton color="primary" @click="() => {
authSection = 'forgot-password',
navigateTo('/auth')
}">
Request New Link
</NuxtUiButton>
</template>
</NuxtUiCard>
</div>
</div>
</div>
</div>
</NuxtLayout>
</template>

9
pages/auth/index.vue Normal file
View File

@ -0,0 +1,9 @@
<template>
<NuxtLayout name="default">
<div class="w-screen h-screen grid">
<NuxtUiCard class="w-full max-w-sm m-auto shadow-xl rounded-2xl">
<Auth />
</NuxtUiCard>
</div>
</NuxtLayout>
</template>

View File

@ -1,5 +0,0 @@
<template>
<NuxtLayout name="auth">
<div></div>
</NuxtLayout>
</template>

View File

@ -1,5 +0,0 @@
<template>
<NuxtLayout name="auth">
<div></div>
</NuxtLayout>
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB