Skip to content

Commit

Permalink
feat(core): add components for change password
Browse files Browse the repository at this point in the history
  • Loading branch information
bc-alexsaiannyi committed Mar 26, 2024
1 parent cfab55b commit 841ffc1
Show file tree
Hide file tree
Showing 7 changed files with 271 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/stale-tigers-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst-core": minor
---

add components for change password
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use server';

import { ZodError } from 'zod';

import {
ChangePasswordSchema,
submitChangePassword,
} from '~/client/mutations/submit-change-password';

export interface State {
status: 'idle' | 'error' | 'success';
message?: string;
}

export const submitChangePasswordForm = async (_previousState: unknown, formData: FormData) => {
try {
const parsedData = ChangePasswordSchema.parse({
customerId: formData.get('customer-id'),
customerToken: formData.get('customer-token'),
newPassword: formData.get('new-password'),
confirmPassword: formData.get('confirm-password'),
});

const response = await submitChangePassword({
newPassword: parsedData.newPassword,
token: parsedData.customerToken,
customerEntityId: Number(parsedData.customerId),
});

if (response.customer.resetPassword.errors.length === 0) {
return { status: 'success', message: '' };
}

return {
status: 'error',
message: response.customer.resetPassword.errors.map((error) => error.message).join('\n'),
};
} catch (error: unknown) {
if (error instanceof ZodError) {
return {
status: 'error',
message: error.issues
.map(({ path, message }) => `${path.toString()}: ${message}.`)
.join('\n'),
};
}

if (error instanceof Error) {
return {
status: 'error',
message: error.message,
};
}

return { status: 'error', message: 'Unknown error' };
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
'use client';

import { Button } from '@bigcommerce/components/button';
import {
Field,
FieldControl,
FieldLabel,
FieldMessage,
Form,
FormSubmit,
} from '@bigcommerce/components/form';
import { Input } from '@bigcommerce/components/input';
import { Message } from '@bigcommerce/components/message';
import { Loader2 as Spinner } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { ChangeEvent, useRef, useState } from 'react';
import { useFormState, useFormStatus } from 'react-dom';

import { useRouter } from '~/navigation';

import { submitChangePasswordForm } from '../_actions/submit-change-password-form';

interface Props {
customerId: number;
customerToken: string;
}

const SubmitButton = () => {
const { pending } = useFormStatus();
const t = useTranslations('Account.SubmitChangePassword');

return (
<Button
className="relative w-fit items-center px-8 py-2"
data-button
disabled={pending}
variant="primary"
>
<>
{pending && (
<>
<span className="absolute z-10 flex h-full w-full items-center justify-center bg-gray-400">
<Spinner aria-hidden="true" className="animate-spin" />
</span>
<span className="sr-only">{t('spinnerText')}</span>
</>
)}
<span aria-hidden={pending}>{t('submitText')}</span>
</>
</Button>
);
};

export const ChangePasswordForm = ({ customerId, customerToken }: Props) => {
const form = useRef<HTMLFormElement>(null);
const router = useRouter();
const [state, formAction] = useFormState(submitChangePasswordForm, {
status: 'idle',
message: '',
});

const [newPassword, setNewPasssword] = useState('');
const [isConfirmPasswordValid, setIsConfirmPasswordValid] = useState(true);

const t = useTranslations('Account.ChangePassword');
let messageText = '';

if (state.status === 'error') {
messageText = state.message;
}

if (state.status === 'success') {
messageText = t('successMessage');
}

const handleNewPasswordChange = (e: ChangeEvent<HTMLInputElement>) =>
setNewPasssword(e.target.value);
const handleConfirmPasswordValidation = (e: ChangeEvent<HTMLInputElement>) => {
const confirmPassword = e.target.value;

return setIsConfirmPasswordValid(confirmPassword === newPassword);
};

if (state.status === 'success') {
setTimeout(() => router.push('/login'), 2000);
}

return (
<>
{(state.status === 'error' || state.status === 'success') && (
<Message className="mb-8 w-full text-gray-500" variant={state.status}>
<p>{messageText}</p>
</Message>
)}

<Form action={formAction} className="mb-14 flex flex-col gap-4 md:py-4 lg:p-0" ref={form}>
<Field className="hidden" name="customer-id">
<FieldControl asChild>
<Input id="customer-id" readOnly type="number" value={customerId} />
</FieldControl>
</Field>
<Field className="hidden" name="customer-token">
<FieldControl asChild>
<Input id="customer-token" readOnly type="text" value={customerToken} />
</FieldControl>
</Field>
<Field className="relative space-y-2 pb-7" name="new-password">
<FieldLabel htmlFor="new-password" isRequired={true}>
{t('newPasswordLabel')}
</FieldLabel>
<FieldControl asChild>
<Input
autoComplete="none"
id="new-password"
onChange={handleNewPasswordChange}
required
type="password"
variant={state.status === 'error' ? 'error' : undefined}
/>
</FieldControl>
</Field>

<Field className="relative space-y-2 pb-7" name="confirm-password">
<FieldLabel htmlFor="confirm-password" isRequired={true}>
{t('confirmPasswordLabel')}
</FieldLabel>
<FieldControl asChild>
<Input
autoComplete="none"
id="confirm-password"
onChange={handleConfirmPasswordValidation}
onInvalid={handleConfirmPasswordValidation}
required
type="password"
variant={!isConfirmPasswordValid || state.status === 'error' ? 'error' : undefined}
/>
</FieldControl>
<FieldMessage
className="absolute inset-x-0 bottom-0 inline-flex w-full text-sm text-gray-500"
match={(value: string) => value !== newPassword}
>
{t('confirmPasswordValidationMessage')}
</FieldMessage>
</Field>

<FormSubmit asChild>
<SubmitButton />
</FormSubmit>
</Form>
</>
);
};
35 changes: 29 additions & 6 deletions apps/core/app/[locale]/(default)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Button } from '@bigcommerce/components/button';
import { NextIntlClientProvider, useMessages, useTranslations } from 'next-intl';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations } from 'next-intl/server';

import { Link } from '~/components/link';
import { LocaleType } from '~/i18n';

import { ChangePasswordForm } from './_components/change-password-form';
import { LoginForm } from './_components/login-form';

export const metadata = {
Expand All @@ -14,17 +16,38 @@ interface Props {
params: {
locale: LocaleType;
};
searchParams: {
[key: string]: string | string[] | undefined;
action?: 'create_account' | 'reset_password' | 'change_password';
c?: string;
t?: string;
};
}

export default function Login({ params: { locale } }: Props) {
const messages = useMessages();
const t = useTranslations('Account.Login');
export default async function Login({ params: { locale }, searchParams }: Props) {
const messages = await getMessages({ locale });
const Account = messages.Account ?? {};
const t = await getTranslations({ locale, namespace: 'Account.Login' });
const action = searchParams.action;
const customerId = searchParams.c;
const customerToken = searchParams.t;

if (action === 'change_password' && customerId && customerToken) {
return (
<div className="mx-auto my-6 max-w-4xl">
<h2 className="mb-8 text-4xl font-black lg:text-5xl">{t('changePasswordHeading')}</h2>
<NextIntlClientProvider locale={locale} messages={{ Account }}>
<ChangePasswordForm customerId={Number(customerId)} customerToken={customerToken} />
</NextIntlClientProvider>
</div>
);
}

return (
<div className="mx-auto my-6 max-w-4xl">
<h2 className="text-h2 mb-8">{t('heading')}</h2>
<h2 className="text-h2 mb-8 text-4xl font-black lg:text-5xl">{t('heading')}</h2>
<div className="mb-12 grid grid-cols-1 lg:grid-cols-2 lg:gap-x-8">
<NextIntlClientProvider locale={locale} messages={{ Account: messages.Account ?? {} }}>
<NextIntlClientProvider locale={locale} messages={{ Account }}>
<LoginForm />
</NextIntlClientProvider>
<div className="flex flex-col gap-4 bg-gray-100 p-8">
Expand Down
12 changes: 8 additions & 4 deletions apps/core/client/mutations/submit-change-password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import { z } from 'zod';
import { client } from '..';
import { graphql } from '../graphql';

export const ChangePasswordSchema = z.object({
newPassword: z.string(),
confirmPassword: z.string(),
});
export const ChangePasswordSchema = z
.object({
customerId: z.string(),
customerToken: z.string(),
newPassword: z.string(),
confirmPassword: z.string(),
})
.required();

interface SubmitChangePassword {
newPassword: z.infer<typeof ChangePasswordSchema>['newPassword'];
Expand Down
12 changes: 12 additions & 0 deletions apps/core/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@
},
"Login": {
"heading": "Log In",
"resetPasswordHeading": "Reset password",
"changePasswordHeading": "Change password",
"Form": {
"errorMessage": "Your email address or password is incorrect. Try signing in again or reset your password",
"emailLabel": "Email",
Expand All @@ -179,6 +181,16 @@
"wishlists": "Save items to your Wish List",
"createLink": "Create Account "
}
},
"ChangePassword": {
"newPasswordLabel": "New password",
"confirmPasswordLabel": "Confirm password",
"confirmPasswordValidationMessage": "Entered passwords are mismatched. Please try again.",
"successMessage": "Password has been updated successfully!"
},
"SubmitChangePassword": {
"spinnerText": "Submitting...",
"submitText": "Change password"
}
},
"NotFound": {
Expand Down
20 changes: 8 additions & 12 deletions packages/components/src/components/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,14 @@ const Field = forwardRef<

Field.displayName = 'Field';

interface FieldMessageProps
extends Omit<ComponentPropsWithRef<typeof FormPrimitive.Message>, 'match'> {
match?: ValidationPattern;
}

const FieldMessage = forwardRef<ElementRef<typeof FormPrimitive.Message>, FieldMessageProps>(
({ className, children, ...props }, ref) => (
<FormPrimitive.Message className={cn(className)} ref={ref} {...props}>
{children}
</FormPrimitive.Message>
),
);
const FieldMessage = forwardRef<
ElementRef<typeof FormPrimitive.Message>,
ComponentPropsWithRef<typeof FormPrimitive.Message>
>(({ className, children, ...props }, ref) => (
<FormPrimitive.Message className={cn(className)} ref={ref} {...props}>
{children}
</FormPrimitive.Message>
));

FieldMessage.displayName = 'FieldMessage';

Expand Down

0 comments on commit 841ffc1

Please sign in to comment.