diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index e9ffa308..310232aa 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -1,7 +1,22 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ images: {
+ // Allow specific remote hosts for Next/Image
+ domains: ["fakestoreapi.com", "i.pravatar.cc"],
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "fakestoreapi.com",
+ pathname: "/img/**",
+ },
+ {
+ protocol: "https",
+ hostname: "i.pravatar.cc",
+ pathname: "/**",
+ },
+ ],
+ },
};
export default nextConfig;
diff --git a/frontend/package.json b/frontend/package.json
index 72b917d9..ded7ad57 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,19 +9,21 @@
"lint": "next lint"
},
"dependencies": {
+ "framer-motion": "^12.23.24",
+ "next": "15.2.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "next": "15.2.4"
+ "react-icons": "^5.5.0"
},
"devDependencies": {
- "typescript": "^5",
+ "@eslint/eslintrc": "^3",
+ "@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
- "@tailwindcss/postcss": "^4",
- "tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.2.4",
- "@eslint/eslintrc": "^3"
+ "tailwindcss": "^4",
+ "typescript": "^5"
}
}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index f7fa87eb..9b557e73 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -1,33 +1,25 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import type { ReactNode } from "react";
import "./globals.css";
+import Nav from "@/components/Nav";
+import Footer from "@/components/Footer";
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
-
-export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
-};
+export const metadata = {
+ title: "Codedability Store",
+ description: "A modern store demo showcasing products with search and details.",
+} as const;
export default function RootLayout({
children,
}: Readonly<{
- children: React.ReactNode;
+ children: ReactNode;
}>) {
return (
-
+
+
+
{children}
+
);
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index e68abe6b..231c5cf6 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -1,103 +1,48 @@
-import Image from "next/image";
+"use client";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { motion } from "framer-motion";
+import { fadeIn, slideUp } from "@/lib/motion";
export default function Home() {
return (
-
-
-
-
- -
- Get started by editing{" "}
-
- src/app/page.tsx
-
- .
-
- -
- Save and see your changes instantly.
-
-
+
+
+
+
+ Discover Our Store
+
+
+ Browse our curated collection of contemporary pieces crafted for the discerning individual.
+
+
+
+
+
+
+
+
+
-
-
-
- Deploy now
-
-
- Read our docs
-
+
+
+
Curated Quality
+
Every piece is handpicked for craftsmanship and timeless appeal.
-
-
+
+
Modern Aesthetics
+
Clean lines and neutral tones designed for a refined look.
+
+
+
Fast Shipping
+
Reliable delivery so you can enjoy your purchase sooner.
+
+
);
}
diff --git a/frontend/src/app/products/[id]/MotionDetail.tsx b/frontend/src/app/products/[id]/MotionDetail.tsx
new file mode 100644
index 00000000..1fdf1aec
--- /dev/null
+++ b/frontend/src/app/products/[id]/MotionDetail.tsx
@@ -0,0 +1,29 @@
+"use client";
+import { motion } from "framer-motion";
+import { fadeIn, slideUp } from "@/lib/motion";
+
+export function Title({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function ImageWrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function Content({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+
diff --git a/frontend/src/app/products/[id]/loading.tsx b/frontend/src/app/products/[id]/loading.tsx
new file mode 100644
index 00000000..07109d4e
--- /dev/null
+++ b/frontend/src/app/products/[id]/loading.tsx
@@ -0,0 +1,31 @@
+import { Skeleton } from "@/components/ui/skeleton";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+
+export default function LoadingProduct() {
+ return (
+
+ );
+}
+
+
diff --git a/frontend/src/app/products/[id]/page.tsx b/frontend/src/app/products/[id]/page.tsx
new file mode 100644
index 00000000..9fb9af8b
--- /dev/null
+++ b/frontend/src/app/products/[id]/page.tsx
@@ -0,0 +1,65 @@
+import Image from "next/image";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import type { Product } from "@/types/product";
+import { Title, ImageWrapper, Content } from "./MotionDetail";
+
+async function fetchProduct(id: string): Promise
{
+ const res = await fetch(`https://fakestoreapi.com/products/${id}`, { cache: "no-store" });
+ if (!res.ok) {
+ return null;
+ }
+ return res.json();
+}
+
+export default async function ProductDetail({ params }: { params: { id: string } }) {
+ const { id } = params;
+ const product = await fetchProduct(id);
+
+ if (!product) {
+ return (
+
+
Product not found.
+
+
+ );
+ }
+
+ return (
+
+
+
+ {product.title}
+
+
+
+
+
+
+
+ ${product.price.toFixed(2)}
+ Category: {product.category}
+ {product.rating && (
+ Rating: {product.rating.rate} ({product.rating.count})
+ )}
+ {product.description}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+
diff --git a/frontend/src/app/products/loading.tsx b/frontend/src/app/products/loading.tsx
new file mode 100644
index 00000000..a90959d7
--- /dev/null
+++ b/frontend/src/app/products/loading.tsx
@@ -0,0 +1,25 @@
+import { Skeleton } from "@/components/ui/skeleton";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+
+export default function Loading() {
+ return (
+
+
+ {Array.from({ length: 9 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+
diff --git a/frontend/src/app/products/page.tsx b/frontend/src/app/products/page.tsx
new file mode 100644
index 00000000..b6f3307a
--- /dev/null
+++ b/frontend/src/app/products/page.tsx
@@ -0,0 +1,13 @@
+import type { Product } from "@/types/product";
+import ProductsClient from "./products.client";
+
+async function fetchProducts(): Promise {
+ const res = await fetch("https://fakestoreapi.com/products", { next: { revalidate: 3600 } });
+ if (!res.ok) throw new Error("Failed to fetch products");
+ return res.json();
+}
+
+export default async function ProductsPage() {
+ const products = await fetchProducts();
+ return ;
+}
diff --git a/frontend/src/app/products/products.client.tsx b/frontend/src/app/products/products.client.tsx
new file mode 100644
index 00000000..0a9e01ed
--- /dev/null
+++ b/frontend/src/app/products/products.client.tsx
@@ -0,0 +1,139 @@
+"use client";
+import * as React from "react";
+import Image from "next/image";
+import Link from "next/link";
+import { Input } from "@/components/ui/input";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import type { Product } from "@/types/product";
+import { motion } from "framer-motion";
+import { fadeIn, slideUp } from "@/lib/motion";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Select } from "@/components/ui/select";
+import { FaTshirt, FaGem, FaBolt, FaFemale, FaMale, FaFilter } from "react-icons/fa";
+
+export default function ProductsClient({ initialProducts }: { initialProducts: Product[] }) {
+ const [query, setQuery] = React.useState("");
+ const [category, setCategory] = React.useState("all");
+ const [sort, setSort] = React.useState("featured");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ // initialize from URL
+ React.useEffect(() => {
+ const q = searchParams.get("q") ?? "";
+ const c = searchParams.get("c") ?? "all";
+ const s = searchParams.get("s") ?? "featured";
+ setQuery(q);
+ setCategory(c);
+ setSort(s);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // sync to URL
+ React.useEffect(() => {
+ const sp = new URLSearchParams();
+ if (query) sp.set("q", query);
+ if (category && category !== "all") sp.set("c", category);
+ if (sort && sort !== "featured") sp.set("s", sort);
+ const qs = sp.toString();
+ router.replace(`/products${qs ? `?${qs}` : ""}`);
+ }, [query, category, sort, router]);
+
+ const categories = React.useMemo(() => {
+ return Array.from(new Set(initialProducts.map((p) => p.category)));
+ }, [initialProducts]);
+
+ const filtered = React.useMemo(() => {
+ const q = query.trim().toLowerCase();
+ let base = initialProducts;
+ if (category !== "all") base = base.filter((p) => p.category === category);
+ if (sort === "price-asc") base = [...base].sort((a, b) => a.price - b.price);
+ if (sort === "price-desc") base = [...base].sort((a, b) => b.price - a.price);
+ if (!q) return base;
+ return base.filter((p) => p.title.toLowerCase().includes(q));
+ }, [initialProducts, query, category, sort]);
+
+ return (
+
+ {/* Sidebar */}
+
+
+ {/* Content */}
+
+
+ Featured Collection
+ Handpicked pieces that define modern luxury and timeless style.
+
+
+ {filtered.length === 0 && (
+
No products match your filters.
+ )}
+
+ {filtered.length > 0 && (
+
+ {filtered.map((p, idx) => (
+
+
+
+ {p.title}
+
+
+
+
+
+
+
+
+
${p.price.toFixed(2)}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx
new file mode 100644
index 00000000..2985fd60
--- /dev/null
+++ b/frontend/src/components/Footer.tsx
@@ -0,0 +1,12 @@
+export default function Footer() {
+ return (
+
+ );
+}
+
+
diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx
new file mode 100644
index 00000000..db045bd3
--- /dev/null
+++ b/frontend/src/components/Nav.tsx
@@ -0,0 +1,29 @@
+"use client";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+
+export default function Nav() {
+ const pathname = usePathname();
+ const linkClasses = (href: string) =>
+ cn(
+ "px-3 py-2 rounded-md text-sm font-medium transition-colors cursor-pointer",
+ pathname === href
+ ? "bg-black/10 dark:bg-white/10"
+ : "hover:bg-black/5 dark:hover:bg-white/10"
+ );
+
+ return (
+
+ );
+}
+
+
diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx
new file mode 100644
index 00000000..4d7e4996
--- /dev/null
+++ b/frontend/src/components/ui/badge.tsx
@@ -0,0 +1,17 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export interface BadgeProps extends React.HTMLAttributes {
+ variant?: "default" | "secondary" | "outline";
+}
+
+export function Badge({ className, variant = "default", ...props }: BadgeProps) {
+ const variants: Record = {
+ default: "bg-black/10 dark:bg-white/20 text-foreground",
+ secondary: "bg-black/5 dark:bg-white/10 text-foreground",
+ outline: "border border-black/10 dark:border-white/20 text-foreground",
+ };
+ return ;
+}
+
+
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx
new file mode 100644
index 00000000..30ab5a78
--- /dev/null
+++ b/frontend/src/components/ui/button.tsx
@@ -0,0 +1,36 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export interface ButtonProps extends React.ButtonHTMLAttributes {
+ variant?: "default" | "outline" | "ghost";
+ size?: "sm" | "md" | "lg";
+}
+
+export const Button = React.forwardRef(
+ ({ className, variant = "default", size = "md", ...props }, ref) => {
+ const base = "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none disabled:opacity-50 disabled:pointer-events-none cursor-pointer select-none active:scale-[0.98]";
+ const variants: Record = {
+ default: "bg-foreground text-background hover:bg-[#383838] dark:hover:bg-[#ccc]",
+ outline: "border border-black/10 dark:border-white/20 bg-transparent hover:bg-black/[.05] dark:hover:bg-white/[.06]",
+ ghost: "bg-transparent hover:bg-black/[.05] dark:hover:bg-white/[.06]",
+ };
+ const sizes: Record = {
+ sm: "h-9 px-3 text-sm",
+ md: "h-10 px-4 text-sm",
+ lg: "h-12 px-6 text-base",
+ };
+
+ return (
+
+ );
+ }
+);
+Button.displayName = "Button";
+
+export default Button;
+
+
diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx
new file mode 100644
index 00000000..91aeca4b
--- /dev/null
+++ b/frontend/src/components/ui/card.tsx
@@ -0,0 +1,20 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function Card({ className, ...props }: React.HTMLAttributes) {
+ return ;
+}
+
+export function CardHeader({ className, ...props }: React.HTMLAttributes) {
+ return ;
+}
+
+export function CardContent({ className, ...props }: React.HTMLAttributes) {
+ return ;
+}
+
+export function CardFooter({ className, ...props }: React.HTMLAttributes) {
+ return ;
+}
+
+
diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx
new file mode 100644
index 00000000..023d14dc
--- /dev/null
+++ b/frontend/src/components/ui/input.tsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export type InputProps = React.InputHTMLAttributes;
+
+export const Input = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Input.displayName = "Input";
+
+export default Input;
+
+
diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx
new file mode 100644
index 00000000..b46ad6a8
--- /dev/null
+++ b/frontend/src/components/ui/select.tsx
@@ -0,0 +1,20 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export interface SelectProps extends React.SelectHTMLAttributes {}
+
+export function Select({ className, children, ...props }: SelectProps) {
+ return (
+
+ );
+}
+
+
diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx
new file mode 100644
index 00000000..f292642c
--- /dev/null
+++ b/frontend/src/components/ui/skeleton.tsx
@@ -0,0 +1,8 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export function Skeleton({ className, ...props }: React.HTMLAttributes) {
+ return ;
+}
+
+
diff --git a/frontend/src/lib/motion.ts b/frontend/src/lib/motion.ts
new file mode 100644
index 00000000..a5f5ed21
--- /dev/null
+++ b/frontend/src/lib/motion.ts
@@ -0,0 +1,18 @@
+export const fadeIn = {
+ hidden: { opacity: 0 },
+ visible: { opacity: 1, transition: { duration: 0.5 } },
+};
+
+export const slideUp = {
+ hidden: { opacity: 0, y: 24 },
+ visible: { opacity: 1, y: 0, transition: { duration: 0.6 } },
+};
+
+export const pop = {
+ initial: { scale: 0.98 },
+ animate: { scale: 1, transition: { duration: 0.2 } },
+ whileHover: { scale: 1.02 },
+ whileTap: { scale: 0.98 },
+};
+
+
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
new file mode 100644
index 00000000..a1fe6504
--- /dev/null
+++ b/frontend/src/lib/utils.ts
@@ -0,0 +1,5 @@
+export function cn(...classes: Array) {
+ return classes.filter(Boolean).join(" ");
+}
+
+
diff --git a/frontend/src/types/product.ts b/frontend/src/types/product.ts
new file mode 100644
index 00000000..f8e84378
--- /dev/null
+++ b/frontend/src/types/product.ts
@@ -0,0 +1,14 @@
+export interface Product {
+ id: number;
+ title: string;
+ price: number;
+ description: string;
+ category: string;
+ image: string;
+ rating?: {
+ rate: number;
+ count: number;
+ };
+}
+
+
diff --git a/package-lock.json b/package-lock.json
index c61d591b..adfaa341 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,9 +22,11 @@
"frontend": {
"version": "0.1.0",
"dependencies": {
+ "framer-motion": "^12.23.24",
"next": "15.2.4",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "react-icons": "^5.5.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -10356,6 +10358,32 @@
"node": ">= 0.6"
}
},
+ "node_modules/framer-motion": {
+ "version": "12.23.24",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
+ "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
+ "dependencies": {
+ "motion-dom": "^12.23.23",
+ "motion-utils": "^12.23.6",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/freeport-async": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz",
@@ -13694,6 +13722,19 @@
"resolved": "mobile",
"link": true
},
+ "node_modules/motion-dom": {
+ "version": "12.23.23",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
+ "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
+ "dependencies": {
+ "motion-utils": "^12.23.6"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.23.6",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
+ "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -14979,6 +15020,14 @@
"react": ">=17.0.0"
}
},
+ "node_modules/react-icons": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
+ "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",