|
|
@@ -0,0 +1,281 @@
|
|
|
+<template>
|
|
|
+ <q-page class="login-page column items-center justify-center">
|
|
|
+ <div class="login-overlay" />
|
|
|
+
|
|
|
+ <div class="login-card column items-center">
|
|
|
+ <div class="column items-center fields-card">
|
|
|
+ <q-img :src="Logo" class="login-logo q-mb-lg" />
|
|
|
+
|
|
|
+ <UserTypeBadge tipo="associado" />
|
|
|
+
|
|
|
+ <p class="login-title">{{ $t("auth.enter_code_title") }}</p>
|
|
|
+
|
|
|
+ <div class="code-input-container q-mb-md" @click="focusCodeInput">
|
|
|
+ <div class="code-slots row items-end justify-center">
|
|
|
+ <div
|
|
|
+ v-for="i in 6"
|
|
|
+ :key="i"
|
|
|
+ class="code-slot"
|
|
|
+ :class="{
|
|
|
+ 'code-slot--filled': (form.codigo?.length ?? 0) >= i,
|
|
|
+ 'code-slot--active': inputFocused && (form.codigo?.length ?? 0) === i - 1,
|
|
|
+ }"
|
|
|
+ >
|
|
|
+ <span class="code-char">{{ form.codigo?.[i - 1] ?? "" }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <input
|
|
|
+ ref="codeInputRef"
|
|
|
+ v-model="form.codigo"
|
|
|
+ class="code-hidden-input"
|
|
|
+ maxlength="6"
|
|
|
+ inputmode="numeric"
|
|
|
+ autocomplete="one-time-code"
|
|
|
+ :disabled="loading"
|
|
|
+ @focus="inputFocused = true"
|
|
|
+ @blur="inputFocused = false"
|
|
|
+ @input="onCodeInput"
|
|
|
+ @keydown="filterNonNumeric"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="loading" class="q-mb-md">
|
|
|
+ <q-spinner color="violet-normal" size="24px" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <p class="email-text q-py-md">{{ email }}</p>
|
|
|
+
|
|
|
+ <q-btn
|
|
|
+ unelevated
|
|
|
+ :label="$t('common.actions.resend_email')"
|
|
|
+ color="violet-normal"
|
|
|
+ class="resend-btn"
|
|
|
+ :loading="resending"
|
|
|
+ @click="onResend"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div class="column items-center q-mt-sm q-pt-md">
|
|
|
+ <a href="#" class="login-link q-py-md" @click.prevent="goToLogin">
|
|
|
+ {{ $t("auth.remember_password") }}
|
|
|
+ <strong>{{ $t("auth.do_login") }}</strong>
|
|
|
+ </a>
|
|
|
+ <a href="#" class="login-link q-mt-xs q-pt-md" @click.prevent="goBack">
|
|
|
+ <q-icon name="mdi-arrow-left" size="12px" class="q-mr-xs" />
|
|
|
+ {{ $t("auth.back_to_site") }}
|
|
|
+ </a>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </q-page>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, nextTick } from "vue";
|
|
|
+import { useRouter, useRoute } from "vue-router";
|
|
|
+import { useQuasar } from "quasar";
|
|
|
+import { useI18n } from "vue-i18n";
|
|
|
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
|
|
|
+import { verifyCode, forgotPassword } from "src/api/auth";
|
|
|
+
|
|
|
+import Logo from "src/assets/logo_serprati.svg";
|
|
|
+import UserTypeBadge from "./component/UserTypeBadge.vue";
|
|
|
+
|
|
|
+const router = useRouter();
|
|
|
+const route = useRoute();
|
|
|
+const $q = useQuasar();
|
|
|
+const { t } = useI18n();
|
|
|
+
|
|
|
+const email = route.query.email ?? "";
|
|
|
+
|
|
|
+const codeInputRef = ref(null);
|
|
|
+const inputFocused = ref(false);
|
|
|
+const resending = ref(false);
|
|
|
+const form = ref({ codigo: "" });
|
|
|
+
|
|
|
+const { loading, execute: submitForm } = useSubmitHandler({
|
|
|
+ onSuccess: () =>
|
|
|
+ router.push({ name: "ResetPasswordPage", query: { email, codigo: form.value.codigo } }),
|
|
|
+ onError: () => {
|
|
|
+ $q.notify({ type: "negative", message: t("auth.invalid_code") });
|
|
|
+ form.value.codigo = "";
|
|
|
+ nextTick(() => codeInputRef.value?.focus());
|
|
|
+ },
|
|
|
+});
|
|
|
+
|
|
|
+const focusCodeInput = () => codeInputRef.value?.focus();
|
|
|
+
|
|
|
+const filterNonNumeric = (e) => {
|
|
|
+ const allowed = ["Backspace", "Delete", "Tab", "ArrowLeft", "ArrowRight"];
|
|
|
+ if (!allowed.includes(e.key) && !/^\d$/.test(e.key)) {
|
|
|
+ e.preventDefault();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const onCodeInput = async () => {
|
|
|
+ form.value.codigo = (form.value.codigo ?? "").replace(/\D/g, "").slice(0, 6);
|
|
|
+ if (form.value.codigo.length === 6) {
|
|
|
+ await submitForm(() => verifyCode(email, form.value.codigo));
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const onResend = async () => {
|
|
|
+ resending.value = true;
|
|
|
+ try {
|
|
|
+ await forgotPassword(email, "associado");
|
|
|
+ $q.notify({ type: "positive", message: t("auth.code_resent") });
|
|
|
+ } catch {
|
|
|
+ $q.notify({ type: "negative", message: t("auth.resend_error") });
|
|
|
+ } finally {
|
|
|
+ resending.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const goToLogin = () => router.push({ name: "LoginPage" });
|
|
|
+const goBack = () => router.push({ name: "LoginPage" });
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+@use "sass:map";
|
|
|
+@use "src/css/quasar.variables.scss" as *;
|
|
|
+
|
|
|
+.login-page {
|
|
|
+ min-height: 100dvh;
|
|
|
+ background-image: url("src/assets/pessoas_fundo.jpg");
|
|
|
+ background-size: cover;
|
|
|
+ background-position: center;
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+.login-overlay {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ background: rgba(74, 20, 140, 0.48);
|
|
|
+ z-index: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.login-card {
|
|
|
+ z-index: 1;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: calc(100% - 32px);
|
|
|
+ min-height: calc(100dvh - 32px);
|
|
|
+ background: rgba(255, 255, 255, 0.747);
|
|
|
+ border-radius: 16px;
|
|
|
+ box-shadow: 0 4px 32px rgba(0, 0, 0, 0.15);
|
|
|
+ padding-top: env(safe-area-inset-top);
|
|
|
+ padding-bottom: env(safe-area-inset-bottom);
|
|
|
+}
|
|
|
+
|
|
|
+.fields-card {
|
|
|
+ width: 100%;
|
|
|
+ max-width: 400px;
|
|
|
+ padding: 32px;
|
|
|
+ border-radius: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.login-logo {
|
|
|
+ width: 280px;
|
|
|
+ max-width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.login-title {
|
|
|
+ color: map.get($colors, "violet-normal");
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 700;
|
|
|
+ margin: 0 0 24px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.code-input-container {
|
|
|
+ position: relative;
|
|
|
+ cursor: text;
|
|
|
+ width: 100%;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.code-hidden-input {
|
|
|
+ position: absolute;
|
|
|
+ opacity: 0;
|
|
|
+ pointer-events: none;
|
|
|
+ width: 1px;
|
|
|
+ height: 1px;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.code-slots {
|
|
|
+ display: flex;
|
|
|
+ gap: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.code-slot {
|
|
|
+ width: 40px;
|
|
|
+ height: 52px;
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-end;
|
|
|
+ justify-content: center;
|
|
|
+ padding-bottom: 6px;
|
|
|
+ border-bottom: 2.5px solid map.get($colors, "violet-normal");
|
|
|
+ position: relative;
|
|
|
+ transition: border-color 0.15s;
|
|
|
+
|
|
|
+ &--filled {
|
|
|
+ border-bottom-color: map.get($colors, "violet-normal-hover");
|
|
|
+ }
|
|
|
+
|
|
|
+ &--active {
|
|
|
+ border-bottom-color: map.get($colors, "violet-normal-active");
|
|
|
+
|
|
|
+ &::after {
|
|
|
+ content: "";
|
|
|
+ position: absolute;
|
|
|
+ bottom: 6px;
|
|
|
+ width: 1.5px;
|
|
|
+ height: 20px;
|
|
|
+ background: map.get($colors, "violet-normal");
|
|
|
+ animation: blink-cursor 1s step-end infinite;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.code-char {
|
|
|
+ font-size: 22px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: map.get($colors, "violet-normal");
|
|
|
+ line-height: 1;
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes blink-cursor {
|
|
|
+ 0%, 100% { opacity: 1; }
|
|
|
+ 50% { opacity: 0; }
|
|
|
+}
|
|
|
+
|
|
|
+.email-text {
|
|
|
+ color: map.get($colors, "violet-normal");
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 500;
|
|
|
+ text-align: center;
|
|
|
+ margin: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.resend-btn {
|
|
|
+ font-size: 13px;
|
|
|
+ height: 44px;
|
|
|
+ font-weight: 600;
|
|
|
+ letter-spacing: 0.5px;
|
|
|
+}
|
|
|
+
|
|
|
+.login-link {
|
|
|
+ color: map.get($colors, "violet-normal");
|
|
|
+ font-size: 13px;
|
|
|
+ text-decoration: none;
|
|
|
+ cursor: pointer;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ color: map.get($colors, "violet-normal-hover");
|
|
|
+ text-decoration: underline;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|