diff --git a/frontend/src/assets/styles/main.css b/frontend/src/assets/styles/main.css index 412e04da..d8cbf055 100644 --- a/frontend/src/assets/styles/main.css +++ b/frontend/src/assets/styles/main.css @@ -177,8 +177,9 @@ } /* Force First Time User Experience to be different from the rest of the site */ -.page-ftue { +.page-ftue, .new-design { font-family: 'Inter', 'sans-serif'; + font-size: 0.8125rem; } @@ -199,3 +200,7 @@ display: block; } } + +.modal-active { + overflow: hidden; +} diff --git a/frontend/src/components/GenericModal.vue b/frontend/src/components/GenericModal.vue new file mode 100644 index 00000000..ef0a8454 --- /dev/null +++ b/frontend/src/components/GenericModal.vue @@ -0,0 +1,228 @@ + + + + diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index c068de00..2b52a3b3 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -227,7 +227,7 @@ "home": "Home", "immediately": "instant", "inPerson": "In person", - "inviteCode": "Have an invite code?", + "inviteCode": "Invite code", "language": "Choose language", "legal": "Legal", "light": "Light", diff --git a/frontend/src/tbpro/elements/TextInput.vue b/frontend/src/tbpro/elements/TextInput.vue index 7d5bbcc3..8167aab9 100644 --- a/frontend/src/tbpro/elements/TextInput.vue +++ b/frontend/src/tbpro/elements/TextInput.vue @@ -2,6 +2,10 @@ import { ref } from 'vue'; import { HTMLInputElementEvent } from '@/models'; +const model = defineModel(); +const isInvalid = ref(false); +const validationMessage = ref(''); +const isDirty = ref(false); const inputRef = ref(null); /** * Forwards focus intent to the text input element. @@ -17,29 +21,38 @@ const focus = () => { // component properties interface Props { name: string; + help?: string; + remoteError?: string; type?: string; placeholder?: string; required?: boolean; disabled?: boolean; -}; +} withDefaults(defineProps(), { type: 'text', + help: null, + remoteError: null, placeholder: '', required: false, disabled: false, -}) +}); defineEmits(['submit']); defineExpose({ focus }); -const model = defineModel(); -const isInvalid = ref(false); -const validationMessage = ref(''); - const onInvalid = (evt: HTMLInputElementEvent) => { isInvalid.value = true; + isDirty.value = true; validationMessage.value = evt.target.validationMessage; }; +/** + * On any change we mark the element as dirty + * this is so we can delay :invalid until + * the user does something worth invalidating + */ +const onChange = () => { + isDirty.value = true; +}; @@ -86,18 +107,18 @@ const onInvalid = (evt: HTMLInputElementEvent) => { } .help-label { - visibility: hidden; display: flex; - color: var(--colour-danger-default); + color: var(--colour-ti-base); width: 100%; min-height: 0.9375rem; font-size: 0.625rem; line-height: 0.9375rem; -} + padding: 0.1875rem; -.visible { - visibility: visible; + &.invalid { + color: var(--colour-danger-default); + } } .required { @@ -124,7 +145,7 @@ const onInvalid = (evt: HTMLInputElementEvent) => { border-radius: 0.125rem; } - &:invalid { + &.dirty:invalid { --colour-btn-border: var(--colour-ti-critical); } diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index a3b2d7b4..1d0391b8 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -9,10 +9,13 @@ import { import { BooleanResponse, AuthUrlResponse, Exception, AuthUrl, Error, } from '@/models'; -import PrimaryButton from '@/elements/PrimaryButton.vue'; -import AlertBox from '@/elements/AlertBox.vue'; import { posthog, usePosthog } from '@/composables/posthog'; import { MetricEvents } from '@/definitions'; +import GenericModal from '@/components/GenericModal.vue'; +import HomeView from '@/views/HomeView.vue'; +import TextInput from '@/tbpro/elements/TextInput.vue'; +import PrimaryButton from '@/tbpro/elements/PrimaryButton.vue'; +import WordMark from '@/elements/WordMark.vue'; // component constants const user = useUserStore(); @@ -25,32 +28,44 @@ const route = useRoute(); const router = useRouter(); const isPasswordAuth = inject(isPasswordAuthKey); const isFxaAuth = inject(isFxaAuthKey); -// Show the invite flow if they've failed to login -const showInviteFlow = ref(false); // Don't show the invite code field, only the "Join the waiting list" part -const onlyShowJoin = ref(false); +const hideInviteField = ref(false); const isLoading = ref(false); +const formRef = ref(); + +// eslint-disable-next-line no-shadow +enum LoginSteps { + Login = 1, + SignUp = 2, + SignUpConfirm = 3, +} // form input and error const email = ref(''); const password = ref(''); -const loginError = ref(null); const inviteCode = ref(''); -const showConfirmEmailScreen = ref(false); +const loginStep = ref(LoginSteps.Login); +const loginError = ref(null); onMounted(() => { if (route.name === 'join-the-waiting-list') { - showInviteFlow.value = true; - onlyShowJoin.value = true; + hideInviteField.value = true; + loginStep.value = LoginSteps.SignUp; } }); -const closeError = () => { - loginError.value = null; -}; +const handleFormError = (errObj: Exception) => { + const { detail } = errObj; + console.log(formRef.value, detail); + const fields = formRef.value.elements; -const goHome = () => { - router.push('/'); + detail.forEach((err) => { + const name = err?.loc[1]; + if (name) { + fields[name].setCustomValidity(err.ctx.reason); + } + }); + console.log(fields); }; /** @@ -69,6 +84,7 @@ const signUp = async () => { if (error?.value) { // Handle error + handleFormError(data.value as Exception); loginError.value = (data?.value as Exception)?.detail[0]?.msg; isLoading.value = false; return; @@ -84,7 +100,8 @@ const signUp = async () => { }); } } else { - showConfirmEmailScreen.value = true; + // Advance them to the "Check your email" step + loginStep.value = LoginSteps.SignUpConfirm; if (usePosthog) { posthog.capture(MetricEvents.SignUp, { @@ -98,21 +115,19 @@ const signUp = async () => { }; const login = async () => { - if (!email.value || (isPasswordAuth && !password.value)) { - loginError.value = t('error.credentialsIncomplete'); - return; - } - isLoading.value = true; // If they come here the first time we check if they're allowed to login // If they come here a second time after not being allowed it's because they have an invite code. - if (!showInviteFlow.value) { + if (loginStep.value === LoginSteps.Login) { const { data: canLogin, error }: BooleanResponse = await call('can-login').post({ email: email.value, }).json(); + console.log(error.value, canLogin.value); if (error?.value) { + // Handle error + handleFormError(canLogin.value as Exception); // Bleh loginError.value = (canLogin?.value as Exception)?.detail[0]?.msg; isLoading.value = false; @@ -120,7 +135,8 @@ const login = async () => { } if (!canLogin.value) { - showInviteFlow.value = true; + // Advance them to the SignUp step (waiting list right now!) + loginStep.value = LoginSteps.SignUp; isLoading.value = false; return; } @@ -168,14 +184,17 @@ const login = async () => { /** * What to do when hitting the enter key on a particular input - * @param isEmailField - Is this the email field? Only needed for password auth */ -const onEnter = (isEmailField: boolean) => { - if (isEmailField && !isFxaAuth) { +const onEnter = () => { + // Validate the form first + loginError.value = null; + if (!formRef.value.checkValidity()) { + formRef.value.reportValidity(); + isLoading.value = false; return; } - if (showInviteFlow.value && onlyShowJoin.value) { + if (loginStep.value === LoginSteps.SignUp || hideInviteField.value) { signUp(); } else { login(); @@ -184,98 +203,84 @@ const onEnter = (isEmailField: boolean) => { +