Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ NEXT_PUBLIC_PLACEHOLDER_SMALL_IMAGE_URL="https://res.cloudinary.com/placeholder-
NEXT_PUBLIC_PLACEHOLDER_LARGE_IMAGE_URL="https://via.placeholder.com/600"
NEXT_PUBLIC_ALGOLIA_APP_ID = "changeme"
NEXT_PUBLIC_ALGOLIA_PUBLIC_API_KEY = "changeme"
NODE_ENV="development"
NODE_ENV="development"
NEXT_PUBLIC_AUTH_TOKEN_SS_KEY="auth-token"
NEXT_PUBLIC_REFRESH_TOKEN_LS_KEY="refresh-token"
NEXT_PUBLIC_SESSION_TOKEN_LS_KEY="session-token"
NEXT_PUBLIC_AUTH_KEY_TIMEOUT="300000"
38 changes: 37 additions & 1 deletion src/components/Header/Navbar.component.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Link from 'next/link';

import { useEffect, useState } from 'react';
import { hasCredentials } from '../../utils/auth';
import Cart from './Cart.component';
import AlgoliaSearchBox from '../AlgoliaSearch/AlgoliaSearchBox.component';
import MobileSearch from '../AlgoliaSearch/MobileSearch.component';
Expand All @@ -9,6 +10,12 @@ import MobileSearch from '../AlgoliaSearch/MobileSearch.component';
* Includes mobile menu.
*/
const Navbar = () => {
const [loggedIn, setLoggedIn] = useState(false);

useEffect(() => {
setLoggedIn(hasCredentials());
}, []);

return (
<header className="border-b border-gray-200">
<nav id="header" className="top-0 z-50 w-full bg-white">
Expand Down Expand Up @@ -51,6 +58,35 @@ const Navbar = () => {
</Link>
<div className="flex items-center space-x-3">
<AlgoliaSearchBox />
{loggedIn ? (
<Link href="/min-konto">
<span className="text-base uppercase tracking-wider group relative">
<span className="relative inline-block">
<span className="absolute -bottom-1 left-0 w-0 h-px bg-gray-900 group-hover:w-full transition-all duration-500"></span>
Min konto
</span>
</span>
</Link>
) : (
<>
<Link href="/logg-inn">
<span className="text-base uppercase tracking-wider group relative">
<span className="relative inline-block">
<span className="absolute -bottom-1 left-0 w-0 h-px bg-gray-900 group-hover:w-full transition-all duration-500"></span>
Logg inn
</span>
</span>
</Link>
<Link href="/registrer">
<span className="text-base uppercase tracking-wider group relative">
<span className="relative inline-block">
<span className="absolute -bottom-1 left-0 w-0 h-px bg-gray-900 group-hover:w-full transition-all duration-500"></span>
Registrer
</span>
</span>
</Link>
</>
)}
<Cart />
</div>
</div>
Expand Down
61 changes: 61 additions & 0 deletions src/components/User/CustomerAccount.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useQuery } from '@apollo/client';
import { GET_CUSTOMER_ORDERS } from '../../utils/gql/GQL_QUERIES';
import LoadingSpinner from '../LoadingSpinner/LoadingSpinner.component';

interface Order {
id: string;
orderNumber: number;
status: string;
total: string;
date: string;
}

/**
* Customer account component that displays user's orders
* @function CustomerAccount
* @returns {JSX.Element} - Rendered component with order history
*/
const CustomerAccount = () => {
const { loading, error, data } = useQuery(GET_CUSTOMER_ORDERS);

if (loading) return <LoadingSpinner />;
if (error) return <p>Error: {error.message}</p>;

const orders = data?.customer?.orders?.nodes;

return (
<div>
<h1 className="text-2xl font-bold mb-4">Mine ordre</h1>
{orders && orders.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full bg-white">
<thead>
<tr>
<th className="py-2 px-4 border-b">Ordrenummer</th>
<th className="py-2 px-4 border-b">Dato</th>
<th className="py-2 px-4 border-b">Status</th>
<th className="py-2 px-4 border-b">Total</th>
</tr>
</thead>
<tbody>
{orders.map((order: Order) => (
<tr key={order.id}>
<td className="py-2 px-4 border-b">{order.orderNumber}</td>
<td className="py-2 px-4 border-b">
{new Date(order.date).toLocaleDateString()}
</td>
<td className="py-2 px-4 border-b">{order.status}</td>
<td className="py-2 px-4 border-b">{order.total}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p>Du har ingen ordre.</p>
)}
</div>
);
};

export default CustomerAccount;
85 changes: 85 additions & 0 deletions src/components/User/UserLogin.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { login } from '../../utils/auth';
import { InputField } from '../Input/InputField.component';
import LoadingSpinner from '../LoadingSpinner/LoadingSpinner.component';
import Button from '../UI/Button.component';
import { useRouter } from 'next/router';

interface ILoginData {
username: string;
password: string;
}

/**
* User login component that handles user authentication
* @function UserLogin
* @returns {JSX.Element} - Rendered component with login form
*/
const UserLogin = () => {
const methods = useForm<ILoginData>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();

const onSubmit = async (data: ILoginData) => {
setLoading(true);
setError(null);
try {
const customer = await login(data.username, data.password);
if (customer) {
router.push('/min-konto');
} else {
throw new Error('Failed to login');
}
} catch (error: unknown) {
if (error instanceof Error) {
setError(error.message);
} else {
setError('An unknown error occurred.');
}
console.error('Login error:', error);
} finally {
setLoading(false);
}
};

return (
<section className="text-gray-700 container p-4 py-2 mx-auto mb-[8rem] md:mb-0">
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<div className="mx-auto lg:w-1/2 flex flex-wrap">
<InputField
inputName="username"
inputLabel="Brukernavn eller e-post"
type="text"
customValidation={{ required: true }}
/>
<InputField
inputName="password"
inputLabel="Passord"
type="password"
customValidation={{ required: true }}
/>

{error && (
<div className="w-full p-2 text-red-600 text-sm text-center">
{error}
</div>
)}

<div className="w-full p-2">
<div className="mt-4 flex justify-center">
<Button variant="primary" buttonDisabled={loading}>
{loading ? <LoadingSpinner /> : 'Logg inn'}
</Button>
</div>
</div>
</div>
</form>
</FormProvider>
</section>
);
};

export default UserLogin;
25 changes: 25 additions & 0 deletions src/components/User/withAuth.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useRouter } from 'next/router';
import { useEffect, ComponentType } from 'react';
import { hasCredentials } from '../../utils/auth';

const withAuth = <P extends object>(WrappedComponent: ComponentType<P>) => {
const Wrapper = (props: P) => {
const router = useRouter();

useEffect(() => {
if (!hasCredentials()) {
router.push('/logg-inn');
}
}, [router]);

if (!hasCredentials()) {
return null; // or a loading spinner
}

return <WrappedComponent {...props} />;
};

return Wrapper;
};

export default withAuth;
15 changes: 15 additions & 0 deletions src/pages/logg-inn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Layout from '@/components/Layout/Layout.component';
import UserLogin from '@/components/User/UserLogin.component';
import type { NextPage } from 'next';

const LoginPage: NextPage = () => {
return (
<Layout title="Logg inn">
<div className="container mx-auto px-4 py-8">
<UserLogin />
</div>
</Layout>
);
};

export default LoginPage;
16 changes: 16 additions & 0 deletions src/pages/min-konto.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Layout from '@/components/Layout/Layout.component';
import CustomerAccount from '@/components/User/CustomerAccount.component';
import type { NextPage } from 'next';
import withAuth from '@/components/User/withAuth.component';

const CustomerAccountPage: NextPage = () => {
return (
<Layout title="Min konto">
<div className="container mx-auto px-4 py-8">
<CustomerAccount />
</div>
</Layout>
);
};

export default withAuth(CustomerAccountPage);
23 changes: 17 additions & 6 deletions src/utils/apollo/ApolloClient.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
/*eslint complexity: ["error", 6]*/

/* eslint-disable unicorn/no-thenable */
import {
ApolloClient,
InMemoryCache,
createHttpLink,
ApolloLink,
} from '@apollo/client';
import { getAuthToken } from '../auth';

const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds

/**
* Middleware operation
* If we have a session token in localStorage, add it to the GraphQL request as a Session header.
*/
export const middleware = new ApolloLink((operation, forward) => {
export const middleware = new ApolloLink(async (operation, forward) => {
/**
* If session data exist in local storage, set value as session header.
* Here we also delete the session if it is older than 7 days
Expand All @@ -22,6 +24,8 @@ export const middleware = new ApolloLink((operation, forward) => {
? JSON.parse(localStorage.getItem('woo-session'))
: null;

const headers = {};

if (sessionData && sessionData.token && sessionData.createdTime) {
const { token, createdTime } = sessionData;

Expand All @@ -32,14 +36,21 @@ export const middleware = new ApolloLink((operation, forward) => {
localStorage.setItem('woocommerce-cart', JSON.stringify({}));
} else {
// If it's not, use the token
operation.setContext(() => ({
headers: {
'woocommerce-session': `Session ${token}`,
},
}));
headers['woocommerce-session'] = `Session ${token}`;
}
}

if (process.browser) {
const authToken = await getAuthToken();
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
}
}

operation.setContext({
headers,
});

return forward(operation);
});

Expand Down
Loading
Loading