Skip to content

Commit

Permalink
feat: sign in form
Browse files Browse the repository at this point in the history
  • Loading branch information
dlhck committed Jan 22, 2025
1 parent 7e5cb55 commit b55d8a4
Show file tree
Hide file tree
Showing 21 changed files with 1,070 additions and 21 deletions.
31 changes: 31 additions & 0 deletions app/(account)/sign-in/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from '@/ui-components/ui/card';
import Link from 'next/link';
import {SignInForm} from "@/components/account/sign-in-form";

export default async function SignIn() {
return (
<section className="flex mt-24 items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Sign in</CardTitle>
<CardDescription>Sign in to your account</CardDescription>
</CardHeader>
<CardContent>
<SignInForm />
</CardContent>
<CardFooter>
<Link className="text-center text-neutral-500 underline" href="/forgot-password">
Forgot your password?
</Link>
</CardFooter>
</Card>
</section>
);
}
33 changes: 18 additions & 15 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { getActiveChannel, getActiveOrder } from 'lib/vendure';
import { ensureStartsWith } from 'lib/utils';
import { cookies } from 'next/headers';
import { ReactNode } from 'react';
import { Toaster } from 'sonner';
import './globals.css';
import { ChannelProvider } from '../components/cart/channel-context';
import { Toaster } from '@/ui-components/ui/toaster';
import { ToastProvider } from '@/ui-components/ui/toast';

const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
Expand Down Expand Up @@ -44,19 +45,21 @@ export default async function RootLayout({ children }: { children: ReactNode })
const activeChannel = getActiveChannel();

return (
<html lang="en" className={GeistSans.variable}>
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
<ChannelProvider channelPromise={activeChannel}>
<CartProvider activeOrderPromise={activeOrder}>
<Navbar />
<main>
{children}
<Toaster closeButton />
<WelcomeToast />
</main>
</CartProvider>
</ChannelProvider>
</body>
</html>
<ToastProvider>
<html lang="en" className={GeistSans.variable}>
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
<ChannelProvider channelPromise={activeChannel}>
<CartProvider activeOrderPromise={activeOrder}>
<Navbar />
<main>
{children}
<Toaster />
<WelcomeToast />
</main>
</CartProvider>
</ChannelProvider>
</body>
</html>
</ToastProvider>
);
}
61 changes: 61 additions & 0 deletions components/account/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use server';

import { authenticateCustomer } from '@/lib/vendure';
import { revalidateTag } from 'next/cache';
import { AUTH_COOKIE_KEY, TAGS } from '@/lib/constants';
import { cookies } from 'next/headers';

export type SignInState =
| {
type: 'success';
id: string;
}
| {
type: 'error';
message: string;
}
| null;

export async function signIn(
prevState: SignInState | null,
formData: FormData
): Promise<SignInState> {
const username = formData.get('username');
const password = formData.get('password');

if (!username || !password) {
return {
type: 'error',
message: 'Missing username or password'
};
}

try {
const res = await authenticateCustomer(username.toString(), password.toString());
revalidateTag(TAGS.customer);

if (res.__typename === 'CurrentUser') {
return {
type: 'success',
id: res.id
};
}

if (res.__typename === 'InvalidCredentialsError' || res.__typename === 'NotVerifiedError') {
return {
type: 'error',
message: res.message
};
}

return {
type: 'error',
message: 'Error signing in'
};
} catch (e) {
return {
type: 'error',
message: 'Error signing in'
};
}
}
14 changes: 14 additions & 0 deletions components/account/open-sign-in.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { UserIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';

export default function OpenSignIn({ className }: { className?: string }) {
return (
<Link
href={'/sign-in'}
className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white"
>
<UserIcon className={clsx('h-4 transition-all ease-in-out hover:scale-110', className)} />
</Link>
);
}
94 changes: 94 additions & 0 deletions components/account/sign-in-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
'use client';

import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/ui-components/ui/form';
import { Input } from '@/ui-components/ui/input';
import { LoaderButton } from '@/components/loader-button';
import { signIn, SignInState } from '@/components/account/actions';
import { useActionState, useEffect, useTransition } from 'react';
import { useToast } from '@/ui-components/hooks/use-toast';

const formSchema = z.object({
username: z.string().min(3),
password: z.string().min(6)
});

type FormSchema = z.infer<typeof formSchema>;

export function SignInForm() {
const { toast } = useToast();
const form = useForm<FormSchema>({
mode: 'all',
resolver: zodResolver(formSchema)
});
const [state, formAction] = useActionState<SignInState, FormData>(signIn, null);
const [pending, startTransaction] = useTransition();

useEffect(() => {
if (state?.type === 'error') {
toast({
variant: 'destructive',
title: 'Error',
description: state.message
});
} else if (state?.type === 'success') {
toast({
variant: 'default',
title: 'Success',
description: 'Welcome back!'
});
}
}, [state]);

return (
<Form {...form}>
<form
action={(formData) => startTransaction(() => formAction(formData))}
className="space-y-4"
>
<FormField
control={form.control}
name="username"
render={({ field }) => {
return (
<FormItem>
<FormLabel>E-Mail</FormLabel>
<FormControl>
<Input placeholder="[email protected]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<LoaderButton loading={pending} className="w-full" type="submit">
Sign in
</LoaderButton>
</form>
</Form>
);
}
3 changes: 1 addition & 2 deletions components/cart/add-to-cart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ function SubmitButton({
</button>
);
}

console.log(selectedVariantId);

if (!selectedVariantId) {
return (
<button
Expand Down
6 changes: 4 additions & 2 deletions components/layout/navbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Link from 'next/link';
import { Suspense } from 'react';
import MobileMenu from './mobile-menu';
import Search, { SearchSkeleton } from './search';
import OpenSignIn from '@/components/account/open-sign-in';

const { SITE_NAME } = process.env;

Expand Down Expand Up @@ -32,7 +33,7 @@ export async function Navbar() {
</Link>
{menu.length ? (
<ul className="hidden gap-6 text-sm md:flex md:items-center">
{menu.map(item => (
{menu.map((item) => (
<li key={item.slug}>
<Link
href={`/search/${item.slug}`}
Expand All @@ -51,7 +52,8 @@ export async function Navbar() {
<Search />
</Suspense>
</div>
<div className="flex justify-end md:w-1/3">
<div className="flex justify-end gap-2 md:w-1/3">
<OpenSignIn />
<CartModal />
</div>
</div>
Expand Down
19 changes: 19 additions & 0 deletions components/loader-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ComponentProps } from 'react';
import { Button } from '@/ui-components/ui/button';
import { CgSpinner } from 'react-icons/cg';

type LoaderButtonProps = {
loading?: boolean;
};

export function LoaderButton({
loading,
children,
...props
}: LoaderButtonProps & ComponentProps<typeof Button>) {
return (
<Button {...props} disabled={loading}>
{loading ? <CgSpinner /> : children}
</Button>
);
}
5 changes: 4 additions & 1 deletion lib/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const AUTH_COOKIE_KEY = 'vendure-auth-token'

export type SortFilterItem = {
name: string;
slug: string | null;
Expand Down Expand Up @@ -29,5 +31,6 @@ export const TAGS = {
products: 'products',
cart: 'cart',
channel: 'channel',
facets: 'facets'
facets: 'facets',
customer: 'customer'
};
22 changes: 22 additions & 0 deletions lib/vendure/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
AddItemToOrderMutationVariables,
AdjustOrderLineMutation,
AdjustOrderLineMutationVariables,
AuthenticateMutation,
AuthenticateMutationVariables,
CollectionsQuery,
FacetValueFilterInput,
GetActiveChannelQuery,
Expand Down Expand Up @@ -46,6 +48,7 @@ import { DocumentNode } from 'graphql';
import { getActiveOrderQuery } from './queries/active-order';
import { getActiveChannelQuery } from './queries/active-channel';
import { getFacetsQuery } from './queries/facets';
import { authenticate } from '@/lib/vendure/mutations/customer';

const endpoint = process.env.VENDURE_ENDPOINT || 'http://localhost:3000/shop-api';

Expand Down Expand Up @@ -323,6 +326,25 @@ export async function getProducts({
return res.body.search.items;
}

export async function authenticateCustomer(username: string, password: string) {
const res = await vendureFetch<AuthenticateMutation, AuthenticateMutationVariables>({
query: authenticate,
tags: [TAGS.customer],
variables: {
input: {
native: {
username,
password
}
}
}
});

await updateAuthCookie(res.headers);

return res.body.authenticate;
}

export async function getPage(slug: string) {
// TODO: Implement with custom entity
return undefined;
Expand Down
21 changes: 21 additions & 0 deletions lib/vendure/mutations/customer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import gql from 'graphql-tag';

export const authenticate = gql`
mutation authenticate($input: AuthenticationInput!) {
authenticate(input: $input) {
... on CurrentUser {
__typename
id
identifier
}
... on InvalidCredentialsError {
__typename
message
}
... on NotVerifiedError {
__typename
message
}
}
}
`;
Loading

0 comments on commit b55d8a4

Please sign in to comment.