ws-02 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/CLAUDE.md +1 -0
- package/README.md +36 -0
- package/app/cart/page.js +88 -0
- package/app/components/DarkLightToggle.js +39 -0
- package/app/components/LoginForm.js +78 -0
- package/app/components/Navbar.js +58 -0
- package/app/components/ProductCard.js +50 -0
- package/app/context/AuthContext.js +46 -0
- package/app/context/CartContext.js +62 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +29 -0
- package/app/layout.tsx +44 -0
- package/app/login/page.js +9 -0
- package/app/page.tsx +39 -0
- package/app/products/[id]/page.js +23 -0
- package/app/profile/page.js +49 -0
- package/app/utils/api.js +39 -0
- package/eslint.config.mjs +18 -0
- package/next.config.ts +16 -0
- package/package.json +28 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<!-- BEGIN:nextjs-agent-rules -->
|
|
2
|
+
# This is NOT the Next.js you know
|
|
3
|
+
|
|
4
|
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
|
5
|
+
<!-- END:nextjs-agent-rules -->
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
|
2
|
+
|
|
3
|
+
## Getting Started
|
|
4
|
+
|
|
5
|
+
First, run the development server:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm run dev
|
|
9
|
+
# or
|
|
10
|
+
yarn dev
|
|
11
|
+
# or
|
|
12
|
+
pnpm dev
|
|
13
|
+
# or
|
|
14
|
+
bun dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
18
|
+
|
|
19
|
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
20
|
+
|
|
21
|
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
|
22
|
+
|
|
23
|
+
## Learn More
|
|
24
|
+
|
|
25
|
+
To learn more about Next.js, take a look at the following resources:
|
|
26
|
+
|
|
27
|
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
28
|
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
29
|
+
|
|
30
|
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
31
|
+
|
|
32
|
+
## Deploy on Vercel
|
|
33
|
+
|
|
34
|
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
35
|
+
|
|
36
|
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
package/app/cart/page.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import Image from "next/image";
|
|
3
|
+
import { useCart } from "@/app/context/CartContext";
|
|
4
|
+
|
|
5
|
+
export default function CartPage() {
|
|
6
|
+
const { cart, increaseQuantity, decreaseQuantity, removeFromCart } = useCart();
|
|
7
|
+
|
|
8
|
+
const total = cart.reduce(
|
|
9
|
+
(sum, item) => sum + item.price * item.quantity,
|
|
10
|
+
0
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
if (cart.length === 0) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="p-4">
|
|
16
|
+
<h1 className="text-3xl font-bold mb-4">Votre Panier</h1>
|
|
17
|
+
<p className="text-lg">
|
|
18
|
+
Vous n'avez encore ajouté aucun produit à votre panier.
|
|
19
|
+
</p>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="max-w-screen-lg mx-auto p-6">
|
|
26
|
+
<h1 className="text-3xl font-bold mb-4">Votre Panier</h1>
|
|
27
|
+
|
|
28
|
+
<ul className="space-y-4">
|
|
29
|
+
{cart.map((item) => (
|
|
30
|
+
<li
|
|
31
|
+
key={item.id}
|
|
32
|
+
className="flex justify-between items-center border-b pb-4 gap-4"
|
|
33
|
+
>
|
|
34
|
+
<div className="flex items-center space-x-4 min-w-0">
|
|
35
|
+
<div className="relative w-16 h-16 shrink-0 bg-gray-50">
|
|
36
|
+
<Image
|
|
37
|
+
src={item.image}
|
|
38
|
+
alt={item.title}
|
|
39
|
+
fill
|
|
40
|
+
className="object-contain"
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
<div className="min-w-0">
|
|
44
|
+
<h2 className="font-bold truncate">{item.title}</h2>
|
|
45
|
+
<p className="text-gray-600">{item.price.toFixed(2)} €</p>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div className="flex items-center gap-4 shrink-0">
|
|
50
|
+
<div className="flex items-center gap-2">
|
|
51
|
+
<button
|
|
52
|
+
onClick={() => decreaseQuantity(item.id)}
|
|
53
|
+
aria-label="Diminuer la quantité"
|
|
54
|
+
className="w-8 h-8 flex items-center justify-center rounded border border-gray-300 hover:bg-gray-100"
|
|
55
|
+
>
|
|
56
|
+
−
|
|
57
|
+
</button>
|
|
58
|
+
<span className="w-6 text-center">{item.quantity}</span>
|
|
59
|
+
<button
|
|
60
|
+
onClick={() => increaseQuantity(item.id)}
|
|
61
|
+
aria-label="Augmenter la quantité"
|
|
62
|
+
className="w-8 h-8 flex items-center justify-center rounded border border-gray-300 hover:bg-gray-100"
|
|
63
|
+
>
|
|
64
|
+
+
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<span className="w-20 text-right font-semibold">
|
|
69
|
+
{(item.price * item.quantity).toFixed(2)} €
|
|
70
|
+
</span>
|
|
71
|
+
|
|
72
|
+
<button
|
|
73
|
+
onClick={() => removeFromCart(item.id)}
|
|
74
|
+
className="text-red-600 hover:text-red-800 hover:underline"
|
|
75
|
+
>
|
|
76
|
+
Supprimer
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
</li>
|
|
80
|
+
))}
|
|
81
|
+
</ul>
|
|
82
|
+
|
|
83
|
+
<div className="mt-6 text-right text-xl font-bold">
|
|
84
|
+
Total : {total.toFixed(2)} €
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
|
|
4
|
+
const DarkLightToggle = () => {
|
|
5
|
+
const [isDarkMode, setIsDarkMode] = useState(false);
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (localStorage.getItem('theme') === 'dark') {
|
|
9
|
+
setIsDarkMode(true);
|
|
10
|
+
document.documentElement.classList.add('dark');
|
|
11
|
+
} else {
|
|
12
|
+
setIsDarkMode(false);
|
|
13
|
+
document.documentElement.classList.remove('dark');
|
|
14
|
+
}
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
const toggleTheme = () => {
|
|
18
|
+
if (isDarkMode) {
|
|
19
|
+
document.documentElement.classList.remove('dark');
|
|
20
|
+
localStorage.setItem('theme', 'light');
|
|
21
|
+
} else {
|
|
22
|
+
document.documentElement.classList.add('dark');
|
|
23
|
+
localStorage.setItem('theme', 'dark');
|
|
24
|
+
}
|
|
25
|
+
setIsDarkMode(!isDarkMode);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<button
|
|
30
|
+
onClick={toggleTheme}
|
|
31
|
+
aria-label="Basculer entre le mode clair et le mode sombre"
|
|
32
|
+
className="p-2 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
33
|
+
>
|
|
34
|
+
{isDarkMode ? <span>🌙</span> : <span>🌞</span>}
|
|
35
|
+
</button>
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default DarkLightToggle;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useAuth } from "@/app/context/AuthContext";
|
|
5
|
+
|
|
6
|
+
export default function LoginForm() {
|
|
7
|
+
const { login, error } = useAuth();
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
|
|
10
|
+
const [username, setUsername] = useState("");
|
|
11
|
+
const [password, setPassword] = useState("");
|
|
12
|
+
const [submitting, setSubmitting] = useState(false);
|
|
13
|
+
|
|
14
|
+
const handleSubmit = async (e) => {
|
|
15
|
+
e.preventDefault();
|
|
16
|
+
setSubmitting(true);
|
|
17
|
+
const success = await login(username, password);
|
|
18
|
+
setSubmitting(false);
|
|
19
|
+
|
|
20
|
+
if (success) {
|
|
21
|
+
router.push("/profile");
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<form
|
|
27
|
+
onSubmit={handleSubmit}
|
|
28
|
+
className="w-full max-w-sm mx-auto bg-white p-6 rounded-2xl shadow space-y-4"
|
|
29
|
+
>
|
|
30
|
+
<h1 className="text-2xl font-bold text-gray-800">Connexion</h1>
|
|
31
|
+
|
|
32
|
+
{error && (
|
|
33
|
+
<p className="bg-red-100 text-red-700 text-sm rounded p-2">{error}</p>
|
|
34
|
+
)}
|
|
35
|
+
|
|
36
|
+
<div className="flex flex-col gap-1">
|
|
37
|
+
<label htmlFor="username" className="text-sm font-medium text-gray-700">
|
|
38
|
+
Identifiant
|
|
39
|
+
</label>
|
|
40
|
+
<input
|
|
41
|
+
id="username"
|
|
42
|
+
type="text"
|
|
43
|
+
value={username}
|
|
44
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
45
|
+
required
|
|
46
|
+
className="border border-gray-300 rounded px-3 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div className="flex flex-col gap-1">
|
|
51
|
+
<label htmlFor="password" className="text-sm font-medium text-gray-700">
|
|
52
|
+
Mot de passe
|
|
53
|
+
</label>
|
|
54
|
+
<input
|
|
55
|
+
id="password"
|
|
56
|
+
type="password"
|
|
57
|
+
value={password}
|
|
58
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
59
|
+
required
|
|
60
|
+
className="border border-gray-300 rounded px-3 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<button
|
|
65
|
+
type="submit"
|
|
66
|
+
disabled={submitting}
|
|
67
|
+
className="w-full bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors disabled:opacity-60"
|
|
68
|
+
>
|
|
69
|
+
{submitting ? "Connexion..." : "Se connecter"}
|
|
70
|
+
</button>
|
|
71
|
+
|
|
72
|
+
<p className="text-xs text-gray-500">
|
|
73
|
+
Démo FakeStoreAPI — identifiant : <code>mor_2314</code>, mot de
|
|
74
|
+
passe : <code>83r5^_</code>
|
|
75
|
+
</p>
|
|
76
|
+
</form>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { AiOutlineShoppingCart } from 'react-icons/ai';
|
|
5
|
+
import { useCart } from "@/app/context/CartContext";
|
|
6
|
+
import { useAuth } from "@/app/context/AuthContext";
|
|
7
|
+
import DarkLightToggle from "@/app/components/DarkLightToggle";
|
|
8
|
+
|
|
9
|
+
export default function Navbar() {
|
|
10
|
+
const { cart } = useCart();
|
|
11
|
+
const { accessToken, logout } = useAuth();
|
|
12
|
+
const router = useRouter();
|
|
13
|
+
|
|
14
|
+
const itemCount = cart.reduce((sum, item) => sum + item.quantity, 0);
|
|
15
|
+
|
|
16
|
+
const handleLogout = () => {
|
|
17
|
+
logout();
|
|
18
|
+
router.push("/");
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<nav className="bg-gray-800 text-white p-4">
|
|
23
|
+
<div className="container mx-auto flex justify-between items-center">
|
|
24
|
+
<Link href="/">
|
|
25
|
+
<span className="text-2xl font-bold cursor-pointer">Fake Store</span>
|
|
26
|
+
</Link>
|
|
27
|
+
|
|
28
|
+
<div className="flex items-center gap-6">
|
|
29
|
+
<DarkLightToggle />
|
|
30
|
+
|
|
31
|
+
{accessToken ? (
|
|
32
|
+
<>
|
|
33
|
+
<Link href="/profile" className="hover:underline">
|
|
34
|
+
Profil
|
|
35
|
+
</Link>
|
|
36
|
+
<button onClick={handleLogout} className="hover:underline">
|
|
37
|
+
Déconnexion
|
|
38
|
+
</button>
|
|
39
|
+
</>
|
|
40
|
+
) : (
|
|
41
|
+
<Link href="/login" className="hover:underline">
|
|
42
|
+
Connexion
|
|
43
|
+
</Link>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
<Link href="/cart" className="relative" aria-label="Voir le panier">
|
|
47
|
+
<AiOutlineShoppingCart className="text-2xl cursor-pointer" />
|
|
48
|
+
{itemCount > 0 && (
|
|
49
|
+
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full h-5 min-w-5 px-1 flex items-center justify-center">
|
|
50
|
+
{itemCount}
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
</Link>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</nav>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import Image from "next/image";
|
|
4
|
+
import { useCart } from "@/app/context/CartContext";
|
|
5
|
+
|
|
6
|
+
export default function ProductCard({ product }) {
|
|
7
|
+
const { addToCart } = useCart();
|
|
8
|
+
|
|
9
|
+
const handleAddToCart = () => {
|
|
10
|
+
addToCart(product);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex flex-col bg-white dark:bg-gray-800 rounded-2xl shadow hover:shadow-lg transition-shadow duration-200 overflow-hidden h-full">
|
|
15
|
+
<Link href={`/products/${product.id}`} className="flex flex-col flex-1 cursor-pointer">
|
|
16
|
+
<div className="relative w-full h-48 bg-gray-50 dark:bg-gray-700">
|
|
17
|
+
<Image
|
|
18
|
+
src={product.image}
|
|
19
|
+
alt={product.title}
|
|
20
|
+
fill
|
|
21
|
+
className="object-contain p-4"
|
|
22
|
+
/>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div className="flex flex-col flex-1 p-4 gap-2">
|
|
26
|
+
<span className="text-xs font-medium text-indigo-600 dark:text-indigo-400 uppercase tracking-wide">
|
|
27
|
+
{product.category}
|
|
28
|
+
</span>
|
|
29
|
+
|
|
30
|
+
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-100 line-clamp-2 flex-1">
|
|
31
|
+
{product.title}
|
|
32
|
+
</h2>
|
|
33
|
+
|
|
34
|
+
<span className="text-lg font-bold text-gray-900 dark:text-white mt-2">
|
|
35
|
+
${product.price.toFixed(2)}
|
|
36
|
+
</span>
|
|
37
|
+
</div>
|
|
38
|
+
</Link>
|
|
39
|
+
|
|
40
|
+
<div className="p-4 pt-0">
|
|
41
|
+
<button
|
|
42
|
+
onClick={handleAddToCart}
|
|
43
|
+
className="w-full bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors"
|
|
44
|
+
>
|
|
45
|
+
Ajouter au panier
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useContext, useState } from "react";
|
|
3
|
+
import { loginUser } from "@/app/utils/api";
|
|
4
|
+
|
|
5
|
+
const AuthContext = createContext();
|
|
6
|
+
|
|
7
|
+
export const AuthProvider = ({ children }) => {
|
|
8
|
+
const [accessToken, setAccessToken] = useState(null);
|
|
9
|
+
const [error, setError] = useState(null);
|
|
10
|
+
|
|
11
|
+
const login = async (username, password) => {
|
|
12
|
+
setError(null);
|
|
13
|
+
try {
|
|
14
|
+
const data = await loginUser(username, password);
|
|
15
|
+
const { token } = data;
|
|
16
|
+
|
|
17
|
+
if (token) {
|
|
18
|
+
setAccessToken(token);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
setError("Connexion impossible : aucun token reçu.");
|
|
23
|
+
return false;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
const apiMessage =
|
|
26
|
+
typeof error.response?.data === "string"
|
|
27
|
+
? error.response.data
|
|
28
|
+
: error.response?.data?.message;
|
|
29
|
+
setError(apiMessage || "Identifiant ou mot de passe incorrect.");
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const logout = () => {
|
|
35
|
+
setAccessToken(null);
|
|
36
|
+
setError(null);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<AuthContext.Provider value={{ accessToken, error, login, logout }}>
|
|
41
|
+
{children}
|
|
42
|
+
</AuthContext.Provider>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const useAuth = () => useContext(AuthContext);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useContext, useState } from "react";
|
|
3
|
+
|
|
4
|
+
const CartContext = createContext();
|
|
5
|
+
|
|
6
|
+
export const CartProvider = ({ children }) => {
|
|
7
|
+
const [cart, setCart] = useState([]);
|
|
8
|
+
|
|
9
|
+
const addToCart = (product) => {
|
|
10
|
+
setCart((prevCart) => {
|
|
11
|
+
const existing = prevCart.find((item) => item.id === product.id);
|
|
12
|
+
if (existing) {
|
|
13
|
+
return prevCart.map((item) =>
|
|
14
|
+
item.id === product.id
|
|
15
|
+
? { ...item, quantity: item.quantity + 1 }
|
|
16
|
+
: item
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return [...prevCart, { ...product, quantity: 1 }];
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const increaseQuantity = (id) => {
|
|
24
|
+
setCart((prevCart) =>
|
|
25
|
+
prevCart.map((item) =>
|
|
26
|
+
item.id === id ? { ...item, quantity: item.quantity + 1 } : item
|
|
27
|
+
)
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const decreaseQuantity = (id) => {
|
|
32
|
+
setCart((prevCart) =>
|
|
33
|
+
prevCart
|
|
34
|
+
.map((item) =>
|
|
35
|
+
item.id === id
|
|
36
|
+
? { ...item, quantity: item.quantity - 1 }
|
|
37
|
+
: item
|
|
38
|
+
)
|
|
39
|
+
.filter((item) => item.quantity > 0)
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const removeFromCart = (id) => {
|
|
44
|
+
setCart((prevCart) => prevCart.filter((item) => item.id !== id));
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<CartContext.Provider
|
|
49
|
+
value={{
|
|
50
|
+
cart,
|
|
51
|
+
addToCart,
|
|
52
|
+
increaseQuantity,
|
|
53
|
+
decreaseQuantity,
|
|
54
|
+
removeFromCart,
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
{children}
|
|
58
|
+
</CartContext.Provider>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const useCart = () => useContext(CartContext);
|
package/app/favicon.ico
ADDED
|
Binary file
|
package/app/globals.css
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
/* Tailwind v4 : pilote la variante `dark:` via la classe `.dark`
|
|
4
|
+
(au lieu de prefers-color-scheme), pour que le bouton de bascule
|
|
5
|
+
contrôle réellement le thème. */
|
|
6
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
7
|
+
|
|
8
|
+
:root {
|
|
9
|
+
--background: #ffffff;
|
|
10
|
+
--foreground: #171717;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.dark {
|
|
14
|
+
--background: #0a0a0a;
|
|
15
|
+
--foreground: #ededed;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@theme inline {
|
|
19
|
+
--color-background: var(--background);
|
|
20
|
+
--color-foreground: var(--foreground);
|
|
21
|
+
--font-sans: var(--font-geist-sans);
|
|
22
|
+
--font-mono: var(--font-geist-mono);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
body {
|
|
26
|
+
background: var(--background);
|
|
27
|
+
color: var(--foreground);
|
|
28
|
+
font-family: Arial, Helvetica, sans-serif;
|
|
29
|
+
}
|
package/app/layout.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { Geist, Geist_Mono } from "next/font/google";
|
|
3
|
+
import "./globals.css";
|
|
4
|
+
|
|
5
|
+
import Navbar from '@/app/components/Navbar.js';
|
|
6
|
+
import { CartProvider } from '@/app/context/CartContext';
|
|
7
|
+
import { AuthProvider } from '@/app/context/AuthContext';
|
|
8
|
+
|
|
9
|
+
const geistSans = Geist({
|
|
10
|
+
variable: "--font-geist-sans",
|
|
11
|
+
subsets: ["latin"],
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const geistMono = Geist_Mono({
|
|
15
|
+
variable: "--font-geist-mono",
|
|
16
|
+
subsets: ["latin"],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const metadata: Metadata = {
|
|
20
|
+
title: "Create Next App",
|
|
21
|
+
description: "Generated by create next app",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default function RootLayout({
|
|
25
|
+
children,
|
|
26
|
+
}: Readonly<{
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
}>) {
|
|
29
|
+
return (
|
|
30
|
+
<html
|
|
31
|
+
lang="en"
|
|
32
|
+
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
|
33
|
+
>
|
|
34
|
+
<body className="min-h-full flex flex-col">
|
|
35
|
+
<AuthProvider>
|
|
36
|
+
<CartProvider>
|
|
37
|
+
<Navbar />
|
|
38
|
+
{children}
|
|
39
|
+
</CartProvider>
|
|
40
|
+
</AuthProvider>
|
|
41
|
+
</body>
|
|
42
|
+
</html>
|
|
43
|
+
);
|
|
44
|
+
}
|
package/app/page.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import Image from "next/image";
|
|
3
|
+
import ProductCard from "@/app/components/ProductCard.js";
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
import { getProducts } from '@/app/utils/api.js';
|
|
6
|
+
|
|
7
|
+
interface Product {
|
|
8
|
+
id: number;
|
|
9
|
+
title: string;
|
|
10
|
+
price: number;
|
|
11
|
+
description: string;
|
|
12
|
+
category: string;
|
|
13
|
+
image: string;
|
|
14
|
+
rating: { rate: number; count: number };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function Home() {
|
|
18
|
+
const [products, setProducts] = useState<Product[]>([]);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const fetchProducts = async () => {
|
|
22
|
+
const data = await getProducts();
|
|
23
|
+
setProducts(data);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
fetchProducts();
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="container mx-auto p-4">
|
|
31
|
+
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-4">Nos produits</h1>
|
|
32
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
33
|
+
{products.map((product) => (
|
|
34
|
+
<ProductCard key={product.id} product={product} />
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import Image from 'next/image';
|
|
2
|
+
import { getProductById } from '@/app/utils/api';
|
|
3
|
+
|
|
4
|
+
export default async function ProductDetails({ params }) {
|
|
5
|
+
const { id } = await params;
|
|
6
|
+
|
|
7
|
+
const product = await getProductById(id);
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="max-w-3xl mx-auto p-6">
|
|
11
|
+
<div className="relative w-full h-80 bg-gray-50">
|
|
12
|
+
<Image src={product.image} alt={product.title} fill className="object-contain p-4" />
|
|
13
|
+
</div>
|
|
14
|
+
<h1 className="text-2xl font-bold mt-4">{product.title}</h1>
|
|
15
|
+
<p className="text-gray-600 mt-2">{product.category}</p>
|
|
16
|
+
<p className="text-xl font-semibold mt-2">{product.price} €</p>
|
|
17
|
+
<p className="mt-4">{product.description}</p>
|
|
18
|
+
<p className="mt-2 text-sm">
|
|
19
|
+
Note : {product.rating?.rate} / 5 ({product.rating?.count} avis)
|
|
20
|
+
</p>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useAuth } from "@/app/context/AuthContext";
|
|
5
|
+
|
|
6
|
+
export default function ProfilePage() {
|
|
7
|
+
const { accessToken, logout } = useAuth();
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!accessToken) {
|
|
12
|
+
router.replace("/login");
|
|
13
|
+
}
|
|
14
|
+
}, [accessToken, router]);
|
|
15
|
+
|
|
16
|
+
if (!accessToken) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const handleLogout = () => {
|
|
21
|
+
logout();
|
|
22
|
+
router.push("/login");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="max-w-screen-lg mx-auto p-6">
|
|
27
|
+
<h1 className="text-3xl font-bold mb-4">Mon Profil</h1>
|
|
28
|
+
<p className="text-gray-700 mb-4">
|
|
29
|
+
Bienvenue ! Vous êtes connecté et pouvez accéder à cette page protégée.
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
|
33
|
+
<p className="text-sm font-medium text-gray-600 mb-1">
|
|
34
|
+
Token d'accès
|
|
35
|
+
</p>
|
|
36
|
+
<p className="text-xs font-mono text-gray-800 break-all">
|
|
37
|
+
{accessToken}
|
|
38
|
+
</p>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<button
|
|
42
|
+
onClick={handleLogout}
|
|
43
|
+
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 transition-colors"
|
|
44
|
+
>
|
|
45
|
+
Se déconnecter
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
package/app/utils/api.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
|
|
3
|
+
const apiClient = axios.create({
|
|
4
|
+
baseURL: "https://fakestoreapi.com",
|
|
5
|
+
timeout: 5000,
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const getProducts = async () => {
|
|
9
|
+
try {
|
|
10
|
+
const reponse = await apiClient.get("/products");
|
|
11
|
+
return reponse.data;
|
|
12
|
+
} catch (error) {
|
|
13
|
+
console.error("Erreur lors de la récupération des produits :", error);
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const getProductById = async (id) => {
|
|
19
|
+
try {
|
|
20
|
+
const reponse = await apiClient.get(`/products/${id}`);
|
|
21
|
+
return reponse.data;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error("Erreur lors de la récupération du produit :", error);
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const loginUser = async (username, password) => {
|
|
29
|
+
try {
|
|
30
|
+
const response = await apiClient.post("/auth/login", {
|
|
31
|
+
username,
|
|
32
|
+
password,
|
|
33
|
+
});
|
|
34
|
+
return response.data; // Renvoie le token ou d'autres données
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error("Erreur lors de la connexion :", error);
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig, globalIgnores } from "eslint/config";
|
|
2
|
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
|
3
|
+
import nextTs from "eslint-config-next/typescript";
|
|
4
|
+
|
|
5
|
+
const eslintConfig = defineConfig([
|
|
6
|
+
...nextVitals,
|
|
7
|
+
...nextTs,
|
|
8
|
+
// Override default ignores of eslint-config-next.
|
|
9
|
+
globalIgnores([
|
|
10
|
+
// Default ignores of eslint-config-next:
|
|
11
|
+
".next/**",
|
|
12
|
+
"out/**",
|
|
13
|
+
"build/**",
|
|
14
|
+
"next-env.d.ts",
|
|
15
|
+
]),
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export default eslintConfig;
|
package/next.config.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
|
|
3
|
+
const nextConfig: NextConfig = {
|
|
4
|
+
images: {
|
|
5
|
+
remotePatterns: [
|
|
6
|
+
{
|
|
7
|
+
protocol: 'https',
|
|
8
|
+
hostname: 'fakestoreapi.com',
|
|
9
|
+
port: '',
|
|
10
|
+
pathname: '/img/**',
|
|
11
|
+
},
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default nextConfig;
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ws-02",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"lint": "eslint"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"axios": "^1.17.0",
|
|
13
|
+
"next": "16.2.7",
|
|
14
|
+
"react": "19.2.4",
|
|
15
|
+
"react-dom": "19.2.4",
|
|
16
|
+
"react-icons": "^5.6.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@tailwindcss/postcss": "^4",
|
|
20
|
+
"@types/node": "^20",
|
|
21
|
+
"@types/react": "^19",
|
|
22
|
+
"@types/react-dom": "^19",
|
|
23
|
+
"eslint": "^9",
|
|
24
|
+
"eslint-config-next": "16.2.7",
|
|
25
|
+
"tailwindcss": "^4",
|
|
26
|
+
"typescript": "^5"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/public/file.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
package/public/globe.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
package/public/next.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "react-jsx",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [
|
|
17
|
+
{
|
|
18
|
+
"name": "next"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"paths": {
|
|
22
|
+
"@/*": ["./*"]
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"include": [
|
|
26
|
+
"next-env.d.ts",
|
|
27
|
+
"**/*.ts",
|
|
28
|
+
"**/*.tsx",
|
|
29
|
+
".next/types/**/*.ts",
|
|
30
|
+
".next/dev/types/**/*.ts",
|
|
31
|
+
"**/*.mts"
|
|
32
|
+
],
|
|
33
|
+
"exclude": ["node_modules"]
|
|
34
|
+
}
|