diff --git a/README.md b/README.md
index 2439f3a..caeac4b 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# NextJS-App-Router-All-In-One
+# NextJS15-GraphQL-Boilerplate
-`Next15 + App router` boilerplate
+`Next15 + GraphQL` boilerplate
## How to use
@@ -65,6 +65,16 @@ $ yarn g
[You can check detail here](./.github/workflows/pull-request-build-check.yml)
+### GraphQL
+
+#### Apollo client with [experimental-nextjs-app-support](https://www.npmjs.com/package/@apollo/experimental-nextjs-app-support)
+
+You can see client apollo provider [here](./src/providers/CustomApolloProvider.tsx)
+
+YOu can see server apollo client [here](./src/graphql/apolloServerClient.ts)
+
+#### Graphql upload with [apollo-upload-client](apollo-upload-client)
+
### Folder Structure
```bash
@@ -82,6 +92,7 @@ $ yarn g
│ ├── assets # Static assets like images, fonts, etc.
│ ├── components # Reusable React components
│ ├── constant # Constants used throughout the application
+│ ├── graphql # Graphql related files like apollo client
│ ├── hooks # Custom React hooks
│ ├── libs # Library files and utilities
│ ├── provider # Context providers for global state management
@@ -112,4 +123,4 @@ $ yarn g
- [x] Jest(with server component)
- [x] Storybook(with server component)
- [x] Generator
-- [ ] GraphQL
+- [x] GraphQL
diff --git a/package.json b/package.json
index d37287b..fe5e60c 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,11 @@
"e2e:headless": "start-server-and-test dev http://localhost:3000 \"cypress run --e2e\""
},
"dependencies": {
+ "@apollo/client": "^3.12.4",
+ "@apollo/experimental-nextjs-app-support": "^0.11.7",
+ "apollo-upload-client": "^18.0.1",
+ "cookies-next": "^5.0.2",
+ "graphql": "^16.10.0",
"next": "15.1.0",
"react": "^19",
"react-dom": "^19",
@@ -43,6 +48,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
+ "@types/apollo-upload-client": "^18.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22",
"@types/react": "^19",
diff --git a/src/app/cars/page.tsx b/src/app/cars/page.tsx
index d22e79d..b4a1939 100644
--- a/src/app/cars/page.tsx
+++ b/src/app/cars/page.tsx
@@ -2,6 +2,8 @@ import 'server-only';
import Cars from '~/components/Cars';
+export const dynamic = 'force-dynamic';
+
export default async function CarsPage(...props: any) {
return ;
}
diff --git a/src/app/landpads/[id]/loading.tsx b/src/app/landpads/[id]/loading.tsx
new file mode 100644
index 0000000..ae4b294
--- /dev/null
+++ b/src/app/landpads/[id]/loading.tsx
@@ -0,0 +1,7 @@
+'use client';
+
+const LandpadLoading = () => {
+ return
Loading
;
+};
+
+export default LandpadLoading;
diff --git a/src/app/landpads/[id]/page-ui.tsx b/src/app/landpads/[id]/page-ui.tsx
new file mode 100644
index 0000000..f71c646
--- /dev/null
+++ b/src/app/landpads/[id]/page-ui.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import Link from 'next/link';
+
+import { Landpad } from '../page-ui';
+import { LandpadIdPageStyled } from './styled';
+
+interface LandpadIdUIPageProps {
+ data: Landpad;
+}
+
+const LandpadsIdUIPage = ({ data }: LandpadIdUIPageProps) => {
+ return (
+
+ {data.full_name}
+ {data.details}
+ {data.status}
+
+ {data.wikipedia}
+
+
+ Go to landpads page
+
+
+ );
+};
+
+export default LandpadsIdUIPage;
diff --git a/src/app/landpads/[id]/page.test.tsx b/src/app/landpads/[id]/page.test.tsx
new file mode 100644
index 0000000..452c569
--- /dev/null
+++ b/src/app/landpads/[id]/page.test.tsx
@@ -0,0 +1,40 @@
+import { render } from '@testing-library/react';
+
+import { apolloServerClient } from '~/graphql/apolloServerClient';
+import { GET_ONE_LANDPAD } from '~/graphql/query/landpad';
+
+import LandpadsIdpage from './page';
+
+jest.mock('../../../graphql/apolloServerClient', () => ({
+ apolloServerClient: jest.fn(),
+}));
+
+describe('LandpadDetailPage', () => {
+ it('should call useQuery with correct variables', async () => {
+ const response = {
+ attempted_landings: null,
+ details: 'details',
+ full_name: 'full_name',
+ id: 'id',
+ landing_type: null,
+ location: { latitude: 0, longitude: 0, name: 'name', region: 'region' },
+ status: 'status',
+ successful_landings: null,
+ wikipedia: 'wikipedia',
+ };
+
+ const mockClient = {
+ query: jest.fn().mockResolvedValue({ data: { landpad: response } }),
+ };
+ (apolloServerClient as jest.Mock).mockResolvedValue(mockClient);
+
+ const params = { id: '1' };
+
+ render(await LandpadsIdpage({ params: Promise.resolve(params) }));
+
+ expect(mockClient.query).toHaveBeenCalledWith({
+ query: GET_ONE_LANDPAD,
+ variables: params,
+ });
+ });
+});
diff --git a/src/app/landpads/[id]/page.tsx b/src/app/landpads/[id]/page.tsx
new file mode 100644
index 0000000..378a594
--- /dev/null
+++ b/src/app/landpads/[id]/page.tsx
@@ -0,0 +1,21 @@
+import 'server-only';
+
+import { apolloServerClient } from '~/graphql/apolloServerClient';
+import { GET_ONE_LANDPAD } from '~/graphql/query/landpad';
+
+import { Landpad } from '../page-ui';
+import LandpadsIdUIPage from './page-ui';
+
+const LandpadsIdpage = async ({ params }: { params: Promise<{ id: string }> }) => {
+ const { id } = await params;
+
+ const client = await apolloServerClient();
+ const { data } = await client.query<{ landpad: Landpad }, { id: string }>({
+ query: GET_ONE_LANDPAD,
+ variables: { id },
+ });
+
+ return ;
+};
+
+export default LandpadsIdpage;
diff --git a/src/app/landpads/[id]/styled.ts b/src/app/landpads/[id]/styled.ts
new file mode 100644
index 0000000..d3e8884
--- /dev/null
+++ b/src/app/landpads/[id]/styled.ts
@@ -0,0 +1,3 @@
+import styled from 'styled-components';
+
+export const LandpadIdPageStyled = styled.div``;
diff --git a/src/app/landpads/page-ui.test.tsx b/src/app/landpads/page-ui.test.tsx
new file mode 100644
index 0000000..59dd51a
--- /dev/null
+++ b/src/app/landpads/page-ui.test.tsx
@@ -0,0 +1,34 @@
+import { render, screen } from '@testing-library/react';
+
+import LandpadsUIPage from './page-ui';
+
+describe('LandpadsUIPage', () => {
+ it('should display loading indicator when loading is true', () => {
+ render();
+
+ expect(screen.getByText('loading')).toBeInTheDocument();
+ });
+
+ it('should render landpad data when loading is false', () => {
+ const data = [
+ {
+ attempted_landings: null,
+ details: 'Landing site details',
+ full_name: 'Landing Pad 1',
+ id: '1',
+ landing_type: null,
+ location: { latitude: 0, longitude: 0, name: 'Location 1', region: 'Region 1' },
+ status: 'Active',
+ successful_landings: null,
+ wikipedia: 'https://wikipedia.org',
+ },
+ ];
+
+ render();
+
+ expect(screen.getByText('Landing Pad 1')).toBeInTheDocument();
+ expect(screen.getByText('Landing site details')).toBeInTheDocument();
+ expect(screen.getByText('Active')).toBeInTheDocument();
+ expect(screen.getByText('https://wikipedia.org')).toBeInTheDocument();
+ });
+});
diff --git a/src/app/landpads/page-ui.tsx b/src/app/landpads/page-ui.tsx
new file mode 100644
index 0000000..25795a9
--- /dev/null
+++ b/src/app/landpads/page-ui.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import Link from 'next/link';
+
+import { LandpadsLoadingStyled, LandpadsPageStyled } from './styled';
+
+export interface Landpad {
+ attempted_landings: string | null;
+ details: string;
+ full_name: string;
+ id: string;
+ landing_type: string | null;
+ location: { latitude: number; longitude: number; name: string; region: string } | null;
+ status: string;
+ successful_landings: string | null;
+ wikipedia: string;
+}
+
+interface LandpadsUIPageProps {
+ data: Landpad[] | undefined;
+ loading: boolean;
+}
+
+const LandpadsUIPage = ({ data, loading }: LandpadsUIPageProps) => {
+ if (loading) return loading;
+
+ return (
+
+ {data?.map(v => (
+
+ {v.full_name}
+ {v.details}
+ {v.status}
+ {
+ e.preventDefault();
+ window.open(v.wikipedia);
+ }}
+ >
+ {v.wikipedia}
+
+
+ ))}
+
+ );
+};
+
+export default LandpadsUIPage;
diff --git a/src/app/landpads/page.test.tsx b/src/app/landpads/page.test.tsx
new file mode 100644
index 0000000..dbcf443
--- /dev/null
+++ b/src/app/landpads/page.test.tsx
@@ -0,0 +1,46 @@
+import { render } from '@testing-library/react';
+
+import { useQuery } from '@apollo/client';
+
+import { GET_LANDPADS } from '~/graphql/query/landpad';
+
+import LandPadsPage from './page';
+
+jest.mock('@apollo/client', () => ({
+ useQuery: jest.fn(),
+ gql: jest.fn(),
+}));
+
+describe('LandPadsPage', () => {
+ it('should call useQuery with correct variables', () => {
+ const response = {
+ data: {
+ landpads: [
+ {
+ attempted_landings: null,
+ details: 'details',
+ full_name: 'full_name',
+ id: 'id',
+ landing_type: null,
+ location: { latitude: 0, longitude: 0, name: 'name', region: 'region' },
+ status: 'status',
+ successful_landings: null,
+ wikipedia: 'wikipedia',
+ },
+ ],
+ },
+ loading: false,
+ };
+
+ (useQuery as jest.Mock).mockReturnValue(response);
+
+ render();
+
+ expect(useQuery).toHaveBeenCalledWith(
+ GET_LANDPADS,
+ expect.objectContaining({
+ variables: { options: { paginate: { page: 1, limit: 10 } } },
+ }),
+ );
+ });
+});
diff --git a/src/app/landpads/page.tsx b/src/app/landpads/page.tsx
new file mode 100644
index 0000000..92779df
--- /dev/null
+++ b/src/app/landpads/page.tsx
@@ -0,0 +1,20 @@
+'use client';
+
+import { useQuery } from '@apollo/client';
+
+import { GET_LANDPADS } from '~/graphql/query/landpad';
+
+import LandpadsUIPage, { Landpad } from './page-ui';
+
+const LandpadsPage = () => {
+ const { data, loading } = useQuery<
+ { landpads: Landpad[] },
+ { options: { paginate: { page: number; limit: number } } }
+ >(GET_LANDPADS, {
+ variables: { options: { paginate: { page: 1, limit: 10 } } },
+ });
+
+ return ;
+};
+
+export default LandpadsPage;
diff --git a/src/app/landpads/styled.ts b/src/app/landpads/styled.ts
new file mode 100644
index 0000000..0d06f71
--- /dev/null
+++ b/src/app/landpads/styled.ts
@@ -0,0 +1,13 @@
+import styled from 'styled-components';
+
+export const LandpadsLoadingStyled = styled.div``;
+
+export const LandpadsPageStyled = styled.div`
+ .status {
+ color: red;
+ }
+
+ a {
+ color: inherit;
+ }
+`;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 05e0402..957ee98 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -6,6 +6,7 @@ import 'server-only';
import type { Metadata } from 'next';
+import { CustomApolloProvider } from '~/providers/CustomApolloProvider';
import CustomThemeProvider from '~/providers/CustomThemeProvider';
import { notoSans } from '~/styles/theme';
@@ -28,7 +29,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
- {children}
+
+ {children}
+
);
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 7517afa..372fb15 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
+import { deleteCookie, getCookie } from 'cookies-next/client';
import Link from 'next/link';
import Button from '~/components/Button';
@@ -14,9 +15,9 @@ export default function Home() {
useEffect(() => {
if (typeof window === 'undefined') return;
- const sessionLogin = sessionStorage.getItem('isLoggedIn');
+ const token = getCookie('token');
- if (sessionLogin !== 'true') return;
+ if (!token) return;
setIsLoggedIn(true);
}, []);
@@ -26,11 +27,14 @@ export default function Home() {
{isLoggedIn ? (
<>
-
+
+
+
+