integrated api

This commit is contained in:
Rynare 2025-05-08 23:18:29 +07:00
parent fd14f8a6c1
commit b7d6c1247a
41 changed files with 1409 additions and 350 deletions

View File

@ -2,3 +2,12 @@
<NuxtPage />
<NuxtUiNotifications />
</template>
<script lang="ts" setup>
const { authState } = useMyAppState()
const { logoutNow } = useAuthLogout()
watch(authState, (newVal, oldVal) => {
if (oldVal === 'logged-in' && newVal === 'logged-out') {
logoutNow()
}
})
</script>

View File

@ -12,7 +12,7 @@
<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" />
<LoaderPadlock class="scale-50" />
<p>Please Wait...</p>
</div>
</div>

View File

@ -3,69 +3,67 @@
<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">
<NuxtUiForm :schema="schema" :state="forgotPasswordForm" class="space-y-4" @submit="submitNow"
v-if="submitStatus !== 'success'">
<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 label="Email" name="email">
<NuxtUiInput v-model="forgotPasswordForm.email" icon="i-heroicons-envelope" />
</NuxtUiFormGroup>
<NuxtUiButton :loading="isSubmitting" type="submit">
<NuxtUiButton :loading="submitStatus === 'pending'" type="submit">
Submit
</NuxtUiButton>
</NuxtUiForm>
<div v-else>
<div v-else-if="submitStatus === 'success'">
<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.
{{ data?.message || `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>
<NuxtUiModal v-model="showEmailNotActivatedModal" title="Email Not Activated" v-if="submitStatus !== 'success'"
:prevent-close="true">
<NuxtUiCard class="p-4 text-center space-y-2">
<div class="space-y-4">
<NuxtUiIcon name="i-heroicons-exclamation-triangle" class="text-orange-500 leading-none mx-auto"
style="font-size: 64px;" />
<div class="space-y-2">
<h3 class="text-lg font-semibold">{{ data?.message || 'Email not activated' }}</h3>
<p class="text-sm text-gray-500">
Your email has not been activated. Please check your inbox or spam folder for
the activation
email.
</p>
</div>
</div>
<template #footer>
<div class="flex justify-center">
<NuxtUiButton @click="showEmailNotActivatedModal = false">Confirm</NuxtUiButton>
</div>
</template>
</NuxtUiCard>
</NuxtUiModal>
</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 {
forgotPasswordForm, schema,
submitNow, data, submitError, submitStatus,
isEmailNotActivated
} = useAuthForgotPasswordGetToken()
const schema = z.object({
email: z.string().email('Invalid email')
})
const showEmailNotActivatedModal = ref(false)
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);
watch(submitStatus, (val) => {
if (val === 'error' && isEmailNotActivated.value) {
showEmailNotActivatedModal.value = true
}
})
</script>

View File

@ -1,8 +1,6 @@
<script setup lang="ts">
import { z } from 'zod'
import type { FormSubmitEvent } from '#ui/types'
const route = useRoute();
import { useAuthLogin } from '~/composables/useAuth'
const emit = defineEmits(['is-loading', 'section:forgot-password'])
const invalidCredentials = ref<boolean>(false)
@ -10,33 +8,19 @@ 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')
})
const {
loginForm, schema,
loginNow, loginStatus,
} = useAuthLogin()
type Schema = z.output<typeof schema>
const state = reactive({
email: undefined,
password: undefined
})
async function onSubmit(event: FormSubmitEvent<Schema>) {
watch(loginStatus, newVal => {
if (newVal === 'success' || newVal === 'pending') {
isSubmitting.value = true
setTimeout(() => {
const { email, password } = event.data
if (email === 'fahim@gmail.com' && password === '99999999') {
} else {
isSubmitting.value = false
invalidCredentials.value = false
console.log('✅ Data User:', event.data)
return
}
})
invalidCredentials.value = true
isSubmitting.value = false
}, 3000);
}
</script>
<template>
@ -44,9 +28,9 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
Login to access your stock predictions dashboard
</p>
<NuxtUiForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<NuxtUiForm :schema="schema" :state="loginForm" class="space-y-4" @submit="loginNow">
<NuxtUiFormGroup label="Email" name="email" :error="invalidCredentials ? 'Invalid Email or Password' : ''">
<NuxtUiInput v-model="state.email" icon="i-heroicons-envelope" />
<NuxtUiInput v-model="loginForm.email" icon="i-heroicons-envelope" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Password" name="password"
@ -57,10 +41,9 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
Forgot Password?
</NuxtUiButton>
</template>
<NuxtUiInput v-model="state.password" type="password" icon="i-heroicons-lock-closed" id="auth-pw" />
<NuxtUiInput v-model="loginForm.password" type="password" icon="i-heroicons-lock-closed" id="auth-pw" />
</NuxtUiFormGroup>
<NuxtUiButton type="submit" :loading="isSubmitting">
Submit
</NuxtUiButton>

View File

@ -0,0 +1,26 @@
<template>
<NuxtUiModal v-model="registeredModalShown" :prevent-close="true">
<NuxtUiCard>
<div class="space-y-4">
<div class="text-green-500 flex justify-center items-center text-[100px]">
<NuxtUiIcon name="i-heroicons-check-badge" />
</div>
<h2 class="text-base text-center font-semibold text-green-600 leading-none">
<slot name="header" />
</h2>
<div class="flex justify-center ">
<p class="max-w-[250px] text-gray-700 text-center">
<slot />
</p>
</div>
<div class="flex justify-center" v-if="slots.footer">
<slot name="footer" />
</div>
</div>
</NuxtUiCard>
</NuxtUiModal>
</template>
<script lang="ts" setup>
const registeredModalShown = ref(false)
const slots = useSlots()
</script>

View File

@ -3,68 +3,63 @@
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" />
<NuxtUiForm :schema="schema" :state="registerForm" class="space-y-4" @submit="registerNow">
<NuxtUiFormGroup label="Email" name="email" :error="!!isEmailAlreadyRegistered ? 'Email already used.' : ''">
<NuxtUiInput v-model="registerForm.email" icon="i-heroicons-envelope" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Password" name="password">
<NuxtUiInput v-model="state.password" type="password" icon="i-heroicons-lock-closed" />
<NuxtUiInput v-model="registerForm.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" />
<NuxtUiInput v-model="registerForm.password_confirmation" type="password" icon="i-heroicons-lock-closed" />
</NuxtUiFormGroup>
<NuxtUiButton type="submit" :loading="isSubmitting">
Submit
</NuxtUiButton>
</NuxtUiForm>
<AuthModalEmailActivationSent v-model="registeredModalShown">
<template #header>
Registration Successful
</template>
{{ registerData?.message || 'User registered. Please check your email to verify your account.'
}}
<template #footer>
<NuxtUiButton color="green" class="mt-4" @click="onRegisteredModalBtnClick">
OK
</NuxtUiButton>
</template>
</AuthModalEmailActivationSent>
</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 {
registerForm, schema,
isEmailAlreadyRegistered,
registerNow, registerStatus,
registerData, registerError
} = useAuthRegister()
const registeredModalShown = ref(false)
function onRegisteredModalBtnClick() {
window.location.href = '/auth'
}
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>) {
watch(registerStatus, newVal => {
if (newVal === 'pending') {
isSubmitting.value = true
setTimeout(() => {
if (event.data.email === 'fahim@gmail.com') {
usedEmail.value = 'fahim@gmail.com'
} else {
isSubmitting.value = false
return
}
usedEmail.value = null
console.log('✅ Data User:', event.data)
isSubmitting.value = false
}, 3000);
if (newVal === 'success') {
registeredModalShown.value = true
}
})
</script>

View File

@ -1,8 +1,10 @@
<template>
<div>
<NuxtUiButton @click="modalShown = true" color="blue" label="Analyze" />
<NuxtUiButton @click="modalShown = true" color="blue" label="Analyze" icon="i-heroicons-arrow-trending-up-solid"
:disabled="props.disabled" />
<NuxtUiModal v-model="modalShown">
<NuxtUiCard>
<NuxtUiForm :schema="schema" :state="state">
<div class="space-y-2 mb-4">
<h2 class="text-lg font-bold text-center">
Prediction Setup
@ -22,20 +24,42 @@
<NuxtUiSelectMenu v-model="model.predictionPeriod" :options="predictionOptions"
placeholder="Select prediction period" />
</NuxtUiFormGroup>
<div class="space-y-2">
<NuxtUiFormGroup label="Prediction Mode">
<NuxtUiSelectMenu v-model="model.predictionMode" :options="predictionModeOptions"
value-attribute="value" option-attribute="label" />
</NuxtUiFormGroup>
<div class="flex gap-2" v-if="model.predictionMode === 'custom'">
<NuxtUiFormGroup label="Model AR">
<NuxtUiInput type='number' v-model="state.modelAR" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Differencing">
<NuxtUiInput type='number' v-model="state.differencing" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="Model MA">
<NuxtUiInput type='number' v-model="state.modelMA" />
</NuxtUiFormGroup>
</div>
<div class="flex justify-end gap-2">
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<NuxtUiButton label="Cancel" color="red" @click="modalShown = false" />
<NuxtUiButton @click="() => {
emit('prepared')
modalShown = false
}">Analyze Now!</NuxtUiButton>
</div>
</NuxtUiForm>
</NuxtUiCard>
</NuxtUiModal>
</div>
</template>
<script lang="ts" setup>
import type { TDurationType, TModalMakePredictionModel, TModalMakePredictionProps } from '~/types/landing-page/demo/modalMakePrediction';
import { z } from 'zod';
import type { TDurationType, TModalMakePredictionModel, TModalMakePredictionProps, TPredictionMode } from '~/types/landing-page/demo/modalMakePrediction';
const modalShown = ref<boolean>(false)
const model = defineModel<TModalMakePredictionModel>({
@ -43,6 +67,8 @@ const model = defineModel<TModalMakePredictionModel>({
recordPeriod: undefined,
selectedProduct: undefined,
predictionPeriod: undefined,
predictionMode: 'optimal',
arimaModel: undefined
},
required: true
})
@ -52,7 +78,8 @@ watch(() => model.value.recordPeriod, () => {
})
const props = withDefaults(defineProps<TModalMakePredictionProps>(), {
products: undefined
products: undefined,
disabled: false,
})
const emit = defineEmits(['prepared'])
@ -64,4 +91,32 @@ const predictionOptions = computed(() => {
function filterPeriods(periods: TDurationType[], current?: TDurationType) {
return periods.filter(p => periods.indexOf(p) >= periods.indexOf(current || 'daily'));
}
const schema = z.object({
modelAR: z.string().min(1, { message: 'Model AR wajib diisi' }).regex(/^\d+$/, { message: 'Model AR harus angka' }),
differencing: z.string().min(1, { message: 'Differencing wajib diisi' }).regex(/^\d+$/, { message: 'Differencing harus angka' }).max(2),
modelMA: z.string().min(1, { message: 'Model MA wajib diisi' }).regex(/^\d+$/, { message: 'Model MA harus angka' }),
})
const state = reactive<Record<string, number>>({
modelAR: 2,
differencing: 1,
modelMA: 2,
})
watch(state, newVal => {
const { modelAR, differencing, modelMA } = newVal
model.value.arimaModel = [modelAR, differencing, modelMA]
})
type TPredictionModeOpt = {
value: TPredictionMode,
label: string
}
const predictionModeOptions: TPredictionModeOpt[] = [
{ value: 'optimal', label: 'Optimal (2,1,2)' },
{ value: 'auto', label: 'Auto (Flexible)' },
{ value: 'custom', label: 'Custom' },
]
</script>

View File

@ -5,14 +5,14 @@
option-attribute="label" value-attribute="value" />
</NuxtUiFormGroup>
<NuxtUiFormGroup class="grow" label="Value" v-if="valueFormat === 'text'">
<NuxtUiInput />
<NuxtUiInput v-model="localValue" />
</NuxtUiFormGroup>
<NuxtUiFormGroup class="grow" label="Value" v-else-if="valueFormat === 'number'">
<MyInputNumber />
<MyInputNumber v-model="localValue" />
</NuxtUiFormGroup>
<div v-else-if="valueFormat === 'date'">
<NuxtUiFormGroup class="grow" label="Value">
<MyInputNumber />
<MyInputNumber v-model="localValue" />
</NuxtUiFormGroup>
<NuxtUiFormGroup class="grow" label="Format">
<MyInputNumber />
@ -27,6 +27,8 @@ type TValueFormatOptions = {
label: string,
}
const model = defineModel()
const props = defineProps<{
name: string
}>()
@ -38,6 +40,8 @@ const valueFormatOptions: TValueFormatOptions[] = [
{ value: 'number', label: 'Number' },
]
const localValue = ref()
onMounted(() => {
if (props.name.includes('date')) {
valueFormat.value = 'date'
@ -47,4 +51,12 @@ onMounted(() => {
valueFormat.value = 'text'
}
});
onMounted(() => {
localValue.value = model.value
})
onUnmounted(() => {
model.value = localValue.value
})
</script>

View File

@ -0,0 +1,39 @@
<!-- components/UiConfirmModal.vue -->
<template>
<NuxtUiModal v-model="modelValue">
<NuxtUiCard>
<!-- Slot header -->
<template #header>
<slot name="header">
<h3 class="text-lg font-semibold">Confirmation</h3>
</slot>
</template>
<!-- Slot body / default -->
<template #default>
<slot>
<p>Are you sure to continue this action?</p>
</slot>
</template>
<!-- Slot footer -->
<template #footer>
<slot name="footer">
<div class="flex justify-end gap-2">
<NuxtUiButton color="red" variant="soft" @click="emit('cancel')">Cancel</NuxtUiButton>
<NuxtUiButton color="green" :loading="loading" @click="emit('confirm')">Confirm</NuxtUiButton>
</div>
</slot>
</template>
</NuxtUiCard>
</NuxtUiModal>
</template>
<script setup lang="ts">
const modelValue = defineModel<boolean>();
const props = defineProps<{ loading?: boolean }>();
const emit = defineEmits<{
(e: 'confirm'): void;
(e: 'cancel'): void;
}>();
</script>

View File

@ -0,0 +1,13 @@
<template>
<div>
<NuxtUiButton label="Tutorial" icon="i-heroicons-information-circle" @click="modalShown = true" />
<NuxtUiModal v-model="modalShown">
<NuxtUiCard>
</NuxtUiCard>
</NuxtUiModal>
</div>
</template>
<script lang="ts" setup>
const modalShown = ref(false)
</script>

View File

@ -1,4 +1,24 @@
<template>
<div>
</div>
<NuxtUiModal v-model="modelModalShown">
<NuxtUiCard>
<template v-if="!!modelTableData">
<MyFormInputWithType v-for="(item, index) in Object.entries(modelTableData)" :key="index"
v-model="getComputedField(item[0]).value" :name="item[0]">
</MyFormInputWithType>
</template>
</NuxtUiCard>
</NuxtUiModal>
</template>
<script lang="ts" setup>
const modelModalShown = defineModel<boolean>('shown')
const modelTableData = defineModel<Record<string, any>>('table-data')
const getComputedField = (key: string) => computed({
get: () => modelTableData.value?.[key],
set: (val) => {
if (modelTableData.value)
modelTableData.value[key] = val
}
})
</script>

View File

@ -0,0 +1,16 @@
<template>
<NuxtUiModal v-model="modelModalShown">
<NuxtUiCard>
<table v-if="!!modelTableData">
<tr v-for="(item, index) in Object.entries(modelTableData)" :key="index">
<td>{{ item[0] }}</td>
<td>{{ item[1] }}</td>
</tr>
</table>
</NuxtUiCard>
</NuxtUiModal>
</template>
<script lang="ts" setup>
const modelModalShown = defineModel<boolean>('shown')
const modelTableData = defineModel<Record<string, any>>('table-data')
</script>

13
composables/core.ts Normal file
View File

@ -0,0 +1,13 @@
import { useLocalStorage } from "@vueuse/core";
export function useMyAppState() {
return {
isOffline: useState<boolean>('myAppState-isOffline', () => false),
authState: useCookie<'logged-in' | 'logged-out' | 'uncheck'>('myAppState-authState', {
default: () => 'uncheck',
}),
apiAccessToken: useCookie<string | null>("myAppState-accessToken", {
default: () => null,
}),
}
}

View File

@ -0,0 +1,115 @@
import { useLocalStorage } from '@vueuse/core';
import type { AsyncDataRequestStatus } from 'nuxt/app';
import type { TAPIResponse } from '~/types/api-response/basicResponse';
import type { NitroFetchOptions, NitroFetchRequest } from 'nitropack';
export function use$fetchWithAutoReNew<Data = TAPIResponse, ErrorData = Error>(
url: string,
options?: NitroFetchOptions<NitroFetchRequest>
) {
const config = useRuntimeConfig();
const { authState, apiAccessToken } = useMyAppState();
const headers = computed(() => {
let h = {}
if (!!options?.headers) {
if (Array.isArray(options.headers)) {
Object.assign(h, Object.fromEntries(options.headers))
} else if (typeof options.headers === 'object') {
Object.assign(h, options.headers)
}
}
Object.assign(h, {
Authorization: apiAccessToken.value ? `Bearer ${apiAccessToken.value}` : '',
Accept: 'application/json',
})
return h
})
const data = ref<Data | null>(null);
const status = ref<AsyncDataRequestStatus>('idle');
const error = ref<ErrorData | null>(null);
async function refreshAccessToken(): Promise<boolean> {
try {
let newAccessToken = null
const res = await $fetch<TAPIResponse<{ accessToken: string }>>('/auth/refresh-token', {
baseURL: config.public.API_HOST,
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
onResponse: async (ctx) => {
if (ctx.response.ok) {
newAccessToken = ctx.response._data.data.accessToken;
}
},
onResponseError: async (ctx) => {
const statusCode = ctx.response?.status;
if ([401, 403].includes(statusCode)) {
authState.value = 'logged-out'
apiAccessToken.value = null;
}
},
});
if (!!newAccessToken) {
apiAccessToken.value = newAccessToken;
return true;
}
throw new Error('No accessToken received');
} catch (e) {
console.error('🔄 Failed to refresh token', e);
return false;
}
}
async function execute() {
status.value = 'pending';
error.value = null;
try {
await $fetch<Data>(url, {
...options,
headers: headers.value,
baseURL: config.public.API_HOST,
// credentials: 'include',
onResponse: async (ctx) => {
data.value = ctx.response._data;
if (ctx.response.ok) {
if (typeof options?.onResponse === 'function') {
await options.onResponse(ctx);
}
status.value = 'success';
}
},
onResponseError: async (ctx) => {
error.value = ctx?.error ?? ctx.response._data ?? null;
const statusCode = ctx.response?.status;
if ([401, 403].includes(statusCode)) {
const refreshed = await refreshAccessToken();
if (refreshed) {
await execute();
return;
}
}
if (typeof options?.onResponseError === 'function') {
await options?.onResponseError?.(ctx);
}
status.value = 'error';
},
});
} catch (err) {
error.value = err as ErrorData;
status.value = 'error';
console.error('❌ Fetch failed:', err);
}
}
return { data, status, error, execute };
}

180
composables/useAuth.ts Normal file
View File

@ -0,0 +1,180 @@
import { z } from "zod"
import type { TAPIResponse } from "~/types/api-response/basicResponse"
export function useAuthLogin() {
const { authState, apiAccessToken } = useMyAppState()
type Schema = z.output<typeof schema>
const loginForm = reactive<Schema>({
email: '',
password: ''
})
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Must be at least 8 characters')
})
const { execute: loginNow, status: loginStatus } = use$fetchWithAutoReNew<TAPIResponse<{
accessToken: string
}>>('/auth/login', {
body: loginForm,
method: 'post',
credentials: 'include',
onResponse(ctx) {
authState.value = 'logged-in'
apiAccessToken.value = ctx.response._data.data.accessToken
navigateTo('/dashboard')
}
})
return {
loginForm, schema,
loginNow, loginStatus
}
}
export function useAuthLogout() {
const { authState, apiAccessToken } = useMyAppState()
const { execute: logoutNow, status: logoutStatus } = use$fetchWithAutoReNew('/auth/logout', {
method: 'get',
credentials: 'include',
onResponse(ctx) {
authState.value = 'logged-out'
apiAccessToken.value = null
navigateTo('/auth')
}
})
return {
logoutNow, logoutStatus
}
}
export function useAuthRegister() {
const config = useRuntimeConfig()
const baseSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string()
.min(8, 'Must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain at least one uppercase letter')
.regex(/[a-z]/, 'Must contain at least one lowercase letter')
.regex(/[0-9]/, 'Must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Must contain at least one special character'),
password_confirmation: z.string().min(8, 'Must be at least 8 characters'),
emailActivationPage: z.optional(z.string()),
})
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 registerForm = reactive<Schema>({
email: '',
password: '',
password_confirmation: '',
emailActivationPage: `${config.public.HOST}/auth/verify`
})
const isEmailAlreadyRegistered = ref<boolean>(false)
const {
execute: registerNow,
status: registerStatus,
error: registerError,
data: registerData
} = use$fetchWithAutoReNew<TAPIResponse<any>>('/auth/register', {
method: 'post',
body: registerForm,
onResponseError(ctx) {
if (ctx.response._data.message === 'Email already exists') {
isEmailAlreadyRegistered.value = true
}
}
})
return { registerForm, schema, registerNow, registerStatus, registerData, registerError, isEmailAlreadyRegistered }
}
export function useAuthForgotPasswordGetToken() {
const config = useRuntimeConfig()
const schema = z.object({
email: z.string().email('Invalid email'),
emailActivationPage: z.optional(z.string()),
})
type Schema = z.output<typeof schema>
const forgotPasswordForm = reactive<Schema>({
email: '',
emailActivationPage: `${config.public.HOST}/auth/forgot-password`
})
const isEmailNotActivated = ref(false)
const {
execute: submitNow,
status: submitStatus,
error: submitError,
data: data
} = use$fetchWithAutoReNew<TAPIResponse<any>>('/auth/forgot-password', {
method: 'post',
body: forgotPasswordForm,
onResponseError(ctx) {
if (ctx.response.status === 403) {
isEmailNotActivated.value = true
}
}
})
return { forgotPasswordForm, schema, submitNow, submitStatus, data, submitError, isEmailNotActivated }
}
export function useAuthForgotPasswordChangePassword() {
const route = useRoute();
const baseSchema = z.object({
password: z.string()
.min(8, 'Must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain at least one uppercase letter')
.regex(/[a-z]/, 'Must contain at least one lowercase letter')
.regex(/[0-9]/, 'Must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Must contain at least one special character'),
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 forgotPasswordForm = reactive<Schema>({
password: '',
password_confirmation: ''
})
const isTokenExpired = ref<boolean>(false)
const {
execute: submitNow,
status: submitStatus,
error: submitError,
data: data
} = use$fetchWithAutoReNew<TAPIResponse<any>>(`/auth/forgot-password/${route.params.token}`, {
method: 'patch',
body: forgotPasswordForm,
onResponseError(ctx) {
isTokenExpired.value = true
}
})
return { forgotPasswordForm, schema, submitNow, submitStatus, data, submitError, isTokenExpired }
}

View File

@ -0,0 +1,96 @@
import { useLocalStorage, useNetwork } from '@vueuse/core';
import type { UseFetchOptions } from 'nuxt/app';
import type { TAPIResponse } from '~/types/api-response/basicResponse';
export function useFetchWithAutoReNew<Data = TAPIResponse>(
url: string | Request | Ref<string | Request> | (() => string | Request),
options?: UseFetchOptions<Data>
) {
const config = useRuntimeConfig();
const { authState, apiAccessToken } = useMyAppState();
const originalHeadersAsObject = () => {
if (options?.headers) {
if (Array.isArray(options.headers)) {
return Object.fromEntries(options.headers as any[][]);
} else {
return options.headers;
}
}
}
const headers = computed<HeadersInit>(() => {
return {
...originalHeadersAsObject,
Authorization: `Bearer ${apiAccessToken.value}`,
Accept: 'application/json',
};
});
const mergedOptions: UseFetchOptions<Data> = {
...options,
headers,
baseURL: config.public.API_HOST,
async onResponse(ctx) {
if (ctx.response.ok) {
if (typeof options?.onResponse === "function") {
options.onResponse(ctx);
}
}
},
async onResponseError(ctx) {
const status = ctx.response.status;
if ([401, 403].includes(status)) {
await refreshAccessToken()
}
if (typeof options?.onResponseError === "function") {
options.onResponseError(ctx);
}
},
};
const { data, status, error, refresh, clear } = useFetch(url, mergedOptions)
async function refreshAccessToken(): Promise<boolean> {
try {
let newAccessToken = null
await $fetch<TAPIResponse<{
accessToken: string
}>>(`/auth/refresh-token`, {
baseURL: config.public.API_HOST,
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json'
},
onResponse: async (ctx) => {
if (ctx.response.ok) {
newAccessToken = ctx.response._data.data.accessToken;
}
},
onResponseError: async (ctx) => {
error.value = ctx?.error ?? ctx.response._data ?? null;
const statusCode = ctx.response?.status;
if ([401, 403].includes(statusCode)) {
authState.value = 'logged-out'
apiAccessToken.value = null;
}
},
});
if (!!newAccessToken) {
apiAccessToken.value = newAccessToken;
return true;
}
throw new Error('No accessToken received');
} catch (e) {
console.error("🔄 Failed to refresh token:", e);
return false;
} finally {
}
}
return { data, status, error, refresh, clear }
}

View File

@ -1,38 +1,150 @@
import dayjs from '#build/dayjs.imports.mjs'
import type { TableColumn } from '#ui/types'
export function usePredictionTableColumn() {
const requiredColumn = ['date', 'product code', 'product name', 'sold(qty)']
const columnKeys = ref<string[]>([])
const columns: ComputedRef<TableColumn[]> = computed(() => {
if (columnKeys.value.length >= 1) {
return [{
key: 'actions',
sortable: true,
label: 'Actions'
}, ...columnKeys.value.map(v => ({
key: v,
sortable: true,
label: v
}) as TableColumn)]
import type { TProduct } from '~/types/landing-page/demo/modalMakePrediction'
const requiredColumn = [
{ label: 'Date', key: 'date', sortable: true, },
{ label: 'Product Code', key: 'product code', sortable: true, },
{ label: 'Product Name', key: 'product name', sortable: true, },
{ label: 'Sold(qty)', key: 'sold(qty)', sortable: true, }
]
export function usePredictionTable() {
const records = ref<Record<string, any>[]>([])
const processedRecords = ref<Record<string, any>[]>([])
const status = ref<'idle' | 'loading' | 'loaded'>('idle')
const loadingDetail = ref<string | undefined>();
const mismatchDetail = ref<string[]>([])
const columns = ref<TableColumn[]>([])
const missingColumns = ref<string[]>([])
const page = ref(1)
const pageCount = ref(10)
const products = ref<Record<string, any>[]>([])
const rows = computed(() => {
return processedRecords.value.slice((page.value - 1) * pageCount.value, (page.value) * pageCount.value)
})
watch(records, newVal => {
status.value = 'loading'
mismatchDetail.value = []
loadingDetail.value = 'Collecting and validating record'
processedRecords.value = normalizeRecord(newVal, requiredColumn.map(v => v.key))
})
watch(processedRecords, newVal => {
console.log('processed')
loadingDetail.value = 'Load all column keys from record'
columns.value = getColumnsFromRecord(newVal[0], requiredColumn)
loadingDetail.value = 'Checking missing column'
missingColumns.value = getMissingColumns(
Object.keys(newVal[0]),
requiredColumn.map(v => v.key)
)
loadingDetail.value = 'Collecting product list'
const hasProductCode = Object.keys(newVal[0]).includes('product code')
const listedProduct = new Set()
let dateInvalidCounter: number = 0
newVal.forEach((v, i) => {
if (hasProductCode) {
const cv = v['product code']
const cl = v['product name']
if (!listedProduct.has(cv)) {
listedProduct.add(cv)
products.value?.push({
label: cv,
value: cl
})
}
} else {
return [{
key: 'actions',
sortable: true,
label: 'Actions'
}, ...requiredColumn.map(v => ({
key: v,
sortable: true,
label: v,
}))]
const cl = v['product name']
if (!listedProduct.has(cl)) {
listedProduct.add(cl)
products.value?.push({
label: cl,
value: cl
})
}
}
if (!dayjs(v.date).isValid()) {
dateInvalidCounter += 1
}
})
const missingColumn = computed(() => {
const currentColumn = columns.value.map(v => v.key)
return requiredColumn.filter(v => !currentColumn.includes(v))
if (dateInvalidCounter >= 1) {
mismatchDetail.value.push(`There is ${dateInvalidCounter} invalid date inside column 'date'.`)
}
loadingDetail.value = undefined
status.value = 'loaded'
})
return {
columnKeys,
columns,
missingColumn
records, processedRecords, status, loadingDetail, mismatchDetail,
columns, missingColumns,
page, pageCount,
products, rows
}
}
function getProductList(records: Record<string, any>[]) {
return
}
function normalizeRecord(records: Record<string, any>[], requiredColumnKey: string[]) {
return records.map(record => {
const normalized: Record<string, any> = {}
Object.entries(record).forEach(([key, value]) => {
const lowerKey = key.toLowerCase()
if (requiredColumnKey.includes(lowerKey)) {
normalized[lowerKey] = value
} else {
normalized[key] = value
}
})
return normalized
})
}
function getColumnsFromRecord(
records: Record<string, any> = {},
requiredColumn: TableColumn[]
) {
const columnKeys: string[] | Record<string, any>[] = Object.keys(records || {})
const requiredColumnKey = requiredColumn.map(v => v.key)
const finalColumn = [
// {
// key: 'actions',
// sortable: true,
// label: 'Actions'
// },
...requiredColumn,
]
if (columnKeys.length >= 1) {
const candidateCol = columnKeys.map(v => ({
key: v,
label: v,
sortable: true,
}))
finalColumn.push(
...candidateCol.filter(v => !requiredColumnKey.includes(v.key))
)
}
return finalColumn
}
function getMissingColumns(
columnsKey: string[],
requiredColumnKey: string[]
) {
return requiredColumnKey.filter(v => !columnsKey.includes(v))
}

View File

@ -14,6 +14,30 @@ export function useFileToJSON() {
try {
const json = await sheetToJSON(newVal);
if (json) {
const newJSON = json.map((jsonObj) => {
const entries = Object.entries(
jsonObj as Record<string, any>
).map(([key, value]) => {
switch (key.toLowerCase().trim()) {
case 'date':
key = 'date'
break;
case 'product code':
key = 'product code'
break;
case 'product name':
key = 'product name'
break;
case 'sold(qty)':
key = 'sold(qty)'
break;
default:
break;
}
return [key, value];
});
return Object.fromEntries(entries);
})
result.value = json as Record<string, any>[]
}
} catch (e: unknown) {

6
env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare namespace NodeJS {
interface ProcessEnv {
HOST: string
API_HOST: string
}
}

View File

@ -0,0 +1,17 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
const route = useRoute();
const activationToken = route.params.token
const emailVerification = useState<'activated' | 'invalid-token' | 'unset'>('email-verification-state', () => 'unset')
if (!!activationToken) {
const { execute } = use$fetchWithAutoReNew(`/auth/verify/${activationToken}`, {
onResponse(ctx) {
emailVerification.value = 'activated'
},
onResponseError(ctx) {
emailVerification.value = 'invalid-token'
}
})
await execute()
}
});

View File

@ -0,0 +1,15 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
const { apiAccessToken } = useMyAppState();
if (!apiAccessToken.value) {
let gotoAuth = false
const { execute } = use$fetchWithAutoReNew('/auth/refresh-token', {
onResponseError() {
gotoAuth = true
}
})
await execute()
if (gotoAuth)
return navigateTo('/auth')
}
});

View File

@ -1,42 +1,17 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
const token = to.params.token;
const forgotPasswordTokenStatus = useState<'unset' | 'invalid' | 'valid'>('forgot-password-token-status', () => 'unset')
const route = useRoute();
const forgotPasswordToken = route.params.token
const forgotPasswordState = useState<'valid' | 'invalid' | 'unset'>('forgot-password-state', () => 'unset')
if (!token) {
return navigateTo('/login');
if (!!forgotPasswordToken) {
const { execute } = use$fetchWithAutoReNew(`/auth/forgot-password/${forgotPasswordToken}`, {
onResponse(ctx) {
forgotPasswordState.value = 'valid'
},
onResponseError(ctx) {
forgotPasswordState.value = 'invalid'
}
try {
const isValid = await verifyToken(token as string);
if (!isValid) {
forgotPasswordTokenStatus.value = 'invalid'
} else {
forgotPasswordTokenStatus.value = 'valid'
})
await execute()
}
} 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;
}
});

15
middleware/guest.ts Normal file
View File

@ -0,0 +1,15 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
const { authState } = useMyAppState();
if (authState.value === 'logged-in') {
let gotoDashboard = false
const { execute } = use$fetchWithAutoReNew('/is-authenticated', {
onResponse() {
gotoDashboard = true
}
})
await execute()
if (gotoDashboard)
return navigateTo('/dashboard')
}
});

View File

@ -16,6 +16,12 @@ export default defineNuxtConfig({
ui: {
prefix: 'NuxtUi'
},
runtimeConfig: {
public: {
HOST: process.env.HOST,
API_HOST: process.env.API_HOST
}
},
shadcn: {
/**
* Prefix for all the imported component

20
package-lock.json generated
View File

@ -25,6 +25,9 @@
"vue-router": "^4.5.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.15.12"
}
},
"node_modules/@alloc/quick-lru": {
@ -2975,6 +2978,16 @@
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.15.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.12.tgz",
"integrity": "sha512-K0fpC/ZVeb8G9rm7bH7vI0KAec4XHEhBam616nVJCV51bKzJ6oA3luG4WdKoaztxe70QaNjS/xBmcDLmr4PiGw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/parse-path": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
@ -10570,6 +10583,13 @@
"unplugin": "^2.1.0"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unenv": {
"version": "2.0.0-rc.15",
"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.15.tgz",

View File

@ -28,5 +28,8 @@
"vue-router": "^4.5.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.15.12"
}
}

View File

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

View File

@ -4,63 +4,90 @@ import type { FormSubmitEvent } from '#ui/types'
definePageMeta({
middleware: ['forgot-password-confirmation'],
});
const forgotPasswordTokenStatus = useState<'unset' | 'invalid' | 'valid'>('forgot-password-token-status', () => 'unset')
const forgotPasswordState = useState<'valid' | 'invalid' | 'unset'>('forgot-password-state', () => '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);
}
const {
forgotPasswordForm, schema,
submitNow, submitStatus,
isTokenExpired
} = useAuthForgotPasswordChangePassword()
</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>
<section class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-sm">
<div v-if="submitStatus === 'success'">
<NuxtUiCard class="text-center space-y-2">
<NuxtUiIcon name="lucide:check-circle" class="text-green-600 w-11 h-11 mx-auto" />
<div class="space-y-4">
<h2 class="text-lg font-semibold">Password Updated</h2>
<p class="text-sm text-gray-600">Your password has been successfully reset. You can now log
in with your new password.</p>
</div>
<div class="w-full">
<div v-if="forgotPasswordTokenStatus === 'valid'">
<NuxtUiForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<template #footer>
<NuxtUiButton @click="() => {
authSection = 'login'
navigateTo('/auth')
}">Back to Login</NuxtUiButton>
</template>
</NuxtUiCard>
</div>
<div class="min-h-screen flex items-center justify-center p-4 bg-gray-50"
v-else-if="forgotPasswordState === 'valid' && !isTokenExpired">
<div
class="w-full max-w-md bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden transition-all duration-300 hover:shadow-md">
<div class="p-6 border-b border-gray-100">
<h2 class="text-2xl font-semibold text-gray-800 flex items-center gap-2">
<NuxtUiIcon name="i-heroicons-key" />
Reset Password
</h2>
<p class="text-gray-500 mt-1 text-sm">Create a new secure password for your account</p>
</div>
<div class="px-6">
<NuxtUiForm :schema="schema" :state="forgotPasswordForm" class="space-y-5"
@submit="submitNow">
<NuxtUiFormGroup label="New Password" name="password">
<NuxtUiInput v-model="state.newPassword" />
<NuxtUiInput v-model="forgotPasswordForm.password" type="password"
placeholder="Enter your new password" class="focus:ring-primary" />
</NuxtUiFormGroup>
<NuxtUiFormGroup label="New Password Confirmation" name="password_confirmation">
<NuxtUiInput v-model="state.newPassword2" type="password" />
<NuxtUiFormGroup label="Confirm New Password" name="password_confirmation">
<NuxtUiInput v-model="forgotPasswordForm.password_confirmation" type="password"
placeholder="Confirm your new password" class="focus:ring-primary" />
</NuxtUiFormGroup>
<NuxtUiButton type="submit" :loading="isSubmitting">
Submit
<div class="pt-2">
<NuxtUiButton type="submit" :loading="submitStatus === 'pending'" block
class="bg-primary hover:bg-primary/90 transition-colors">
<template #leading>
<SaveIcon v-if="submitStatus !== 'pending'" class="h-4 w-4" />
</template>
{{ submitStatus === 'pending' ? 'Processing...' : 'Reset Password' }}
</NuxtUiButton>
</div>
</NuxtUiForm>
</div>
<div v-else>
<NuxtUiCard class="max-w-md mx-auto text-center">
<div class="px-6 py-4 bg-gray-50 text-center">
<NuxtUiButton @click="() => {
authSection = 'login'
navigateTo('/auth')
}" variant="link" class="text-sm text-primary hover:underline">
Return to login
</NuxtUiButton>
</div>
</div>
</div>
<div v-else-if="forgotPasswordState === 'invalid' || isTokenExpired">
<NuxtUiCard class="text-center space-y-2">
<template #header>
<h2 class="text-xl font-semibold text-red-600">Invalid or Expired Token</h2>
</template>
@ -74,7 +101,7 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
<template #footer>
<NuxtUiButton color="primary" @click="() => {
authSection = 'forgot-password',
authSection = 'forgot-password'
navigateTo('/auth')
}">
Request New Link
@ -83,7 +110,7 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
</NuxtUiCard>
</div>
</div>
</div>
</div>
</section>
</NuxtLayout>
</template>

View File

@ -0,0 +1,160 @@
<template>
<NuxtLayout name="default">
<div class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
<!-- Unset state - Loading -->
<div v-if="emailVerification === 'unset'" class="text-center">
<div class="flex justify-center mb-6">
<NuxtUiIcon name="i-lucide-loader-2" class="text-primary w-16 h-16 animate-spin" />
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-2">Verifying Your Account</h1>
<p class="text-gray-600">Please wait while we verify your email address...</p>
</div>
<!-- Activated state - Success -->
<div v-else-if="emailVerification === 'activated'" class="text-center">
<div class="flex justify-center mb-6">
<NuxtUiIcon name="i-lucide-check-circle" class="text-green-500 w-16 h-16" />
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-2">Account Activated!</h1>
<p class="text-gray-600 mb-6">Your email has been verified successfully. You can now log in to your
account.</p>
<NuxtUiButton to="/auth" block color="primary" size="lg">
Go to Login
</NuxtUiButton>
</div>
<!-- Invalid token state - Error -->
<div v-else-if="emailVerification === 'invalid-token'" class="text-center">
<div class="flex justify-center mb-6">
<NuxtUiIcon name="i-lucide-alert-triangle" class="text-red-500 w-16 h-16" />
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-2">Verification Failed</h1>
<p class="text-gray-600 mb-6">The activation link is invalid or has expired. Please request a new
verification email.</p>
<NuxtUiButton @click="isReSendEmailModalOpen = true" block color="primary" size="lg"
:loading="resendEmailStatus === 'pending'">
Request New Verification
</NuxtUiButton>
<div class="mt-4">
<NuxtUiButton @click="isHelpModalOpen = true" variant="ghost" size="sm" class="text-gray-600">
Need Help?
</NuxtUiButton>
</div>
</div>
</div>
</div>
<!-- Help Modal -->
<NuxtUiModal v-model="isHelpModalOpen" :ui="{ width: 'sm:max-w-lg' }">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-bold text-gray-900">How to Find Your Verification Email</h3>
<NuxtUiButton icon="i-lucide-x" color="gray" variant="ghost" size="sm"
@click="isHelpModalOpen = false" />
</div>
<div class="space-y-4 text-gray-700">
<p>If you can't find your verification email in your inbox, it might be in your spam or junk folder.
Here's how to check:</p>
<div class="space-y-2">
<h4 class="font-semibold">Gmail:</h4>
<ol class="list-decimal ml-5 space-y-1">
<li>Look for the "Spam" folder in the left sidebar</li>
<li>Click on "Spam" to open the folder</li>
<li>Search for emails from our domain</li>
<li>If found, mark it as "Not spam" to ensure future emails arrive in your inbox</li>
</ol>
</div>
<div class="space-y-2">
<h4 class="font-semibold">Outlook/Hotmail:</h4>
<ol class="list-decimal ml-5 space-y-1">
<li>Check the "Junk Email" folder in the left sidebar</li>
<li>If found, right-click the email and select "Mark as not junk"</li>
</ol>
</div>
<div class="space-y-2">
<h4 class="font-semibold">Yahoo Mail:</h4>
<ol class="list-decimal ml-5 space-y-1">
<li>Check the "Spam" folder in the left sidebar</li>
<li>If found, click "Not Spam" to move it to your inbox</li>
</ol>
</div>
<p class="mt-4">Still can't find it? Click the button below to request a new verification email.</p>
</div>
<div class="mt-6 flex justify-between">
<NuxtUiButton @click="isHelpModalOpen = false" variant="outline">Close</NuxtUiButton>
<NuxtUiButton @click="isReSendEmailModalOpen = true" color="primary">Request New Email
</NuxtUiButton>
</div>
</div>
</NuxtUiModal>
<MyModalBasicConfirmation v-model="isReSendEmailModalOpen">
<template #header>
<h3 class="text-lg font-semibold">Resend Activation Email?</h3>
</template>
<p class="text-gray-600">
We will resend the activation email to your registered email address.
Make sure to also check your spam or junk folder.
</p>
<template #footer>
<div class="flex justify-end gap-2">
<NuxtUiButton color="gray" variant="soft" @click="isReSendEmailModalOpen = false">
Cancel
</NuxtUiButton>
<NuxtUiButton :loading="resendEmailStatus === 'pending'" color="green" @click="resendNow">
Resend
</NuxtUiButton>
</div>
</template>
</MyModalBasicConfirmation>
<AuthModalEmailActivationSent v-model="isEmailActivationSentModalIsOpen">
<template #header>
Email Sent Successful
</template>
{{ data?.message || 'Email activation link sent successful. Please check your inbox or spam.'
}}
</AuthModalEmailActivationSent>
</NuxtLayout>
</template>
<script lang="ts" setup>
import type { TAPIResponse } from '~/types/api-response/basicResponse';
definePageMeta({
middleware: 'account-activation'
})
const route = useRoute();
const config = useRuntimeConfig()
const emailVerification = useState<'activated' | 'invalid-token' | 'unset'>('email-verification-state', () => 'unset')
const isHelpModalOpen = ref(false)
const isReSendEmailModalOpen = ref(false)
const isEmailActivationSentModalIsOpen = ref(false)
const { execute: resendNow, data, status: resendEmailStatus } = use$fetchWithAutoReNew<TAPIResponse>(`/auth/re-send-email-activation/${route.params.token}`, {
method: "post",
body: {
emailActivationPage: `${config.public.HOST}/auth/verify`
},
onResponse() {
isHelpModalOpen.value = false
isReSendEmailModalOpen.value = false
isEmailActivationSentModalIsOpen.value = true
},
onResponseError() {
navigateTo('/auth')
}
})
</script>

View File

@ -1,5 +1,9 @@
<template>
<NuxtLayout name="main">
<div></div>
</NuxtLayout>
</template>
<script lang="ts" setup>
definePageMeta({
middleware: 'authentication'
})
</script>

View File

@ -5,72 +5,132 @@
handleDragFile(e)
}
}">
<div class="my-3">
<NuxtUiCard>
<div class="space-y-3">
<h2 class="text-base font-medium">Prediction Dashboard</h2>
</div>
</NuxtUiCard>
</div>
<div>
<NuxtUiCard>
<template #header>
<div class="mb-3 flex gap-2">
<div>
<label for="convert-file-input" class="nuxtui-btn ">
<NuxtUiIcon name="i-heroicons-document-arrow-down" size="16px" />
Import
</label>
<input id="convert-file-input" type="file" hidden @input="handleFileInput" />
</div>
<LandingDemoModalMakePrediction v-model="modalMakePredictionModel" :products />
<LandingDemoModalMakePrediction v-model="modalMakePredictionModel" :products
:disabled="analyzeBtnDisabled" />
</div>
<div class="warning space-y-2">
<NuxtUiAlert v-for="(item, index) in missingColumn" :key="index"
<NuxtUiAlert v-for="(item, index) in missingColumns" :key="index"
icon="i-heroicons-exclamation-circle" color="orange" variant="subtle"
:description="`Column '${item}' is missing.`">
</NuxtUiAlert>
<NuxtUiAlert v-for="(msg, index) in mismatchDetail" :key="index"
icon="i-heroicons-exclamation-circle" color="red" variant="subtle" :description="msg">
</NuxtUiAlert>
</div>
</template>
<template #default>
<NuxtUiTable :columns :loading="convertStatus === 'loading'" :rows="rowsData">
<template #actions-data="{ row }">
<NuxtUiButton icon="i-heroicons-ellipsis-vertical-solid" color="blue"
@click="console.log(row)" />
<NuxtUiTable :columns :loading="convertStatus === 'loading'" :rows="rows">
<template #actions-data="{ row, column, getRowData }">
<!-- <NuxtUiDropdown :items="items" :popper="{ placement: 'bottom-start' }">
<NuxtUiButton icon="i-heroicons-ellipsis-vertical-solid" color="blue" />
<template #item="{ item }: (Record<'item', TPredictionTableDropdown>)">
<div @click="(event) => {
item.data!.value = row;
item.modalShown!.value = true;
}" class="flex items-center justify-between w-full">
<span class="truncate">{{ item.label }}</span>
<NuxtUiIcon :name="(item.icon as string)"
class="flex-shrink-0 h-4 w-4 ms-auto" :class="[
!!item.iconClass ?
item.iconClass :
'text-gray-400 dark:text-gray-500'
]" />
</div>
</template>
</NuxtUiDropdown> -->
</template>
</NuxtUiTable>
</template>
<template #footer>
<div class="flex justify-between">
<span v-if="rows.length < 1">
Nothing here. Please import your spreadsheet or drag your spreadsheet file here.
</span>
<span v-else>
Show {{ rows.length }} data from {{ processedRecords.length }} data
</span>
<div v-if="!!processedRecords && processedRecords.length > 0">
<NuxtUiPagination v-model="page" :page-count="pageCount"
:total="processedRecords.length" />
</div>
</div>
</template>
</NuxtUiCard>
{{ rowsData }}
<MyInputNumber v-model="a" />
</div>
<MyModalViewPredictionRow v-model:shown="items[0][0].modalShown!.value"
v-model:table-data="items[0][0].data!.value" />
<MyModalUpdatePredictionRow v-model:shown="items[0][1].modalShown!.value"
v-model:table-data="items[0][1].data!.value" />
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
import type { TModalMakePredictionModel, TProduct } from '~/types/landing-page/demo/modalMakePrediction'
const a = ref()
definePageMeta({
middleware: 'guest'
})
import type { DropdownItem } from '#ui/types'
import type { TModalMakePredictionModel } from '~/types/landing-page/demo/modalMakePrediction'
const { file, result: convertResult, status: convertStatus } = useFileToJSON()
const { columnKeys, columns, missingColumn } = usePredictionTableColumn()
const rowsData = ref(convertResult.value)
const products = ref<TProduct[]>([])
watch(convertResult, newVal => {
try {
columnKeys.value = Object.keys(newVal[0])
rowsData.value = newVal
products.value = []
const { product_code, product_name } = newVal[0]
const hasRequiredKeys = !!product_code && !!product_name
newVal.forEach(v => {
if (hasRequiredKeys)
products.value.push({
product_code: v.product_code,
product_name: v.product_name
})
})
} catch (error) {
columnKeys.value = []
}
const {
records, processedRecords, status, loadingDetail, mismatchDetail,
columns, missingColumns,
page, pageCount,
products, rows
} = usePredictionTable()
watch(convertResult, newVal => records.value = newVal)
const analyzeBtnDisabled = computed(() => {
const notHaveAnyProduct = products.value.length < 1
const hasMissingColumn = missingColumns.value.length >= 1
const tableHasError = mismatchDetail.value.length >= 1
const tableIsLoading = status.value === 'loading'
return (
notHaveAnyProduct ||
hasMissingColumn ||
tableHasError ||
tableIsLoading
)
})
const modalMakePredictionModel = reactive<TModalMakePredictionModel>({
predictionPeriod: undefined,
recordPeriod: undefined,
selectedProduct: undefined
selectedProduct: undefined,
arimaModel: undefined,
predictionMode: 'optimal'
})
const modal = reactive({
view: {
shown: false,
data: {}
},
update: {
shown: false,
data: {}
},
delete: {
shown: false
}
})
function handleDragFile(e: DragEvent) {
@ -88,4 +148,34 @@ function handleFileInput(e: Event) {
file.value = uploaded;
}
}
type TPredictionTableDropdown = DropdownItem & {
data?: Ref<Record<string, any>>,
modalShown?: Ref<boolean>
}
const items:
TPredictionTableDropdown[][] = [
[{
label: 'View',
icon: 'i-heroicons-eye-20-solid',
shortcuts: ['V'],
iconClass: '',
data: ref({}),
modalShown: ref(false)
}, {
label: 'Edit',
icon: 'i-heroicons-pencil-square-20-solid',
shortcuts: ['E'],
iconClass: '',
data: ref({}),
modalShown: ref(false)
}], [{
label: 'Delete',
icon: 'i-heroicons-trash-20-solid',
shortcuts: ['D'],
iconClass: '',
data: ref({}),
modalShown: ref(false)
}]
]
</script>

View File

@ -0,0 +1,15 @@
export type TAPIResponse<T = Record<string, any>> = {
success: boolean;
message?: string;
error?: Record<string, any>,
data?: T;
};
export type TPaginatedResponse<T = Record<string, any>> = TAPIResponse<T> & {
pagination?: {
total: number;
page: number;
pageSize: number;
totalPages: number;
}
};

View File

@ -1,13 +1,18 @@
export type TPredictionMode = 'auto' | 'optimal' | 'custom'
export type TDurationType = 'daily' | 'weekly' | 'monthly'
export type TModalMakePredictionModel = {
recordPeriod?: TDurationType,
selectedProduct?: string,
predictionPeriod?: TDurationType,
predictionMode?: TPredictionMode,
arimaModel?: number[]
}
export type TProduct = {
product_code: string,
product_name: string
}
export type TModalMakePredictionProps = {
products?: TProduct[]
products?: TProduct[],
disabled?: boolean,
[key: string]: any;
}