diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1 @@
+{}
diff --git a/README.md b/README.md
index 31466b54c2..4c71189ba4 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,89 @@
-# Final Project
+
-Replace this readme with your own information about your project.
+# Naima — Fika with Benefits
-Start by briefly describing the assignment in a sentence or two. Keep it short and to the point.
+A full-stack MERN app for Naima, a Swedish fika brand. The site showcases products, the brand story, an interactive store map, and a company dashboard for wholesale partners.
-## The problem
+## 🧩 The assignment
-Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next?
+Build a production-style full-stack web app with persistent data, authentication, and a modern, accessible UX. Ship a public site and protected “company” area, integrate at least one external service, and document everything.
-## View it live
+## The plan
-Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about.
\ No newline at end of file
+Goals
+
+- Represent Naima’s brand online (products, story, social, where to buy).
+- Serve real data from a backend (products, partners, retailers).
+- Provide a simple, secure wholesale dashboard.
+- Be fast and accessible.
+
+Approach
+
+- Plan: MVP → iterate. Start with static JSON/data and progressively wire backend.
+- Tech choices:
+ - Frontend: React (Vite), styled-components, Zustand, Framer Motion, React Hook Form, Leaflet.
+ - Backend: Express, Mongoose/MongoDB Atlas, JWT auth, express-validator, rate-limit.
+ - Performance: lazy images, pre-geocoded retailers, simple animations that respect prefers-reduced-motion.
+ - A11y first: skip link, keyboard hamburger with focus trap, visible focus, ≥44px tap targets, ARIA-labeled carousel dots.
+ If we had more time: admin CRUD, IG Graph API with caching, tests, and analytics.
+
+## ✨ Highlights
+
+- Modern stack: React + Vite, themeable design system via styled-components.
+- Real data: Products & partners from the backend; retailers shown on a map.
+- Map: Leaflet + OSM tiles; inline SVG pins tinted with brand palette.
+- Auth: JWT; protected company dashboard.
+- A11y: Lighthouse Accessibility 100% (skip link, keyboard nav, proper labels).
+- Performance: Lazy images, static geocoded JSON, reduced motion support.
+
+## Frontend Structure
+
+The frontend uses a React component-based architecture.
+Main folders:
+src/components/: Reusable UI components
+src/pages/: Route components (views)
+src/sections/: Page sections/layouts
+src/data/: Static data (models)
+src/styles/: Styling system
+
+## Backend Structure
+
+The backend follows an MVC pattern with a service layer (Node.js + Express + MongoDB).
+
+Main layers:
+
+- Routes: URL mapping (presentation/routing)
+- Controllers: HTTP request/response handling
+- Services: Business logic
+- Models: Data access (MongoDB schemas and operations)
+
+Architecture patterns used: MVC, 3-Tier, Layered, and Clean Architecture.
+
+## 🌐 View it live
+
+Deployed Frontend: https://resetwithnaima.netlify.app/
+
+### 🔐 Demo user:
+
+Email: demo@company.com
+Password: Demo123!
+
+Deployed Backend: https://naima-website.onrender.com/
+
+## 👩💻 Developers - Find us:
+
+
diff --git a/backend/.babelrc b/backend/.babelrc
index cedf24f1a5..1320b9a327 100644
--- a/backend/.babelrc
+++ b/backend/.babelrc
@@ -1,5 +1,3 @@
{
- "presets": [
- "@babel/preset-env"
- ]
-}
\ No newline at end of file
+ "presets": ["@babel/preset-env"]
+}
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000000..bcb6cf6262
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,22 @@
+# Dependencies
+node_modules/
+
+# Environment variables
+.env
+.env.local
+.env.production
+
+# Logs
+*.log
+npm-debug.log*
+
+# Database
+*.db
+*.sqlite
+
+# Uploads
+uploads/
+
+# OS
+.DS_Store
+Thumbs.db
\ No newline at end of file
diff --git a/backend/README.md b/backend/README.md
index d1438c9108..e600916ce4 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1,8 +1,128 @@
-# Backend part of Final Project
+# Naima Website — Backend
-This project includes the packages and babel setup for an express server, and is just meant to make things a little simpler to get up and running with.
+Lightweight README for the backend service (Express + Mongoose).
-## Getting Started
+## Overview
-1. Install the required dependencies using `npm install`.
-2. Start the development server using `npm run dev`.
\ No newline at end of file
+This Express app provides the API for the Naima website: companies, customers, products, orders, retailers, partners and admin operations. Routes are organized under `routes/` and handlers in `controllers/`. Mongoose models live in `models/`. Basic JWT authentication is implemented in `middleware/auth.js`.
+
+## Requirements
+
+- Node.js 18+ (or your project's target)
+- npm
+- MongoDB (local or Atlas)
+
+## Quick start
+
+1. Install
+ ```bash
+ npm install
+ ```
+2. Create a `.env` file in this folder (see Environment).
+3. Start development server
+ ```bash
+ npm run dev
+ ```
+4. Start production server
+ ```bash
+ npm start
+ ```
+
+## Environment variables
+
+Create `.env` with at least:
+
+- PORT=3001
+- MONGODB_URI=mongodb://localhost:27017/naima
+- JWT_SECRET=your_jwt_secret
+- NODE_ENV=development
+
+Adjust values to your environment (Atlas connection string, secrets, etc).
+
+## Scripts
+
+Common scripts (expected):
+
+- `npm run dev` — development with auto-reload (nodemon)
+- `npm start` — start production server
+- `node scripts/seedData.js` — seed data (if present)
+
+Check `package.json` for exact script names in this repo.
+
+## API overview
+
+Routes are mounted in `routes/`:
+
+- `/api/products` — product listing and management (controllers/productControllers.js)
+- `/api/orders` — create and manage orders (controllers/orderController.js)
+- `/api/company` — company auth/profile (controllers/companyControllers.js)
+- `/api/customers` — customer endpoints (controllers/customerControllers.js)
+- `/api/retailers` — retailer locations (controllers/retailerController.js)
+- `/api/partners` — partners
+- `/api/admin` — admin endpoints
+- `/api/contact` — contact form messages
+
+Use the route files to see exact endpoints and required params.
+
+## Authentication
+
+- JWT-based. Tokens are signed with `JWT_SECRET`.
+- Auth middleware: `middleware/auth.js` — verifies tokens and attaches decoded user to `req`.
+- Protect routes by applying `authenticate` and `authorize` where required.
+
+Example header for protected routes:
+
+```
+Authorization: Bearer
+```
+
+## Models (high level)
+
+Look in `models/` for schemas:
+
+- Company, Customer, Admin
+- Product
+- Order (items, totalCost, status)
+- RetailLocation, Partner, ContactMessage
+
+## Seeding & data import
+
+- `scripts/seedData.js` and `scripts/import-retailers.mjs` are available for seeding and importing retailer geocoded data. Inspect and run with Node as needed.
+
+## Logging & debugging
+
+- Check server console for auth / JWT errors (invalid signature, expired).
+- Ensure `MONGODB_URI` is correct when you see connection errors.
+- Use Postman / curl to test endpoints. Inspect response status (401 for auth issues, 500 for server errors).
+
+Example: log in (company) and fetch order
+
+```bash
+# login -> get token
+curl -X POST http://localhost:3001/api/company/login -H "Content-Type: application/json" -d '{"email":"...","password":"..."}'
+
+# fetch order
+curl http://localhost:3001/api/orders/ -H "Authorization: Bearer "
+```
+
+## Troubleshooting
+
+- JsonWebTokenError: invalid signature — check `JWT_SECRET` consistency between token issuer and verifier.
+- 401 Unauthorized — ensure token is present and not expired.
+- CORS issues — check server CORS settings and client origin.
+- 500 Server Error — check server console for stack trace and controller errors.
+
+## Deployment
+
+- Set env variables on your host (PORT, MONGODB_URI, JWT_SECRET).
+- Use a process manager (pm2) or containerize with Docker.
+- Ensure proper CORS and HTTPS handling in production.
+
+## Contributing
+
+- Follow the existing project structure: routes → controllers → models → services.
+- Keep controllers focused and move reusable logic to `services/`.
+
+## Contacts
+
+- Repository owner / maintainer: see project-level README or repository meta.
diff --git a/backend/controllers/adminControllers.js b/backend/controllers/adminControllers.js
new file mode 100644
index 0000000000..8b6a9eef19
--- /dev/null
+++ b/backend/controllers/adminControllers.js
@@ -0,0 +1,94 @@
+import bcrypt from 'bcryptjs'
+import jwt from 'jsonwebtoken'
+
+import Admin from '../models/Admin.js'
+
+// Register a new admin
+export const registerAdmin = async (req, res) => {
+ try {
+ const { name, email, password } = req.body
+ if (!password) {
+ return res.status(400).json({ error: 'Password is required' })
+ }
+ const hashedPassword = await bcrypt.hash(password, 10)
+ const admin = new Admin({
+ name: req.body.name,
+ email: req.body.email,
+ password: hashedPassword,
+ role: 'admin' // Set role explicitly
+ })
+ await admin.save()
+ res.status(201).json({ message: 'Admin registered!' })
+ } catch (error) {
+ res.status(400).json({ error: error.message })
+ }
+}
+
+// Login an admin
+export const loginAdmin = async (req, res) => {
+ const { email, password } = req.body
+ const admin = await Admin.findOne({ email })
+ if (!admin) return res.status(401).json({ error: 'Invalid credentials' })
+
+ const valid = await bcrypt.compare(password, admin.password)
+ if (!valid) return res.status(401).json({ error: 'Invalid credentials' })
+
+ const token = jwt.sign({ adminId: admin._id }, process.env.JWT_SECRET, {
+ expiresIn: '1d'
+ })
+ res.json({ token, admin: { name: admin.name, email: admin.email } })
+}
+
+// Get all admins
+export const getAllAdmins = async (req, res) => {
+ try {
+ const admins = await Admin.find()
+ res.json(admins)
+ } catch (error) {
+ res.status(500).json({ error: error.message })
+ }
+}
+
+// Get a single admin by ID
+export const getAdminById = async (req, res) => {
+ try {
+ const admin = await Admin.findById(req.params.id)
+ if (!admin) return res.status(404).json({ error: 'Admin not found' })
+ res.json(admin)
+ } catch (error) {
+ res.status(500).json({ error: error.message })
+ }
+}
+
+// Update an admin's details
+export const updateAdmin = async (req, res) => {
+ try {
+ const admin = await Admin.findByIdAndUpdate(req.params.id, req.body, {
+ new: true,
+ runValidators: true
+ })
+ if (!admin) return res.status(404).json({ error: 'Admin not found' })
+ res.json(admin)
+ } catch (error) {
+ res.status(400).json({ error: error.message })
+ }
+}
+
+// Delete an admin
+export const deleteAdmin = async (req, res) => {
+ try {
+ const admin = await Admin.findByIdAndDelete(req.params.id)
+ if (!admin) return res.status(404).json({ error: 'Admin not found' })
+ res.json({ message: 'Admin deleted successfully' })
+ } catch (error) {
+ res.status(500).json({ error: error.message })
+ }
+}
+
+// Middleware to check if the user is an admin
+export const isAdmin = (req, res, next) => {
+ if (req.user.role !== 'admin') {
+ return res.status(403).json({ error: 'Forbidden' })
+ }
+ next()
+}
diff --git a/backend/controllers/companyControllers.js b/backend/controllers/companyControllers.js
new file mode 100644
index 0000000000..ac6f4df3de
--- /dev/null
+++ b/backend/controllers/companyControllers.js
@@ -0,0 +1,113 @@
+import bcrypt from 'bcryptjs'
+import jwt from 'jsonwebtoken'
+
+import Company from '../models/Company.js'
+import Customer from '../models/Customer.js'
+
+// Register a new company
+export const registerCompany = async (req, res) => {
+ try {
+ const { name, email, password, address, contactPerson } = req.body
+ if (!password) {
+ return res.status(400).json({ error: 'Password is required' })
+ }
+ const hashedPassword = await bcrypt.hash(password, 10)
+ const company = new Company({
+ name,
+ email,
+ password: hashedPassword,
+ address,
+ contactPerson,
+ role: 'company' // Set role explicitly
+ })
+ await company.save()
+
+ // Create linked customer profile
+ const customer = new Customer({
+ name,
+ email,
+ address,
+ phone: req.body.phone,
+ company: company._id // Link to company
+ })
+ await customer.save()
+
+ res.status(201).json({ message: 'Company registered!', company, customer })
+ } catch (error) {
+ res.status(400).json({ error: error.message })
+ }
+}
+
+// Login a company
+export const loginCompany = async (req, res) => {
+ try {
+ const { email, password } = req.body
+ const company = await Company.findOne({ email, role: 'company' })
+ if (!company) return res.status(401).json({ error: 'Invalid credentials' })
+
+ const valid = await bcrypt.compare(password, company.password)
+ if (!valid) return res.status(401).json({ error: 'Invalid credentials' })
+
+ const token = jwt.sign(
+ { id: company._id, role: 'company', companyId: company._id },
+ process.env.JWT_SECRET,
+ { expiresIn: '1d' }
+ )
+ const customer = await Customer.findOne({ company: company._id })
+ res.json({ token, company, customer })
+ } catch (error) {
+ res.status(500).json({ error: error.message })
+ }
+}
+
+// Logout a company
+export const logoutCompany = (req, res) => {
+ // Invalidate the token on the client side
+ res.json({ message: 'Logged out successfully' })
+}
+
+// Get all companies
+export const getAllCompanies = async (req, res) => {
+ try {
+ const companies = await Company.find()
+ res.json(companies)
+ } catch (error) {
+ res.status(500).json({ error: error.message })
+ }
+}
+
+// Get a single company by ID
+export const getCompanyById = async (req, res) => {
+ try {
+ const company = await Company.findById(req.params.id)
+ if (!company) return res.status(404).json({ error: 'Company not found' })
+ res.json(company)
+ } catch (error) {
+ res.status(500).json({ error: error.message })
+ }
+}
+
+// Update a company's details
+export const updateCompany = async (req, res) => {
+ try {
+ const company = await Company.findByIdAndUpdate(req.params.id, req.body, {
+ new: true,
+ runValidators: true
+ })
+ if (!company) return res.status(404).json({ error: 'Company not found' })
+ res.json(company)
+ } catch (error) {
+ res.status(400).json({ error: error.message })
+ }
+}
+
+// Delete a company
+export const deleteCompany = async (req, res) => {
+ try {
+ const company = await Company.findByIdAndDelete(req.params.id)
+ if (!company) return res.status(404).json({ error: 'Company not found' })
+ res.json({ message: 'Company deleted successfully' })
+ } catch (error) {
+ res.status(500).json({ error: error.message })
+ }
+}
diff --git a/backend/controllers/contactController.js b/backend/controllers/contactController.js
new file mode 100644
index 0000000000..7c86152f8f
--- /dev/null
+++ b/backend/controllers/contactController.js
@@ -0,0 +1,70 @@
+import { body, validationResult } from "express-validator";
+
+import contactMessage from "../models/contactMessage.js";
+
+// validation + sanitization (keep phone optional)
+export const validateContact = [
+ body("name")
+ .trim()
+ .isLength({ min: 2, max: 80 })
+ .withMessage("Name must be 2–80 characters"),
+ body("email")
+ .trim()
+ .isEmail()
+ .withMessage("Enter a valid email")
+ .normalizeEmail(),
+ body("phone")
+ .optional({ checkFalsy: true })
+ .trim()
+ .isLength({ min: 7, max: 20 })
+ .withMessage("Phone must be 7–20 characters")
+ .matches(/^[+()\d\s-]+$/)
+ .withMessage("Phone can contain digits, spaces, (), +, -"),
+ body("subject")
+ .optional({ checkFalsy: true })
+ .trim()
+ .isLength({ max: 120 })
+ .withMessage("Subject max 120 characters"),
+ body("message")
+ .trim()
+ .isLength({ min: 10, max: 2000 })
+ .withMessage("Message must be 10–2000 characters"),
+ // 🛡️ simple honeypot – should be empty
+ body("botField")
+ .custom((v) => v === "")
+ .withMessage("Spam detected"),
+];
+
+// controller
+export const submitContactForm = async (req, res) => {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res
+ .status(400)
+ .json({ error: "Validation failed", details: errors.array() });
+ }
+
+ const { name, email, phone, subject, message } = req.body;
+
+ try {
+ const newMessage = await contactMessage.create({
+ name,
+ email,
+ phone,
+ subject,
+ message,
+ createdAt: new Date(),
+ });
+ res.status(201).json({
+ message: "Your message has been sent successfully.",
+ data: newMessage,
+ });
+ } catch (error) {
+ console.error("Error submitting contact form:", error);
+ res
+ .status(500)
+ .json({
+ error: "Failed to submit the contact form. Please try again later.",
+ });
+ }
+};
diff --git a/backend/controllers/customerControllers.js b/backend/controllers/customerControllers.js
new file mode 100644
index 0000000000..53869a3283
--- /dev/null
+++ b/backend/controllers/customerControllers.js
@@ -0,0 +1,78 @@
+import Customer from '../models/Customer.js'
+import Order from '../models/Order.js'
+
+// Get all customers
+export const getAllCustomers = async (req, res) => {
+ try {
+ const customers = await Customer.find()
+ res.json(customers)
+ } catch (error) {
+ res.status(500).json({ error: error.message })
+ }
+}
+
+// Get customer by ID
+export const getCustomerById = async (req, res) => {
+ try {
+ const customer = await Customer.findById(req.params.id)
+ if (!customer) {
+ return res.status(404).json({ message: 'Customer not found' })
+ }
+ res.json(customer)
+ } catch (error) {
+ res.status(500).json({ error: error.message })
+ }
+}
+
+// Create new customer
+export const createCustomer = async (req, res) => {
+ try {
+ const { name, email, address, phone } = req.body
+ const customer = new Customer({ name, email, address, phone })
+ await customer.save()
+ res.status(201).json(customer)
+ } catch (error) {
+ res.status(400).json({ error: error.message })
+ }
+}
+
+// Update customer by ID
+export const updateCustomerById = async (req, res) => {
+ try {
+ // If the user is a company, check if they own this customer profile
+ if (req.user.role === 'company') {
+ const customer = await Customer.findById(req.params.id)
+ if (!customer) {
+ return res.status(404).json({ message: 'Customer not found' })
+ }
+ // Only allow update if the company owns this customer profile
+ if (String(customer.company) !== String(req.user.companyId)) {
+ return res.status(403).json({ message: 'Forbidden: Not your profile' })
+ }
+ }
+
+ const customer = await Customer.findByIdAndUpdate(req.params.id, req.body, {
+ new: true,
+ runValidators: true
+ })
+ if (!customer) {
+ return res.status(404).json({ message: 'Customer not found' })
+ }
+ res.json(customer)
+ } catch (error) {
+ res.status(400).json({ error: error.message })
+ }
+}
+
+// Delete customer by ID
+export const deleteCustomerById = async (req, res) => {
+ try {
+ const customer = await Customer.findByIdAndDelete(req.params.id)
+ if (!customer) {
+ return res.status(404).json({ message: 'Customer not found' })
+ }
+ res.json({ message: 'Customer deleted' })
+ } catch (error) {
+ res.status(500).json({ error: error.message })
+ }
+}
diff --git a/backend/controllers/orderController.js b/backend/controllers/orderController.js
new file mode 100644
index 0000000000..753dd1a83c
--- /dev/null
+++ b/backend/controllers/orderController.js
@@ -0,0 +1,109 @@
+import Customer from '../models/Customer.js'
+import Order from '../models/Order.js'
+
+// Create a new order
+// This will also create a customer if they don't exist
+export const createOrder = async (req, res) => {
+ let customer = null
+
+ try {
+ // Find or create customer
+ customer = await Customer.findOne({ email: req.body.email })
+ if (!customer) {
+ customer = new Customer({
+ name: req.body.name,
+ email: req.body.email,
+ address: req.body.address,
+ phone: req.body.phone,
+ company: req.body.company || undefined
+ })
+ await customer.save()
+ }
+ } catch (error) {
+ return res.status(400).json({ error: error.message })
+ }
+
+ try {
+ // Calculate totalCost from items
+ const items = req.body.items || []
+ const totalCost = items.reduce(
+ (sum, item) => sum + (item.price || 0) * (item.quantity || 0),
+ 0
+ )
+
+ const order = new Order({
+ ...req.body,
+ customer: customer._id,
+ totalCost
+ })
+ await order.save()
+ res.status(201).json({ message: 'Order received!', order })
+ } catch (error) {
+ res.status(400).json({ error: error.message })
+ }
+}
+
+// Get all orders with customer details
+export const getAllOrders = async (req, res) => {
+ try {
+ const orders = await Order.find().populate('customer')
+ res.json(orders)
+ } catch (error) {
+ res.status(500).json({ error: error.message })
+ }
+}
+
+// Get order by ID with customer details
+export const getOrderById = async (req, res) => {
+ try {
+ const order = await Order.findById(req.params.id).populate('customer')
+ if (!order) return res.status(404).json({ error: 'Order not found' })
+ res.json(order)
+ } catch (error) {
+ console.error('getOrderById error:', error)
+ res.status(500).json({ error: error.message || 'Server error' })
+ }
+}
+
+// Update an order
+export const updateOrder = async (req, res) => {
+ try {
+ const order = await Order.findByIdAndUpdate(req.params.id, req.body, {
+ new: true,
+ runValidators: true
+ })
+ if (!order) return res.status(404).json({ error: 'Order not found' })
+ res.json(order)
+ } catch (error) {
+ res.status(400).json({ error: error.message })
+ }
+}
+
+// Delete an order
+export const deleteOrder = async (req, res) => {
+ try {
+ const order = await Order.findByIdAndDelete(req.params.id)
+ if (!order) return res.status(404).json({ error: 'Order not found' })
+ res.json({ message: 'Order deleted' })
+ } catch (error) {
+ res.status(500).json({ error: error.message })
+ }
+}
+
+// Get all orders for a customer
+export const getOrdersForCustomer = async (req, res) => {
+ try {
+ const orders = await Order.find({ customer: req.params.id })
+ res.json(orders)
+ } catch (error) {
+ res.status(500).json({ error: error.message })
+ }
+}
+
+// Middleware to check if the user is an admin
+export const isAdmin = (req, res, next) => {
+ if (req.user.role !== 'admin') {
+ return res.status(403).json({ error: 'Forbidden' })
+ }
+ next()
+}
diff --git a/backend/controllers/partnerController.js b/backend/controllers/partnerController.js
new file mode 100644
index 0000000000..66c5762a71
--- /dev/null
+++ b/backend/controllers/partnerController.js
@@ -0,0 +1,39 @@
+import Partner from '../models/Partner.js'
+
+export const getAllPartners = async (req, res) => {
+ try {
+ const { type, isActive = true } = req.query
+
+ let query = Partner.find({ isActive: isActive === 'true' })
+
+ if (type) {
+ query = query.where('type').equals(type)
+ }
+
+ const partners = await query.sort({ name: 1 })
+ res.json(partners)
+ } catch (error) {
+ console.error('Error fetching partners:', error)
+ res.status(500).json({ message: 'Failed to fetch partners' })
+ }
+}
+
+export const getServedAtPartners = async (req, res) => {
+ try {
+ const partners = await Partner.findServedAt()
+ res.json(partners)
+ } catch (error) {
+ console.error('Error fetching served-at partners:', error)
+ res.status(500).json({ message: 'Failed to fetch served-at partners' })
+ }
+}
+
+export const getCateringPartners = async (req, res) => {
+ try {
+ const partners = await Partner.findCateringPartners()
+ res.json(partners)
+ } catch (error) {
+ console.error('Error fetching catering partners:', error)
+ res.status(500).json({ message: 'Failed to fetch catering partners' })
+ }
+}
diff --git a/backend/controllers/productControllers.js b/backend/controllers/productControllers.js
new file mode 100644
index 0000000000..a30e423a46
--- /dev/null
+++ b/backend/controllers/productControllers.js
@@ -0,0 +1,114 @@
+import Product from '../models/Product.js'
+
+export const getAllProducts = async (req, res) => {
+ try {
+ const { category, featured, status = 'active', search } = req.query
+
+ let filter = { status }
+
+ // Add category filter
+ if (category) filter.category = category
+
+ // Add featured filter
+ if (featured === 'true') filter.featured = true
+
+ let query = Product.find(filter)
+
+ // Add text search if provided
+ if (search) {
+ query = Product.searchProducts(search, status)
+ } else {
+ query = query.sort({ createdAt: -1 })
+ }
+
+ const products = await query
+ res.json(products)
+ } catch (error) {
+ console.error('Error fetching products:', error)
+ res.status(500).json({ message: 'Failed to fetch products' })
+ }
+}
+
+// Get single product by ID
+export const getProductById = async (req, res) => {
+ try {
+ const product = await Product.findById(req.params.id)
+ if (!product) {
+ return res.status(404).json({ message: 'Product not found' })
+ }
+ res.json(product)
+ } catch (error) {
+ console.error('Error fetching product:', error)
+ res.status(500).json({ message: 'Failed to fetch product' })
+ }
+}
+
+// Get featured products
+export const getFeaturedProducts = async (req, res) => {
+ try {
+ const products = await Product.findFeatured()
+ res.json(products)
+ } catch (error) {
+ console.error('Error fetching featured products:', error)
+ res.status(500).json({ message: 'Failed to fetch featured products' })
+ }
+}
+
+// Get products by category
+export const getProductsByCategory = async (req, res) => {
+ try {
+ const { category } = req.params
+ const products = await Product.findByCategory(category)
+ res.json(products)
+ } catch (error) {
+ console.error('Error fetching products by category:', error)
+ res.status(500).json({ message: 'Failed to fetch products by category' })
+ }
+}
+
+// Create new product
+export const createProduct = async (req, res) => {
+ try {
+ const product = new Product(req.body)
+ await product.save()
+ res.status(201).json(product)
+ } catch (error) {
+ console.error('Error creating product:', error)
+ res
+ .status(400)
+ .json({ message: 'Failed to create product', error: error.message })
+ }
+}
+
+export const updateProduct = async (req, res) => {
+ try {
+ const product = await Product.findByIdAndUpdate(req.params.id, req.body, {
+ new: true,
+ runValidators: true
+ })
+ if (!product) {
+ return res.status(404).json({ message: 'Product not found' })
+ }
+ res.json(product)
+ } catch (error) {
+ console.error('Error updating product:', error)
+ res
+ .status(400)
+ .json({ message: 'Failed to update product', error: error.message })
+ }
+}
+
+export const deleteProduct = async (req, res) => {
+ try {
+ const product = await Product.findByIdAndDelete(req.params.id)
+ if (!product) {
+ return res.status(404).json({ message: 'Product not found' })
+ }
+ res.json({ message: 'Product deleted' })
+ } catch (error) {
+ console.error('Error deleting product:', error)
+ res
+ .status(500)
+ .json({ message: 'Failed to delete product', error: error.message })
+ }
+}
diff --git a/backend/controllers/retailerController.js b/backend/controllers/retailerController.js
new file mode 100644
index 0000000000..edf02fb8b2
--- /dev/null
+++ b/backend/controllers/retailerController.js
@@ -0,0 +1,25 @@
+import RetailLocation from '../models/RetailLocation.js'
+
+export const listRetailers = async (req, res) => {
+ const { brand, city, limit = 100 } = req.query
+ const q = {}
+ if (brand) q.brand = brand
+ if (city) q.city = city
+ const items = await RetailLocation.find(q).limit(Number(limit))
+ res.json({ items })
+}
+
+export const retailersNear = async (req, res) => {
+ const { lat, lng, km = 10 } = req.query
+ if (!lat || !lng) return res.status(400).json({ error: 'lat & lng required' })
+ const meters = Number(km) * 1000
+ const items = await RetailLocation.find({
+ location: {
+ $near: {
+ $geometry: { type: 'Point', coordinates: [Number(lng), Number(lat)] },
+ $maxDistance: meters
+ }
+ }
+ }).limit(200)
+ res.json({ items })
+}
diff --git a/backend/data/geocodedRetailers.json b/backend/data/geocodedRetailers.json
new file mode 100644
index 0000000000..b8842a21c3
--- /dev/null
+++ b/backend/data/geocodedRetailers.json
@@ -0,0 +1,677 @@
+[
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Drottninggatan 90B",
+ "street": "Drottninggatan 90B",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "90B, Drottninggatan, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 36, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0588188,
+ 59.3367324
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Götgatan 90",
+ "street": "Götgatan 90",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "90, Götgatan, Skanstull, Södermalm, Södermalms stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 118 62, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0749826,
+ 59.3102855
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Regeringsgatan 54",
+ "street": "Regeringsgatan 54",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "54, Regeringsgatan, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 56, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0678265,
+ 59.335141
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Sveavägen 55",
+ "street": "Sveavägen 55",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "55, Sveavägen, Sibirien, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 59, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.058462,
+ 59.3405686
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Sveavägen 71",
+ "street": "Sveavägen 71",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "71, Sveavägen, Sibirien, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 50, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0557634,
+ 59.3432833
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Odengatan 22 (Birger Jarlsgatan)",
+ "street": "Odengatan 22",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "22, Odengatan, Sibirien, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 51, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0620223,
+ 59.3457683
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Odengatan 32 (Tulegatan)",
+ "street": "Odengatan 32",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "32, Odengatan, Sibirien, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 55, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0598249,
+ 59.3452247
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Drottning Kristinas väg 9 (KTH)",
+ "street": "Drottning Kristinas väg 9",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "9, Drottning Kristinas Väg, Ruddammen, Norra Djurgården, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 114 28, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0717982,
+ 59.3465424
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Vasagatan 44",
+ "street": "Vasagatan 44",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "44, Vasagatan, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 20, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0568699,
+ 59.3336408
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Hornsbruksgatan 28",
+ "street": "Hornsbruksgatan 28",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "28, Hornsbruksgatan, Hornstull, Södermalm, Södermalms stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 117 34, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0345027,
+ 59.3161104
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Sturegatan 9",
+ "street": "Sturegatan 9",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "9, Sturegatan, Villastaden, Östermalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 114 36, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0762633,
+ 59.3407835
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Rörstrandsgatan 10",
+ "street": "Rörstrandsgatan 10",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "10, Rörstrandsgatan, Rörstrand, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 40, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0342217,
+ 59.3400268
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Västerlånggatan 38",
+ "street": "Västerlånggatan 38",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "38, Västerlånggatan, Gamla stan, Södermalms stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 29, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0698815,
+ 59.3244842
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Linköping",
+ "street": "Ågatan 21",
+ "city": "Linköping",
+ "country": "Sweden",
+ "fullAddress": "Moccadeli, 21, Ågatan, Innerstaden, Linköpings Sankt Lars, Linköping, Linköpings kommun, Östergötland County, 582 22, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 15.6263508,
+ 58.4119846
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Västervik",
+ "street": "Fiskaretorget 1",
+ "city": "Västervik",
+ "country": "Sweden",
+ "fullAddress": "Vikens Food & Friends, 1, Fiskaretorget, Reginelund, Ludvigsborg, Västervik, Västerviks kommun, Kalmar County, 593 30, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 16.6390801,
+ 57.7592454
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Eskilstuna",
+ "street": "Fristadstorget 4",
+ "city": "Eskilstuna",
+ "country": "Sweden",
+ "fullAddress": "Fristadstorget, Söder, Eskilstuna, Eskilstuna kommun, Södermanland County, 632 18, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 16.513925,
+ 59.3707345
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Skövde",
+ "street": "Storgatan 12B",
+ "city": "Skövde",
+ "country": "Sweden",
+ "fullAddress": "12B, Storgatan, Vasastaden, Skövde, Skövde kommun, Västra Götaland County, 541 30, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 13.8448467,
+ 58.3896963
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Örebro",
+ "street": "Stortorget 7",
+ "city": "Örebro",
+ "country": "Sweden",
+ "fullAddress": "Stortorget, Eklunda, Örebro, Örebro kommun, Örebro County, 702 12, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 15.214789,
+ 59.2715472
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Norrköping",
+ "street": "Tunnbindaregatan 3",
+ "city": "Norrköping",
+ "country": "Sweden",
+ "fullAddress": "Mocca Deli, 3, Tunnbindaregatan, Mjölnaren, Nordantill, Norrköping, Norrköpings kommun, Östergötland County, 602 21, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 16.1804993,
+ 58.590035
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Västerås",
+ "street": "Stora torget 2",
+ "city": "Västerås",
+ "country": "Sweden",
+ "fullAddress": "2, Stora torget, Kyrkbacken, Västerås, Västerås kommun, Västmanland County, 722 15, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 16.5432668,
+ 59.6107377
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Lidköping",
+ "street": "Nya Stadens Torg 1",
+ "city": "Lidköping",
+ "country": "Sweden",
+ "fullAddress": "Nya Stadens torg, Gamla staden, Lidköping distrikt, Lidköping, Lidköpings kommun, Västra Götaland County, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 13.1572925,
+ 58.5038249
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Borås",
+ "street": "Västerbrogatan 5",
+ "city": "Borås",
+ "country": "Sweden",
+ "fullAddress": "Västerbrogatan, Norrby, Borås, Borås kommun, Västra Götaland County, 503 30, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 12.9371291,
+ 57.7211513
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Kalmar",
+ "street": "Storgatan 21",
+ "city": "Kalmar",
+ "country": "Sweden",
+ "fullAddress": "21, Storgatan, Kvarnholmen, Kalmar, Kalmar kommun, Kalmar County, 392 32, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 16.3633499,
+ 56.663523
+ ]
+ }
+ },
+ {
+ "brand": "PBX",
+ "name": "PBX Sveavägen 67A",
+ "street": "Sveavägen 67A",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "67A, Sveavägen, Sibirien, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 50, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0567707,
+ 59.3421661
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Odenplan",
+ "street": "Norrtullsgatan 9",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "9, Norrtullsgatan, Sibirien, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 29, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0519259,
+ 59.3417797
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Medborgarplatsen",
+ "street": "Folkungagatan 43",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "43, Folkungagatan, Södermalm, Södermalms stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 118 26, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0722778,
+ 59.3141715
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Sveavägen 51",
+ "street": "Sveavägen 51",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "51, Sveavägen, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 59, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0590015,
+ 59.3399961
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Humlegården",
+ "street": "Humlegårdsgatan 20",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "20, Humlegårdsgatan, Villastaden, Östermalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 114 46, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0747907,
+ 59.3368854
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Garnisonen",
+ "street": "Karlavägen 100",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "100, Karlavägen, Östermalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 115 26, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0963301,
+ 59.3361407
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Mäster Samuelsgatan 9",
+ "street": "Mäster Samuelsgatan 9",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "9, Mäster Samuelsgatan, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 46, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0720428,
+ 59.3342298
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Sveavägen 31",
+ "street": "Sveavägen 31",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "31, Sveavägen, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 37, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0621907,
+ 59.3366104
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Gallerian",
+ "street": "Hamngatan 37",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "37, Hamngatan, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 53, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0671567,
+ 59.3324687
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Centralstationen",
+ "street": "Centralplan 15",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "Centralens huvudentré, 15, Centralplan, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 64, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.058744,
+ 59.330381
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Åhléns City",
+ "street": "Sergels torg",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "Sergels torg, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 51, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0640247,
+ 59.3321933
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Stockholm Quality Outlet",
+ "street": "Flyginfarten 4",
+ "city": "Järfälla",
+ "country": "Sweden",
+ "fullAddress": "Samsøe Samsøe, 4, Flyginfarten, Barkarbystaden, Söderhöjden, Järfälla kommun, Sollentuna kommun, Stockholm County, 177 38, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 17.858617,
+ 59.4170361
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Kungsbron 8B",
+ "street": "Kungsbron 8B",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "Kungsbron, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 112 24, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0512998,
+ 59.332258
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Tyresö Centrum",
+ "street": "Östangränd 18",
+ "city": "Tyresö",
+ "country": "Sweden",
+ "fullAddress": "Östangränd, Bollmora, Tyresö kommun, Stockholm County, 135 38, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.230101,
+ 59.2458764
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Mörby Centrum",
+ "street": "Mörbyleden 182",
+ "city": "Danderyd",
+ "country": "Sweden",
+ "fullAddress": "Mörbyleden, Ösby, Klingsta, Djursholm, Danderyds kommun, Stockholm County, 182 17, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0412507,
+ 59.3981567
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Mall of Scandinavia",
+ "street": "Stjärntorget 2 (Plan 1 bredvid SATS)",
+ "city": "Solna",
+ "country": "Sweden",
+ "fullAddress": "Westfield Mall of Scandinavia, 2, Stjärntorget, Arenastaden, Ritorp, Solna, Solna kommun, Stockholm County, 169 79, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0030108,
+ 59.3704371
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Arlanda T5",
+ "street": "Terminal 5",
+ "city": "Arlanda",
+ "country": "Sweden",
+ "fullAddress": "7-Eleven, Arlandaleden, Sigtuna kommun, Stockholm County, 190 45, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 17.9310876,
+ 59.6514205
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Brunkebergstorg 12",
+ "street": "Brunkebergstorg 12",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "Hawaii poké, 12, Brunkebergstorg, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 51, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0668659,
+ 59.3317707
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Signalfabriken",
+ "street": "Sundbybergs torg 1",
+ "city": "Sundbyberg",
+ "country": "Sweden",
+ "fullAddress": "Bistro Berg, 1, Sundbybergs torg, Centrala Sundbyberg, Sundbybergs kommun, Stockholm County, 172 65, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 17.9666432,
+ 59.3616324
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Uppsala Centrum",
+ "street": "Dragarbrunnsgatan 44",
+ "city": "Uppsala",
+ "country": "Sweden",
+ "fullAddress": "Royal, 44, Dragarbrunnsgatan, Höganäs, Centrum, Uppsala, Uppsala kommun, Uppsala County, 753 20, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 17.6413531,
+ 59.8588918
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Malmö Södertull",
+ "street": "Södra Vallgatan 3",
+ "city": "Malmö",
+ "country": "Sweden",
+ "fullAddress": "Vibliotek, 3, Södra Vallgatan, Old Town, Norr, Malmö, Malmö kommun, Skåne County, 211 40, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 13.0012344,
+ 55.6015232
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Göteborg Femman",
+ "street": "Postgatan 26",
+ "city": "Göteborg",
+ "country": "Sweden",
+ "fullAddress": "Cervera, 26-32, Postgatan, North Town, Inom Vallgraven, Centrum, Gothenburg, Göteborgs Stad, Västra Götaland County, 411 06, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 11.9702305,
+ 57.7090971
+ ]
+ }
+ }
+]
\ No newline at end of file
diff --git a/backend/data/seedRetailers.json b/backend/data/seedRetailers.json
new file mode 100644
index 0000000000..78127bafbf
--- /dev/null
+++ b/backend/data/seedRetailers.json
@@ -0,0 +1,327 @@
+[
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Drottninggatan 90B",
+ "street": "Drottninggatan 90B",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Götgatan 90",
+ "street": "Götgatan 90",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Regeringsgatan 54",
+ "street": "Regeringsgatan 54",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Sveavägen 55",
+ "street": "Sveavägen 55",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Sveavägen 71",
+ "street": "Sveavägen 71",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Odengatan 22 (Birger Jarlsgatan)",
+ "street": "Odengatan 22",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Odengatan 32 (Tulegatan)",
+ "street": "Odengatan 32",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Drottning Kristinas väg 9 (KTH)",
+ "street": "Drottning Kristinas väg 9",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Vasagatan 44",
+ "street": "Vasagatan 44",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Hornsbruksgatan 28",
+ "street": "Hornsbruksgatan 28",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Sturegatan 9",
+ "street": "Sturegatan 9",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Rörstrandsgatan 10",
+ "street": "Rörstrandsgatan 10",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Västerlånggatan 38",
+ "street": "Västerlånggatan 38",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Linköping",
+ "street": "Ågatan 21",
+ "city": "Linköping",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Västervik",
+ "street": "Fiskaretorget 1",
+ "city": "Västervik",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Eskilstuna",
+ "street": "Fristadstorget 4",
+ "city": "Eskilstuna",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Skövde",
+ "street": "Storgatan 12B",
+ "city": "Skövde",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Örebro",
+ "street": "Stortorget 7",
+ "city": "Örebro",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Norrköping",
+ "street": "Tunnbindaregatan 3",
+ "city": "Norrköping",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Västerås",
+ "street": "Stora torget 2",
+ "city": "Västerås",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Lidköping",
+ "street": "Nya Stadens Torg 1",
+ "city": "Lidköping",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Borås",
+ "street": "Västerbrogatan 5",
+ "city": "Borås",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Kalmar",
+ "street": "Storgatan 21",
+ "city": "Kalmar",
+ "country": "Sweden"
+ },
+
+ {
+ "brand": "PBX",
+ "name": "PBX Sveavägen 67A",
+ "street": "Sveavägen 67A",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Odenplan",
+ "street": "Norrtullsgatan 9",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Medborgarplatsen",
+ "street": "Folkungagatan 43",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Sveavägen 51",
+ "street": "Sveavägen 51",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Humlegården",
+ "street": "Humlegårdsgatan 20",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Garnisonen",
+ "street": "Karlavägen 100",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Mäster Samuelsgatan 9",
+ "street": "Mäster Samuelsgatan 9",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Sveavägen 31",
+ "street": "Sveavägen 31",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Gallerian",
+ "street": "Hamngatan 37",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Centralstationen",
+ "street": "Centralplan 15",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Åhléns City",
+ "street": "Sergels torg",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Stockholm Quality Outlet",
+ "street": "Flyginfarten 4",
+ "city": "Järfälla",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Solna Centrum",
+ "street": "Bibliotekstorget 4",
+ "city": "Solna",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Kungsbron 8B",
+ "street": "Kungsbron 8B",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Tyresö Centrum",
+ "street": "Östangränd 18",
+ "city": "Tyresö",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Mörby Centrum",
+ "street": "Mörbyleden 182",
+ "city": "Danderyd",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Mall of Scandinavia",
+ "street": "Stjärntorget 2 (Plan 1 bredvid SATS)",
+ "city": "Solna",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Arlanda T5",
+ "street": "Terminal 5",
+ "city": "Arlanda",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Brunkebergstorg 12",
+ "street": "Brunkebergstorg 12",
+ "city": "Stockholm",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Signalfabriken",
+ "street": "Sundbybergs torg 1",
+ "city": "Sundbyberg",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Uppsala Centrum",
+ "street": "Dragarbrunnsgatan 44",
+ "city": "Uppsala",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Malmö Södertull",
+ "street": "Södra Vallgatan 3",
+ "city": "Malmö",
+ "country": "Sweden"
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Göteborg Femman",
+ "street": "Postgatan 26",
+ "city": "Göteborg",
+ "country": "Sweden"
+ }
+]
diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js
new file mode 100644
index 0000000000..db0bdce92b
--- /dev/null
+++ b/backend/middleware/auth.js
@@ -0,0 +1,45 @@
+import jwt from 'jsonwebtoken'
+
+import Admin from '../models/Admin.js'
+import Company from '../models/Company.js'
+import Customer from '../models/Customer.js'
+
+const getJwtSecret = () => {
+ const secret = process.env.JWT_SECRET
+ if (!secret || !secret.trim()) {
+ throw new Error('Missing JWT_SECRET env var')
+ }
+ return secret
+}
+
+export const authenticate = async (req, res, next) => {
+ const token = req.headers.authorization?.split(' ')[1]
+ if (!token) return res.status(401).json({ error: 'No token provided' })
+ try {
+ const decoded = jwt.verify(token, getJwtSecret())
+ let user
+ if (decoded.role === 'company') {
+ user = await Company.findById(decoded.id)
+ req.user = { id: user._id, role: user.role, companyId: user._id }
+ } else if (decoded.role === 'admin') {
+ user = await Admin.findById(decoded.id)
+ req.user = { id: user._id, role: user.role }
+ } else if (decoded.role === 'customer') {
+ user = await Customer.findById(decoded.id)
+ req.user = { id: user._id, role: user.role }
+ }
+ if (!user) return res.status(401).json({ error: 'User not found' })
+ next()
+ } catch (err) {
+ console.error('Authenticate middleware unexpected error:', err)
+ // Return 401 for JWT errors, not 500
+ return res.status(401).json({ error: 'Invalid or expired token' })
+ }
+}
+
+export const authorize = (roles) => (req, res, next) => {
+ if (!roles.includes(req.user.role)) {
+ return res.status(403).json({ error: 'Forbidden' })
+ }
+ next()
+}
diff --git a/backend/models/Admin.js b/backend/models/Admin.js
new file mode 100644
index 0000000000..c7e5da000e
--- /dev/null
+++ b/backend/models/Admin.js
@@ -0,0 +1,14 @@
+import mongoose from 'mongoose'
+
+const adminSchema = new mongoose.Schema({
+ name: { type: String, required: true },
+ email: { type: String, required: true, unique: true },
+ password: { type: String, required: true }, // hashed
+ role: {
+ type: String,
+ enum: ['admin', 'company', 'customer'],
+ default: 'admin'
+ }
+})
+
+export default mongoose.model('Admin', adminSchema)
diff --git a/backend/models/Company.js b/backend/models/Company.js
new file mode 100644
index 0000000000..7e7cf0a940
--- /dev/null
+++ b/backend/models/Company.js
@@ -0,0 +1,16 @@
+import mongoose from 'mongoose'
+
+const companySchema = new mongoose.Schema({
+ name: { type: String, required: true },
+ email: { type: String, required: true, unique: true },
+ password: { type: String, required: true }, // hashed
+ address: String,
+ contactPerson: String,
+ role: {
+ type: String,
+ enum: ['admin', 'company', 'customer'],
+ default: 'company'
+ }
+})
+
+export default mongoose.model('Company', companySchema)
diff --git a/backend/models/Customer.js b/backend/models/Customer.js
new file mode 100644
index 0000000000..1ec87ec3ea
--- /dev/null
+++ b/backend/models/Customer.js
@@ -0,0 +1,18 @@
+import mongoose from 'mongoose'
+
+const customerSchema = new mongoose.Schema({
+ name: { type: String, required: true },
+ email: { type: String, required: true },
+ address: { type: String },
+ phone: { type: String },
+ company: { type: mongoose.Schema.Types.ObjectId, ref: 'Company' }, // Reference to Company
+ createdAt: { type: Date, default: Date.now },
+ role: {
+ type: String,
+ enum: ['admin', 'company', 'customer'],
+ default: 'customer'
+ }
+})
+
+const Customer = mongoose.model('Customer', customerSchema)
+export default Customer
diff --git a/backend/models/Order.js b/backend/models/Order.js
new file mode 100644
index 0000000000..c63149e7e5
--- /dev/null
+++ b/backend/models/Order.js
@@ -0,0 +1,39 @@
+import mongoose from 'mongoose'
+
+const orderSchema = new mongoose.Schema({
+ customer: { type: mongoose.Schema.Types.ObjectId, ref: 'Customer' },
+ name: { type: String, required: true },
+ company: { type: mongoose.Schema.Types.ObjectId, ref: 'Company' },
+ email: { type: String, required: true },
+ address: { type: String },
+ phone: { type: String },
+ instructions: { type: String },
+ items: [
+ {
+ productId: { type: mongoose.Schema.Types.ObjectId, ref: 'Product' },
+ name: String,
+ quantity: Number,
+ price: Number
+ }
+ ],
+
+ // changed: keep Number stored, but add a getter that rounds to 2 decimals for output
+ totalCost: {
+ type: Number,
+ get: (v) => (v == null ? v : Math.round(v * 100) / 100)
+ },
+
+ status: {
+ type: String,
+ enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled'],
+ default: 'pending'
+ },
+ createdAt: { type: Date, default: Date.now }
+})
+
+// ensure getters run when converting to JSON / objects
+orderSchema.set('toJSON', { getters: true, virtuals: false })
+orderSchema.set('toObject', { getters: true, virtuals: false })
+
+const Order = mongoose.model('Order', orderSchema)
+export default Order
diff --git a/backend/models/Partner.js b/backend/models/Partner.js
new file mode 100644
index 0000000000..6632e62b6a
--- /dev/null
+++ b/backend/models/Partner.js
@@ -0,0 +1,132 @@
+import mongoose from 'mongoose'
+
+// Schema for partner contact information
+const contactSchema = new mongoose.Schema(
+ {
+ email: {
+ type: String,
+ trim: true,
+ lowercase: true
+ },
+ phone: {
+ type: String,
+ trim: true
+ }
+ },
+ { _id: false }
+)
+
+// Schema for partner logo
+const logoSchema = new mongoose.Schema(
+ {
+ url: {
+ type: String,
+ default: null
+ },
+ alt: {
+ type: String,
+ required: true
+ }
+ },
+ { _id: false }
+)
+
+// Main Partner schema
+const partnerSchema = new mongoose.Schema(
+ {
+ name: {
+ type: String,
+ required: [true, 'Partner name is required'],
+ trim: true,
+ maxlength: [100, 'Partner name cannot exceed 100 characters']
+ },
+ type: {
+ type: String,
+ required: [true, 'Partner type is required'],
+ enum: {
+ values: ['served_at', 'catering_partner'],
+ message: 'Type must be either served_at or catering_partner'
+ }
+ },
+ logo: {
+ type: logoSchema,
+ default: () => ({ url: null, alt: '' })
+ },
+ website: {
+ type: String,
+ trim: true,
+ validate: {
+ validator: function (url) {
+ if (!url) return true // Allow empty URLs
+ return /^https?:\/\/.+/.test(url)
+ },
+ message: 'Website must be a valid URL starting with http:// or https://'
+ }
+ },
+ contact: {
+ type: contactSchema,
+ default: () => ({})
+ },
+ isActive: {
+ type: Boolean,
+ default: true
+ },
+ // Additional fields for future use
+ description: {
+ type: String,
+ maxlength: [500, 'Description cannot exceed 500 characters']
+ },
+ location: {
+ type: String,
+ maxlength: [200, 'Location cannot exceed 200 characters']
+ }
+ },
+ {
+ timestamps: true,
+ toJSON: { virtuals: true },
+ toObject: { virtuals: true }
+ }
+)
+
+// Indexes for better query performance
+partnerSchema.index({ type: 1, isActive: 1 })
+partnerSchema.index({ name: 'text' }) // Text search on name
+
+// Virtual for partner display name with type
+partnerSchema.virtual('displayInfo').get(function () {
+ return `${this.name} (${this.type.replace('_', ' ')})`
+})
+
+// Static methods for common queries
+partnerSchema.statics.findByType = function (type, isActive = true) {
+ return this.find({ type, isActive }).sort({ name: 1 })
+}
+
+partnerSchema.statics.findServedAt = function (isActive = true) {
+ return this.findByType('served_at', isActive)
+}
+
+partnerSchema.statics.findCateringPartners = function (isActive = true) {
+ return this.findByType('catering_partner', isActive)
+}
+
+// Instance methods
+partnerSchema.methods.activate = function () {
+ this.isActive = true
+ return this.save()
+}
+
+partnerSchema.methods.deactivate = function () {
+ this.isActive = false
+ return this.save()
+}
+
+// Pre-save middleware to set default alt text if not provided
+partnerSchema.pre('save', function (next) {
+ if (this.logo && !this.logo.alt) {
+ this.logo.alt = this.name
+ }
+ next()
+})
+
+export default mongoose.model('Partner', partnerSchema)
diff --git a/backend/models/Product.js b/backend/models/Product.js
new file mode 100644
index 0000000000..68fc3ba686
--- /dev/null
+++ b/backend/models/Product.js
@@ -0,0 +1,260 @@
+import mongoose from 'mongoose'
+
+// Schema for nutrition information
+const nutritionSchema = new mongoose.Schema(
+ {
+ energy: {
+ type: String,
+ required: false
+ },
+ fat: {
+ type: String,
+ required: false
+ },
+ saturatedFat: {
+ type: String,
+ required: false
+ },
+ carbohydrates: {
+ type: String,
+ required: false
+ },
+ sugars: {
+ type: String,
+ required: false
+ },
+ fiber: {
+ type: String,
+ required: false
+ },
+ protein: {
+ type: String,
+ required: false
+ },
+ salt: {
+ type: String,
+ required: false
+ }
+ },
+ { _id: false }
+) // Don't create separate IDs for subdocuments
+
+// Schema for product images
+const imageSchema = new mongoose.Schema(
+ {
+ url: {
+ type: String,
+ required: true
+ },
+ alt: {
+ type: String,
+ required: true
+ },
+ isPrimary: {
+ type: Boolean,
+ default: false
+ }
+ },
+ { _id: false }
+)
+
+// Schema for product sizes/packaging
+const sizeSchema = new mongoose.Schema(
+ {
+ weight: {
+ type: String,
+ required: true
+ },
+ packaging: {
+ type: String,
+ required: true
+ },
+ price: {
+ type: Number,
+ required: true
+ } // Add this line
+ },
+ { _id: false }
+)
+
+// Main Product schema
+const productSchema = new mongoose.Schema(
+ {
+ name: {
+ type: String,
+ required: [true, 'Product name is required'],
+ trim: true,
+ maxlength: [100, 'Product name cannot exceed 100 characters']
+ },
+ description: {
+ type: String,
+ required: [true, 'Product description is required'],
+ maxlength: [500, 'Description cannot exceed 500 characters']
+ },
+ category: {
+ type: String,
+ required: [true, 'Product category is required'],
+ enum: {
+ values: ['bites', 'cheezecakes', 'limited'],
+ message: 'Category must be one of: bites, cheezecakes, limited'
+ }
+ },
+ images: {
+ type: [imageSchema],
+ validate: {
+ validator: function (images) {
+ return images && images.length > 0
+ },
+ message: 'At least one image is required'
+ }
+ },
+ ingredients: {
+ type: [String],
+ required: [true, 'Ingredients list is required'],
+ validate: {
+ validator: function (ingredients) {
+ return ingredients && ingredients.length > 0
+ },
+ message: 'At least one ingredient is required'
+ }
+ },
+ allergens: {
+ type: [String],
+ default: []
+ },
+ nutrition: {
+ per34g: nutritionSchema,
+ per30g: nutritionSchema,
+ per55g: nutritionSchema
+ },
+ sizes: {
+ type: [sizeSchema],
+ required: [true, 'Size information is required'],
+ validate: {
+ validator: function (sizes) {
+ return sizes && sizes.length > 0
+ },
+ message: 'At least one size option is required'
+ }
+ },
+ keywords: {
+ type: [String],
+ default: []
+ },
+ featured: {
+ type: Boolean,
+ default: false
+ },
+ status: {
+ type: String,
+ enum: {
+ values: ['active', 'limited', 'discontinued'],
+ message: 'Status must be one of: active, limited, discontinued'
+ },
+ default: 'active'
+ },
+ // SEO and search optimization
+ slug: {
+ type: String,
+ unique: true,
+ sparse: true // Allow null values but ensure uniqueness when present
+ },
+ // Inventory tracking (for future use)
+ stock: {
+ type: Number,
+ min: 0,
+ default: 0
+ },
+ // Ratings (for future use)
+ averageRating: {
+ type: Number,
+ min: 0,
+ max: 5,
+ default: 0
+ },
+ ratingCount: {
+ type: Number,
+ min: 0,
+ default: 0
+ }
+ },
+ {
+ timestamps: true, // Adds createdAt and updatedAt automatically
+ toJSON: { virtuals: true },
+ toObject: { virtuals: true }
+ }
+)
+
+// Indexes for better query performance
+productSchema.index({ category: 1, status: 1 })
+productSchema.index({ featured: 1, status: 1 })
+productSchema.index({ keywords: 1 })
+productSchema.index({ name: 'text', description: 'text' }) // Text search
+
+// Virtual for getting primary image
+productSchema.virtual('primaryImage').get(function () {
+ const primary = this.images?.find((img) => img.isPrimary)
+ return primary || this.images?.[0] || null
+})
+
+// Virtual for formatted price
+productSchema.virtual('formattedPrice').get(function () {
+ // Use the first size's price if available
+ const price =
+ this.price ??
+ (Array.isArray(this.sizes) && this.sizes.length > 0
+ ? this.sizes[0].price
+ : undefined)
+ return price !== undefined ? `$${price.toFixed(2)}` : ''
+})
+
+// Virtual for checking if product is available
+productSchema.virtual('isAvailable').get(function () {
+ return this.status === 'active' && this.stock > 0
+})
+
+// Pre-save middleware to generate slug
+productSchema.pre('save', function (next) {
+ if (this.isNew || this.isModified('name')) {
+ this.slug = this.name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/(^-|-$)+/g, '')
+ }
+ next()
+})
+
+// Static methods for common queries
+productSchema.statics.findByCategory = function (category, status = 'active') {
+ return this.find({ category, status }).sort({ createdAt: -1 })
+}
+
+productSchema.statics.findFeatured = function (status = 'active') {
+ return this.find({ featured: true, status }).sort({ createdAt: -1 })
+}
+
+productSchema.statics.searchProducts = function (query, status = 'active') {
+ return this.find(
+ {
+ $text: { $search: query },
+ status
+ },
+ { score: { $meta: 'textScore' } }
+ ).sort({ score: { $meta: 'textScore' } })
+}
+
+// Instance methods
+productSchema.methods.addRating = function (rating) {
+ const totalRating = this.averageRating * this.ratingCount + rating
+ this.ratingCount += 1
+ this.averageRating = totalRating / this.ratingCount
+ return this.save()
+}
+
+productSchema.methods.updateStock = function (quantity) {
+ this.stock = Math.max(0, this.stock + quantity)
+ return this.save()
+}
+
+// Export the model
+export default mongoose.model('Product', productSchema)
diff --git a/backend/models/RetailLocation.js b/backend/models/RetailLocation.js
new file mode 100644
index 0000000000..a361ece2c9
--- /dev/null
+++ b/backend/models/RetailLocation.js
@@ -0,0 +1,17 @@
+import mongoose from 'mongoose'
+
+const RetailLocationSchema = new mongoose.Schema({
+ brand: { type: String, index: true },
+ name: String,
+ street: String,
+ city: String,
+ country: String,
+ fullAddress: String,
+ location: {
+ type: { type: String, enum: ['Point'], required: true },
+ coordinates: { type: [Number], required: true } // [lng, lat]
+ }
+}, { timestamps: true })
+
+RetailLocationSchema.index({ location: '2dsphere' })
+export default mongoose.model('RetailLocation', RetailLocationSchema)
diff --git a/backend/models/contactMessage.js b/backend/models/contactMessage.js
new file mode 100644
index 0000000000..703569dcc6
--- /dev/null
+++ b/backend/models/contactMessage.js
@@ -0,0 +1,15 @@
+import mongoose from "mongoose"
+
+const contactMessageSchema = new mongoose.Schema(
+ {
+ name: { type: String, required: true, trim: true },
+ email: { type: String, required: true, lowercase: true, trim: true },
+ subject: { type: String, trim: true },
+ phone: { type: String, trim: true },
+ message: { type: String, required: true, trim: true },
+ createdAt: { type: Date, default: Date.now },
+ // status: { type: String, enum: ["new","read","archived"], default: "new" },
+ },
+);
+
+export default mongoose.model("contactMessage", contactMessageSchema);
diff --git a/backend/package.json b/backend/package.json
index 08f29f2448..529eef84d5 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,20 +1,27 @@
{
- "name": "project-final-backend",
+ "name": "naima-backend",
"version": "1.0.0",
- "description": "Server part of final project",
+ "type": "module",
"scripts": {
"start": "babel-node server.js",
- "dev": "nodemon server.js --exec babel-node"
+ "dev": "nodemon server.js --exec babel-node",
+ "seed": "babel-node scripts/seedData.js"
},
- "author": "",
- "license": "ISC",
"dependencies": {
- "@babel/core": "^7.17.9",
- "@babel/node": "^7.16.8",
- "@babel/preset-env": "^7.16.11",
+ "bcryptjs": "^3.0.2",
"cors": "^2.8.5",
- "express": "^4.17.3",
- "mongoose": "^8.4.0",
- "nodemon": "^3.0.1"
+ "dotenv": "^16.0.0",
+ "express": "^4.18.2",
+ "express-list-endpoints": "^7.1.1",
+ "express-rate-limit": "^8.0.1",
+ "express-validator": "^7.2.1",
+ "jsonwebtoken": "^9.0.2",
+ "mongoose": "^7.0.0"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.22.0",
+ "@babel/node": "^7.22.0",
+ "@babel/preset-env": "^7.22.0",
+ "nodemon": "^3.1.10"
}
-}
\ No newline at end of file
+}
diff --git a/backend/routes/adminRoutes.js b/backend/routes/adminRoutes.js
new file mode 100644
index 0000000000..5a7f5cca87
--- /dev/null
+++ b/backend/routes/adminRoutes.js
@@ -0,0 +1,23 @@
+import express from 'express'
+
+import { authenticate, authorize } from '../middleware/auth.js'
+
+const router = express.Router()
+
+import {
+ registerAdmin,
+ loginAdmin,
+ getAllAdmins,
+ getAdminById,
+ updateAdmin,
+ deleteAdmin
+} from '../controllers/adminControllers'
+
+router.post('/register', authenticate, authorize(['admin']), registerAdmin)
+router.post('/login', loginAdmin)
+router.get('/', authenticate, authorize(['admin']), getAllAdmins)
+router.get('/:id', authenticate, authorize(['admin']), getAdminById)
+router.put('/:id', authenticate, authorize(['admin']), updateAdmin)
+router.delete('/:id', authenticate, authorize(['admin']), deleteAdmin)
+
+export default router
diff --git a/backend/routes/companyRoutes.js b/backend/routes/companyRoutes.js
new file mode 100644
index 0000000000..408f545a16
--- /dev/null
+++ b/backend/routes/companyRoutes.js
@@ -0,0 +1,27 @@
+import express from 'express'
+
+import { authenticate, authorize } from '../middleware/auth.js'
+
+const router = express.Router()
+
+import {
+ loginCompany,
+ logoutCompany,
+ registerCompany,
+ getAllCompanies,
+ getCompanyById,
+ updateCompany,
+ deleteCompany
+} from '../controllers/companyControllers.js'
+
+router.post('/register', registerCompany)
+router.post('/login', loginCompany)
+router.post('logout', logoutCompany)
+
+// Admin routes
+router.get('/', authenticate, authorize(['admin']), getAllCompanies)
+router.get('/:id', authenticate, authorize(['admin']), getCompanyById)
+router.put('/:id', authenticate, authorize(['admin']), updateCompany)
+router.delete('/:id', authenticate, authorize(['admin']), deleteCompany)
+
+export default router
diff --git a/backend/routes/contactRoutes.js b/backend/routes/contactRoutes.js
new file mode 100644
index 0000000000..d7b3f4a528
--- /dev/null
+++ b/backend/routes/contactRoutes.js
@@ -0,0 +1,18 @@
+import express from 'express'
+import rateLimit from 'express-rate-limit'
+
+import { submitContactForm, validateContact } from '../controllers/contactController.js'
+
+export const router = express.Router()
+
+// 🛡️ basic rate limit for spam bursts
+const limiter = rateLimit({
+ windowMs: 60 * 1000,
+ max: 10,
+ standardHeaders: true,
+ legacyHeaders: false,
+})
+
+router.post('/', limiter, validateContact, submitContactForm)
+
+export default router
diff --git a/backend/routes/customerRoutes.js b/backend/routes/customerRoutes.js
new file mode 100644
index 0000000000..cd801c2c65
--- /dev/null
+++ b/backend/routes/customerRoutes.js
@@ -0,0 +1,26 @@
+import express from 'express'
+
+import {
+ createCustomer,
+ deleteCustomerById,
+ getAllCustomers,
+ getCustomerById,
+ updateCustomerById
+} from '../controllers/customerControllers.js'
+import { authenticate, authorize } from '../middleware/auth.js'
+
+const router = express.Router()
+
+// Admin routes
+router.get('/', authenticate, authorize(['admin']), getAllCustomers)
+router.get('/:id', authenticate, authorize(['admin']), getCustomerById)
+router.post('/', authenticate, authorize(['admin']), createCustomer)
+router.put(
+ '/:id',
+ authenticate,
+ authorize(['admin', 'company']),
+ updateCustomerById
+)
+router.delete('/:id', authenticate, authorize(['admin']), deleteCustomerById)
+
+export default router
diff --git a/backend/routes/orderRoutes.js b/backend/routes/orderRoutes.js
new file mode 100644
index 0000000000..1a335e4998
--- /dev/null
+++ b/backend/routes/orderRoutes.js
@@ -0,0 +1,47 @@
+import express from 'express'
+
+import {
+ createOrder,
+ getAllOrders,
+ getOrderById,
+ getOrdersForCustomer
+} from '../controllers/orderController.js'
+import { authenticate, authorize } from '../middleware/auth.js'
+import Order from '../models/Order.js'
+
+const router = express.Router()
+
+// Company routes
+router.post('/', authenticate, authorize(['company']), createOrder)
+
+router.get(
+ '/company',
+ authenticate,
+ authorize(['company']),
+ async (req, res) => {
+ const companyId = req.user.companyId || req.user.id
+ try {
+ const orders = await Order.find({ company: companyId }).populate(
+ 'customer'
+ )
+ res.json(orders)
+ } catch (error) {
+ console.error('Error in /api/orders/company:', error)
+ res.status(500).json({ error: error.message })
+ }
+ }
+)
+
+// Admin routes
+router.get('/', authenticate, authorize(['admin']), getAllOrders)
+
+// Mixed routes (move param route last so it doesn't shadow static routes)
+router.get(
+ '/customer/:id',
+ authenticate,
+ authorize(['admin', 'company']),
+ getOrdersForCustomer
+)
+router.get('/:id', authenticate, authorize(['admin', 'company']), getOrderById) // Allow admin and company
+
+export default router
diff --git a/backend/routes/partnerRoutes.js b/backend/routes/partnerRoutes.js
new file mode 100644
index 0000000000..64a1d7d880
--- /dev/null
+++ b/backend/routes/partnerRoutes.js
@@ -0,0 +1,16 @@
+import express from 'express'
+
+import {
+ getAllPartners,
+ getCateringPartners,
+ getServedAtPartners
+} from '../controllers/partnerController.js'
+
+const router = express.Router()
+
+// Admin routes
+router.get('/', getAllPartners)
+router.get('/served-at', getServedAtPartners)
+router.get('/catering', getCateringPartners)
+
+export default router
diff --git a/backend/routes/productRoutes.js b/backend/routes/productRoutes.js
new file mode 100644
index 0000000000..16327e2d6d
--- /dev/null
+++ b/backend/routes/productRoutes.js
@@ -0,0 +1,27 @@
+import express from 'express'
+
+import {
+ createProduct,
+ deleteProduct,
+ getAllProducts,
+ getFeaturedProducts,
+ getProductById,
+ getProductsByCategory,
+ updateProduct
+} from '../controllers/productControllers.js'
+import { authenticate, authorize } from '../middleware/auth.js'
+
+const router = express.Router()
+
+// Public routes (no auth)
+router.get('/', getAllProducts)
+router.get('/:id', getProductById)
+router.get('/featured/list', getFeaturedProducts)
+router.get('/category/:category', getProductsByCategory)
+
+// Admin routes (require auth)
+router.post('/', authenticate, authorize(['admin']), createProduct)
+router.put('/:id', authenticate, authorize(['admin']), updateProduct)
+router.delete('/:id', authenticate, authorize(['admin']), deleteProduct)
+
+export default router
diff --git a/backend/routes/retailerRoutes.js b/backend/routes/retailerRoutes.js
new file mode 100644
index 0000000000..8518a88453
--- /dev/null
+++ b/backend/routes/retailerRoutes.js
@@ -0,0 +1,8 @@
+import express from 'express'
+import { listRetailers, retailersNear } from '../controllers/retailerController.js'
+
+const router = express.Router()
+router.get('/', listRetailers)
+router.get('/near', retailersNear)
+
+export default router
diff --git a/backend/scripts/geocode-retailers.mjs b/backend/scripts/geocode-retailers.mjs
new file mode 100644
index 0000000000..50c3bcb3eb
--- /dev/null
+++ b/backend/scripts/geocode-retailers.mjs
@@ -0,0 +1,52 @@
+import fs from 'node:fs/promises'
+
+// 1) read seed (no coords yet)
+const inputPath = 'backend/data/seedRetailers.json'
+const outputPath = 'backend/data/geocodedRetailers.json'
+const input = JSON.parse(await fs.readFile(inputPath, 'utf8'))
+const out = []
+
+// Manual overrides for tricky addresses
+const OVERRIDES = {
+ 'Bibliotekstorget 4, Solna, Sweden':
+ 'Bibliotekstorget 4, 171 45 Solna, Sweden',
+ 'Stjärntorget 2 (Plan 1 bredvid SATS), Solna, Sweden':
+ 'Stjärntorget 2, 169 79 Solna, Sweden' // Mall of Scandinavia
+}
+
+// 2) Nominatim (OpenStreetMap) geocoder — free, no key.
+// Be polite: add a User-Agent and delay between calls.
+const GEOCODE = async (q) => {
+ const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(
+ q
+ )}&limit=1&countrycodes=se&addressdetails=1&accept-language=en`
+ const r = await fetch(url, {
+ headers: { 'User-Agent': 'NaimaWebsite/1.0 (contact@naima.example)' }
+ })
+ if (!r.ok) return null
+ const j = await r.json()
+ const f = j?.[0]
+ return f
+ ? { lat: Number(f.lat), lng: Number(f.lon), label: f.display_name }
+ : null
+}
+
+for (const item of input) {
+ // Builds the default query, then apply overrides if present
+ const rawQuery = `${item.street}, ${item.city}, ${item.country}`
+ const query = OVERRIDES[rawQuery] || rawQuery
+
+ const res = await GEOCODE(query)
+ if (!res) {
+ console.warn('No match:', query)
+ continue
+ }
+ out.push({
+ ...item,
+ fullAddress: res.label || `${item.street}, ${item.city}, ${item.country}`,
+ location: { type: 'Point', coordinates: [res.lng, res.lat] } // [lng, lat]
+ })
+ await new Promise((r) => setTimeout(r, 1200)) // be a good citizen
+}
+
+await fs.writeFile(outputPath, JSON.stringify(out, null, 2))
diff --git a/backend/scripts/import-retailers.mjs b/backend/scripts/import-retailers.mjs
new file mode 100644
index 0000000000..5c7f976ef8
--- /dev/null
+++ b/backend/scripts/import-retailers.mjs
@@ -0,0 +1,14 @@
+import mongoose from 'mongoose'
+import fs from 'node:fs/promises'
+
+import RetailLocation from '../models/RetailLocation.js'
+
+await mongoose.connect(process.env.MONGO_URL, { dbName: 'naima-website' })
+const data = JSON.parse(
+ await fs.readFile('data/retailers.geocoded.json', 'utf8')
+)
+
+await RetailLocation.deleteMany({})
+await RetailLocation.insertMany(data)
+
+await mongoose.disconnect()
diff --git a/backend/scripts/seedData.js b/backend/scripts/seedData.js
new file mode 100644
index 0000000000..b32d491715
--- /dev/null
+++ b/backend/scripts/seedData.js
@@ -0,0 +1,402 @@
+import dotenv from 'dotenv'
+import mongoose from 'mongoose'
+
+import Product from '../models/Product.js'
+
+dotenv.config()
+
+const seedProducts = async () => {
+ try {
+ const products = [
+ {
+ name: 'Cinnamon Bun Bite',
+ description:
+ 'Two layer bites in naimas signature square format of 34g or 20g per bite.',
+ category: 'bites',
+ images: [
+ {
+ url: '/images/cinnamon.webp',
+ alt: 'Cinnamon Bun Bite',
+ isPrimary: true
+ }
+ ],
+ ingredients: [
+ 'Dates',
+ 'coconut oil',
+ 'gluten-free oats',
+ 'rice syrup',
+ 'water',
+ 'coconut flakes',
+ 'Ceylon cinnamon',
+ 'cardamom',
+ 'sea salt',
+ 'vanilla'
+ ],
+ allergens: [
+ 'Contains gluten-free oat flakes',
+ 'Nut Free - May contain traces of cashews, pecans, pistachios'
+ ],
+ nutrition: {
+ per34g: {
+ energy: '584.5 kJ (139.7 kcal)',
+ fat: '8.16g',
+ saturatedFat: '6.8g',
+ carbohydrates: '14.96g',
+ sugars: '10.2g',
+ fiber: '1.6g',
+ protein: '1.19g',
+ salt: '0.1g'
+ }
+ },
+ sizes: [
+ {
+ _id: new mongoose.Types.ObjectId(),
+ weight: '34g',
+ packaging: '35 x 34g',
+ price: 20.99
+ },
+ {
+ _id: new mongoose.Types.ObjectId(),
+ weight: '20g',
+ packaging: '60 x 20g',
+ price: 20.99
+ }
+ ],
+ keywords: ['glutenfree', 'lactosefree', 'plantbased'],
+ featured: false,
+ status: 'active'
+ },
+ {
+ name: 'Blueberry & Vanilla Cheezecake',
+ description: 'Creamy Cheezecakes in our signature square format.',
+ category: 'cheezecakes',
+ images: [
+ {
+ url: '/images/blueberry.jpg',
+ alt: 'Blueberry & Vanilla Cheezecake',
+ isPrimary: true
+ }
+ ],
+ ingredients: [
+ 'Dates*',
+ 'soaked cashew nuts*',
+ 'rice syrup*',
+ 'water',
+ 'coconut flakes*',
+ 'gluten-free oats*',
+ 'coconut oil*',
+ 'buckwheat*',
+ 'freeze-dried blueberries*',
+ 'vanilla extract*',
+ 'salt*',
+ 'butterfly pea flower*',
+ 'ashwagandha*',
+ "lion's mane*",
+ 'reishi*'
+ ],
+ allergens: [
+ 'Contains cashew nuts',
+ 'Contains gluten-free oats',
+ 'May contain traces of other nuts'
+ ],
+ nutrition: {
+ per55g: {
+ energy: '907.5 kJ (214.5 kcal)',
+ fat: '12.65g',
+ saturatedFat: '7.7g',
+ carbohydrates: '21.45g',
+ sugars: '13.2g',
+ fiber: '2.64g',
+ protein: '3.4g',
+ salt: '0.12g'
+ },
+ per30g: {
+ energy: '462 kJ (109.2 kcal)',
+ fat: '6.44g',
+ saturatedFat: '3.92g',
+ carbohydrates: '10.92g',
+ sugars: '6.72g',
+ fiber: '1.34g',
+ protein: '1.7g',
+ salt: '0.06g'
+ }
+ },
+ sizes: [
+ {
+ _id: new mongoose.Types.ObjectId(),
+ weight: '55g',
+ packaging: '35 x 55g',
+ price: 20.99
+ },
+ {
+ _id: new mongoose.Types.ObjectId(),
+ weight: '30g',
+ packaging: '60 x 30g',
+ price: 15.99
+ }
+ ],
+ keywords: ['glutenfree', 'plantbased', 'superfoods'],
+ featured: true,
+ status: 'active'
+ },
+ {
+ name: 'Lemoncurd Cheezecake',
+ description:
+ 'Creamy Cheezecakes in our signature square format. Comes in boxes 60 x 30g or 35 x 55g. Order them from our listed partners.',
+ category: 'cheezecakes',
+ images: [
+ {
+ url: '/images/lemoncurd.webp',
+ alt: 'Lemoncurd Cheezecake',
+ isPrimary: true
+ }
+ ],
+ ingredients: [
+ 'Dates*',
+ 'soaked cashew nuts*',
+ 'rice syrup*',
+ 'coconut oil*',
+ 'coconut flakes*',
+ 'gluten-free oats*',
+ 'water',
+ 'buckwheat*',
+ 'lemon juice*',
+ 'turmeric*',
+ 'ashwagandha*',
+ 'lucuma*'
+ ],
+ allergens: [
+ 'Contains cashew nuts',
+ 'Contains gluten-free oats',
+ 'May contain traces of other nuts'
+ ],
+ nutrition: {
+ per55g: {
+ energy: '935 kJ (220 kcal)',
+ fat: '13.2g',
+ saturatedFat: '8.25g',
+ carbohydrates: '21.45g',
+ sugars: '12.65g',
+ fiber: '2.7g',
+ protein: '3.4g',
+ salt: '0.12g'
+ },
+ per30g: {
+ energy: '476 kJ (112 kcal)',
+ fat: '6.72g',
+ saturatedFat: '4.48g',
+ carbohydrates: '10.92g',
+ sugars: '6.44g',
+ fiber: '1.34g',
+ protein: '1.7g',
+ salt: '0.02g'
+ }
+ },
+ sizes: [
+ {
+ _id: new mongoose.Types.ObjectId(),
+ weight: '55g',
+ packaging: '35 x 55g',
+ price: 20.99
+ },
+ {
+ _id: new mongoose.Types.ObjectId(),
+ weight: '30g',
+ packaging: '60 x 30g',
+ price: 20.99
+ }
+ ],
+ keywords: ['glutenfree', 'plantbased', 'superfoods'],
+ featured: false,
+ status: 'active'
+ },
+ {
+ name: 'Raspberry & Licorice Cheezecake',
+ description:
+ 'Creamy Cheezecakes in our signature square format. Comes in boxes 60 x 30g or 35 x 55g. Order them from our listed partners.',
+ category: 'cheezecakes',
+ images: [
+ {
+ url: '/images/raspberry.webp',
+ alt: 'Raspberry & Licorice Cheezecake',
+ isPrimary: true
+ }
+ ],
+ ingredients: [
+ 'Dates*',
+ 'soaked cashew nuts*',
+ 'rice syrup*',
+ 'coconut oil*',
+ 'coconut flakes*',
+ 'gluten-free oats*',
+ 'water',
+ 'buckwheat*',
+ 'lemon juice*',
+ 'licorice powder*',
+ 'salmiak',
+ 'freeze-dried raspberries*',
+ 'amla*',
+ 'baobab*',
+ 'hibiscus*',
+ 'maca*',
+ 'beetroot powder*'
+ ],
+ allergens: [
+ 'Contains cashew nuts',
+ 'Contains gluten-free oats',
+ 'May contain traces of other nuts'
+ ],
+ nutrition: {
+ per55g: {
+ energy: '935 kJ (220 kcal)',
+ fat: '13.2g',
+ saturatedFat: '8.25g',
+ carbohydrates: '21.45g',
+ sugars: '12.65g',
+ fiber: '2.7g',
+ protein: '3.4g',
+ salt: '0.12g'
+ },
+ per30g: {
+ energy: '476 kJ (112 kcal)',
+ fat: '6.72g',
+ saturatedFat: '4.2g',
+ carbohydrates: '10.92g',
+ sugars: '6.44g',
+ fiber: '1.4g',
+ protein: '1.7g',
+ salt: '0.06g'
+ }
+ },
+ sizes: [
+ {
+ _id: new mongoose.Types.ObjectId(),
+ weight: '55g',
+ packaging: '35 x 55g',
+ price: 20.99
+ },
+ {
+ _id: new mongoose.Types.ObjectId(),
+ weight: '30g',
+ packaging: '60 x 30g',
+ price: 20.99
+ }
+ ],
+ keywords: ['glutenfree', 'plantbased', 'superfoods'],
+ featured: false,
+ status: 'active'
+ },
+ {
+ name: 'Chocolate Ball Bite',
+ description:
+ 'Two layer bites in naimas signature square format of 34g or 20g per bite. Comes in boxes 60 x 20g or 35 x 34g.',
+ category: 'bites',
+ images: [
+ {
+ url: '/images/chocolate.webp',
+ alt: 'Chocolate Ball Bite',
+ isPrimary: true
+ }
+ ],
+ ingredients: [
+ 'Dates',
+ 'coconut oil',
+ 'rice syrup',
+ 'gluten-free oats',
+ 'coconut flakes',
+ 'brewed coffee (water, coffee)',
+ 'cocoa',
+ 'reishi',
+ 'sea salt'
+ ],
+ allergens: [
+ 'Contains gluten-free oat flakes',
+ 'Nut free - May contain traces of cashews, pecans, pistachios, hazelnuts, and peanuts'
+ ],
+ nutrition: {
+ per34g: {
+ energy: '592 kJ (141 kcal)',
+ fat: '8.84g',
+ saturatedFat: '7.14g',
+ carbohydrates: '13.6g',
+ sugars: '9.86g',
+ fiber: '2.38g',
+ protein: '1.67g',
+ salt: '0.09g'
+ }
+ },
+ sizes: [
+ {
+ _id: new mongoose.Types.ObjectId(),
+ weight: '34g',
+ packaging: '35 x 34g',
+ price: 20.99
+ },
+ {
+ _id: new mongoose.Types.ObjectId(),
+ weight: '20g',
+ packaging: '60 x 20g',
+ price: 20.99
+ }
+ ],
+ keywords: ['glutenfree', 'lactosefree', 'plantbased'],
+ featured: true,
+ status: 'active'
+ },
+ {
+ name: 'Limited Edition Treats',
+ description:
+ 'Straciatella & Coal / Matcha & Lime / Semla / Chocolate & Saffron / Lingonberry & Gingerbread and more variants depending on season and collaboration. Contact us for more information',
+ category: 'limited',
+ images: [
+ {
+ url: '/images/limited.webp',
+ alt: 'Limited Edition Treats',
+ isPrimary: true
+ }
+ ],
+ ingredients: ['Varies by variant - contact for specific information'],
+ allergens: [
+ 'Allergens vary by variant - contact for specific information'
+ ],
+ nutrition: {
+ // Empty as it varies by variant
+ },
+ sizes: [
+ { weight: 'Various', packaging: 'Contact for details', price: 0 }
+ ],
+ keywords: ['limited', 'seasonal', 'collaboration'],
+ featured: false,
+ status: 'limited'
+ }
+ ]
+
+ await Product.deleteMany({})
+ await Product.insertMany(products)
+ console.log('Products seeded!')
+ console.log('Database seeded successfully!')
+ console.log(`✅ Seeded ${products.length} products`)
+ process.exit(0)
+ } catch (error) {
+ console.error('Seeding failed:', error)
+ process.exit(1)
+ }
+}
+
+const runSeed = async () => {
+ try {
+ await mongoose.connect(process.env.MONGO_URL$, {
+ useNewUrlParser: true,
+ useUnifiedTopology: true
+ })
+ console.log('Connected to MongoDB')
+ await seedProducts()
+ } catch (error) {
+ console.error('Error connecting to MongoDB:', error)
+ process.exit(1)
+ } finally {
+ await mongoose.disconnect()
+ }
+}
+
+runSeed()
diff --git a/backend/server.js b/backend/server.js
index 070c875189..b6d9cdc18d 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -1,22 +1,94 @@
-import express from "express";
-import cors from "cors";
-import mongoose from "mongoose";
+import cors from 'cors'
+import dotenv from 'dotenv'
+import express from 'express'
+import listEndpoints from 'express-list-endpoints'
+import mongoose from 'mongoose'
-const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project";
-mongoose.connect(mongoUrl);
-mongoose.Promise = Promise;
+import companyRoutes from './routes/companyRoutes.js'
+import contactRoutes from './routes/contactRoutes.js'
+import customerRoutes from './routes/customerRoutes.js'
+import orderRoutes from './routes/orderRoutes.js'
+import partnerRoutes from './routes/partnerRoutes.js'
+import productRoutes from './routes/productRoutes.js'
+import retailerRoutes from './routes/retailerRoutes.js'
-const port = process.env.PORT || 8080;
-const app = express();
+dotenv.config()
-app.use(cors());
-app.use(express.json());
+// Increase listeners limit
+import { EventEmitter } from 'events'
+EventEmitter.defaultMaxListeners = 20
-app.get("/", (req, res) => {
- res.send("Hello Technigo!");
-});
+const mongoUrl = process.env.MONGO_URL
+const port = process.env.PORT || 3001
-// Start the server
-app.listen(port, () => {
- console.log(`Server running on http://localhost:${port}`);
-});
+// Connect to MongoDB
+mongoose
+ .connect(mongoUrl, {
+ dbName: 'naima-website'
+ })
+ .then(() => {
+ console.log('✅ Connected to MongoDB Atlas')
+ })
+ .catch((error) => {
+ console.error('❌ MongoDB connection error:', error)
+ })
+
+const app = express()
+
+// CORS configuration
+// allow only your frontend origin (recommended):
+const FRONTEND_ORIGIN =
+ process.env.FRONTEND_ORIGIN ||
+ 'https://resetwithnaima.netlify.app' ||
+ process.env.FRONTEND_URL ||
+ 'http://localhost:5173'
+
+// Build a list of allowed origins, prefer env vars and fall back to sensible defaults
+const allowedOrigins = [
+ process.env.FRONTEND_ORIGIN,
+ process.env.FRONTEND_URL,
+ 'https://resetwithnaima.netlify.app',
+ 'http://localhost:5173'
+].filter(Boolean)
+
+app.use(
+ cors({
+ origin: (origin, callback) => {
+ // allow non-browser requests like curl/postman (no origin)
+ if (!origin) return callback(null, true)
+ if (allowedOrigins.includes(origin)) return callback(null, true)
+ callback(new Error('CORS: origin not allowed'))
+ },
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
+ credentials: true
+ })
+)
+
+app.use(express.json())
+
+// Register routes
+app.use('/api/products', productRoutes)
+app.use('/api/partners', partnerRoutes)
+app.use('/api/orders', orderRoutes)
+app.use('/api/customers', customerRoutes)
+app.use('/api/companies', companyRoutes)
+app.use('/api/contact', contactRoutes)
+app.use('/api/retailers', retailerRoutes)
+
+// List Endpoints API
+app.get('/', (req, res) => {
+ const endpoints = listEndpoints(app)
+ res.json({
+ message: 'Naima API is running!',
+ endpoints: endpoints
+ })
+})
+
+// health-check example
+app.get('/api/health', (req, res) => res.json({ status: 'ok' }))
+
+// Global error handler
+app.use((err, req, res, next) => {
+ console.error('Global error handler:', err)
+ res.status(500).json({ error: err.message })
+})
diff --git a/backend/services/companyService.js b/backend/services/companyService.js
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frontend/.nvmrc b/frontend/.nvmrc
new file mode 100644
index 0000000000..8fdd954df9
--- /dev/null
+++ b/frontend/.nvmrc
@@ -0,0 +1 @@
+22
\ No newline at end of file
diff --git a/frontend/README.md b/frontend/README.md
index 5cdb1d9cf3..fc1f33bcbc 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -1,8 +1,159 @@
-# Frontend part of Final Project
+# Naima — Fika with Benefits
-This boilerplate is designed to give you a head start in your React projects, with a focus on understanding the structure and components. As a student of Technigo, you'll find this guide helpful in navigating and utilizing the repository.
+A full-stack MERN app for **Naima**, a Swedish fika brand. The site showcases products, the brand story, retail partners on an interactive map, and a company dashboard for existing wholesale partners.
-## Getting Started
+---
-1. Install the required dependencies using `npm install`.
-2. Start the development server using `npm run dev`.
\ No newline at end of file
+## Quick start
+
+- Install dependencies
+
+ - cd frontend
+ - npm install
+
+- Run dev server
+
+ - npm run dev
+
+- Build for production
+ - npm run build
+ - npm run preview (serve the built app locally)
+
+---
+
+## Environment
+
+Create a `.env` in the frontend (if required) and in the backend with the following keys as needed:
+
+- VITE_API_BASE or API_BASE — base URL for the backend API (example: https://naima-api.example.com)
+- Other keys used by components or services (check `vite.config.js` and `src/services/api.js`)
+
+---
+
+## Folder structure (recommended reference)
+
+This is the current working layout; use it as a guide when adding files.
+
+- public/
+ - fonts/
+ - images/
+ - data/
+ - index.html
+- src/
+ - App.jsx
+ - main.jsx
+ - index.css
+ - pages/ — route-level pages (Home, Shop, Checkout, CompanyPortal, ...)
+ - sections/ — composable page sections (Hero, SocialProof, InstagramGrid, ...)
+ - components/ — smaller reusable components (Button, ProductCard, Nav, Cart, ...)
+ - primitives/ — low-level UI primitives (optional)
+ - layout/ — PageContainer, PageTitle, Footer
+ - stores/ — zustand stores (useCartStore, useUiStore, ...)
+ - services/ — API client (api.js) and domain helpers
+ - styles/ — theme.js, GlobalStyles, SkeletonTheme
+ - hooks/ — custom hooks (useBreakpoint, useInView)
+ - utils/ — helpers
+- build / dist
+
+Keep cross-cutting providers (theme, skeleton, stores) near `src/main.jsx` so they're applied globally.
+
+---
+
+## ⭐ Highlights
+
+- **Modern stack:** React + Vite, Styled-Components theme system, Express + Mongoose, MongoDB Atlas.
+- **Real data:** Products & partners served from the backend. Retailer locations pre-geocoded and rendered on a map.
+- **Accessible UX:** Skip link, keyboard-navigable mobile menu with focus trap, large touch targets, visible focus, a11y carousel indicators.
+- **Performance wins:** Lazy images, static geocoded JSON, small animations respecting `prefers-reduced-motion`.
+- **Design system:** Centralized `theme.js` + `GlobalStyles`, semantic typography, brand palette:
+ - **primary (mint)** `#BCE8C2`
+ - **blush** `#F7CDD0`
+ - **salmon** `#F4A6A3`
+ - **lavender** `#D0C3F1`
+ - **sky** `#B3D9F3`
+
+---
+
+## 🧭 MVP Scope
+
+### Public site
+
+- Home: hero carousel, “served at” rolling logo track, featured products, Instagram grid _(pending client access → Graph API planned)_.
+- Find us: responsive **Leaflet** map with brand-colored pins; directions links.
+- Our story: founder & mission sections, responsive imagery, reveal animations.
+- Contact: validated form via `react-hook-form`; posts to backend; success/failure UI.
+- Products: featured grid; product cards.
+- Navigation: sticky header, mobile hamburger (focus-trapped), active link states (`NavLink`).
+- Footer: full-bleed banner background; social links with hover/focus states.
+
+### Auth & dashboard (wholesale/company)
+
+- JWT auth: `JWT_SECRET` required (see `.env`).
+- Protected routes: middleware `authenticate` + `authorize(role[])`.
+- Company dashboard nav: a11y (≥44px targets, focus outlines), active route styling, native select on mobile.
+- Company features _(ongoing)_: shop/orders/profile UI (routes scaffolded, data/UX polish in progress).
+
+### Data & APIs
+
+- Partners & Products: Mongoose models + routes mounted at `/api/products`, `/api/partners`.
+- Retailer map data: geocoded with **Nominatim** script → static JSON served to frontend.
+- Instagram feed: via **Meta Graph API** once client provides tokens _(instructions prepared)_.
+
+---
+
+## ✅ Technigo Final — Requirements Checklist
+
+- [x] **Fullstack** (backend + database + frontend)
+- [x] **Persistent data** (MongoDB/Mongoose) — Products, Partners, Retailers
+- [x] **REST API endpoints** — `/api/products`, `/api/partners`, `/api/contact`, `/api/orders` _(as applicable)_
+- [x] **Authentication / Protected pages** — JWT, middleware, company dashboard
+- [x] **Responsive design** — breakpoints in `theme.breakpoints`, mobile-first
+- [x] **Accessibility** — Skip link, focus styles, keyboard menu, ≥44px tap targets, improved carousel indicators
+- [x] **External service / API** — Leaflet + OpenStreetMap tiles, Nominatim geocoding _(IG Graph API planned)_
+- [x] **Collaboration workflow (Git)** — branching, PRs; see below
+- [x] **Deployment** — ⏳
+- [ ] **Testing** — _(manual + Lighthouse done)_
+
+---
+
+## 🗺️ Map Details
+
+- **Leaflet + react-leaflet** with **OpenStreetMap** tiles.
+- Brand-colored **SVG pins** generated inline (no external icon files), themed via `useTheme()`.
+- `fitBounds` to include all retailers; popup shows address + **Directions** link.
+- Data loaded from `frontend/public/data/geocodedRetailers.json` for speed (no API call at runtime).
+
+---
+
+## ♿ Accessibility
+
+- **Skip link:** first in DOM, visually hidden until focus, targets ``.
+- **Keyboard navigation:**
+ - Hamburger menu opens/closes via keyboard; focus is trapped within and returns to trigger on close.
+ - `Esc` closes the menu.
+- **Visible focus:** consistent `:focus-visible` outlines using brand colors.
+- **Touch targets:** ≥44px for nav links & controls.
+- **Carousel indicators:** real ``s with `aria-label="Go to slide n"` & `aria-current="true"` on the active dot.
+- **Reduced motion:** animations respect `prefers-reduced-motion`.
+- **Lighthouse Accessibility:** **100%**
+
+---
+
+## 🧭 Roadmap / Stretch
+
+- Instagram Graph API feed (client tokens) with caching.
+- Admin CRUD UI for products/partners.
+- Order management UI for companies.
+- Automated tests (Jest/React Testing Library, Supertest).
+- Production analytics & error tracking.
+
+---
+
+## 👯 Collaboration Workflow
+
+- **Branch naming:**
+ Use clear prefixes to describe the change type.
+ `feat/` • `fix/` • `chore/`
+
+- **Keep `main` deployable:**
+ All changes go through **Pull Requests (PRs)**. Keep PRs small with clear titles & descriptions.
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000000..d3da143966
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,9 @@
+import js from "@eslint/js";
+import globals from "globals";
+import pluginReact from "eslint-plugin-react";
+import { defineConfig } from "eslint/config";
+
+export default defineConfig([
+ { files: ["**/*.{js,mjs,cjs,jsx}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
+ pluginReact.configs.flat.recommended,
+]);
diff --git a/frontend/index.html b/frontend/index.html
index 664410b5b9..516615b028 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,12 +2,47 @@
-
- Technigo React Vite Boiler Plate
+
+
+
+
+ Naima — Fika with Benefits
+
+
+
+
+
+
+
+
+
diff --git a/frontend/netlify.toml b/frontend/netlify.toml
new file mode 100644
index 0000000000..f961a62dcb
--- /dev/null
+++ b/frontend/netlify.toml
@@ -0,0 +1,8 @@
+[build]
+ command = "npm run build"
+ publish = "dist"
+
+
+[build.environment]
+ NODE_VERSION = "22"
+ SECRETS_SCAN_OMIT_KEYS = "VITE_API_BASE,VITE_API_URL"
\ No newline at end of file
diff --git a/frontend/package.json b/frontend/package.json
index 7b2747e949..8d5793005c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,20 +7,40 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
- "preview": "vite preview"
+ "preview": "vite preview --port 5173"
},
"dependencies": {
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@mui/material": "^7.3.1",
+ "framer-motion": "^12.23.12",
+ "leaflet": "^1.9.4",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-awesome-styled-grid": "^4.0.1",
+ "react-dom": "^18.2.0",
+ "react-hook-form": "^7.62.0",
+ "react-icons": "^5.5.0",
+ "react-leaflet": "^4.2.1",
+ "react-router-dom": "^7.7.1",
+ "react-scroll-parallax": "^3.4.5",
+ "styled-components": "^6.1.19",
+ "zustand": "^5.0.7"
},
"devDependencies": {
+ "@eslint/js": "^9.33.0",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
- "@vitejs/plugin-react": "^4.0.3",
- "eslint": "^8.45.0",
- "eslint-plugin-react": "^7.32.2",
+ "@vitejs/plugin-react": "^4.7.0",
+ "babel-plugin-styled-components": "^2.1.4",
+ "eslint": "^8.57.1",
+ "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
+ "globals": "^16.3.0",
+ "madge": "^8.0.0",
"vite": "^6.3.5"
+ },
+ "engines": {
+ "node": ">=22"
}
}
diff --git a/frontend/public/data/geocodedRetailers.json b/frontend/public/data/geocodedRetailers.json
new file mode 100644
index 0000000000..b8842a21c3
--- /dev/null
+++ b/frontend/public/data/geocodedRetailers.json
@@ -0,0 +1,677 @@
+[
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Drottninggatan 90B",
+ "street": "Drottninggatan 90B",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "90B, Drottninggatan, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 36, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0588188,
+ 59.3367324
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Götgatan 90",
+ "street": "Götgatan 90",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "90, Götgatan, Skanstull, Södermalm, Södermalms stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 118 62, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0749826,
+ 59.3102855
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Regeringsgatan 54",
+ "street": "Regeringsgatan 54",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "54, Regeringsgatan, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 56, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0678265,
+ 59.335141
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Sveavägen 55",
+ "street": "Sveavägen 55",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "55, Sveavägen, Sibirien, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 59, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.058462,
+ 59.3405686
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Sveavägen 71",
+ "street": "Sveavägen 71",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "71, Sveavägen, Sibirien, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 50, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0557634,
+ 59.3432833
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Odengatan 22 (Birger Jarlsgatan)",
+ "street": "Odengatan 22",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "22, Odengatan, Sibirien, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 51, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0620223,
+ 59.3457683
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Odengatan 32 (Tulegatan)",
+ "street": "Odengatan 32",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "32, Odengatan, Sibirien, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 55, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0598249,
+ 59.3452247
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Drottning Kristinas väg 9 (KTH)",
+ "street": "Drottning Kristinas väg 9",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "9, Drottning Kristinas Väg, Ruddammen, Norra Djurgården, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 114 28, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0717982,
+ 59.3465424
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Vasagatan 44",
+ "street": "Vasagatan 44",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "44, Vasagatan, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 20, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0568699,
+ 59.3336408
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Hornsbruksgatan 28",
+ "street": "Hornsbruksgatan 28",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "28, Hornsbruksgatan, Hornstull, Södermalm, Södermalms stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 117 34, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0345027,
+ 59.3161104
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Sturegatan 9",
+ "street": "Sturegatan 9",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "9, Sturegatan, Villastaden, Östermalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 114 36, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0762633,
+ 59.3407835
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Rörstrandsgatan 10",
+ "street": "Rörstrandsgatan 10",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "10, Rörstrandsgatan, Rörstrand, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 40, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0342217,
+ 59.3400268
+ ]
+ }
+ },
+ {
+ "brand": "7-Eleven",
+ "name": "7-Eleven Västerlånggatan 38",
+ "street": "Västerlånggatan 38",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "38, Västerlånggatan, Gamla stan, Södermalms stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 29, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0698815,
+ 59.3244842
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Linköping",
+ "street": "Ågatan 21",
+ "city": "Linköping",
+ "country": "Sweden",
+ "fullAddress": "Moccadeli, 21, Ågatan, Innerstaden, Linköpings Sankt Lars, Linköping, Linköpings kommun, Östergötland County, 582 22, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 15.6263508,
+ 58.4119846
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Västervik",
+ "street": "Fiskaretorget 1",
+ "city": "Västervik",
+ "country": "Sweden",
+ "fullAddress": "Vikens Food & Friends, 1, Fiskaretorget, Reginelund, Ludvigsborg, Västervik, Västerviks kommun, Kalmar County, 593 30, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 16.6390801,
+ 57.7592454
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Eskilstuna",
+ "street": "Fristadstorget 4",
+ "city": "Eskilstuna",
+ "country": "Sweden",
+ "fullAddress": "Fristadstorget, Söder, Eskilstuna, Eskilstuna kommun, Södermanland County, 632 18, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 16.513925,
+ 59.3707345
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Skövde",
+ "street": "Storgatan 12B",
+ "city": "Skövde",
+ "country": "Sweden",
+ "fullAddress": "12B, Storgatan, Vasastaden, Skövde, Skövde kommun, Västra Götaland County, 541 30, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 13.8448467,
+ 58.3896963
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Örebro",
+ "street": "Stortorget 7",
+ "city": "Örebro",
+ "country": "Sweden",
+ "fullAddress": "Stortorget, Eklunda, Örebro, Örebro kommun, Örebro County, 702 12, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 15.214789,
+ 59.2715472
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Norrköping",
+ "street": "Tunnbindaregatan 3",
+ "city": "Norrköping",
+ "country": "Sweden",
+ "fullAddress": "Mocca Deli, 3, Tunnbindaregatan, Mjölnaren, Nordantill, Norrköping, Norrköpings kommun, Östergötland County, 602 21, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 16.1804993,
+ 58.590035
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Västerås",
+ "street": "Stora torget 2",
+ "city": "Västerås",
+ "country": "Sweden",
+ "fullAddress": "2, Stora torget, Kyrkbacken, Västerås, Västerås kommun, Västmanland County, 722 15, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 16.5432668,
+ 59.6107377
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Lidköping",
+ "street": "Nya Stadens Torg 1",
+ "city": "Lidköping",
+ "country": "Sweden",
+ "fullAddress": "Nya Stadens torg, Gamla staden, Lidköping distrikt, Lidköping, Lidköpings kommun, Västra Götaland County, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 13.1572925,
+ 58.5038249
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Borås",
+ "street": "Västerbrogatan 5",
+ "city": "Borås",
+ "country": "Sweden",
+ "fullAddress": "Västerbrogatan, Norrby, Borås, Borås kommun, Västra Götaland County, 503 30, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 12.9371291,
+ 57.7211513
+ ]
+ }
+ },
+ {
+ "brand": "Mocca Deli",
+ "name": "Mocca Deli Kalmar",
+ "street": "Storgatan 21",
+ "city": "Kalmar",
+ "country": "Sweden",
+ "fullAddress": "21, Storgatan, Kvarnholmen, Kalmar, Kalmar kommun, Kalmar County, 392 32, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 16.3633499,
+ 56.663523
+ ]
+ }
+ },
+ {
+ "brand": "PBX",
+ "name": "PBX Sveavägen 67A",
+ "street": "Sveavägen 67A",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "67A, Sveavägen, Sibirien, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 50, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0567707,
+ 59.3421661
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Odenplan",
+ "street": "Norrtullsgatan 9",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "9, Norrtullsgatan, Sibirien, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 29, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0519259,
+ 59.3417797
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Medborgarplatsen",
+ "street": "Folkungagatan 43",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "43, Folkungagatan, Södermalm, Södermalms stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 118 26, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0722778,
+ 59.3141715
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Sveavägen 51",
+ "street": "Sveavägen 51",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "51, Sveavägen, Vasastaden, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 113 59, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0590015,
+ 59.3399961
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Humlegården",
+ "street": "Humlegårdsgatan 20",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "20, Humlegårdsgatan, Villastaden, Östermalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 114 46, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0747907,
+ 59.3368854
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Garnisonen",
+ "street": "Karlavägen 100",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "100, Karlavägen, Östermalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 115 26, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0963301,
+ 59.3361407
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Mäster Samuelsgatan 9",
+ "street": "Mäster Samuelsgatan 9",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "9, Mäster Samuelsgatan, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 46, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0720428,
+ 59.3342298
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Sveavägen 31",
+ "street": "Sveavägen 31",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "31, Sveavägen, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 37, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0621907,
+ 59.3366104
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Gallerian",
+ "street": "Hamngatan 37",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "37, Hamngatan, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 53, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0671567,
+ 59.3324687
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Centralstationen",
+ "street": "Centralplan 15",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "Centralens huvudentré, 15, Centralplan, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 64, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.058744,
+ 59.330381
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Åhléns City",
+ "street": "Sergels torg",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "Sergels torg, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 51, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0640247,
+ 59.3321933
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Stockholm Quality Outlet",
+ "street": "Flyginfarten 4",
+ "city": "Järfälla",
+ "country": "Sweden",
+ "fullAddress": "Samsøe Samsøe, 4, Flyginfarten, Barkarbystaden, Söderhöjden, Järfälla kommun, Sollentuna kommun, Stockholm County, 177 38, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 17.858617,
+ 59.4170361
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Kungsbron 8B",
+ "street": "Kungsbron 8B",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "Kungsbron, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 112 24, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0512998,
+ 59.332258
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Tyresö Centrum",
+ "street": "Östangränd 18",
+ "city": "Tyresö",
+ "country": "Sweden",
+ "fullAddress": "Östangränd, Bollmora, Tyresö kommun, Stockholm County, 135 38, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.230101,
+ 59.2458764
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Mörby Centrum",
+ "street": "Mörbyleden 182",
+ "city": "Danderyd",
+ "country": "Sweden",
+ "fullAddress": "Mörbyleden, Ösby, Klingsta, Djursholm, Danderyds kommun, Stockholm County, 182 17, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0412507,
+ 59.3981567
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Mall of Scandinavia",
+ "street": "Stjärntorget 2 (Plan 1 bredvid SATS)",
+ "city": "Solna",
+ "country": "Sweden",
+ "fullAddress": "Westfield Mall of Scandinavia, 2, Stjärntorget, Arenastaden, Ritorp, Solna, Solna kommun, Stockholm County, 169 79, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0030108,
+ 59.3704371
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Arlanda T5",
+ "street": "Terminal 5",
+ "city": "Arlanda",
+ "country": "Sweden",
+ "fullAddress": "7-Eleven, Arlandaleden, Sigtuna kommun, Stockholm County, 190 45, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 17.9310876,
+ 59.6514205
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Brunkebergstorg 12",
+ "street": "Brunkebergstorg 12",
+ "city": "Stockholm",
+ "country": "Sweden",
+ "fullAddress": "Hawaii poké, 12, Brunkebergstorg, Klara, Norrmalm, Norra innerstadens stadsdelsområde, Stockholm, Stockholm Municipality, Stockholm County, 111 51, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 18.0668659,
+ 59.3317707
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Signalfabriken",
+ "street": "Sundbybergs torg 1",
+ "city": "Sundbyberg",
+ "country": "Sweden",
+ "fullAddress": "Bistro Berg, 1, Sundbybergs torg, Centrala Sundbyberg, Sundbybergs kommun, Stockholm County, 172 65, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 17.9666432,
+ 59.3616324
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Uppsala Centrum",
+ "street": "Dragarbrunnsgatan 44",
+ "city": "Uppsala",
+ "country": "Sweden",
+ "fullAddress": "Royal, 44, Dragarbrunnsgatan, Höganäs, Centrum, Uppsala, Uppsala kommun, Uppsala County, 753 20, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 17.6413531,
+ 59.8588918
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Malmö Södertull",
+ "street": "Södra Vallgatan 3",
+ "city": "Malmö",
+ "country": "Sweden",
+ "fullAddress": "Vibliotek, 3, Södra Vallgatan, Old Town, Norr, Malmö, Malmö kommun, Skåne County, 211 40, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 13.0012344,
+ 55.6015232
+ ]
+ }
+ },
+ {
+ "brand": "Hawaii Poké",
+ "name": "Hawaii Poké Göteborg Femman",
+ "street": "Postgatan 26",
+ "city": "Göteborg",
+ "country": "Sweden",
+ "fullAddress": "Cervera, 26-32, Postgatan, North Town, Inom Vallgraven, Centrum, Gothenburg, Göteborgs Stad, Västra Götaland County, 411 06, Sweden",
+ "location": {
+ "type": "Point",
+ "coordinates": [
+ 11.9702305,
+ 57.7090971
+ ]
+ }
+ }
+]
\ No newline at end of file
diff --git a/frontend/public/fonts/arcamajora3-bold.woff b/frontend/public/fonts/arcamajora3-bold.woff
new file mode 100644
index 0000000000..4f6d0072e6
Binary files /dev/null and b/frontend/public/fonts/arcamajora3-bold.woff differ
diff --git a/frontend/public/fonts/arcamajora3-bold.woff2 b/frontend/public/fonts/arcamajora3-bold.woff2
new file mode 100644
index 0000000000..a3f6ebaef1
Binary files /dev/null and b/frontend/public/fonts/arcamajora3-bold.woff2 differ
diff --git a/frontend/public/fonts/arcamajora3-heavy.woff b/frontend/public/fonts/arcamajora3-heavy.woff
new file mode 100644
index 0000000000..525a31f40d
Binary files /dev/null and b/frontend/public/fonts/arcamajora3-heavy.woff differ
diff --git a/frontend/public/fonts/arcamajora3-heavy.woff2 b/frontend/public/fonts/arcamajora3-heavy.woff2
new file mode 100644
index 0000000000..f887de254a
Binary files /dev/null and b/frontend/public/fonts/arcamajora3-heavy.woff2 differ
diff --git a/frontend/public/fonts/neuzeit_s_lt_std_book.woff b/frontend/public/fonts/neuzeit_s_lt_std_book.woff
new file mode 100644
index 0000000000..c40c73681f
Binary files /dev/null and b/frontend/public/fonts/neuzeit_s_lt_std_book.woff differ
diff --git a/frontend/public/fonts/neuzeit_s_lt_std_book.woff2 b/frontend/public/fonts/neuzeit_s_lt_std_book.woff2
new file mode 100644
index 0000000000..493a5b9978
Binary files /dev/null and b/frontend/public/fonts/neuzeit_s_lt_std_book.woff2 differ
diff --git a/frontend/public/images/blueberry.webp b/frontend/public/images/blueberry.webp
new file mode 100644
index 0000000000..6ab62112a9
Binary files /dev/null and b/frontend/public/images/blueberry.webp differ
diff --git a/frontend/public/images/carousel-1.webp b/frontend/public/images/carousel-1.webp
new file mode 100644
index 0000000000..e9ed48c6c6
Binary files /dev/null and b/frontend/public/images/carousel-1.webp differ
diff --git a/frontend/public/images/carousel-2.webp b/frontend/public/images/carousel-2.webp
new file mode 100644
index 0000000000..10b52be108
Binary files /dev/null and b/frontend/public/images/carousel-2.webp differ
diff --git a/frontend/public/images/carousel-3.webp b/frontend/public/images/carousel-3.webp
new file mode 100644
index 0000000000..33bf7b2527
Binary files /dev/null and b/frontend/public/images/carousel-3.webp differ
diff --git a/frontend/public/images/chocolate.webp b/frontend/public/images/chocolate.webp
new file mode 100644
index 0000000000..17ec55d244
Binary files /dev/null and b/frontend/public/images/chocolate.webp differ
diff --git a/frontend/public/images/cinnamon.webp b/frontend/public/images/cinnamon.webp
new file mode 100644
index 0000000000..8cb7948723
Binary files /dev/null and b/frontend/public/images/cinnamon.webp differ
diff --git a/frontend/public/images/contact.jpg b/frontend/public/images/contact.jpg
new file mode 100644
index 0000000000..26cc1f538d
Binary files /dev/null and b/frontend/public/images/contact.jpg differ
diff --git a/frontend/public/images/contactImage.JPG b/frontend/public/images/contactImage.JPG
new file mode 100644
index 0000000000..382cb4433f
Binary files /dev/null and b/frontend/public/images/contactImage.JPG differ
diff --git a/frontend/public/images/footer-banner.jpeg b/frontend/public/images/footer-banner.jpeg
new file mode 100644
index 0000000000..73141dd9b7
Binary files /dev/null and b/frontend/public/images/footer-banner.jpeg differ
diff --git a/frontend/public/images/footer-vert.jpg b/frontend/public/images/footer-vert.jpg
new file mode 100644
index 0000000000..0883cd7c6f
Binary files /dev/null and b/frontend/public/images/footer-vert.jpg differ
diff --git a/frontend/public/images/lemoncurd.webp b/frontend/public/images/lemoncurd.webp
new file mode 100644
index 0000000000..8394311ba4
Binary files /dev/null and b/frontend/public/images/lemoncurd.webp differ
diff --git a/frontend/public/images/limited.webp b/frontend/public/images/limited.webp
new file mode 100644
index 0000000000..f07831f3b7
Binary files /dev/null and b/frontend/public/images/limited.webp differ
diff --git a/frontend/public/images/naima-favicon.png b/frontend/public/images/naima-favicon.png
new file mode 100644
index 0000000000..6255f75e76
Binary files /dev/null and b/frontend/public/images/naima-favicon.png differ
diff --git a/frontend/public/images/naima-founder.webp b/frontend/public/images/naima-founder.webp
new file mode 100644
index 0000000000..5683629d40
Binary files /dev/null and b/frontend/public/images/naima-founder.webp differ
diff --git a/frontend/public/images/naima-team.webp b/frontend/public/images/naima-team.webp
new file mode 100644
index 0000000000..fdf1209551
Binary files /dev/null and b/frontend/public/images/naima-team.webp differ
diff --git a/frontend/public/images/orange.jpg b/frontend/public/images/orange.jpg
new file mode 100644
index 0000000000..fda97ebd72
Binary files /dev/null and b/frontend/public/images/orange.jpg differ
diff --git a/frontend/public/images/pink-shrooms.jpg b/frontend/public/images/pink-shrooms.jpg
new file mode 100644
index 0000000000..a1de47bf1f
Binary files /dev/null and b/frontend/public/images/pink-shrooms.jpg differ
diff --git a/frontend/public/images/placeholder.png b/frontend/public/images/placeholder.png
new file mode 100644
index 0000000000..03afc2b666
Binary files /dev/null and b/frontend/public/images/placeholder.png differ
diff --git a/frontend/public/images/raspberry.webp b/frontend/public/images/raspberry.webp
new file mode 100644
index 0000000000..ce3c8ab15d
Binary files /dev/null and b/frontend/public/images/raspberry.webp differ
diff --git a/frontend/public/partners/7eleven.svg b/frontend/public/partners/7eleven.svg
new file mode 100644
index 0000000000..34283ff4b3
--- /dev/null
+++ b/frontend/public/partners/7eleven.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/partners/caterbee.png b/frontend/public/partners/caterbee.png
new file mode 100644
index 0000000000..d3d411c72b
Binary files /dev/null and b/frontend/public/partners/caterbee.png differ
diff --git a/frontend/public/partners/johan-nystrom.png b/frontend/public/partners/johan-nystrom.png
new file mode 100644
index 0000000000..5bbf224e27
Binary files /dev/null and b/frontend/public/partners/johan-nystrom.png differ
diff --git a/frontend/public/partners/martinandservera.png b/frontend/public/partners/martinandservera.png
new file mode 100644
index 0000000000..39f0da680f
Binary files /dev/null and b/frontend/public/partners/martinandservera.png differ
diff --git a/frontend/public/partners/menigo.png b/frontend/public/partners/menigo.png
new file mode 100644
index 0000000000..2cdab32036
Binary files /dev/null and b/frontend/public/partners/menigo.png differ
diff --git a/frontend/public/partners/outofhome.png b/frontend/public/partners/outofhome.png
new file mode 100644
index 0000000000..60c6aa9f6b
Binary files /dev/null and b/frontend/public/partners/outofhome.png differ
diff --git a/frontend/public/partners/radisson.svg b/frontend/public/partners/radisson.svg
new file mode 100644
index 0000000000..46c5a3b331
--- /dev/null
+++ b/frontend/public/partners/radisson.svg
@@ -0,0 +1,46 @@
+
+
+
+
+
+ RH-Radisson-Hotels_RGB-WHT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/partners/radisson_2_logo.png b/frontend/public/partners/radisson_2_logo.png
new file mode 100644
index 0000000000..70e4440f56
Binary files /dev/null and b/frontend/public/partners/radisson_2_logo.png differ
diff --git a/frontend/public/partners/strawberry.svg b/frontend/public/partners/strawberry.svg
new file mode 100644
index 0000000000..3dd98003ab
--- /dev/null
+++ b/frontend/public/partners/strawberry.svg
@@ -0,0 +1 @@
+strawberryLogo
\ No newline at end of file
diff --git a/frontend/public/partners/svensk-cater.png b/frontend/public/partners/svensk-cater.png
new file mode 100644
index 0000000000..1de993b077
Binary files /dev/null and b/frontend/public/partners/svensk-cater.png differ
diff --git a/frontend/public/partners/switsbake.png b/frontend/public/partners/switsbake.png
new file mode 100644
index 0000000000..92c31881b6
Binary files /dev/null and b/frontend/public/partners/switsbake.png differ
diff --git a/frontend/public/partners/yasuragi.svg b/frontend/public/partners/yasuragi.svg
new file mode 100644
index 0000000000..880964de61
--- /dev/null
+++ b/frontend/public/partners/yasuragi.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg
deleted file mode 100644
index e7b8dfb1b2..0000000000
--- a/frontend/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 0a24275e6e..be73d47094 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,8 +1,151 @@
-export const App = () => {
+import { useEffect, useLayoutEffect } from 'react'
+import { Route, Routes, useLocation } from 'react-router-dom'
+import { ThemeProvider } from 'styled-components'
+import styled from 'styled-components'
+
+import { CompanyNav } from './components/CompanyNav'
+// import ErrorBoundary from './components/ErrorBoundary' // ❌ Temporarily disable
+import { Nav } from './components/Nav'
+import { ProtectedRoute } from './components/ProtectedRoute'
+import SkipLink from './components/SkipLink'
+import CompanyCheckout from './pages/CompanyCheckout'
+import CompanyDashboard from './pages/CompanyDashboard'
+import CompanyOrderDetails from './pages/CompanyOrderDetails'
+import CompanyOrders from './pages/CompanyOrders'
+import CompanyPortal from './pages/CompanyPortal'
+import CompanyProfile from './pages/CompanyProfile'
+import ContactUs from './pages/ContactUs'
+import FindUs from './pages/FindUs'
+import Home from './pages/Home'
+import OurStory from './pages/OurStory'
+import Products from './pages/Products'
+import Shop from './pages/Shop'
+import { Footer } from './sections/Footerv2'
+import { api } from './services/api'
+import { useAuthStore } from './stores/useAuthStore'
+import GlobalStyles from './styles/GlobalStyles'
+import theme from './styles/theme'
+
+const AppContainer = styled.div`
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+`
+
+const MainContent = styled.main`
+ flex: 1;
+`
+
+function App() {
+ const location = useLocation()
+ const { company, companyToken, setAuth, setCompany } = useAuthStore()
+
+ useEffect(() => {
+ if (!company && companyToken) {
+ ;(async () => {
+ try {
+ const profile = await api.companies.getProfile(companyToken)
+ if (profile)
+ setAuth ? setAuth(companyToken, profile) : setCompany?.(profile)
+ } catch (err) {
+ console.error('Failed to hydrate company profile on app start', err)
+ }
+ })()
+ }
+ }, [company, companyToken, setAuth, setCompany])
+
+ // Scroll to top on route change, with respect for reduced motion preferences
+ useLayoutEffect(() => {
+ const prefersReduced =
+ typeof window !== 'undefined' &&
+ window.matchMedia &&
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
+
+ window.scrollTo({
+ top: 0,
+ left: 0,
+ behavior: prefersReduced ? 'auto' : 'smooth'
+ })
+ }, [location.pathname])
+
+ const isLoggedIn = useAuthStore((state) => state.isLoggedIn)
+ const companyName = useAuthStore((state) => state.companyName)
return (
- <>
- Welcome to Final Project!
- >
- );
-};
+
+
+ {/* */} {/* ❌ Temporarily disable */}
+
+
+
+ {isLoggedIn && }{' '}
+ {/* <-- Only visible to logged-in companies */}
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+
+ {/* */} {/* ❌ Temporarily disable */}
+
+ )
+}
+
+export default App
diff --git a/frontend/src/assets/boiler-plate.svg b/frontend/src/assets/boiler-plate.svg
deleted file mode 100644
index c9252833b4..0000000000
--- a/frontend/src/assets/boiler-plate.svg
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg
deleted file mode 100644
index 6c87de9bb3..0000000000
--- a/frontend/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/technigo-logo.svg b/frontend/src/assets/technigo-logo.svg
deleted file mode 100644
index 3f0da3e572..0000000000
--- a/frontend/src/assets/technigo-logo.svg
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/components/Button.jsx b/frontend/src/components/Button.jsx
new file mode 100644
index 0000000000..01ad6a2e39
--- /dev/null
+++ b/frontend/src/components/Button.jsx
@@ -0,0 +1,162 @@
+import styled from 'styled-components'
+
+const buttonSizes = {
+ small: '0.9rem',
+ medium: '1rem',
+ large: '1.1rem'
+}
+
+const buttonVariants = {
+ primary: {
+ background: (theme) => theme.colors.brand.primary,
+ color: (theme) => theme.colors.text.primary,
+ border: 'none',
+ opacity: '1',
+ transform: 'translateY(0)',
+ hoverBackground: (theme) => theme.colors.brand.primary,
+ hoverColor: (theme) => theme.colors.text.primary,
+ // padding can be a function(theme, size) or a plain value
+ padding: (theme, size) =>
+ size === 'small'
+ ? `${theme.spacing.sm}`
+ : size === 'large'
+ ? `${theme.spacing.md} ${theme.spacing.lg || theme.spacing.md}`
+ : `${theme.spacing.sm} ${theme.spacing.md}`
+ },
+ secondary: {
+ background: 'transparent',
+ color: (theme) => theme.colors.text.primary,
+ border: (theme) => `2px solid ${theme.colors.brand.lavender}`,
+ opacity: '1',
+ transform: 'translateY(0)',
+ hoverBackground: (theme) => theme.colors.brand.lavender,
+ hoverColor: (theme) => theme.colors.text.primary,
+ padding: (theme, size) =>
+ size === 'small'
+ ? `${theme.spacing.xs || '6px'} ${theme.spacing.sm}`
+ : `${theme.spacing.sm} ${theme.spacing.md}`
+ },
+ icon: {
+ background: 'transparent',
+ color: (theme) => theme.colors.primary,
+ border: 'none',
+ hoverBackground: 'none',
+ hoverColor: (theme) => theme.colors.text.primary,
+ // icon buttons are small by default
+ padding: (theme, size) =>
+ size === 'large'
+ ? `${theme.spacing.sm} ${theme.spacing.md}`
+ : `${theme.spacing.xs || '4px'} ${theme.spacing.xs || '4px'}`
+ }
+}
+
+const StyledButton = styled.button`
+ /* ✅ Fix: Use consistent variant access */
+ background-color: ${({ theme, $variant = 'primary' }) => {
+ const variant = buttonVariants[$variant] || buttonVariants.primary
+ return typeof variant.background === 'function'
+ ? variant.background(theme)
+ : variant.background
+ }};
+
+ color: ${({ theme, $variant = 'primary' }) => {
+ const variant = buttonVariants[$variant] || buttonVariants.primary
+ return typeof variant.color === 'function'
+ ? variant.color(theme)
+ : variant.color
+ }};
+
+ border: ${({ theme, $variant = 'primary' }) => {
+ const variant = buttonVariants[$variant] || buttonVariants.primary
+ return typeof variant.border === 'function'
+ ? variant.border(theme)
+ : variant.border || `2px solid ${theme.colors.primary}`
+ }};
+
+ /* ✅ Fix: Handle hover variant initial state */
+ opacity: ${({ $variant = 'primary' }) => {
+ const variant = buttonVariants[$variant] || buttonVariants.primary
+ return variant.opacity || '1'
+ }};
+
+ transform: ${({ $variant = 'primary' }) => {
+ const variant = buttonVariants[$variant] || buttonVariants.primary
+ return variant.transform || 'translateY(0)'
+ }};
+
+ /* Use variant-specific padding when available */
+ padding: ${({ theme, $variant = 'primary', size = 'medium' }) => {
+ const variant = buttonVariants[$variant] || buttonVariants.primary
+ if (variant.padding) {
+ return typeof variant.padding === 'function'
+ ? variant.padding(theme, size)
+ : variant.padding
+ }
+ return `${theme.spacing.sm} ${theme.spacing.md}`
+ }};
+
+ /* Common styles */
+ border-radius: 4px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ font-size: ${({ size = 'medium' }) => buttonSizes[size]};
+
+ &:hover {
+ ${({ $variant = 'primary', theme }) => {
+ if ($variant === 'icon') {
+ return `
+ background-color: transparent;
+ color: ${
+ typeof buttonVariants.icon.color === 'function'
+ ? buttonVariants.icon.color(theme)
+ : buttonVariants.icon.color
+ };
+ opacity: 1;
+ transform: none;
+ `
+ }
+ const variant = buttonVariants[$variant] || buttonVariants.primary
+ return `
+ background-color: ${
+ typeof variant.hoverBackground === 'function'
+ ? variant.hoverBackground(theme)
+ : variant.hoverBackground || theme.colors.primary
+ };
+ color: white;
+ filter: brightness(0.95);
+ transform: translateY(-1px);
+ `
+ }}
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+ }
+
+ /* Respect user motion preferences */
+ @media (prefers-reduced-motion: reduce) {
+ transition: none;
+ }
+`
+
+export const Button = ({
+ children,
+ variant = 'primary',
+ size = 'medium',
+ className,
+ ...props
+}) => {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/frontend/src/components/Carousel.jsx b/frontend/src/components/Carousel.jsx
new file mode 100644
index 0000000000..4d9f775182
--- /dev/null
+++ b/frontend/src/components/Carousel.jsx
@@ -0,0 +1,253 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import styled from 'styled-components'
+
+import { media } from '../styles/media'
+
+const CarouselContainer = styled.div`
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+`
+
+const CarouselTrack = styled.div`
+ display: flex;
+ transition: transform 0.3s ease-in-out;
+ transform: translateX(-${(props) => props.$currentSlide * 100}%);
+ height: 100%;
+`
+
+const CarouselItem = styled.div`
+ min-width: 100%;
+ position: relative;
+ height: 100%;
+
+ ${media.md} {
+ min-width: ${(props) =>
+ props.$slidesToShow ? `${100 / props.$slidesToShow}%` : '100%'};
+ }
+`
+
+const CarouselImage = styled.img`
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+`
+
+const CarouselText = styled.div`
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
+ color: white;
+ padding: 2rem 1rem 1rem;
+ font-weight: 500;
+`
+
+const Navigation = styled.div`
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ background: rgba(255, 255, 255, 0.9);
+ border: none;
+ border-radius: 50%;
+ width: 48px;
+ height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ z-index: 10;
+
+ &:hover {
+ background: white;
+ }
+
+ &.prev {
+ left: 1rem;
+ }
+
+ &.next {
+ right: 1rem;
+ }
+`
+
+const Indicators = styled.div`
+ position: absolute;
+ bottom: 0.5rem;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ justify-content: center;
+ gap: 0.5rem;
+ z-index: 10;
+`
+
+const Indicator = styled.button`
+ /* Big tappable area for a11y */
+ --tap: 48px;
+ inline-size: var(--tap);
+ block-size: var(--tap);
+ padding: 0;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ position: relative;
+ cursor: pointer;
+
+ /* Small visual dot centered inside */
+ &::before {
+ content: '';
+ inline-size: 14px;
+ block-size: 14px;
+ border-radius: 50%;
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ background: ${({ $active, theme }) =>
+ $active ? theme.colors.brand.salmon : theme.colors.surface};
+ transition: background-color 0.2s ease;
+ box-shadow: 0 0 0 1px ${({ theme }) => theme.colors.border} inset;
+ }
+
+ &:hover::before {
+ background: ${({ theme }) => theme.colors.brand.lavender};
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${({ theme }) => theme.colors.brand.salmon};
+ outline-offset: 2px;
+ }
+`
+
+// Uses its own internal (local) state, independent on Zustand stores
+export const Carousel = ({
+ items = [],
+ autoPlay = false,
+ autoPlayInterval = 3000,
+ showArrows = true,
+ showIndicators = true,
+ slidesToShow = 1
+}) => {
+ const [currentSlide, setCurrentSlide] = useState(0)
+ const slideId = (i) => `hero-slide-${i}`
+
+ // ✅ Memoize items to prevent unnecessary re-renders
+ const memoizedItems = useMemo(() => items, [items])
+
+ // ✅ Memoize navigation functions
+ const nextSlide = useCallback(() => {
+ setCurrentSlide((prev) => (prev + 1) % memoizedItems.length)
+ }, [memoizedItems.length])
+
+ const prevSlide = useCallback(() => {
+ setCurrentSlide((prev) =>
+ prev === 0 ? memoizedItems.length - 1 : prev - 1
+ )
+ }, [memoizedItems.length])
+
+ const goToSlide = useCallback((index) => {
+ setCurrentSlide(index)
+ }, [])
+
+ // Keyboard support
+ const onKeyDown = useCallback(
+ (e) => {
+ if (e.key === 'ArrowRight') nextSlide()
+ else if (e.key === 'ArrowLeft') prevSlide()
+ else if (e.key === 'Home') goToSlide(0)
+ else if (e.key === 'End') goToSlide(memoizedItems.length - 1)
+ },
+ [nextSlide, prevSlide, goToSlide, memoizedItems.length]
+ )
+
+ // ✅ Optimize autoplay effect
+ useEffect(() => {
+ if (!autoPlay || memoizedItems.length <= 1) return
+
+ const interval = setInterval(nextSlide, autoPlayInterval)
+ return () => clearInterval(interval)
+ }, [autoPlay, autoPlayInterval, nextSlide, memoizedItems.length])
+
+ // ✅ Early return if no items
+ if (!memoizedItems || memoizedItems.length === 0) {
+ return Loading carousel...
+ }
+
+ return (
+
+
+ {memoizedItems.map((item, index) => (
+
+ {
+ console.warn('Carousel image failed to load:', e.target.src)
+ e.target.style.display = 'none'
+ }}
+ />
+ {item.text && {item.text} }
+
+ ))}
+
+
+ {showArrows && memoizedItems.length > 1 && (
+ <>
+
+ ‹
+
+
+ ›
+
+ >
+ )}
+
+ {showIndicators && memoizedItems.length > 1 && (
+
+ {memoizedItems.map((_, index) => (
+ goToSlide(index)}
+ type='button'
+ aria-label={`Go to slide ${index + 1} of ${memoizedItems.length}`}
+ aria-controls={slideId(index)}
+ aria-current={index === currentSlide ? 'true' : undefined}
+ />
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/Cart.jsx b/frontend/src/components/Cart.jsx
new file mode 100644
index 0000000000..b08cf52bec
--- /dev/null
+++ b/frontend/src/components/Cart.jsx
@@ -0,0 +1,372 @@
+import { IoCloseOutline } from 'react-icons/io5'
+import { MdDelete } from 'react-icons/md'
+import { TiShoppingCart } from 'react-icons/ti'
+import { Link } from 'react-router-dom'
+import styled from 'styled-components'
+
+import { useAuthStore } from '../stores/useAuthStore'
+import { useCartStore } from '../stores/useCartStore'
+import { media } from '../styles/media'
+import { Button } from './Button'
+import { CartNotification } from './CartNotification'
+import { QuantitySelector } from './QuantitySelector'
+import { calcCartTotal, formatCurrency } from '../utils/cart.js'
+
+const StyledH2 = styled.h2`
+ font-size: 1.5rem;
+ margin-bottom: ${(props) => props.theme.spacing.sm};
+ color: ${(props) => props.theme.colors.text.primary};
+`
+
+const CartIconButton = styled.button`
+ position: relative;
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ z-index: 8000; /* stays above most UI */
+
+ ${media.md} {
+ /* ensure hamburger / other nav sits below cart button on desktop if needed */
+ z-index: 10001;
+ }
+
+ &:focus {
+ outline: 2px solid ${(props) => props.theme.colors.primary};
+ }
+`
+
+const CartIcon = styled(TiShoppingCart)`
+ font-size: 2rem;
+ color: ${(props) => props.theme.colors.primary};
+ transition: color 0.2s;
+
+ &:hover {
+ color: ${(props) => props.theme.colors.secondary};
+ }
+
+ ${media.md} {
+ font-size: 1.5rem;
+ }
+`
+
+const CloseButton = styled(IoCloseOutline)`
+ font-size: 2rem;
+ color: ${(props) => props.theme.colors.text.secondary};
+ cursor: pointer;
+ transition: color 0.2s;
+
+ &:hover {
+ color: ${(props) => props.theme.colors.primary};
+ }
+`
+
+const DeleteButton = styled(MdDelete)`
+ font-size: 1.5rem;
+ color: ${(props) => props.theme.colors.text.secondary};
+ cursor: pointer;
+ transition: color 0.2s;
+
+ &:hover {
+ color: ${(props) => props.theme.colors.primary};
+ }
+`
+
+const CartMenuOverlay = styled.div`
+ position: fixed;
+ inset: 0;
+ width: 100vw;
+ height: 100vh;
+ display: flex;
+ justify-content: flex-end; /* keep menu right-anchored on desktop */
+ z-index: 10001;
+
+ /* mobile: full-screen menu already covers content so keep overlay subtle */
+ background: rgba(0, 0, 0, 0.08);
+
+ ${media.md} {
+ /* desktop: darker backdrop for the drawer */
+ background: rgba(0, 0, 0, 0.32);
+ }
+`
+
+/* cart menu: mobile = full-screen; md+ = right-side drawer (old behaviour) */
+const CartMenu = styled.div`
+ box-sizing: border-box;
+ position: fixed;
+ right: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ background: ${(props) => props.theme.colors.background};
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
+ padding: ${(props) => props.theme.spacing.md};
+ z-index: 99999;
+ display: flex;
+ flex-direction: column;
+ gap: ${(props) => props.theme.spacing.md};
+ overflow-y: auto;
+
+ ${media.md} {
+ /* old desktop drawer */
+ width: 420px;
+ height: 100vh;
+ border-radius: 0;
+ right: 0;
+ left: auto;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
+ }
+`
+
+const MenuContent = styled.div`
+ width: 100%;
+ box-sizing: border-box;
+`
+
+const CartHeader = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: ${(p) => p.theme.spacing.sm};
+ margin-bottom: ${(props) => props.theme.spacing.sm};
+
+ h2 {
+ margin: 0;
+ font-size: 1.25rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis; /* avoid overlapping header titles */
+ }
+`
+
+/* Use grid so control widths are stable and text gets the remaining space */
+const CartItem = styled.div`
+ display: grid;
+ grid-template-columns: 64px 1fr 92px 40px;
+ gap: ${(props) => props.theme.spacing.sm};
+ align-items: center;
+ width: 100%;
+ margin-bottom: ${(props) => props.theme.spacing.md};
+ box-sizing: border-box;
+
+ /* mobile: stack controls under the info for better spacing */
+ ${media.sm} {
+ grid-template-columns: 64px 1fr;
+ grid-template-rows: auto auto;
+ grid-auto-flow: row;
+ align-items: start;
+ }
+
+ /* desktop / large: keep a stable horizontal layout */
+ ${media.md} {
+ grid-template-columns: 64px 1fr 120px 48px; /* image | info | qty | remove */
+ align-items: center;
+ }
+`
+
+const CartItemInfo = styled.div`
+ display: flex;
+ flex-direction: column;
+ min-width: 0; /* important for truncation/wrapping */
+ grid-column: 2 / 3;
+ width: 100%;
+ box-sizing: border-box;
+
+ ${media.sm} {
+ grid-column: 2 / 3;
+ }
+
+ ${media.md} {
+ /* ensure info uses the full middle column and text wraps nicely */
+ padding-right: ${(p) => p.theme.spacing.sm};
+ }
+`
+
+const CartItemName = styled.span`
+ font-size: 1rem;
+ color: ${(props) => props.theme.colors.text.primary};
+ display: block;
+ margin-bottom: 4px;
+ line-height: 1.15;
+ max-width: 100%;
+ word-break: break-word;
+ white-space: normal;
+`
+
+const CartItemPrice = styled.span`
+ font-size: 1rem;
+ color: ${(props) => props.theme.colors.text.secondary};
+ margin-top: 6px;
+`
+
+const StyledQuantitySelector = styled(QuantitySelector)`
+ flex: 0 0 92px; /* stable width so layout doesn't jump */
+ width: 92px;
+ height: auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 4px 0;
+ align-self: flex-start;
+
+ ${media.md} {
+ align-self: center; /* center vertically on larger screens */
+ width: 92px;
+ flex: 0 0 92px;
+ margin: 0;
+ }
+`
+
+const StyledImg = styled.img`
+ width: 64px;
+ height: 64px;
+ object-fit: cover;
+ margin-right: 8px;
+ flex: 0 0 auto;
+
+ ${media.md} {
+ width: 60px;
+ height: 60px;
+ }
+`
+
+const TotalRow = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: ${(p) => p.theme.spacing.sm} 0;
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
+ margin-top: ${(p) => p.theme.spacing.md};
+`
+
+const TotalLabel = styled.span`
+ font-weight: 600;
+ color: ${(p) => p.theme.colors.text.primary};
+`
+
+const TotalValue = styled.span`
+ font-weight: 700;
+ color: ${(p) => p.theme.colors.primary};
+`
+
+export const Cart = () => {
+ const { isOpen, items, closeCart, toggleCart, removeFromCart, addToCart } =
+ useCartStore()
+
+ const isLoggedIn = useAuthStore((state) => state.isLoggedIn)
+ const companyToken = useAuthStore((state) => state.companyToken)
+
+ const total = calcCartTotal(items)
+ const format = (v) => formatCurrency(v, { currency: 'USD' })
+
+ // Only show cart if logged in as company
+ if (!isLoggedIn || !companyToken) return null
+
+ if (!isOpen)
+ return (
+
+
+
+
+ )
+
+ return (
+ <>
+
+
+
+
+
+ e.stopPropagation()} // Prevent closing when clicking inside the cart
+ >
+
+
+ Your Cart
+
+
+
+
+ {items.length === 0 ? (
+ Your cart is empty.
+ ) : (
+ items.map((item) => {
+ const primaryImage = item.primaryImage?.url
+ ? item.primaryImage
+ : item.images?.find((img) => img.isPrimary) ||
+ item.images?.[0] ||
+ null
+
+ return (
+
+ {primaryImage && (
+
+ )}
+
+ {item.name}
+ {item.selectedSize && (
+
+ Size: {item.selectedSize.packaging} (
+ {item.selectedSize.weight}g)
+
+ )}
+
+ {item.selectedSize?.price
+ ? `$${item.selectedSize.price}`
+ : item.formattedPrice || `$${item.price}`}
+
+
+
+ removeFromCart(item.cartKey)}
+ aria-label='Remove item from cart'
+ >
+
+
+
+ )
+ })
+ )}
+
+ {/* Total row shown when there are items */}
+ {items.length > 0 && (
+
+ Total
+ {format(total)}
+
+ )}
+
+ {/* Only show button if there are items in the cart */}
+ {items.length > 0 && (
+
+
+ Proceed to order
+
+
+ )}
+ {/* Else show the button as disabled */}
+ {items.length === 0 && (
+
+ Proceed to order
+
+ )}
+
+
+
+ >
+ )
+}
diff --git a/frontend/src/components/CartNotification.jsx b/frontend/src/components/CartNotification.jsx
new file mode 100644
index 0000000000..fe9ad2ad14
--- /dev/null
+++ b/frontend/src/components/CartNotification.jsx
@@ -0,0 +1,40 @@
+import { AnimatePresence, motion } from 'framer-motion'
+import styled from 'styled-components'
+
+import { useCartStore } from '../stores/useCartStore'
+
+const StyledCartNotification = styled(motion.div)`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ top: -8px;
+ right: -8px;
+ width: 20px;
+ height: 20px;
+ font-family: ${(props) => props.theme.fonts.heading};
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: ${(props) => props.theme.colors.text.primary}};
+`
+
+// Notification badge for when items are added to the cart
+export const CartNotification = () => {
+ const items = useCartStore((state) => state.items)
+ const itemCount = items.reduce((sum, item) => sum + (item.quantity || 1), 0)
+
+ return (
+
+ {itemCount > 0 && (
+
+ {itemCount}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/CompanyLogin.jsx b/frontend/src/components/CompanyLogin.jsx
new file mode 100644
index 0000000000..91d712f0a5
--- /dev/null
+++ b/frontend/src/components/CompanyLogin.jsx
@@ -0,0 +1,263 @@
+// CompanyLogin.jsx
+import { useEffect, useRef, useState } from 'react'
+import { useForm } from 'react-hook-form'
+import { useNavigate } from 'react-router-dom'
+import styled, { keyframes } from 'styled-components'
+
+import { api } from '../services/api'
+import { useAuthStore } from '../stores/useAuthStore'
+import { media } from '../styles/media'
+import { Button } from './Button'
+import MotionReveal from './MotionReveal'
+import { PageContainer } from './PageContainer'
+import { PageTitle } from './PageTitle'
+
+/* --- tiny animations --- */
+const fadeUp = keyframes`
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+`
+const shakeX = keyframes`
+ 0%,100%{transform:translateX(0)}
+ 20%{transform:translateX(-4px)}
+ 40%{transform:translateX(4px)}
+ 60%{transform:translateX(-3px)}
+ 80%{transform:translateX(3px)}
+`
+
+const LoginContainer = styled.section`
+ width: 100%;
+ border: 1px solid ${({ theme }) => theme.colors.border};
+ border-radius: 8px;
+ background: ${({ theme }) => theme.colors.background};
+ padding: ${({ theme }) => theme.spacing.md};
+
+ ${media.sm} { max-width: 360px; padding: ${({ theme }) => theme.spacing.md}; }
+ ${media.md} { max-width: 420px; }
+
+ form {
+ display: grid;
+ gap: ${({ theme }) => theme.spacing.md};
+ }
+`
+
+const Field = styled.div`
+ display: grid;
+ gap: ${({ theme }) => theme.spacing.xs};
+`
+
+const Label = styled.label`
+ font-weight: ${({ theme }) => theme.fonts.weights.bold};
+ color: ${({ theme }) => theme.colors.text.primary};
+`
+
+const InputWrap = styled.div`
+ position: relative;
+`
+
+const Input = styled.input`
+ width: 100%;
+ min-height: 44px;
+ box-sizing: border-box;
+ padding: 0.6rem 0.8rem;
+ border: 1px solid ${({ theme }) => theme.colors.border};
+ border-radius: 6px;
+ background: ${({ theme }) => theme.colors.surface};
+ font: inherit;
+
+ &:focus-visible {
+ outline: 2px solid ${({ theme }) => theme.colors.brand.salmon};
+ outline-offset: 2px;
+ }
+
+ &[aria-invalid='true']{
+ border-color: ${({ theme }) => theme.colors.error};
+ animation: ${shakeX} 160ms ease;
+ }
+`
+
+const TogglePwd = styled.button`
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ font-size: .9rem;
+ padding: .25rem .4rem;
+ border-radius: 4px;
+ min-height: 32px;
+
+ &:focus-visible {
+ outline: 2px solid ${({ theme }) => theme.colors.brand.salmon};
+ outline-offset: 2px;
+ }
+`
+
+const ErrorText = styled.p`
+ margin: 0;
+ color: ${({ theme }) => theme.colors.error};
+ font-size: 0.9rem;
+ animation: ${fadeUp} 200ms ease both;
+`
+
+const ErrorSummary = styled.div`
+ margin: .25rem 0;
+ padding: .5rem .75rem;
+ background: #fff5f5;
+ border-radius: 6px;
+ color: ${({ theme }) => theme.colors.error};
+`
+
+const ContactSales = styled.p`
+ margin-top: ${({ theme }) => theme.spacing.md};
+ font-size: 0.95rem;
+
+ a {
+ color: ${({ theme }) => theme.colors.primary};
+ text-decoration: none;
+ font-weight: ${({ theme }) => theme.fonts.weights.bold};
+ }
+`
+
+export const CompanyLogin = () => {
+ const navigate = useNavigate()
+ const setAuth = useAuthStore((s) => s.setAuth)
+
+ const [serverError, setServerError] = useState('')
+ const [showPwd, setShowPwd] = useState(false)
+ const alertRef = useRef(null)
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isSubmitting },
+ setError,
+ } = useForm({
+ mode: 'onSubmit', // validate on submit
+ reValidateMode: 'onBlur', // recheck when user fixes fields
+ shouldFocusError: true
+ })
+
+ useEffect(() => {
+ if (serverError) alertRef.current?.focus()
+ }, [serverError])
+
+ const onSubmit = async (formData) => {
+ setServerError('')
+ try {
+ const resp = await api.companies.login(formData)
+ const token = resp.token || resp.accessToken
+ let company = resp.company || resp.profile
+
+ if (token && !company) {
+ try { company = await api.companies.getProfile(token) } catch {}
+ }
+
+ if (typeof setAuth === 'function') setAuth(token, company)
+ navigate('/company/dashboard')
+ } catch (e) {
+ setServerError('Invalid email or password. Please try again.')
+ // also mark both fields invalid for SR users linked to message
+ setError('email', { type: 'server' })
+ setError('password', { type: 'server' })
+ }
+ }
+
+ return (
+
+
+
+ Partner login
+
+ {/* live region for server errors */}
+ {serverError && (
+
+ {serverError}
+
+ )}
+
+
+
+
+ Not a partner yet?{' '}
+ Contact sales
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/CompanyNav.jsx b/frontend/src/components/CompanyNav.jsx
new file mode 100644
index 0000000000..f5fce67ad6
--- /dev/null
+++ b/frontend/src/components/CompanyNav.jsx
@@ -0,0 +1,151 @@
+import { useMemo } from 'react'
+import { NavLink, useLocation, useNavigate } from 'react-router-dom'
+import styled from 'styled-components'
+
+import { useBreakpoint } from '../hooks/useBreakpoint'
+import { useAuthStore } from '../stores/useAuthStore'
+import { media } from '../styles/media'
+import { Button } from './Button'
+
+const CompanyNavBar = styled.nav`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ height: 56px;
+ background: ${({ theme }) => theme.colors.surface || '#f6f6f6'};
+ color: ${({ theme }) => theme.colors.text.primary || '#333'};
+ padding: 0 ${({ theme }) => theme.spacing.md};
+ border-bottom: 1px solid ${({ theme }) => theme.colors.border || '#eee'};
+ position: sticky;
+ top: 0;
+ z-index: 40;
+`
+
+const NavSection = styled.div`
+ display: flex;
+ align-items: center;
+ gap: ${({ theme }) => theme.spacing.md};
+
+ &.left { justify-content: flex-start; }
+ &.center { justify-content: center; }
+ &.right { justify-content: flex-end; }
+`
+
+const StyledNavLink = styled(NavLink)`
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+
+ padding: 0 6px;
+ border-radius: 6px;
+
+ color: ${({ theme }) => theme.colors.text.primary};
+ font-family: ${({ theme }) => theme.fonts.body};
+ font-weight: ${({ theme }) => theme.fonts.weights.medium};
+ text-decoration: none;
+
+ transition: color .2s ease, background-color .2s ease;
+
+ &:hover { color: ${({ theme }) => theme.colors.brand.salmon}; }
+
+ &:focus-visible {
+ outline: 2px solid ${({ theme }) => theme.colors.brand.salmon};
+ outline-offset: 2px;
+ }
+
+ /* Active route (NavLink adds aria-current="page") */
+ &[aria-current="page"] {
+ color: ${({ theme }) => theme.colors.brand.salmon};
+ text-decoration: underline;
+ text-underline-offset: 4px;
+ }
+`
+
+/* Accessible native select for mobile */
+const MobileSelect = styled.select`
+ min-height: 44px;
+ padding: 8px 10px;
+ border-radius: 8px;
+ border: 1px solid ${({ theme }) => theme.colors.border};
+ background: ${({ theme }) => theme.colors.background};
+ color: ${({ theme }) => theme.colors.text.primary};
+ font: inherit;
+
+ &:focus-visible {
+ outline: 2px solid ${({ theme }) => theme.colors.brand.salmon};
+ outline-offset: 2px;
+ }
+`
+
+const SrOnly = styled.label`
+ position: absolute !important;
+ clip: rect(1px,1px,1px,1px);
+ clip-path: inset(50%);
+ width: 1px; height: 1px; margin: -1px; overflow: hidden; white-space: nowrap;
+ border: 0; padding: 0;
+`
+
+const navOptions = [
+ { label: 'Dashboard', value: '/company/dashboard' },
+ { label: 'Shop', value: '/company/shop' },
+ { label: 'Orders', value: '/company/orders' },
+ { label: 'Profile', value: '/company/profile' }
+]
+
+export const CompanyNav = () => {
+ const navigate = useNavigate()
+ const location = useLocation()
+ const logout = useAuthStore((s) => s.logout)
+ const breakpoint = useBreakpoint()
+ const isMobile = breakpoint === 'mobile'
+
+ const currentPath = location.pathname
+ const selectValue = useMemo(
+ () => navOptions.find(o => o.value === currentPath)?.value ?? navOptions[0].value,
+ [currentPath]
+ )
+
+ const handleLogout = () => {
+ logout()
+ navigate('/company/login')
+ }
+
+ const handleSelectChange = (e) => {
+ const value = e.target.value
+ if (value && value !== currentPath) navigate(value)
+ }
+
+ return (
+
+
+ {isMobile && (
+ <>
+ Navigate company pages
+
+ {navOptions.map(o => (
+ {o.label}
+ ))}
+
+ >
+ )}
+
+
+
+ {!isMobile && navOptions.map(opt => (
+
+ {opt.label}
+
+ ))}
+
+
+
+ Logout
+
+
+ )
+}
diff --git a/frontend/src/components/ContactUsForm.jsx b/frontend/src/components/ContactUsForm.jsx
new file mode 100644
index 0000000000..95a1de1b36
--- /dev/null
+++ b/frontend/src/components/ContactUsForm.jsx
@@ -0,0 +1,265 @@
+import { useEffect, useRef, useState } from 'react'
+import { useForm } from 'react-hook-form'
+import styled, { keyframes } from 'styled-components'
+
+import { media } from '../styles/media'
+import { Button } from './Button'
+
+/* === micro-animations === */
+const fadeUp = keyframes`
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+`
+
+const shakeX = keyframes`
+ 0%, 100% { transform: translateX(0); }
+ 20% { transform: translateX(-4px); }
+ 40% { transform: translateX(4px); }
+ 60% { transform: translateX(-3px); }
+ 80% { transform: translateX(3px); }
+`
+
+const spin = keyframes` to { transform: rotate(360deg); }`
+
+/* === layout === */
+const FormShell = styled.section`
+ width: 100%;
+ display: grid;
+ place-items: center;
+ animation: ${fadeUp} 420ms ease both;
+
+ @media (min-width: 768px) {
+ place-items: start; // Align form to the left on larger screens
+ justify-items: start;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ animation: none;
+ }
+`
+
+const StyledForm = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ theme }) => theme.spacing.md};
+ width: 100%;
+ max-width: 400px;
+ margin: 0;
+
+ ${media.md} {
+ max-width: 600px;
+ padding: 0;
+ }
+`
+
+/* inputs/textarea share styles; no underline effect */
+const BaseInput = styled.input`
+ width: 100%;
+ padding: ${({ theme }) => theme.spacing.sm};
+ border: 1px solid ${({ theme }) => theme.colors.border};
+ border-radius: 8px;
+ font-family: ${({ theme }) => theme.fonts.body};
+ font-size: 1rem;
+ font-weight: ${({ theme }) => theme.fonts.weights.normal};
+ line-height: 1.6;
+ background: ${({ theme }) => theme.colors.surface};
+
+ &[aria-invalid='true'] {
+ border-color: ${({ theme }) => theme.colors.error};
+ animation: ${shakeX} 160ms ease;
+ }
+
+ &:focus {
+ outline: none;
+ border-color: ${({ theme }) => theme.colors.brand.lavender};
+ border-width: 3px;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ &[aria-invalid='true'] {
+ animation: none;
+ }
+ }
+`
+
+const Textarea = styled(BaseInput).attrs({ as: 'textarea' })`
+ resize: vertical;
+ min-height: 140px;
+`
+
+const FeedbackMessage = styled.div`
+ color: ${({ theme }) => theme.colors.success};
+ margin: ${({ theme }) => theme.spacing.md} 0;
+ animation: ${fadeUp} 320ms ease both;
+
+ @media (prefers-reduced-motion: reduce) {
+ animation: none;
+ }
+`
+
+const ErrorText = styled.p`
+ color: ${({ theme }) => theme.colors.error};
+ font-size: 0.9rem;
+ margin-top: 6px;
+ animation: ${fadeUp} 200ms ease both;
+`
+
+const Spinner = styled.span`
+ display: inline-block;
+ width: 1em;
+ height: 1em;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ margin-right: 8px;
+ vertical-align: -2px;
+ animation: ${spin} 700ms linear infinite;
+
+ @media (prefers-reduced-motion: reduce) {
+ animation: none;
+ border-right-color: currentColor; /* becomes a solid dot */
+ }
+`
+const Honeypot = styled.input.attrs({ name: 'botField', autoComplete: 'off', tabIndex: -1 })`
+ position: absolute !important;
+ left: -10000px !important;
+ top: auto !important;
+ width: 1px !important;
+ height: 1px !important;
+ overflow: hidden !important;
+`
+
+const ContactUsForm = () => {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isSubmitting },
+ reset,
+ setError
+ } = useForm()
+ const [success, setSuccess] = useState(false)
+ const successRef = useRef(null)
+
+ useEffect(() => {
+ if (success) successRef.current?.focus()
+ }, [success])
+
+ const onSubmit = async (data) => {
+ try {
+ const res = await fetch(`${import.meta.env.VITE_API_URL}/contact`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ })
+ if (!res.ok) {
+ const err = await res.json().catch(() => null)
+ throw new Error(err?.error || 'Failed to send message')
+ }
+ reset()
+ setSuccess(true)
+ } catch (err) {
+ setError('root', { message: err.message || 'Failed to send your message. Please try again later.' })
+ setSuccess(false)
+ }
+ }
+
+ if (success) {
+ return (
+
+ Thanks! 🎉
+ Your message has been sent. We’ll get back to you soon.
+
+ )
+ }
+
+ return (
+
+
+
+ {/* name */}
+
+
+ {errors.name && {errors.name.message} }
+
+
+ {/* email */}
+
+
+ {errors.email && {errors.email.message} }
+
+
+ {/* phone (optional, format checked) */}
+
+
+ {errors.phone && {errors.phone.message} }
+
+
+ {/* subject (optional) */}
+
+
+ {errors.subject && {errors.subject.message} }
+
+
+ {/* message */}
+
+
+ {errors.message && {errors.message.message} }
+
+
+ {errors.root && {errors.root.message} }
+
+
+ {isSubmitting ? <> Sending…> : 'Send message'}
+
+
+
+ )
+}
+
+export default ContactUsForm
diff --git a/frontend/src/components/DropdownMenu.jsx b/frontend/src/components/DropdownMenu.jsx
new file mode 100644
index 0000000000..0e66576bed
--- /dev/null
+++ b/frontend/src/components/DropdownMenu.jsx
@@ -0,0 +1,54 @@
+import { FiChevronDown } from 'react-icons/fi'
+import styled from 'styled-components'
+
+const StyledDropdownMenu = styled.div`
+ position: relative;
+ display: inline-block;
+ width: 100%;
+`
+
+const DropdownSelect = styled.select`
+ width: 100%;
+ padding: 8px;
+ font-size: 1rem;
+ border-radius: 4px;
+ border: 1px solid #ccc;
+ appearance: none; /* Hide default arrow */
+ background: transparent;
+ /* Add right padding for the icon */
+ padding-right: 2.5em;
+`
+
+const DropdownIcon = styled(FiChevronDown)`
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ pointer-events: none;
+ color: #888;
+ font-size: 1.2em;
+`
+
+export const DropdownMenu = ({
+ options = [],
+ value,
+ onChange,
+ getLabel = (option) => option.label || option,
+ getValue = (option) => option.value || option,
+ ...props
+}) => (
+
+ onChange(e.target.value)}
+ {...props}
+ >
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+)
diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx
new file mode 100644
index 0000000000..665e441655
--- /dev/null
+++ b/frontend/src/components/ErrorBoundary.jsx
@@ -0,0 +1,32 @@
+import React from 'react'
+
+class ErrorBoundary extends React.Component {
+ constructor(props) {
+ super(props)
+ this.state = { hasError: false, error: null }
+ }
+
+ static getDerivedStateFromError(error) {
+ return { hasError: true, error }
+ }
+
+ componentDidCatch(error, errorInfo) {
+ console.error('ErrorBoundary caught an error:', error, errorInfo)
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
Something went wrong!
+
{this.state.error?.message}
+
window.location.reload()}>Reload Page
+
+ )
+ }
+
+ return this.props.children
+ }
+}
+
+export default ErrorBoundary
diff --git a/frontend/src/components/FeaturedProduct.jsx b/frontend/src/components/FeaturedProduct.jsx
new file mode 100644
index 0000000000..6bd04c9e37
--- /dev/null
+++ b/frontend/src/components/FeaturedProduct.jsx
@@ -0,0 +1,58 @@
+import styled from 'styled-components'
+
+const StyledFeaturedProduct = styled.div`
+ position: relative; // Add this!
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+`
+
+const ProductImage = styled.img`
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+`
+
+const ImageOverlay = styled.div`
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ padding: 1rem;
+ background: rgba(0, 0, 0, 0.6);
+ color: #fff;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+`
+
+export const FeaturedProduct = ({ product }) => {
+ if (!product) {
+ return No product data
+ }
+
+ const getProductImage = (product) => {
+ if (product.primaryImage?.url) return product.primaryImage.url
+ if (product.images?.[0]?.url) return product.images[0].url
+ return '/images/placeholder.png'
+ }
+
+ const getImageAlt = (product) => {
+ if (product.primaryImage?.alt) return product.primaryImage.alt
+ if (product.images?.[0]?.alt) return product.images[0].alt
+ return product.name
+ }
+
+ const imageSrc = getProductImage(product)
+ const imageAlt = getImageAlt(product)
+
+ return (
+
+
+
+ {product.name}
+ {product.info || product.description}
+
+
+ )
+}
diff --git a/frontend/src/components/HamburgerMenu.jsx b/frontend/src/components/HamburgerMenu.jsx
new file mode 100644
index 0000000000..6c60926229
--- /dev/null
+++ b/frontend/src/components/HamburgerMenu.jsx
@@ -0,0 +1,170 @@
+import React, { useEffect, useRef, useCallback } from 'react'
+import { createPortal } from 'react-dom'
+import { Link, useLocation } from 'react-router-dom'
+import styled from 'styled-components'
+import { useMenuStore } from '../stores/useMenuStore'
+import { media } from '../styles/media'
+
+const Bar = styled.span`
+ display: block;
+ width: 25px;
+ height: 3px;
+ background: var(--nav-icon-color);
+ margin: ${({ theme }) => theme.spacing.xs} 0;
+ border-radius: 2px;
+ transition: transform .3s, opacity .3s, background-color .2s;
+ &:nth-child(1){ transform: ${({$isOpen}) => $isOpen ? 'rotate(45deg) translateY(9px)' : 'none'}; }
+ &:nth-child(2){ opacity: ${({$isOpen}) => $isOpen ? 0 : 1}; }
+ &:nth-child(3){ transform: ${({$isOpen}) => $isOpen ? 'rotate(-45deg) translateY(-10px)' : 'none'}; }
+`
+
+const HamburgerButton = styled.button`
+ z-index: 6000;
+ display: block;
+ inline-size: 48px;
+ block-size: 48px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ position: absolute;
+ left: ${({ theme }) => theme.spacing.md};
+ &:focus-visible { outline: 2px solid ${({ theme }) => theme.colors.brand.salmon}; outline-offset: 2px; }
+ ${media.md} { display: none; }
+`
+
+/* Non-focusable backdrop */
+const Backdrop = styled.div`
+ position: fixed;
+ top: 80px; left: 0; right: 0; bottom: 0;
+ background: transparent;
+ z-index: 9997;
+`
+
+const Menu = styled.nav`
+ position: fixed;
+ top: 80px; left: 0;
+ width: 100vw;
+ height: calc(100vh - 80px);
+ background: ${({ theme }) => theme.colors.background};
+ z-index: 9998;
+ display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-start;
+ padding: ${({ theme }) => theme.spacing.md};
+ gap: ${({ theme }) => theme.spacing.md};
+ border-top: 1px solid ${({ theme }) => theme.colors.border};
+
+ a {
+ color: ${({ theme }) => `var(--nav-link-color, ${theme.colors.text.primary})`};
+ text-decoration: none;
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ font-weight: ${({ theme }) => theme.fonts.weights.medium};
+ font-size: 16px;
+ padding: 6px 0;
+ }
+ a:hover, a:focus-visible {
+ color: ${({ theme }) => `var(--nav-link-hover, ${theme.colors.brand.salmon})`};
+ }
+`
+
+const Portal = ({ children }) => createPortal(children, document.body)
+
+export const HamburgerMenu = () => {
+ // ✅ selectors so re-renders happen reliably
+ const isOpen = useMenuStore((s) => s.isOpen)
+ const toggleMenu = useMenuStore((s) => s.toggleMenu)
+ const closeMenu = useMenuStore((s) => s.closeMenu)
+
+ const location = useLocation()
+ const buttonRef = useRef(null)
+ const menuRef = useRef(null)
+ const menuId = 'mobile-menu'
+
+ const handleClose = useCallback(() => {
+ closeMenu()
+ buttonRef.current?.focus()
+ }, [closeMenu])
+
+ // ✅ Close when the ROUTE changes (only depends on pathname)
+ useEffect(() => {
+ if (!isOpen) return
+ closeMenu()
+ const id = requestAnimationFrame(() => buttonRef.current?.focus())
+ return () => cancelAnimationFrame(id)
+ // we intentionally omit deps to avoid closing on open
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [location.pathname])
+
+ // ESC to close
+ useEffect(() => {
+ if (!isOpen) return
+ const onKey = (e) => { if (e.key === 'Escape') handleClose() }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [isOpen, handleClose])
+
+ // Prevent body scroll while open
+ useEffect(() => {
+ if (!isOpen) return
+ const original = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => { document.body.style.overflow = original }
+ }, [isOpen])
+
+ // Focus first focusable in menu on open
+ useEffect(() => {
+ if (!isOpen) return
+ const id = requestAnimationFrame(() => {
+ const first = menuRef.current?.querySelector('a, button, [tabindex]:not([tabindex="-1"])')
+ first?.focus()
+ })
+ return () => cancelAnimationFrame(id)
+ }, [isOpen])
+
+ // Trap focus inside the menu
+ const onMenuKeyDown = useCallback((e) => {
+ if (e.key !== 'Tab') return
+ const nodes = menuRef.current?.querySelectorAll('a, button, [tabindex]:not([tabindex="-1"])')
+ if (!nodes || !nodes.length) return
+ const first = nodes[0]
+ const last = nodes[nodes.length - 1]
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
+ }, [])
+
+ return (
+ <>
+
+
+
+
+
+
+ {isOpen && (
+
+
+
+
+ )}
+ >
+ )
+}
diff --git a/frontend/src/components/Image.jsx b/frontend/src/components/Image.jsx
new file mode 100644
index 0000000000..cfc3b87001
--- /dev/null
+++ b/frontend/src/components/Image.jsx
@@ -0,0 +1,23 @@
+import { useState } from 'react'
+import styled from 'styled-components'
+
+const StyledImage = styled.img`
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+`
+
+export const Image = ({ src, alt, className, hoverSrc }) => {
+ const [isHovered, setIsHovered] = useState(false)
+ const displaySrc = isHovered && hoverSrc ? hoverSrc : src
+
+ return (
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ />
+ )
+}
diff --git a/frontend/src/components/ImageGrid.jsx b/frontend/src/components/ImageGrid.jsx
new file mode 100644
index 0000000000..a16032ec34
--- /dev/null
+++ b/frontend/src/components/ImageGrid.jsx
@@ -0,0 +1,401 @@
+import { useEffect, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { IoCloseOutline } from 'react-icons/io5'
+import { Link } from 'react-router-dom'
+import styled from 'styled-components'
+
+import useProductStore from '../stores/useProductStore'
+import { media } from '../styles/media'
+import { Button } from './Button'
+
+const StyledImageGrid = styled.div`
+ display: grid;
+ position: relative; /* make this the positioning context for the overlay */
+ grid-template-columns: 1fr;
+ /* mobile: allow rows to size to their content so InlineInfoBox can expand */
+ grid-auto-rows: min-content;
+ grid-template-areas:
+ 'div1'
+ 'div2'
+ 'div3'
+ 'div4'
+ 'div5'
+ 'div6';
+ grid-gap: 10px;
+ width: 100%;
+ /* give cells a comfortable minimum height on very short content */
+ & > div {
+ min-height: 140px;
+ }
+
+ ${media.sm} {
+ grid-template-columns: 2fr 1fr; // First column is wider
+ /* keep stable row height on tablet */
+ grid-template-rows: repeat(2, 200px);
+ grid-template-areas:
+ 'div1 div2'
+ 'div3 div4'
+ 'div5 div6';
+ grid-column-gap: 10px;
+ grid-row-gap: 10px;
+ width: 100%;
+ min-height: 600px;
+ }
+
+ ${media.md} {
+ grid-template-columns: 2fr 2fr 3fr;
+ grid-template-rows: repeat(2, 300px);
+ grid-template-areas:
+ 'div1 div2 div3'
+ 'div4 div5 div6';
+ grid-column-gap: 10px;
+ grid-row-gap: 10px;
+ width: 100%;
+ }
+
+ ${media.lg} {
+ grid-template-columns: 2fr 2fr 3fr;
+ grid-template-rows: repeat(2, 400px);
+ grid-template-areas:
+ 'div1 div2 div3'
+ 'div4 div5 div6';
+ grid-column-gap: 10px;
+ grid-row-gap: 10px;
+ width: 100%;
+ min-height: 600px; // Optional: increase overall grid height
+ }
+`
+
+const Div1 = styled.div`
+ grid-area: div1;
+ background: ${({ theme }) => `rgba(208, 195, 241, 0.5)`};
+`
+const Div2 = styled.div`
+ grid-area: div2;
+ background: ${({ theme }) => `rgba(208, 195, 241, 0.5)`};
+`
+const Div3 = styled.div`
+ grid-area: div3;
+ background: ${({ theme }) => `rgba(208, 195, 241, 0.5)`};
+`
+const Div4 = styled.div`
+ grid-area: div4;
+ background: ${({ theme }) => `rgba(208, 195, 241, 0.5)`};
+`
+const Div5 = styled.div`
+ grid-area: div5;
+ background: ${({ theme }) => `rgba(208, 195, 241, 0.5)`};
+`
+const Div6 = styled.div`
+ grid-area: div6;
+ background: ${({ theme }) => `rgba(208, 195, 241, 0.5)`};
+`
+
+const ImageWrapper = styled.div`
+ position: relative;
+ width: 100%;
+ height: 100%;
+`
+
+const ImageOverlay = styled.div`
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: ${({ theme }) => `rgba(208, 195, 241, 0.2)`};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.2rem;
+ opacity: 0.7; // always visible, change to 0.7 or add hover effect if you want
+ pointer-events: none;
+ border-radius: 0; // match your image style if needed
+`
+
+const LimitedImageOverlay = styled.div`
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 2;
+
+ span {
+ color: #fff;
+ font-weight: 700;
+ font-size: 2rem;
+ margin: auto;
+ text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
+ }
+`
+
+const InfoOverlay = styled.div`
+ display: none; // hidden by default
+ ${media.md} {
+ /* cover the viewport and center the InfoBox so it appears
+ in the middle of the user's current scroll position */
+ position: fixed;
+ inset: 0; /* fills the viewport */
+ background: rgba(0, 0, 0, 0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 2000;
+ padding: 0.6rem; /* small inset so InfoBox has breathing room */
+ }
+`
+
+const InfoBox = styled.div`
+ /* fill the overlay (which itself is limited to the grid area) */
+ width: 100%;
+ height: 100%;
+ background: ${({ theme }) => theme.colors.background};
+ box-sizing: border-box;
+ padding: 1rem;
+ overflow: auto;
+ position: relative;
+ opacity: 1;
+
+ ${media.md} {
+ /* on larger screens keep the content centered inside the grid overlay */
+ max-width: 900px;
+ max-height: 90%;
+ width: 90%;
+ height: auto;
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.18);
+ text-align: left;
+ }
+`
+
+const CloseButtonWrapper = styled.div`
+ position: absolute;
+ top: 0.1rem;
+ right: 0.1rem;
+ cursor: pointer;
+ z-index: 10;
+`
+
+const CloseButton = styled(IoCloseOutline)`
+ color: ${({ theme }) => theme.colors.text.secondary};
+`
+
+const Header = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+ h2 {
+ margin: 0;
+ font-size: 1.5rem;
+ }
+
+ ${media.md} {
+ h2 {
+ font-size: 1.8rem;
+ }
+ margin-bottom: 1.5rem;
+ }
+`
+
+const Ingredients = styled.div`
+ margin-top: 1rem;
+ font-size: 0.9rem;
+ color: #555;
+
+ strong {
+ font-weight: 600;
+ }
+
+ ul {
+ padding-left: 1.2rem;
+ margin: 0.5rem 0 0 0;
+ }
+
+ li {
+ margin-bottom: 0.3rem;
+ }
+`
+
+const InlineInfoBox = styled(InfoBox)`
+ position: relative;
+ width: 100%;
+ height: auto;
+ max-width: none;
+ max-height: none;
+ padding: 1rem;
+
+ background: ${({ theme }) => theme.colors.background};
+ color: ${({ theme }) => theme.colors.text.primary};
+ border-radius: 0;
+ box-shadow: none;
+`
+
+export const ImageGrid = () => {
+ const [selectedIdx, setSelectedIdx] = useState(null)
+ const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
+ const products = useProductStore((state) => state.products)
+
+ const productImages = products.map((p) => p.images?.[0]) // Get first image of each product
+
+ // Update isMobile state on window resize
+ useEffect(() => {
+ const onResize = () => setIsMobile(window.innerWidth < 768)
+ window.addEventListener('resize', onResize)
+ return () => window.removeEventListener('resize', onResize)
+ }, [])
+
+ // toggle selection: click image again closes it on all sizes
+ const handleImageClick = (idx) =>
+ setSelectedIdx((prev) => (prev === idx ? null : idx))
+ const handleClose = () => setSelectedIdx(null)
+
+ return (
+ <>
+
+ {[Div1, Div2, Div3, Div4, Div5, Div6].map((Div, idx) => (
+
+ {selectedIdx === idx && isMobile ? (
+ // inline info box replaces the image on mobile (or small screens)
+
+ {idx === 5 ? (
+ <>
+
+ Limited Edition Treats
+
+
+ Discover our exclusive seasonal creations — from bold
+ collaborations to festive favorites. Flavors change with
+ the season, ensuring there’s always something new to
+ surprise your taste buds.
+
+ >
+ ) : (
+ products[idx] && (
+ <>
+
+ {products[idx].description}
+
+ {Array.isArray(products[idx].ingredients) && (
+
+ Ingredients: {' '}
+ {products[idx].ingredients.join(', ')}
+
+ )}
+
+ >
+ )
+ )}
+
+ ) : productImages?.[idx] ? (
+
handleImageClick(idx)}
+ style={{ cursor: 'pointer' }}
+ >
+
+
+
+ ) : idx === 5 ? (
+
handleImageClick(idx)}
+ style={{ cursor: 'pointer' }}
+ >
+
+
+
+ Limited edition
+
+
+ ) : null}
+
+ ))}
+
+ {/* desktop / large screens: overlay that covers the viewport (portal) */}
+ {selectedIdx !== null &&
+ !isMobile &&
+ createPortal(
+
+ e.stopPropagation()}>
+ {selectedIdx === 5 ? (
+ <>
+
+
+
+
+
+
+ Limited Edition Treats
+
+
+ Discover our exclusive seasonal creations — from bold
+ collaborations to festive favorites. Flavors change with the
+ season, ensuring there’s always something new to surprise
+ your taste buds. Whether you’re looking to enjoy or
+ co-create,{' '}
+
+ get in touch
+ {' '}
+ to find out what’s baking now..
+
+ >
+ ) : (
+ products[selectedIdx] && (
+ <>
+
+
+
+
+
+
+ {products[selectedIdx].name}
+
+ {products[selectedIdx].description}
+
+ {Array.isArray(products[selectedIdx].ingredients) && (
+
+ Ingredients: {' '}
+ {products[selectedIdx].ingredients.join(', ')}
+
+ )}
+
+ >
+ )
+ )}
+
+ ,
+ document.body
+ )}
+ >
+ )
+}
diff --git a/frontend/src/components/LimitedProduct.jsx b/frontend/src/components/LimitedProduct.jsx
new file mode 100644
index 0000000000..fc2d536c53
--- /dev/null
+++ b/frontend/src/components/LimitedProduct.jsx
@@ -0,0 +1,24 @@
+import styled from 'styled-components'
+
+const StyledLimitedProduct = styled.div`
+ text-align: left;
+ position: relative;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding-bottom: ${(props) => props.theme.spacing.md};
+`
+
+export const LimitedProduct = ({ product }) => {
+ if (!product) {
+ return No product data
+ }
+ return (
+
+ {product.name}
+ {product.description}
+ {/* Add more product details as needed */}
+
+ )
+}
diff --git a/frontend/src/components/Logo.jsx b/frontend/src/components/Logo.jsx
new file mode 100644
index 0000000000..a00aabde7f
--- /dev/null
+++ b/frontend/src/components/Logo.jsx
@@ -0,0 +1,35 @@
+import styled from 'styled-components'
+
+const StyledLogo = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: ${({ theme }) => theme.spacing.md};
+ opacity: 0.7;
+ transition: opacity 0.3s ease;
+
+ &:hover {
+ opacity: 1;
+ }
+`
+
+const LogoImage = styled.img`
+ max-height: 60px;
+ max-width: 120px;
+ object-fit: contain;
+`
+
+const LogoText = styled.span`
+ font-size: 1rem;
+ font-weight: 600;
+ color: ${({ theme }) => theme.colors.text.secondary};
+ text-align: center;
+`
+
+export const Logo = ({ logo, name, alt }) => {
+ return (
+
+ {logo ? : {name} }
+
+ )
+}
diff --git a/frontend/src/components/MotionReveal.jsx b/frontend/src/components/MotionReveal.jsx
new file mode 100644
index 0000000000..928265c10b
--- /dev/null
+++ b/frontend/src/components/MotionReveal.jsx
@@ -0,0 +1,22 @@
+import { motion, useReducedMotion } from 'framer-motion'
+
+const MotionReveal = ({ children, delay = 0 }) => {
+ const reduce = useReducedMotion()
+ const variants = reduce
+ ? { hidden: { opacity: 1, y: 0 }, show: { opacity: 1, y: 0 } }
+ : { hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0 } }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export default MotionReveal
diff --git a/frontend/src/components/Nav.jsx b/frontend/src/components/Nav.jsx
new file mode 100644
index 0000000000..c2af10e029
--- /dev/null
+++ b/frontend/src/components/Nav.jsx
@@ -0,0 +1,157 @@
+import { MdLockOutline } from 'react-icons/md'
+import { Link, useNavigate } from 'react-router-dom'
+import styled from 'styled-components'
+
+import { useAuthStore } from '../stores/useAuthStore'
+import { media } from '../styles/media'
+import { Cart } from './Cart'
+import { HamburgerMenu } from './HamburgerMenu'
+
+const StyledNav = styled.nav`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ padding: ${({ theme }) => theme.spacing.md};
+ background: ${({ theme }) => theme.colors.background};
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ height: ${({ theme }) => theme.layout?.navHeight || '80px'};
+ border-bottom: 1px solid ${({ theme }) => theme.colors.border};
+
+ /* 🌈 Nav color tokens */
+ --nav-link-color: ${({ theme }) => theme.colors.text.primary};
+ --nav-link-hover: ${({ theme }) => theme.colors.brand.salmon};
+ --nav-icon-color: ${({ theme }) => theme.colors.text.secondary};
+ --nav-icon-hover: ${({ theme }) => theme.colors.brand.salmon};
+
+ ${media.md} {
+ justify-content: space-between;
+ padding: ${({ theme }) => theme.spacing.md};
+ }
+`
+
+const Logo = styled(Link)`
+ font-size: 1.5rem;
+ font-family: ${({ theme }) => theme.fonts.heading};
+ font-weight: ${({ theme }) => theme.fonts.weights.bold};
+ text-decoration: none;
+ color: ${({ theme }) => theme.colors.text.primary};
+
+ &:hover, &:focus-visible {
+ color: var(--nav-icon-hover);
+ }
+`
+
+const Links = styled.div`
+ display: none;
+
+ ${media.md} {
+ display: flex;
+ gap: ${({ theme }) => theme.spacing.sm};
+ position: absolute;
+ left: 50%;
+ top: 50%; /* ✅ Center vertically */
+ transform: translate(
+ -50%,
+ -50%
+ ); /* ✅ Center both horizontally and vertically */
+ white-space: nowrap;
+ }
+
+ ${media.lg} {
+ gap: ${({ theme }) => theme.spacing.lg};
+ }
+
+ ${media.xl} {
+ gap: ${({ theme }) => theme.spacing.xl};
+ }
+
+ a {
+ color: var(--nav-link-color);
+ padding: ${({ theme }) => theme.spacing.sm};
+ font-size: 14px;
+ border-radius: 4px;
+ white-space: nowrap;
+
+ &:hover {
+ color: var(--nav-link-hover);
+ }
+ }
+`
+
+const NavSection = styled.div`
+ display: flex;
+ align-items: center;
+
+ &.left {
+ flex: 0 0 auto;
+ order: 1;
+ }
+ &.center {
+ flex: 1 1 0%;
+ justify-content: center;
+ order: 2;
+ }
+ &.right {
+ flex: 0 0 auto;
+ justify-content: flex-end;
+ order: 3;
+ gap: ${(props) => props.theme.spacing.sm};
+ }
+
+ ${media.md} {
+ &.left {
+ order: 1;
+ }
+ &.center {
+ order: 1; /* Move center section to the left */
+ justify-content: flex-start;
+ flex: 1 1 auto;
+ }
+ &.right {
+ order: 2;
+ }
+ }
+`
+
+const LoginIcon = styled(MdLockOutline)`
+ font-size: 20px;
+ color: var(--nav-icon-color);
+ cursor: pointer;
+ transition: color 0.2s;
+ margin: 0 ${(props) => props.theme.spacing.sm} 0;
+
+ &:hover, &:focus-visible {
+ color: var(--nav-icon-hover);
+ }
+`
+
+export const Nav = () => {
+ const isLoggedIn = useAuthStore((state) => state.isLoggedIn)
+ const navigate = useNavigate()
+
+ return (
+
+
+
+
+
+ naima
+
+
+ {!isLoggedIn && (
+ navigate('/company/login')} />
+ )}
+
+
+
+ Products
+ Find us
+ Our story
+ Contact us
+
+
+ )
+}
diff --git a/frontend/src/components/OrderDetails.jsx b/frontend/src/components/OrderDetails.jsx
new file mode 100644
index 0000000000..33f302bd24
--- /dev/null
+++ b/frontend/src/components/OrderDetails.jsx
@@ -0,0 +1,95 @@
+import styled from 'styled-components'
+
+import { media } from '../styles/media'
+
+const StyledOrderDetails = styled.div`
+ background: #fff;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
+ padding: ${(props) => props.theme.spacing.md};
+ margin: ${(props) => props.theme.spacing.lg} auto;
+ max-width: 600px;
+ width: 100%;
+ color: ${(props) => props.theme.colors.text.primary};
+
+ ${media.md} {
+ padding: ${(props) => props.theme.spacing.lg};
+ }
+
+ h2 {
+ font-size: 18px;
+ flex-wrap: wrap;
+ margin-bottom: ${(props) => props.theme.spacing.md};
+
+ ${media.md} {
+ font-size: 1.5rem;
+ }
+ }
+
+ .order-info {
+ font-size: 16px;
+ margin-bottom: ${(props) => props.theme.spacing.md};
+ }
+
+ .order-items {
+ margin-top: ${(props) => props.theme.spacing.md};
+ border-top: 1px solid ${(props) => props.theme.colors.border};
+ padding-top: ${(props) => props.theme.spacing.md};
+ }
+
+ .order-item-row {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: ${(props) => props.theme.spacing.sm};
+ }
+`
+
+const TotalCost = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ font-weight: bold;
+ margin-top: ${(props) => props.theme.spacing.md};
+ font-size: 18px;
+
+ .label {
+ text-align: left;
+ }
+
+ .value {
+ text-align: right;
+ }
+`
+
+export const OrderDetails = ({ order }) => (
+
+ Order #{order._id}
+
+
Date: {new Date(order.createdAt).toLocaleDateString()}
+
Status: {order.status}
+
Total: ${order.totalCost}
+
+ Customer: {order.name} ({order.email})
+
+
Address: {order.address}
+
Phone: {order.phone}
+ {/* Add more info as needed */}
+
+
+
Items
+ {order.items.map((item, idx) => (
+
+
+ {item.name} x {item.quantity}
+
+ ${item.price}
+
+ ))}
+
+ Total
+ ${order.totalCost}
+
+
+
+)
diff --git a/frontend/src/components/OrderForm.jsx b/frontend/src/components/OrderForm.jsx
new file mode 100644
index 0000000000..f769261517
--- /dev/null
+++ b/frontend/src/components/OrderForm.jsx
@@ -0,0 +1,113 @@
+import { Button } from './Button'
+import { useState } from 'react'
+import { useForm } from 'react-hook-form'
+import styled from 'styled-components'
+
+import { api } from '../services/api'
+import { useAuthStore } from '../stores/useAuthStore'
+import { useCartStore } from '../stores/useCartStore'
+import { media } from '../styles/media'
+
+const StyledForm = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: ${(props) => props.theme.spacing.md};
+ width: 100%;
+ max-width: 400px; // Mobile-first: max width
+ margin: 0 auto; // Center horizontally
+ padding: ${(props) => props.theme.spacing.sm};
+
+ input,
+ textarea {
+ width: 100%;
+ font-family: 'Neuzeit S LT Std Medium', sans-serif;
+ padding: ${(props) => props.theme.spacing.sm};
+ border: 1px solid ${(props) => props.theme.colors.border};
+ font-size: 1rem;
+ box-sizing: border-box;
+ }
+
+ ${media.md} {
+ max-width: 800px;
+ padding: ${(props) => props.theme.spacing.md};
+ }
+`
+const StyledH2 = styled.h2`
+ font-size: 1.5rem;
+ margin-bottom: ${(props) => props.theme.spacing.md};
+ text-align: flex-start;
+ color: ${(props) => props.theme.colors.text.primary};
+`
+
+const FeedbackMessage = styled.div`
+ text-align: flex-start;
+ color: ${(props) => props.theme.colors.text.success};
+ margin: ${(props) => props.theme.spacing.md} 0;
+`
+
+export const OrderForm = ({ cartItems, title = '', ...props }) => {
+ const { register, handleSubmit, formState, reset } = useForm()
+ const [success, setSuccess] = useState(false)
+ const user = useAuthStore((state) => state.user)
+ const [name, setName] = useState(user?.name || '')
+ const [email, setEmail] = useState(user?.email || '')
+
+ const onSubmit = async (data) => {
+ const orderData = { ...data, items: cartItems, userId: user?.id }
+ try {
+ await api.orders.submitOrder(orderData)
+ reset()
+ setSuccess(true)
+ useCartStore.getState().clearCart() // Clear the cart
+ } catch (error) {
+ setSuccess(false)
+ }
+ }
+
+ if (success) {
+ return (
+
+ Thank you for your order!
+ We have received your order and will process it soon.
+
+ )
+ }
+
+ return (
+ {
+ e.preventDefault()
+ handleSubmit(onSubmit)()
+ }}
+ >
+ {title}
+ setName(e.target.value)}
+ />
+ setEmail(e.target.value)}
+ />
+
+
+
+
+ {formState.isSubmitting ? 'Submitting...' : 'Submit Order'}
+
+
+ )
+}
diff --git a/frontend/src/components/OrderItem.jsx b/frontend/src/components/OrderItem.jsx
new file mode 100644
index 0000000000..ec59164470
--- /dev/null
+++ b/frontend/src/components/OrderItem.jsx
@@ -0,0 +1,44 @@
+import { IoIosArrowForward } from 'react-icons/io'
+import styled from 'styled-components'
+
+const StyledOrderItem = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ justify-content: space-between;
+ width: 100%;
+ height: 100%;
+ padding: ${(props) => props.theme.spacing.sm};
+ border-bottom: 1px solid ${(props) => props.theme.colors.border};
+ margin-bottom: ${(props) => props.theme.spacing.sm};
+ cursor: pointer;
+ transition: background 0.2s;
+
+ &:hover {
+ background: ${(props) => props.theme.colors.backgroundHover || '#f5f5f5'};
+ }
+`
+
+const ArrowIcon = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: auto;
+ color: ${(props) => props.theme.colors.text.primary};
+ transition: color 0.2s;
+`
+
+export const OrderItem = ({ order, onClick }) => (
+ onClick(order._id)}>
+
+
Order #{order._id}
+
Date: {new Date(order.createdAt).toLocaleDateString()}
+
Status: {order.status}
+
Total: ${order.totalCost}
+
+
+
+
+
+)
diff --git a/frontend/src/components/PageContainer.jsx b/frontend/src/components/PageContainer.jsx
new file mode 100644
index 0000000000..9d4fdd231c
--- /dev/null
+++ b/frontend/src/components/PageContainer.jsx
@@ -0,0 +1,22 @@
+import styled from 'styled-components'
+
+import { media } from '../styles/media'
+
+export const PageContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start; // <--- Change to flex-start for left alignment
+
+ padding: ${(props) => props.theme.spacing.md};
+ max-width: 1200px;
+ margin: 0 auto;
+
+ ${media.sm} {
+ align-items: flex-start;
+ padding: ${(props) => props.theme.spacing.lg};
+ }
+ ${media.md} {
+ align-items: flex-start;
+ padding: ${(props) => props.theme.spacing.xl};
+ }
+`
diff --git a/frontend/src/components/PageFade.jsx b/frontend/src/components/PageFade.jsx
new file mode 100644
index 0000000000..213708b8ac
--- /dev/null
+++ b/frontend/src/components/PageFade.jsx
@@ -0,0 +1,22 @@
+import styled from "styled-components";
+
+const Shell = styled.div`
+ opacity: 0;
+ transform: translateY(8px);
+ animation: fadeUp 400ms ease both;
+
+ @keyframes fadeUp {
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ animation: none;
+ }
+`
+
+const PageFade = ({ children }) => {children} ;
+
+export default PageFade;
diff --git a/frontend/src/components/PageTitle.jsx b/frontend/src/components/PageTitle.jsx
new file mode 100644
index 0000000000..b70945c9ec
--- /dev/null
+++ b/frontend/src/components/PageTitle.jsx
@@ -0,0 +1,26 @@
+import styled from 'styled-components'
+
+import { media } from '../styles/media'
+
+const StyledPageTitle = styled.h1`
+ font-family: ${(props) => props.theme.typography.heading.fontFamily};
+ font-weight: ${(props) => props.theme.typography.heading.fontWeight};
+ line-height: ${(props) => props.theme.typography.heading.lineHeight};
+ color: inherit;
+ margin-bottom: ${(props) => props.theme.spacing.md};
+ text-align: left; /* Always align left */
+ font-size: 2rem;
+ padding: 0;
+
+ ${media.md} {
+ font-size: 2rem;
+ }
+
+ ${media.lg} {
+ font-size: 3rem;
+ }
+`
+
+export const PageTitle = ({ children }) => {
+ return {children}
+}
diff --git a/frontend/src/components/ProductCard.jsx b/frontend/src/components/ProductCard.jsx
new file mode 100644
index 0000000000..d041ae0c59
--- /dev/null
+++ b/frontend/src/components/ProductCard.jsx
@@ -0,0 +1,259 @@
+import { useState } from 'react'
+import styled from 'styled-components'
+
+import { useCartStore } from '../stores/useCartStore'
+import { useProductSelectionStore } from '../stores/useProductSelectionStore'
+import { media } from '../styles/media'
+import { Button } from './Button'
+import { DropdownMenu } from './DropdownMenu'
+import { Image } from './Image'
+import { ProductCardNotification } from './ProductCardNotification'
+import { QuantitySelector } from './QuantitySelector'
+
+const ProductImageWrapper = styled.div`
+ width: 100%;
+ height: 250px;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #f8fafc;
+`
+
+const StyledProductCard = styled.div`
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ background: #fff;
+ overflow: hidden;
+ min-width: 0;
+ height: 100%;
+ padding-bottom: ${(props) => props.theme.spacing.md};
+`
+
+const ProductContent = styled.div`
+ padding: ${(props) => props.theme.spacing.sm};
+
+ ${media.md} {
+ }
+`
+
+const ProductTitleContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${(props) => props.theme.spacing.xs};
+ margin-bottom: ${(props) => props.theme.spacing.sm};
+ width: 100%;
+ height: 2rem;
+ overflow: hidden;
+
+ ${media.sm} {
+ height: 2rem;
+ }
+
+ ${media.md} {
+ height: 2.9rem;
+ }
+`
+
+const ProductTitle = styled.h3`
+ font-size: 16px;
+ font-weight: 600;
+ margin: 0 0 ${(props) => props.theme.spacing.sm} 0;
+ color: ${(props) => props.theme.colors.text.primary};
+
+ ${media.sm} {
+ font-size: 18px;
+ margin: 0 0 ${(props) => props.theme.spacing.sm} 0;
+ }
+
+ ${media.md} {
+ font-size: 20px;
+ margin: 0 0 ${(props) => props.theme.spacing.md} 0;
+ }
+`
+
+const ProductInformation = styled.div`
+ font-size: 14px;
+ color: ${(props) => props.theme.colors.text.secondary};
+ margin-bottom: ${(props) => props.theme.spacing.md};
+`
+
+const ProductDescription = styled.p`
+ line-height: 1.5;
+`
+
+const ProductPrice = styled.span`
+ font-size: 1.2rem;
+ font-weight: 600;
+ color: ${(props) => props.theme.colors.primary};
+ display: block;
+`
+
+const LowerSection = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${(props) => props.theme.spacing.sm};
+ padding: ${(props) => props.theme.spacing.sm};
+ margin-top: auto; // ensures this section is at the bottom of the card
+`
+
+const ButtonContainer = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: stretch; /* stretch children to same height */
+ justify-content: flex-start;
+ gap: ${({ theme }) => theme.spacing.xs};
+ width: 100%;
+ margin-top: auto;
+`
+
+/* wrapper to give the quantity selector a stable width */
+const QuantityWrapper = styled.div`
+ flex: 0 0 auto; /* do not grow, keep intrinsic / fixed size */
+ display: flex;
+ align-items: center;
+ min-width: 88px; /* adjust to match your QuantitySelector UI */
+`
+
+const StyledButton = styled(Button)`
+ flex: 1 1 auto; /* grow to fill remaining space */
+ min-width: 0; /* allow shrinking inside flex */
+ margin-left: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+`
+
+export const ProductCard = ({ product }) => {
+ const addToCart = useCartStore((state) => state.addToCart)
+ const selectedSize = useProductSelectionStore(
+ (state) => state.selectedSizes[product._id]
+ )
+ const setSelectedSize = useProductSelectionStore(
+ (state) => state.setSelectedSize
+ )
+ const cartItem = useCartStore((state) =>
+ state.items.find(
+ (i) =>
+ i.productId === product._id && i.selectedSize?._id === selectedSize?._id
+ )
+ )
+ const [desiredQuantity, setDesiredQuantity] = useState(1)
+ const [showNotification, setShowNotification] = useState(false)
+
+ if (!product) {
+ return No product data
+ }
+
+ const getProductImage = () => {
+ if (product.primaryImage?.url) return product.primaryImage.url
+ if (product.images?.[0]?.url) return product.images[0].url
+ return '/images/placeholder.png'
+ }
+
+ const getHoverImage = () => {
+ if (product.images?.[1]?.url) return product.images[1].url
+ return null
+ }
+
+ const getImageAlt = () => {
+ if (product.primaryImage?.alt) return product.primaryImage.alt
+ if (product.images?.[0]?.alt) return product.images[0].alt
+ return product.name
+ }
+
+ const sizeOptions = product.sizes.map((size, idx) => ({
+ label: `${size.packaging} (${size.weight}g)`,
+ value: String(size._id || idx)
+ }))
+
+ const validPrices = product.sizes
+ .map((s) => s.price)
+ .filter((p) => typeof p === 'number' && p > 0)
+ const lowestPrice =
+ validPrices.length > 0 ? Math.min(...validPrices).toFixed(2) : 'N/A'
+
+ const handleAddToCart = () => {
+ if (selectedSize) {
+ addToCart(product, selectedSize, desiredQuantity)
+ setShowNotification(true)
+ setTimeout(() => setShowNotification(false), 2000)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ {product.name}
+
+
+ {product.description}
+
+
+
+
+ {selectedSize?.price
+ ? `$${selectedSize.price}`
+ : `from $${lowestPrice}`}
+
+ s === selectedSize)
+ )
+ : ''
+ }
+ onChange={(val) => {
+ const size = product.sizes.find(
+ (s, idx) => String(s._id || idx) === val
+ )
+ setSelectedSize(product._id, size || null)
+ }}
+ />
+
+
+
+
+
+
+ Add to Cart
+
+
+
+ {showNotification && (
+ Added to cart!
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ProductCardNotification.jsx b/frontend/src/components/ProductCardNotification.jsx
new file mode 100644
index 0000000000..10827f9d61
--- /dev/null
+++ b/frontend/src/components/ProductCardNotification.jsx
@@ -0,0 +1,19 @@
+import styled from 'styled-components'
+
+const Toast = styled.div`
+ position: absolute;
+ bottom: 84px;
+ right: 0px;
+ background: ${(props) => props.theme.colors.brand.primary};
+ color: ${(props) => props.theme.colors.text.primary};
+ opacity: 0.9;
+ border-radius: 4px;
+ padding: 10px 20px;
+ font-weight: 500;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ z-index: 10;
+`
+
+export const ProductCardNotification = ({ children }) => (
+ {children}
+)
diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx
new file mode 100644
index 0000000000..692b228643
--- /dev/null
+++ b/frontend/src/components/ProtectedRoute.jsx
@@ -0,0 +1,8 @@
+import { Navigate } from 'react-router-dom'
+
+import { useAuthStore } from '../stores/useAuthStore'
+
+export const ProtectedRoute = ({ children }) => {
+ const isLoggedIn = useAuthStore((state) => state.isLoggedIn)
+ return isLoggedIn ? children :
+}
diff --git a/frontend/src/components/QuantitySelector.jsx b/frontend/src/components/QuantitySelector.jsx
new file mode 100644
index 0000000000..a2b7f8385c
--- /dev/null
+++ b/frontend/src/components/QuantitySelector.jsx
@@ -0,0 +1,92 @@
+import styled, { css } from 'styled-components'
+
+import { useCartStore } from '../stores/useCartStore'
+
+const StyledQuantitySelector = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+ flex: 1;
+
+ button,
+ span {
+ flex: 1 1 0;
+ }
+
+ button {
+ background: none;
+ color: ${(props) => props.theme.colors.text.primary};
+ cursor: pointer;
+ ${(props) =>
+ props.$variant === 'card' &&
+ css`
+ font-size: 1.5rem;
+ padding: ${(props) => props.theme.spacing.sm}
+ ${(props) => props.theme.spacing.sm};
+ `}
+ ${(props) =>
+ props.$variant === 'cart' &&
+ css`
+ border: 1px solid black;
+ border-radius: 4px;
+ background: #fff;
+ font-size: 1rem;
+ padding: ${(props) => props.theme.spacing.xs}
+ ${(props) => props.theme.spacing.sm};
+ `}
+ }
+
+ span {
+ text-align: center;
+ font-size: 1rem;
+ font-family: 'Neuzeit S LT Std Medium', sans-serif;
+ color: ${(props) => props.theme.colors.text.primary};
+ padding: ${(props) => props.theme.spacing.sm}
+ ${(props) => props.theme.spacing.xs};
+ ${(props) =>
+ props.$variant === 'card' &&
+ css`
+ font-size: 1.2rem;
+ `}
+ ${(props) =>
+ props.$variant === 'cart' &&
+ css`
+ font-size: 1rem;
+ `}
+ }
+`
+
+export const QuantitySelector = ({ item, variant = 'cart' }) => {
+ const updateQuantity = useCartStore((state) => state.updateQuantity)
+ const { setQuantity, cartKey, quantity } = item
+
+ if (!item) return null
+
+ const handleDecrement = () => {
+ if (setQuantity) {
+ setQuantity(Math.max(1, quantity - 1))
+ } else if (cartKey) {
+ updateQuantity(cartKey, Math.max(1, quantity - 1))
+ }
+ }
+
+ const handleIncrement = () => {
+ if (setQuantity) {
+ setQuantity(quantity + 1)
+ } else if (cartKey) {
+ updateQuantity(cartKey, quantity + 1)
+ }
+ }
+
+ return (
+
+
+ -
+
+ {quantity}
+ +
+
+ )
+}
diff --git a/frontend/src/components/RetailerMap.jsx b/frontend/src/components/RetailerMap.jsx
new file mode 100644
index 0000000000..af5ecb2e9d
--- /dev/null
+++ b/frontend/src/components/RetailerMap.jsx
@@ -0,0 +1,134 @@
+import L from 'leaflet'
+// Keep Leaflet default icon working if a brand icon is missing
+import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png'
+import iconUrl from 'leaflet/dist/images/marker-icon.png'
+import shadowUrl from 'leaflet/dist/images/marker-shadow.png'
+import { useEffect, useMemo, useState } from 'react'
+import { MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet'
+import styled, { useTheme } from 'styled-components'
+
+import 'leaflet/dist/leaflet.css'
+
+// @ts-ignore
+delete L.Icon.Default.prototype._getIconUrl
+L.Icon.Default.mergeOptions({ iconRetinaUrl, iconUrl, shadowUrl })
+
+const Wrap = styled.section`
+ width: min(1200px, 92vw);
+ margin: 0 auto;
+ .leaflet-container {
+ width: 100%;
+ height: clamp(320px, 60vh, 600px);
+ border-radius: 12px;
+ border: 1px solid ${({ theme }) => theme.colors.border};
+ overflow: hidden;
+ z-index: 0;
+ }
+`
+
+const FitBounds = ({ points }) => {
+ const map = useMap()
+ useEffect(() => {
+ if (!points?.length) return
+ const bounds = points.reduce((b, [lng, lat]) => b.extend([lat, lng]), L.latLngBounds())
+ map.fitBounds(bounds, { padding: [20, 20] })
+ }, [points, map])
+ return null
+}
+
+// Inline SVG pin generator
+const makeSvgPin = ({ label = '•', fill = '#F7CDD0', text = '#1e293b' }) => {
+ const svg = `
+
+
+ ${label}
+ `
+ const url = 'data:image/svg+xml;utf8,' + encodeURIComponent(svg)
+ return L.icon({ iconUrl: url, iconSize: [32, 48], iconAnchor: [16, 46], popupAnchor: [0, -38] })
+}
+
+// Pick black/white text for contrast against light pastels
+const getTextOn = (hex) => {
+ const h = (hex || '#ffffff').replace('#','')
+ const r = parseInt(h.slice(0,2),16), g = parseInt(h.slice(2,4),16), b = parseInt(h.slice(4,6),16)
+ const luminance = (0.299*r + 0.587*g + 0.114*b) / 255
+ return luminance > 0.6 ? '#1e293b' : '#ffffff'
+}
+
+const RetailerMap = () => {
+ const [items, setItems] = useState([])
+ const theme = useTheme()
+
+ useEffect(() => {
+ // Ensure file exists at frontend/public/data/geocodedRetailers.json
+ fetch('/data/geocodedRetailers.json')
+ .then((r) => r.json())
+ .then((data) => setItems(Array.isArray(data) ? data : (data.items ?? [])))
+ .catch(() => setItems([]))
+ }, [])
+
+ // Brand → color mapping using Naima palette
+ const brandIcons = useMemo(() => {
+ const palette = {
+ primary: theme?.colors?.brand?.primary || '#BCE8C2',
+ blush: theme?.colors?.brand?.blush || '#F7CDD0',
+ salmon: theme?.colors?.brand?.salmon || '#F4A6A3',
+ lavender: theme?.colors?.brand?.lavender || '#D0C3F1',
+ sky: theme?.colors?.brand?.sky || '#B3D9F3',
+ }
+
+ const fills = {
+ '7-Eleven': palette.primary,
+ 'Hawaii Poké': palette.sky,
+ 'Mocca Deli': palette.salmon,
+ 'PBX': palette.lavender
+ }
+
+ const pin = (label, fillHex) => makeSvgPin({ label, fill: fillHex, text: getTextOn(fillHex) })
+
+ return {
+ default: pin('•', palette.blush),
+ '7-Eleven': pin('7', fills['7-Eleven']),
+ 'Hawaii Poké': pin('HP', fills['Hawaii Poké']),
+ 'Mocca Deli': pin('M', fills['Mocca Deli']),
+ 'PBX': pin('P', fills['PBX'])
+ }
+ }, [theme])
+
+ const coords = useMemo(
+ () => items.map((i) => i.location?.coordinates).filter(Boolean),
+ [items]
+ )
+
+ return (
+
+
+
+
+
+ {items.map((i) => {
+ const [lng, lat] = i.location.coordinates
+ const addr = i.fullAddress || `${i.street}, ${i.city}`
+ const dir = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(addr)}`
+ const icon = brandIcons[i.brand] || brandIcons.default
+
+ return (
+
+
+ {i.name}
+ {addr}
+ Directions
+
+
+ )
+ })}
+
+
+ )
+}
+
+export default RetailerMap
diff --git a/frontend/src/components/Reveal.jsx b/frontend/src/components/Reveal.jsx
new file mode 100644
index 0000000000..e7b3275e3d
--- /dev/null
+++ b/frontend/src/components/Reveal.jsx
@@ -0,0 +1,36 @@
+import styled from "styled-components";
+
+import { useInView } from "../hooks/useInView";
+
+const Shell = styled.div`
+ opacity: 0;
+ transform: translateY(16px);
+ transition: opacity 420ms ease var(--reveal-delay, 0ms),
+ transform 420ms ease var(--reveal-delay, 100ms);
+ &[data-inview="true"] {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ @media (prefers-reduced-motion: reduce) {
+ transition: none;
+ opacity: 1;
+ transform: none;
+ }
+`;
+
+const Reveal = ({ children, as = "div", delay = 0, ...props }) => {
+ const { ref, inView } = useInView();
+ return (
+
+ {children}
+
+ );
+};
+
+export default Reveal;
diff --git a/frontend/src/components/Section.jsx b/frontend/src/components/Section.jsx
new file mode 100644
index 0000000000..214a80b07c
--- /dev/null
+++ b/frontend/src/components/Section.jsx
@@ -0,0 +1,44 @@
+// components/Section.jsx
+import styled from 'styled-components'
+
+import { media } from '../styles/media'
+
+const StyledSection = styled.section`
+ padding: ${(props) => props.theme.spacing.xxl} ${(props) => props.theme.spacing.xl};
+
+ ${media.md} {
+ padding: 6rem ${(props) => props.theme.spacing.xxl};
+ }
+
+ ${(props) =>
+ props.fullHeight &&
+ `
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ `}
+
+ ${(props) =>
+ props.background &&
+ `
+ background: ${props.background};
+ `}
+`
+
+const Container = styled.div`
+ max-width: 1200px;
+ margin: 0 auto;
+ width: 100%;
+`
+
+export const Section = ({ children, fullHeight, background, className }) => {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/frontend/src/components/SkipLink.jsx b/frontend/src/components/SkipLink.jsx
new file mode 100644
index 0000000000..0f5e8a08ce
--- /dev/null
+++ b/frontend/src/components/SkipLink.jsx
@@ -0,0 +1,35 @@
+import styled from "styled-components";
+
+const Skip = styled.a`
+ position: absolute;
+ left: 0;
+ top: 0;
+ z-index: 9999;
+ padding: 10px 14px;
+ border-radius: 10px;
+ background: ${({ theme }) => theme.colors.brand.salmon};
+ color: #111;
+ font-weight: ${({ theme }) => theme.fonts.weights.medium};
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
+
+ /* Visually hidden but focusable */
+ clip: rect(1px, 1px, 1px, 1px);
+ clip-path: inset(50%);
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ white-space: nowrap;
+
+ &:focus {
+ /* Reveal on focus */
+ clip: auto; clip-path: none;
+ width: auto; height: auto;
+ position: fixed;
+ top: 12px; left: 12px;
+ outline: 2px solid #111; outline-offset: 2px;
+ }
+`;
+
+const SkipLink = () => Skip to main content ;
+
+export default SkipLink;
diff --git a/frontend/src/components/SocialIcons.jsx b/frontend/src/components/SocialIcons.jsx
new file mode 100644
index 0000000000..aab461cdd0
--- /dev/null
+++ b/frontend/src/components/SocialIcons.jsx
@@ -0,0 +1,67 @@
+import { FaInstagram, FaSpotify } from 'react-icons/fa'
+import { FaFacebook } from 'react-icons/fa'
+import { FaYoutube } from 'react-icons/fa'
+import { FaTiktok } from 'react-icons/fa6'
+import styled from 'styled-components'
+
+import { media } from '../styles/media'
+
+const SocialIconsContainer = styled.div`
+ display: flex;
+ gap: ${({ theme }) => theme.spacing.sm};
+ margin-top: ${({ theme }) => theme.spacing.md};
+
+ ${media.lg} {
+ margin-top: 0;
+ }
+`
+
+const SocialIconLink = styled.a`
+ color: currentColor;
+ font-size: 1.5rem;
+ transition: all 0.3s ease;
+ padding: ${({ theme }) => theme.spacing.xs};
+ border-radius: 50%;
+ inline-size: 48px;
+ block-size: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover {
+ color: ${({ theme }) => theme.colors.brand.salmon};
+ transform: scale(1.1);
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${({ theme }) => theme.colors.brand.salmon}; outline-offset: 2px;
+ }
+`
+
+// Define social media links
+const socialLinks = [
+ { icon: FaFacebook, url: 'https://facebook.com/resetwithnaima', label: 'Facebook' },
+ { icon: FaInstagram, url: 'https://instagram.com/resetwithnaima', label: 'Instagram' },
+ { icon: FaTiktok, url: 'https://www.tiktok.com/@resetwithnaima', label: 'TikTok' },
+ { icon: FaSpotify, url: 'https://open.spotify.com/show/5EfXKBnLhAToIhZjACKSZz?si=69463996f8304ba7', label: 'Spotify' },
+ // { icon: FaYoutube, url: 'https://youtube.com', label: 'YouTube' },
+]
+
+export const SocialIcons = ({ links }) => {
+ return (
+
+ {/* ✅ Map over social links */}
+ {socialLinks.map(({ icon: Icon, url, label }) => (
+
+
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/components/Video.jsx b/frontend/src/components/Video.jsx
new file mode 100644
index 0000000000..1fabebe949
--- /dev/null
+++ b/frontend/src/components/Video.jsx
@@ -0,0 +1,10 @@
+import styled from 'styled-components'
+
+const StyledVideo = styled.video`
+ width: 100%;
+ height: auto;
+ object-fit: cover;
+`
+export const Video = ({ src, ...props }) => {
+ return
+}
diff --git a/frontend/src/data/carouselData.js b/frontend/src/data/carouselData.js
new file mode 100644
index 0000000000..a26b3576f9
--- /dev/null
+++ b/frontend/src/data/carouselData.js
@@ -0,0 +1,13 @@
+export const homeCarouselItems = [
+ { id: 1, image: '/images/carousel-1.webp', alt: '' },
+ {
+ id: 2,
+ image: '/images/carousel-2.webp',
+ alt: ''
+ },
+ {
+ id: 3,
+ image: '/images/carousel-3.webp',
+ alt: ''
+ }
+]
diff --git a/frontend/src/data/cateringPartners.js b/frontend/src/data/cateringPartners.js
new file mode 100644
index 0000000000..ffd1a31113
--- /dev/null
+++ b/frontend/src/data/cateringPartners.js
@@ -0,0 +1,56 @@
+export const cateringPartners = [
+ {
+ id: 1,
+ name: 'Martin & Servera',
+ logo: 'https://martinservera.se/wp-content/uploads/2020/01/logo-martin-servera.svg',
+ alt: 'Martin & Servera',
+ website: 'https://www.martinservera.se/sokresultat?q=naima+'
+ },
+ {
+ id: 2,
+ name: 'Svensk Cater',
+ logo: 'https://svenskcater.se/wp-content/uploads/2020/01/logo-svensk-cater.svg',
+ alt: 'Svensk Cater',
+ website: 'https://shop.svenskcater.se/sortiment/?q=naima&sb=0'
+ },
+ //Menigo
+ {
+ id: 3,
+ name: 'Menigo',
+ logo: 'https://menigo.se/wp-content/uploads/2020/01/logo-menigo.svg',
+ alt: 'Menigo',
+ website:
+ 'https://www.menigo.se/sok?infinitescroll=1&q=naima&sortBy=popularity'
+ },
+ //Switsbake
+ {
+ id: 4,
+ name: 'Switsbake',
+ logo: 'https://switsbake.se/wp-content/uploads/2020/01/logo-switsbake.svg',
+ alt: 'Switsbake',
+ website: 'https://switsbake.se/'
+ },
+ //outofhome
+ {
+ id: 5,
+ name: 'outofhome',
+ logo: 'https://outofhome.se/wp-content/uploads/2020/01/logo-outofhome.svg',
+ alt: 'Out of Home',
+ website: 'https://outofhome.se/p?search=naima%20'
+ },
+ //CaterBee
+ {
+ id: 6,
+ name: 'CaterBee',
+ logo: 'https://caterbee.se/wp-content/uploads/2020/01/logo-caterbee.svg',
+ alt: 'CaterBee',
+ website: 'https://caterbee.com/catering/stockholm/company/naimassuperfood'
+ }
+]
+
+export const cateringPartnersData = {
+ title: 'Our Catering Partners',
+ description:
+ 'We collaborate with leading catering partners to bring you the best culinary experiences.',
+ partners: cateringPartners
+}
diff --git a/frontend/src/data/companyLogos.js b/frontend/src/data/companyLogos.js
new file mode 100644
index 0000000000..0b983d34ac
--- /dev/null
+++ b/frontend/src/data/companyLogos.js
@@ -0,0 +1,33 @@
+// Company logos data
+export const companyLogos = [
+ {
+ id: 1,
+ name: 'Yasuragi',
+ logo: '/public/partners/yasuragi.svg',
+ alt: 'Yasuragi'
+ },
+ {
+ id: 2,
+ name: 'Radisson Hotels',
+ logo: '/public/partners/radisson_2_logo.svg',
+ alt: 'Radisson Hotels'
+ },
+ {
+ id: 3,
+ name: '7-Eleven',
+ logo: null, // ✅ Will show text instead of missing SVG
+ alt: '7-Eleven'
+ },
+ {
+ id: 4,
+ name: 'Strawberry',
+ logo: null, // ✅ Will show text instead of missing SVG
+ alt: 'Strawberry'
+ },
+ {
+ id: 5,
+ name: 'Johan & Nyström',
+ logo: null, // ✅ Will show text instead of missing PNG
+ alt: 'Johan & Nyström'
+ }
+]
diff --git a/frontend/src/data/keywords.js b/frontend/src/data/keywords.js
new file mode 100644
index 0000000000..c7dcc65b3f
--- /dev/null
+++ b/frontend/src/data/keywords.js
@@ -0,0 +1,70 @@
+export const keywords = [
+ 'glutenfree',
+ 'lactosefree',
+ 'plantbased',
+ 'natural ingredients',
+ 'superfoods',
+ 'no added sugars'
+]
+
+// Alternative: with IDs for mapping
+export const keywordsWithIds = [
+ {
+ id: 1,
+ text: 'glutenfree'
+ },
+ {
+ id: 2,
+ text: 'lactosefree'
+ },
+ {
+ id: 3,
+ text: 'plantbased'
+ },
+ {
+ id: 4,
+ text: 'natural ingredients'
+ },
+ {
+ id: 5,
+ text: 'superfoods'
+ },
+ {
+ id: 6,
+ text: 'no added sugars'
+ }
+]
+
+// Alternative: with display formatting
+export const formattedKeywords = [
+ {
+ id: 1,
+ text: 'glutenfree',
+ display: 'Gluten Free'
+ },
+ {
+ id: 2,
+ text: 'lactosefree',
+ display: 'Lactose Free'
+ },
+ {
+ id: 3,
+ text: 'plantbased',
+ display: 'Plant Based'
+ },
+ {
+ id: 4,
+ text: 'natural ingredients',
+ display: 'Natural Ingredients'
+ },
+ {
+ id: 5,
+ text: 'superfoods',
+ display: 'Superfoods'
+ },
+ {
+ id: 6,
+ text: 'no added sugars',
+ display: 'No Added Sugars'
+ }
+]
diff --git a/frontend/src/data/productImages.js b/frontend/src/data/productImages.js
new file mode 100644
index 0000000000..f932fb58bd
--- /dev/null
+++ b/frontend/src/data/productImages.js
@@ -0,0 +1,11 @@
+const productImages = [
+ '/images/chocolate.webp',
+ '/images/blueberry.jpg',
+ '/images/raspberry.webp',
+ '/images/lemoncurd.webp',
+ '/images/cinnamon.webp',
+ '/images/limited.webp'
+ // Add more image paths as needed
+]
+
+export default productImages
diff --git a/frontend/src/hooks/useBreakpoint.js b/frontend/src/hooks/useBreakpoint.js
new file mode 100644
index 0000000000..aa55fd29af
--- /dev/null
+++ b/frontend/src/hooks/useBreakpoint.js
@@ -0,0 +1,19 @@
+import { useEffect, useState } from 'react'
+
+export const useBreakpoint = () => {
+ const [breakpoint, setBreakpoint] = useState('desktop')
+
+ useEffect(() => {
+ const handleResize = () => {
+ const width = window.innerWidth
+ if (width < 600) setBreakpoint('mobile')
+ else if (width < 900) setBreakpoint('tablet')
+ else setBreakpoint('desktop')
+ }
+ handleResize()
+ window.addEventListener('resize', handleResize)
+ return () => window.removeEventListener('resize', handleResize)
+ }, [])
+
+ return breakpoint
+}
diff --git a/frontend/src/hooks/useInView.js b/frontend/src/hooks/useInView.js
new file mode 100644
index 0000000000..30c8687b7d
--- /dev/null
+++ b/frontend/src/hooks/useInView.js
@@ -0,0 +1,23 @@
+import { useEffect, useRef, useState } from 'react'
+
+export const useInView = (options = { threshold: 0.15, rootMargin: '0px' }) => {
+ const ref = useRef(null)
+ const [inView, setInView] = useState(false)
+
+ useEffect(() => {
+ const el = ref.current
+ if (!el) return
+
+ const obs = new IntersectionObserver(([entry]) => {
+ if (entry.isIntersecting) {
+ setInView(true)
+ obs.unobserve(entry.target) // reveal once
+ }
+ }, options)
+
+ obs.observe(el)
+ return () => obs.disconnect()
+ }, [options])
+
+ return { ref, inView }
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index e69de29bb2..04662932fc 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -0,0 +1,40 @@
+/*Purpose: Font imports only*/
+
+/* Heading Font - Arca Majora */
+@font-face {
+ font-family: 'Arca Majora';
+ src: url('/fonts/arcamajora3-bold.woff2') format('woff2'),
+ url('/fonts/arcamajora3-bold.woff') format('woff');
+ font-weight: 700; /* Bold for headings */
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: 'Arca Majora';
+ src: url('/fonts/arcamajora3-heavy.woff2') format('woff2'),
+ url('/fonts/arcamajora3-heavy.woff') format('woff');
+ font-weight: 900; /* Heavy for emphasis */
+ font-style: normal;
+ font-display: swap;
+}
+
+/* Body Font - Neuzeit */
+@font-face {
+ font-family: 'Neuzeit S LT Std';
+ src: url('/fonts/neuzeit_s_lt_std_book.woff2') format('woff2'),
+ url('/fonts/neuzeit_s_lt_std_book.woff') format('woff');
+ font-weight: 400; /* Regular for body text */
+ font-style: normal;
+ font-display: swap;
+}
+
+/* Add more Neuzeit weights if you have them */
+@font-face {
+ font-family: 'Neuzeit S LT Std';
+ src: url('/fonts/neuzeit_s_lt_std_medium.woff2') format('woff2'),
+ url('/fonts/neuzeit_s_lt_std_medium.woff') format('woff');
+ font-weight: 500; /* Medium weight */
+ font-style: normal;
+ font-display: swap;
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 51294f3998..82e05841eb 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -1,10 +1,15 @@
-import React from "react";
-import ReactDOM from "react-dom/client";
-import { App } from "./App.jsx";
-import "./index.css";
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
-ReactDOM.createRoot(document.getElementById("root")).render(
+import App from './App.jsx'
+
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
-
+
+
+
-);
+)
diff --git a/frontend/src/pages/Checkout.jsx b/frontend/src/pages/Checkout.jsx
new file mode 100644
index 0000000000..214ee46115
--- /dev/null
+++ b/frontend/src/pages/Checkout.jsx
@@ -0,0 +1,182 @@
+import { MdDelete } from 'react-icons/md'
+import styled from 'styled-components'
+
+import { OrderForm } from '../components/OrderForm'
+import { PageContainer } from '../components/PageContainer'
+import { PageTitle } from '../components/PageTitle'
+import { QuantitySelector } from '../components/QuantitySelector'
+import { useCartStore } from '../stores/useCartStore'
+import { media } from '../styles/media'
+
+const StyledH2 = styled.h2`
+ font-size: 1.5rem;
+ margin-bottom: ${(props) => props.theme.spacing.sm};
+ color: ${(props) => props.theme.colors.text.primary};
+`
+
+const StyledH3 = styled.h3`
+ font-size: 1.25rem;
+ margin-bottom: ${(props) => props.theme.spacing.sm};
+`
+
+const StyledH4 = styled.h4`
+ font-size: 1rem;
+ margin-bottom: ${(props) => props.theme.spacing.sm};
+ color: ${(props) => props.theme.colors.text.secondary};
+`
+
+const StyledLink = styled.a`
+ color: ${(props) => props.theme.colors.primary};
+ text-decoration: none;
+ font-weight: bold;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`
+
+const StyledIntro = styled.div`
+ margin-bottom: ${(props) => props.theme.spacing.md};
+`
+
+const CheckoutContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: ${(props) => props.theme.spacing.md};
+
+${media.md} {
+ flex-direction: row;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 4rem;
+ margin: 0;
+`
+
+const CartItems = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: flex-start;
+ gap: ${(props) => props.theme.spacing.sm};
+ width: 100%;
+ max-width: 100%; // Mobile-first: full width
+ min-height: 300px;
+ padding: ${(props) => props.theme.spacing.sm};
+ border: 1px solid ${(props) => props.theme.colors.border};
+ background-color: ${(props) => props.theme.colors.background};
+ color: ${(props) => props.theme.colors.text.primary};
+
+ ${media.md} {
+ max-width: 400px;
+ padding: ${(props) => props.theme.spacing.md};
+ }
+`
+
+const ItemDetails = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ margin-bottom: ${(props) => props.theme.spacing.sm};
+`
+
+const DeleteButton = styled(MdDelete)`
+ font-size: 1.5rem;
+ color: ${(props) => props.theme.colors.text.secondary};
+ cursor: pointer;
+ transition: color 0.2s;
+
+ &:hover {
+ color: ${(props) => props.theme.colors.primary};
+ }
+`
+
+const StyledTotal = styled.div`
+ font-size: 16px;
+ font-weight: bold;
+ margin-top: auto; // Push to the bottom
+`
+
+const StyledForm = styled(OrderForm)`
+ width: 100%;
+ max-width: 100%; // Mobile-first: full width
+
+ ${media.md} {
+ max-width: 800px;
+ }
+`
+
+const Checkout = () => {
+ const { items, removeFromCart } = useCartStore()
+
+ // Map through items to display them
+ return (
+
+
+ Order Your Fika
+
+ By submitting this form you are placing an order for the items below.
+
+
+ If you have any questions, please contact us at{' '}
+
+ hey@resetwithnaima.com
+
+
+
+
+
+
+ Cart items:
+ {items.map((item) => (
+
+
{item.name}
+
+
+
+ à $
+ {item.selectedSize?.price
+ ? item.selectedSize.price
+ : item.price}
+
+ removeFromCart(item.cartKey)}>
+ Remove
+
+
+
+ ))}
+
+
+ Total: $
+ {items
+ .reduce(
+ (total, item) =>
+ total +
+ (item.selectedSize?.price || item.price) *
+ (item.quantity || 1),
+ 0
+ )
+ .toFixed(2)}
+
+
+
+
+
+
+ By placing an order, you agree to our{' '}
+
+ Terms and Conditions
+
+
+
+ )
+}
+
+export default Checkout
diff --git a/frontend/src/pages/ColorDemo.jsx b/frontend/src/pages/ColorDemo.jsx
new file mode 100644
index 0000000000..38a02805dd
--- /dev/null
+++ b/frontend/src/pages/ColorDemo.jsx
@@ -0,0 +1,265 @@
+import React from "react";
+import styled, { createGlobalStyle, css, ThemeProvider } from "styled-components";
+
+/**
+ * Single-file, drop-in demo of the client's palette.
+ * - Self-contained ThemeProvider + GlobalStyles.
+ * - No external project wiring required to preview.
+ *
+ * Usage in your app: render on any route.
+ */
+
+const theme = {
+ colors: {
+ // neutrals (kept close to your existing)
+ background: "#ffffff",
+ surface: "#f8fafc",
+ border: "#e2e8f0",
+ text: {
+ primary: "#1e293b",
+ secondary: "#64748b",
+ muted: "#94a3b8",
+ hero: "#ffffff",
+ },
+
+ // client palette
+ brand: {
+ primary: "#F4A6A3", // CTA
+ blush: "#F7CDD0", // highlight / hero band
+ sky: "#B3D9F3", // info tint
+ lavender: "#D0C3F1", // secondary tint
+ mint: "#BCE8C2", // success tint
+ },
+
+ // text-on-color helpers
+ on: {
+ primary: "#1e293b",
+ tint: "#1e293b",
+ },
+
+ semantic: {
+ infoBg: "#B3D9F3",
+ successBg: "#BCE8C2",
+ highlightBg: "#F7CDD0",
+ },
+ },
+ fonts: {
+ heading: "'Arca Majora', system-ui, sans-serif",
+ body: "'Neuzeit S LT Std', system-ui, sans-serif",
+ weights: {
+ light: 300,
+ normal: 400,
+ medium: 500,
+ semibold: 600,
+ bold: 700,
+ heavy: 900,
+ },
+ },
+ spacing: {
+ xs: "0.25rem",
+ sm: "0.5rem",
+ md: "1rem",
+ lg: "1.5rem",
+ xl: "2rem",
+ xxl: "3rem",
+ },
+ breakpoints: {
+ md: "768px",
+ },
+};
+
+const GlobalStyles = createGlobalStyle`
+ *, *::before, *::after { box-sizing: border-box; }
+ html, body, #root { height: 100%; }
+ body { margin:0; background:${({theme}) => theme.colors.background}; color:${({theme}) => theme.colors.text.primary}; font-family:${({theme}) => theme.fonts.body}; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; }
+ h1,h2,h3,h4,h5,h6 { font-family:${({theme}) => theme.fonts.heading}; font-weight:${({theme}) => theme.fonts.weights.bold}; line-height:1.2; margin:0; }
+ a { color:${({theme}) => theme.colors.brand.primary}; text-decoration:none; }
+ ::selection { background:${({theme}) => theme.colors.brand.sky}; color:${({theme}) => theme.colors.on.tint}; }
+ :focus-visible { outline:3px solid ${({theme}) => theme.colors.brand.sky}; outline-offset:2px; }
+`;
+
+// Layout wrappers
+const Page = styled.main`
+ width: min(1100px, 92vw);
+ margin: 0 auto;
+ padding: ${({theme}) => theme.spacing.xl} 0 ${({theme}) => theme.spacing.xxl};
+ display: grid;
+ gap: ${({theme}) => theme.spacing.xl};
+`;
+
+const Row = styled.div`
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: ${({theme}) => theme.spacing.lg};
+ @media (min-width: ${({theme}) => theme.breakpoints.md}) {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ align-items: stretch;
+ }
+`;
+
+// Buttons
+const Button = styled.button`
+ display: inline-flex; align-items: center; justify-content: center;
+ padding: 0.75rem 1.25rem; border-radius: 9999px; border: 1px solid transparent;
+ font-weight: ${({theme}) => theme.fonts.weights.semibold};
+ transition: filter .15s ease, transform .02s ease; cursor: pointer;
+ ${({ variant = 'primary', theme }) => {
+ if (variant === 'outline') {
+ return css`
+ background: transparent; color: ${theme.colors.text.primary};
+ border-color: ${theme.colors.brand.primary};
+ &:hover { background: ${theme.colors.brand.blush}; }
+ `;
+ }
+ if (variant === 'success') {
+ return css`
+ background: ${theme.colors.brand.mint}; color: ${theme.colors.on.tint};
+ &:hover { filter: brightness(0.95); }
+ `;
+ }
+ return css`
+ background: ${theme.colors.brand.primary}; color: ${theme.colors.on.primary};
+ &:hover { filter: brightness(0.95); }
+ &:active { transform: translateY(1px); }
+ `;
+ }}
+`;
+
+// Surfaces
+const Band = styled.section`
+ background: ${({bg}) => bg};
+ color: ${({theme}) => theme.colors.on.tint};
+ padding: ${({theme}) => theme.spacing.xl} 0;
+ border-top: 1px solid ${({theme}) => theme.colors.border};
+ border-bottom: 1px solid ${({theme}) => theme.colors.border};
+`;
+
+const Card = styled.div`
+ background: ${({bg, theme}) => bg ?? theme.colors.surface};
+ color: ${({theme}) => theme.colors.on.tint};
+ border: 1px solid ${({theme}) => theme.colors.border};
+ border-radius: 14px;
+ padding: ${({theme}) => theme.spacing.lg};
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
+`;
+
+const Tag = styled.span`
+ display: inline-block; border-radius: .5rem; padding: .3rem .6rem;
+ background: ${({bg}) => bg}; color: ${({theme}) => theme.colors.on.tint};
+ font-size: .85rem;
+`;
+
+const Gradient = styled.div`
+ background: linear-gradient(180deg, ${({theme}) => theme.colors.brand.sky} 0%, ${({theme}) => theme.colors.brand.lavender} 100%);
+ border: 1px solid ${({theme}) => theme.colors.border};
+ border-radius: 14px; padding: ${({theme}) => theme.spacing.lg};
+`;
+
+const Swatches = styled.div`
+ display: grid; grid-template-columns: repeat(5, minmax(0,1fr)); gap: ${({theme}) => theme.spacing.md};
+ @media (max-width: 900px) { grid-template-columns: repeat(2, minmax(0,1fr)); }
+`;
+
+const Swatch = styled.div`
+ border: 1px solid ${({theme}) => theme.colors.border}; border-radius: 12px; overflow: hidden;
+`;
+const SwatchChip = styled.div`
+ height: 88px; background: ${({color}) => color};
+`;
+const SwatchLabel = styled.div`
+ padding: .6rem .75rem; display:flex; align-items:center; justify-content:space-between; gap:.5rem;
+ color: ${({theme}) => theme.colors.text.primary}; background: ${({theme}) => theme.colors.background};
+ font-size: .9rem;
+`;
+
+const SectionHeader = styled.h2`
+ font-weight: ${({theme}) => theme.fonts.weights.heavy};
+ letter-spacing: .02em; line-height: 1.05;
+ font-size: clamp(1.75rem, 4.5vw, 3rem);
+`;
+
+const Stack = styled.div`
+ display: grid; gap: ${({theme}) => theme.spacing.md};
+`;
+
+function ContentWidth({ children }) {
+ return (
+ {children}
+ );
+}
+
+export default function BrandPaletteDemo() {
+ return (
+
+
+
+
+
+
+ Primary CTA
+ Outline
+ Success
+
+
+
+
+ Blush Band
+ Great for hero sub-sections, promotions, or newsletter prompts.
+
+
+
+
+
+
+ Lavender Card
+ Use for gentle content areas and product details.
+ Info
+
+
+
+
+
+ Success Notice
+ Order received and confirmed.
+
+ View Order
+
+
+
+
+
+
+ Sky → Lavender Gradient
+ Lovely under photography or as a subtle banner.
+
+
+
+
+
+ Swatches
+ All text/icons shown should use dark text (#1e293b) on these tints for AA/AAA contrast.
+
+ {[
+ {name: 'brand.primary (CTA)', hex: theme.colors.brand.primary},
+ {name: 'brand.blush', hex: theme.colors.brand.blush},
+ {name: 'brand.sky', hex: theme.colors.brand.sky},
+ {name: 'brand.lavender', hex: theme.colors.brand.lavender},
+ {name: 'brand.mint', hex: theme.colors.brand.mint},
+ ].map(s => (
+
+
+
+ {s.name}
+ {s.hex}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/CompanyCheckout.jsx b/frontend/src/pages/CompanyCheckout.jsx
new file mode 100644
index 0000000000..cf4e5cb2c0
--- /dev/null
+++ b/frontend/src/pages/CompanyCheckout.jsx
@@ -0,0 +1,354 @@
+import { useEffect, useState } from 'react'
+import { MdDelete } from 'react-icons/md'
+import { useNavigate } from 'react-router-dom'
+import styled, { keyframes } from 'styled-components'
+
+import { Button } from '../components/Button'
+import { PageContainer } from '../components/PageContainer'
+import { PageTitle } from '../components/PageTitle'
+import { QuantitySelector } from '../components/QuantitySelector'
+import { api } from '../services/api'
+import { useAuthStore } from '../stores/useAuthStore'
+import { useCartStore } from '../stores/useCartStore'
+import { media } from '../styles/media'
+
+/* === micro-animations === */
+const fadeUp = keyframes`
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+`
+
+const StyledLink = styled.a`
+ color: ${(props) => props.theme.colors.primary};
+ text-decoration: none;
+ font-weight: bold;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`
+
+const StyledIntro = styled.div`
+ margin-bottom: ${(props) => props.theme.spacing.md};
+`
+
+const CheckoutContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: flex-start;
+ gap: ${(props) => props.theme.spacing.md};
+ width: 100%;
+
+ ${media.md} {
+ flex-direction: row;
+ align-items: flex-start;
+ justify-content: flex-start; // Align left on desktop
+ gap: 4rem;
+ margin: 0; // Remove auto-centering
+ }
+`
+
+const StyledH3 = styled.h3`
+ font-size: 1.25rem;
+ margin-bottom: ${(props) => props.theme.spacing.sm};
+`
+
+const CartItems = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: stretch; // Make sure items stretch to full width
+ justify-content: flex-start;
+ gap: ${(props) => props.theme.spacing.sm};
+ max-width: 300px;
+ min-height: 300px;
+ width: 100%;
+ padding: ${(props) => props.theme.spacing.md};
+ border: 1px solid ${(props) => props.theme.colors.border};
+ background-color: ${(props) => props.theme.colors.background};
+ color: ${(props) => props.theme.colors.text.primary};
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
+
+ ${media.sm} {
+ max-width: 400px;
+ padding: ${(props) => props.theme.spacing.md};
+ }
+
+ ${media.md} {
+ max-width: 600px;
+ padding: ${(props) => props.theme.spacing.lg};
+ }
+ ${media.lg} {
+ max-width: 900px;
+ }
+`
+
+const ItemDetails = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ width: 100%;
+ gap: ${(props) => props.theme.spacing.xs};
+ margin-bottom: ${(props) => props.theme.spacing.sm};
+ border-bottom: 1px solid ${(props) => props.theme.colors.border};
+`
+
+const DeleteButton = styled(MdDelete)`
+ font-size: 1.5rem;
+ color: ${(props) => props.theme.colors.text.secondary};
+ cursor: pointer;
+ transition: color 0.2s;
+
+ &:hover {
+ color: ${(props) => props.theme.colors.primary};
+ }
+`
+
+const StyledTotal = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 16px;
+ font-weight: bold;
+`
+
+const BottomPart = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ justify-content: center;
+ gap: ${(props) => props.theme.spacing.md};
+ width: 100%;
+ margin-top: auto; // Push to the bottom
+ background-color: ${(props) => props.theme.colors.background};
+ color: ${(props) => props.theme.colors.text.primary};
+`
+
+const ItemControlsRow = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ gap: 0.5rem;
+`
+
+const ItemControlsLeft = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+`
+const FeedbackMessage = styled.div`
+ color: ${({ theme }) => theme.colors.text.primary};
+ margin: ${({ theme }) => theme.spacing.md} 0;
+ animation: ${fadeUp} 320ms ease both;
+
+ @media (prefers-reduced-motion: reduce) {
+ animation: none;
+ }
+`
+
+const Checkout = () => {
+ const { items, removeFromCart, totalCost, clearCart } = useCartStore()
+ const { company, companyToken, setAuth, setCompany } = useAuthStore()
+ const navigate = useNavigate()
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [showFeedback, setShowFeedback] = useState(false)
+ const [loadingCompany, setLoadingCompany] = useState(false)
+
+ // If token exists but company is not loaded, fetch company profile and set it in the auth store
+ useEffect(() => {
+ let mounted = true
+ const loadProfile = async () => {
+ if (!company && companyToken) {
+ setLoadingCompany(true)
+ try {
+ const profile = await api.companies.getProfile(companyToken)
+ if (mounted && profile) {
+ // either set company only or persist token+company
+ if (typeof setAuth === 'function') {
+ setAuth(companyToken, profile)
+ } else if (typeof setCompany === 'function') {
+ setCompany(profile)
+ } else {
+ useAuthStore.getState().setCompany?.(profile)
+ }
+ }
+ } catch (err) {
+ console.error('Failed to load company profile:', err)
+ } finally {
+ mounted && setLoadingCompany(false)
+ }
+ }
+ }
+ loadProfile()
+ return () => {
+ mounted = false
+ }
+ }, [company, companyToken, setAuth, setCompany])
+
+ const handleSubmitOrder = async () => {
+ if (!company || !company._id || !companyToken || !items.length) {
+ alert(
+ 'Company info or cart is missing. Please log in and add items to your cart.'
+ )
+ return
+ }
+
+ // --- changed code: normalize items so backend receives `price` and `productId` ---
+ const mappedItems = items.map((it) => ({
+ productId: it.productId || it._id || null,
+ name: it.name,
+ quantity: Number(it.quantity || 1),
+ // prefer selectedSize price, fallback to top-level price, ensure Number
+ price: Number(it.selectedSize?.price ?? it.price ?? 0)
+ }))
+
+ const orderData = {
+ name: company.name,
+ email: company.email,
+ address: company.address,
+ phone: company.phone || '',
+ company: company._id,
+ customer: company._id,
+ items: mappedItems,
+ totalCost: Number(
+ mappedItems.reduce(
+ (sum, i) => sum + (i.price || 0) * (i.quantity || 0),
+ 0
+ )
+ ).toFixed(2),
+ status: 'pending'
+ }
+ // --- end changed code ---
+
+ setLoading(true)
+ setError(null)
+ try {
+ await api.orders.submitOrder(orderData, companyToken)
+ // clear cart but show a success message instead of the empty cart view
+ clearCart()
+ setShowFeedback(true)
+ // optionally keep the user on this page and let them continue shopping via CTA
+ } catch (err) {
+ console.error('Order submission failed:', err)
+ setError(err?.message || 'Failed to place order')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Map through items to display them
+ return (
+
+
+ Order your fika
+
+ By submitting this form you are placing an order for the items below.
+
+
+ If you have any questions, please contact us at{' '}
+
+ hey@resetwithnaima.com
+
+
+
+ {/* Show a prominent success message after placing an order */}
+ {showFeedback && (
+
+ Order placed successfully. Your cart has been cleared.
+
+ {
+ // go back to company shop (adjust route if needed)
+ setShowFeedback(false)
+ navigate('/company/shop')
+ }}
+ >
+ Continue shopping
+
+ {
+ // close feedback and stay on page (maybe view order history)
+ setShowFeedback(false)
+ navigate('/company/orders')
+ }}
+ >
+ View orders
+
+
+
+ )}
+
+ {/* hide the cart items area when showing positive feedback */}
+ {!showFeedback && (
+
+ Cart items:
+ {items.map((item) => (
+
+
+ {item.name}
+
+
+
+
+ à $
+ {item.selectedSize?.price
+ ? item.selectedSize.price
+ : item.price}
+
+
+ removeFromCart(item.cartKey)}
+ />
+
+
+
+ ))}
+
+
+
+ Total: $
+ {items
+ .reduce(
+ (total, item) =>
+ total +
+ (item.selectedSize?.price || item.price) *
+ (item.quantity || 1),
+ 0
+ )
+ .toFixed(2)}
+
+
+
+ {loading
+ ? 'Placing order...'
+ : showFeedback
+ ? 'Order placed'
+ : 'Place order'}
+
+
+
+ )}
+
+
+
+ By placing an order, you agree to our{' '}
+
+ Terms and Conditions
+
+
+
+ )
+}
+
+export default Checkout
diff --git a/frontend/src/pages/CompanyDashboard.jsx b/frontend/src/pages/CompanyDashboard.jsx
new file mode 100644
index 0000000000..bc3f15d500
--- /dev/null
+++ b/frontend/src/pages/CompanyDashboard.jsx
@@ -0,0 +1,45 @@
+import styled from 'styled-components'
+
+import MotionReveal from '../components/MotionReveal'
+import { PageTitle } from '../components/PageTitle'
+import { useAuthStore } from '../stores/useAuthStore'
+
+const DashboardContainer = styled.div`
+ max-width: 900px;
+ margin: 40px auto;
+ padding: 32px;
+ background: #fff;
+ border-radius: 16px;
+ box-shadow: 0 2px 16px rgba(0, 0, 0, 0.08);
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+`
+
+const Info = styled.div`
+ font-size: 16px;
+ color: ${(props) => props.theme.colors.text.secondary};
+ text-align: left;
+`
+
+const CompanyDashboard = () => {
+ const { company, companyToken } = useAuthStore()
+ if (!companyToken) {
+ return No token provided. Please log in.
+ }
+
+ return (
+
+
+ Welcome, {company?.name || 'Company'}!
+ {/* Add dashboard widgets, stats, links, etc. here */}
+
+ Your company dashboard is under construction.
+ Use the navigation above to explore other sections.
+
+
+
+ )
+}
+
+export default CompanyDashboard
diff --git a/frontend/src/pages/CompanyLogin.jsx b/frontend/src/pages/CompanyLogin.jsx
new file mode 100644
index 0000000000..e018adfe2c
--- /dev/null
+++ b/frontend/src/pages/CompanyLogin.jsx
@@ -0,0 +1,53 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useAuthStore } from '../stores/useAuthStore'
+import { api } from '../services/api'
+
+const CompanyLogin = () => {
+ const navigate = useNavigate()
+ const setAuth = useAuthStore((s) => s.setAuth)
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+
+ const handleLogin = async (e) => {
+ e.preventDefault()
+ try {
+ const { token, company, customer } = await api.companies.login({ email, password })
+ // IMPORTANT: pass company object as second argument so store.persist saves it
+ setAuth(token, company)
+ // navigate or set additional state
+ navigate('/company/dashboard')
+ } catch (err) {
+ console.error('Login failed', err)
+ alert('Login failed')
+ }
+ }
+
+ return (
+
+ )
+}
+export default CompanyLogin
\ No newline at end of file
diff --git a/frontend/src/pages/CompanyOrderDetails.jsx b/frontend/src/pages/CompanyOrderDetails.jsx
new file mode 100644
index 0000000000..6480c30323
--- /dev/null
+++ b/frontend/src/pages/CompanyOrderDetails.jsx
@@ -0,0 +1,38 @@
+import { useEffect } from 'react'
+import { useParams } from 'react-router-dom'
+
+import MotionReveal from '../components/MotionReveal'
+import { OrderDetails } from '../components/OrderDetails'
+import { PageContainer } from '../components/PageContainer'
+import { useAuthStore } from '../stores/useAuthStore'
+import { useOrderStore } from '../stores/useOrderStore'
+
+export const CompanyOrderDetails = () => {
+ const { orderId } = useParams()
+ const token = useAuthStore((s) => s.companyToken)
+ const { order, loading, error, fetchOrderById, setOrder } = useOrderStore()
+
+ useEffect(() => {
+ if (!orderId) return
+ if (!token) {
+ setOrder(null)
+ return
+ }
+ fetchOrderById(orderId, token)
+ return () => setOrder(null)
+ }, [orderId, token, fetchOrderById, setOrder])
+
+ if (loading) return Loading...
+ if (error) return {error}
+ if (!order) return No order found.
+
+ return (
+
+
+
+
+
+ )
+}
+
+export default CompanyOrderDetails
diff --git a/frontend/src/pages/CompanyOrders.jsx b/frontend/src/pages/CompanyOrders.jsx
new file mode 100644
index 0000000000..94cd0a56d9
--- /dev/null
+++ b/frontend/src/pages/CompanyOrders.jsx
@@ -0,0 +1,19 @@
+import styled from 'styled-components'
+
+import MotionReveal from '../components/MotionReveal'
+import { PageContainer } from '../components/PageContainer'
+import { PageTitle } from '../components/PageTitle'
+import { Orders } from '../sections/Orders'
+
+const CompanyOrders = () => {
+ return (
+
+
+ Company orders
+
+
+
+ )
+}
+
+export default CompanyOrders
diff --git a/frontend/src/pages/CompanyPortal.jsx b/frontend/src/pages/CompanyPortal.jsx
new file mode 100644
index 0000000000..83cb9ebc17
--- /dev/null
+++ b/frontend/src/pages/CompanyPortal.jsx
@@ -0,0 +1,21 @@
+import { useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+
+import { CompanyLogin } from '../components/CompanyLogin'
+import { useAuthStore } from '../stores/useAuthStore'
+
+const CompanyPortal = () => {
+ const companyToken = useAuthStore((state) => state.companyToken)
+ const setAuth = useAuthStore((state) => state.setAuth)
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ if (companyToken) {
+ navigate('/company/dashboard')
+ }
+ }, [companyToken, navigate])
+
+ return
+}
+
+export default CompanyPortal
diff --git a/frontend/src/pages/CompanyProfile.jsx b/frontend/src/pages/CompanyProfile.jsx
new file mode 100644
index 0000000000..c51489b8ba
--- /dev/null
+++ b/frontend/src/pages/CompanyProfile.jsx
@@ -0,0 +1,18 @@
+import MotionReveal from '../components/MotionReveal'
+import { PageContainer } from '../components/PageContainer'
+import { PageTitle } from '../components/PageTitle'
+import { CompanySettings } from '../sections/CompanySettings'
+
+const CompanyProfile = () => {
+ return (
+
+
+ Company profile
+ Manage your company profile here.
+
+
+
+ )
+}
+
+export default CompanyProfile
diff --git a/frontend/src/pages/CompanyShop.jsx b/frontend/src/pages/CompanyShop.jsx
new file mode 100644
index 0000000000..a33d35e51f
--- /dev/null
+++ b/frontend/src/pages/CompanyShop.jsx
@@ -0,0 +1,38 @@
+import { useEffect } from 'react'
+
+import MotionReveal from '../components/MotionReveal'
+import { PageContainer } from '../components/PageContainer'
+import { Products } from '../sections/Products'
+import useProductStore from '../stores/useProductStore'
+
+const CompanyShop = () => {
+ const { products, loading, error, filters, fetchProducts, setFilters } =
+ useProductStore()
+
+ useEffect(() => {
+ fetchProducts(filters)
+ }, [filters, fetchProducts])
+
+ const handleOrder = (product) => {}
+
+ const handleFilterChange = (newFilters) => {
+ setFilters(newFilters)
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+export default CompanyShop
diff --git a/frontend/src/pages/ContactUs.jsx b/frontend/src/pages/ContactUs.jsx
new file mode 100644
index 0000000000..d078810839
--- /dev/null
+++ b/frontend/src/pages/ContactUs.jsx
@@ -0,0 +1,46 @@
+import styled from 'styled-components'
+
+import ContactUsForm from '../components/ContactUsForm'
+import { Image } from '../components/Image'
+import { PageContainer } from '../components/PageContainer'
+import { PageTitle } from '../components/PageTitle'
+import { media } from '../styles/media'
+
+const Container = styled.section`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ margin-top: ${(props) => props.theme.spacing.md};
+ gap: ${(props) => props.theme.spacing.lg};
+ width: 100%;
+ height: 100%;
+
+ ${media.sm} {
+ flex-direction: row;
+ align-items: flex-start;
+ justify-content: flex-start;
+ justify-content: space-between;
+ height: 60%;
+ }
+`
+const StyledImage = styled(Image)`
+ flex: 1;
+ width: 280px;
+ height: 400px;
+ object-fit: cover;
+ border-radius: 12px;
+`
+
+const ContactUs = () => {
+ return (
+
+ contact us
+
+
+
+
+ )
+}
+
+export default ContactUs
diff --git a/frontend/src/pages/FindUs.jsx b/frontend/src/pages/FindUs.jsx
new file mode 100644
index 0000000000..152a4706f8
--- /dev/null
+++ b/frontend/src/pages/FindUs.jsx
@@ -0,0 +1,19 @@
+import MotionReveal from '../components/MotionReveal'
+import { PageContainer } from '../components/PageContainer'
+import { PageTitle } from '../components/PageTitle'
+import RetailerMap from '../components/RetailerMap'
+
+const FindUs = () => {
+ return (
+
+
+ find naima near you
+
+
+
+
+
+ )
+}
+
+export default FindUs
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx
new file mode 100644
index 0000000000..094632f70a
--- /dev/null
+++ b/frontend/src/pages/Home.jsx
@@ -0,0 +1,19 @@
+import Benefits from '../sections/Benefits'
+import { FeaturedFika } from '../sections/FeaturedFika'
+import { Hero } from '../sections/Hero'
+import { InstagramGrid } from '../sections/InstagramGrid'
+import { SocialProof } from '../sections/SocialProof'
+
+const Home = () => {
+ return (
+ <>
+
+
+
+ {/* */}
+
+ >
+ )
+}
+
+export default Home
diff --git a/frontend/src/pages/OurStory.jsx b/frontend/src/pages/OurStory.jsx
new file mode 100644
index 0000000000..57aa66f624
--- /dev/null
+++ b/frontend/src/pages/OurStory.jsx
@@ -0,0 +1,41 @@
+import styled from 'styled-components'
+
+import { PageContainer } from '../components/PageContainer'
+import PageFade from '../components/PageFade'
+import { PageTitle } from '../components/PageTitle'
+import Reveal from '../components/Reveal'
+import { AboutFounder } from '../sections/AboutFounder'
+import { AboutMission } from '../sections/AboutMission'
+
+const Measure = styled.div`
+ max-width: ${({ theme }) => theme.layout?.contentMax || '800px'};
+ margin: 0 auto;
+ padding: 0;
+`
+const OurStory = () => {
+ return (
+
+
+
+
+ meet our founder
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default OurStory
diff --git a/frontend/src/pages/Products.jsx b/frontend/src/pages/Products.jsx
new file mode 100644
index 0000000000..68cb801e1f
--- /dev/null
+++ b/frontend/src/pages/Products.jsx
@@ -0,0 +1,31 @@
+import { useEffect } from 'react'
+
+import { ImageGrid } from '../components/ImageGrid'
+import { PageContainer } from '../components/PageContainer'
+import PageFade from '../components/PageFade'
+import { PageTitle } from '../components/PageTitle'
+import CateringPartners from '../sections/CateringPartners'
+import useProductStore from '../stores/useProductStore'
+
+const Products = () => {
+ const fetchProducts = useProductStore((state) => state.fetchProducts)
+ const products = useProductStore((state) => state.products)
+
+ useEffect(() => {
+ fetchProducts()
+ }, [fetchProducts])
+
+ const productImages = products.map((p) => p.images?.[0])
+
+ return (
+
+
+ fika selection
+
+
+
+
+ )
+}
+
+export default Products
diff --git a/frontend/src/pages/Shop.jsx b/frontend/src/pages/Shop.jsx
new file mode 100644
index 0000000000..99df21c9d7
--- /dev/null
+++ b/frontend/src/pages/Shop.jsx
@@ -0,0 +1,44 @@
+import { useEffect } from 'react'
+import styled from 'styled-components'
+import { media } from '../styles/media'
+import { Products } from '../sections/Products'
+import useProductStore from '../stores/useProductStore'
+
+const StyledShop = styled.section`
+ background-color: ${(props) => props.theme.colors.background};
+ color: ${(props) => props.theme.colors.text.primary};
+
+ ${media.sm} {
+ display: flex;
+ }
+`
+
+const Shop = () => {
+ const { products, loading, error, filters, fetchProducts, setFilters } =
+ useProductStore()
+
+ useEffect(() => {
+ fetchProducts(filters)
+ }, [filters, fetchProducts])
+
+ const handleOrder = (product) => {}
+
+ const handleFilterChange = (newFilters) => {
+ setFilters(newFilters)
+ }
+
+ return (
+
+
+
+ )
+}
+
+export default Shop
diff --git a/frontend/src/sections/AboutFounder.jsx b/frontend/src/sections/AboutFounder.jsx
new file mode 100644
index 0000000000..9b17ffd049
--- /dev/null
+++ b/frontend/src/sections/AboutFounder.jsx
@@ -0,0 +1,130 @@
+import styled from 'styled-components'
+
+import Reveal from '../components/Reveal'
+
+const Prose = styled.section`
+ p {
+ margin: 0 0 ${({ theme }) => theme.spacing.md};
+ }
+ strong {
+ font-weight: ${({ theme }) => theme.fonts.weights.bold};
+ }
+ em {
+ font-style: italic;
+ }
+`
+
+const Lead = styled.p`
+ font-size: clamp(1.125rem, 1.2vw + 1rem, 1.375rem);
+ line-height: 1.6;
+ font-weight: ${({ theme }) => theme.fonts.weights.medium};
+ margin: 0 0 ${({ theme }) => theme.spacing.lg};
+ color: ${({ theme }) => theme.colors.text.primary};
+`
+
+const DropCap = styled.p`
+ &:first-letter {
+ float: left;
+ font-family: ${({ theme }) => theme.fonts.heading};
+ font-weight: ${({ theme }) => theme.fonts.weights.heavy};
+ font-size: 3rem;
+ line-height: 0.9;
+ padding-right: 8px;
+ }
+
+ @media (max-width: 640px) {
+ &:first-letter {
+ float: none;
+ font-size: inherit;
+ line-height: inherit;
+ padding-right: 0;
+ }
+ }
+`
+
+const NoteCard = styled.aside`
+ background: ${({ theme }) => theme.colors.brand.lavender};
+ border: 1px solid ${({ theme }) => theme.colors.border};
+ border-left: 4px solid rgba(159, 124, 233, 0.8);
+ border-radius: 4px;
+ padding: ${({ theme }) => theme.spacing.lg};
+ margin: ${({ theme }) => theme.spacing.lg} 0;
+ p {
+ margin-bottom: ${({ theme }) => theme.spacing.sm};
+ }
+`
+
+const Divider = styled.hr`
+ border: none;
+ height: 1px;
+ margin: ${({ theme }) => theme.spacing.lg} 0;
+ background: linear-gradient(
+ to right,
+ transparent,
+ ${({ theme }) => theme.colors.border},
+ transparent
+ );
+`
+
+const PullQuote = styled.blockquote`
+ border-left: 6px solid ${({ theme }) => theme.colors.brand.primary};
+ padding-left: ${({ theme }) => theme.spacing.md};
+ margin: ${({ theme }) => theme.spacing.lg} 0;
+ font-family: ${({ theme }) => theme.fonts.heading};
+ font-weight: ${({ theme }) => theme.fonts.weights.bold};
+ font-size: clamp(1.125rem, 1vw + 1rem, 1.5rem);
+ line-height: 1.3;
+`
+
+export const AboutFounder = () => {
+ return (
+
+ {' '}
+ {/* remove this wrapper if you don’t want the tiny fade-in */}
+
+ Hi there! My name is Sophie Jahn 👋
+
+
+ You might know me from Nattryttarna or as the author of
+ Pappas flicka på Hästgården , but my personal journey with
+ food began long before that. I grew up with a complicated relationship
+ to eating—shame, guilt, control. For years I chased the “perfect
+ diet,” believing health was about restriction and willpower.
+
+
+ As I began to heal, I learned something different.
+
+
+
+ When we nourish ourselves with natural, clean foods, everything
+ changes. Brain fog lifts. Energy returns. Anxiety fades. We feel
+ good—not just physically, but emotionally.
+
+
+ And this is where Naima began: in my kitchen, with a simple mission—
+ to help people feel better through the food they eat.
+
+
+
+
+
+
+ Walk into any grocery store and try to find a snack without a long,
+ processed ingredient list. We couldn’t. And we looked everywhere.
+
+
+ Most products today are full of fillers, additives, refined sugars,
+ and seed oils—things science shows can wear down our bodies and minds.
+
+
+ Poor nutrition is linked to anxiety, depression, and inflammation. And
+ yet, the foods we reach for every day still haven’t caught up.
+
+
+
+ Naima is here to change that—for all of us who want better.
+
+
+
+ )
+}
diff --git a/frontend/src/sections/AboutMission.jsx b/frontend/src/sections/AboutMission.jsx
new file mode 100644
index 0000000000..161eb733b2
--- /dev/null
+++ b/frontend/src/sections/AboutMission.jsx
@@ -0,0 +1,85 @@
+import styled from 'styled-components'
+
+const MissionSection = styled.section`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ theme }) => theme.spacing.lg};
+`
+export const Row = styled.div`
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: ${({ theme }) => theme.spacing.lg};
+ align-items: center;
+
+ /* Mobile: stack */
+ grid-template-areas:
+ 'text'
+ 'media';
+
+ @media (min-width: ${({ theme }) => theme.breakpoints.md}) {
+ grid-template-columns: 1fr 1fr;
+ grid-template-areas: ${({ $reverse }) =>
+ $reverse ? `"media text"` : `"text media"`};
+ }
+`
+export const Text = styled.div`
+ grid-area: text;
+`
+export const Media = styled.div`
+ grid-area: media;
+ width: 100%;
+ aspect-ratio: 4/5;
+ /* max-height: 60vh; */
+ overflow: hidden;
+
+ @media (min-width: ${({ theme }) => theme.breakpoints.md}) {
+ aspect-ratio: auto;
+ /* max-height: none; */
+ }
+`
+export const Img = styled.img`
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: center;
+ display: block;
+`
+
+export const Hashtag = styled.h2`
+ grid-column: 1/-1;
+ justify-self: center;
+ text-align: center;
+ font-size: clamp(2rem, 9vw, 6rem);
+ font-family: ${({ theme }) => theme.fonts.heading};
+ font-weight: ${({ theme }) => theme.fonts.weights.heavy};
+ margin: ${({ theme }) => theme.spacing.xl} 0
+ ${({ theme }) => theme.spacing.lg};
+`
+
+export const AboutMission = () => {
+ return (
+
+
+
+ WHAT STARTED IN A KITCHEN
+ Became a mission to change fika — for good.
+
+
+
+
+
+
+
+
+
+
+ OUR MISSION
+ To help people feel better through the food they eat.
+
+
+
+ #thefutureoffika
+
+
+ )
+}
diff --git a/frontend/src/sections/Benefits.jsx b/frontend/src/sections/Benefits.jsx
new file mode 100644
index 0000000000..8101f1297d
--- /dev/null
+++ b/frontend/src/sections/Benefits.jsx
@@ -0,0 +1,404 @@
+import { motion, useReducedMotion } from "framer-motion";
+import { useEffect, useMemo, useState } from "react"
+import styled from "styled-components";
+
+import Reveal from "../components/Reveal";
+import { media } from "../styles/media";
+
+const Section = styled.section`
+ background: ${({ theme }) => theme.colors.background};
+ padding: ${({ theme }) => theme.spacing.lg} 0;
+`;
+
+const Wrap = styled.div`
+ width: min(1200px, 92vw);
+ margin: 0 auto;
+`;
+
+const Title = styled.h2`
+ font-family: ${({ theme }) => theme.fonts.heading};
+ font-weight: ${({ theme }) => theme.fonts.weights.heavy};
+ text-transform: lowercase;
+ letter-spacing: 0.5px;
+ font-size: clamp(1.5rem, 1rem + 2vw, 2.25rem);
+ margin-bottom: ${({ theme }) => theme.spacing.sm};
+`;
+
+const Intro = styled.p`
+ color: ${({ theme }) => theme.colors.text.secondary};
+ margin-bottom: ${({ theme }) => theme.spacing.lg};
+ max-width: 60ch;
+
+ ${media.lg} {
+ max-width: none;
+ }
+
+ margin-bottom: ${({ theme }) => theme.spacing.lg};
+`;
+
+const Grid = styled.div`
+ display: grid;
+ gap: ${({ theme }) => theme.spacing.lg};
+ grid-template-columns: 1fr;
+ align-items: center;
+
+ ${media.md} {
+ grid-template-columns: 1.1fr 1fr;
+ }
+`;
+
+/* --- IMAGES: square edges, subtle elevation on hover --- */
+const Figure = styled.figure`
+ margin: 0;
+ position: relative;
+ display: grid;
+ gap: ${({ theme }) => theme.spacing.sm};
+
+ .stack {
+ display: grid;
+ gap: ${({ theme }) => theme.spacing.sm};
+ height: 100%;
+
+ ${media.md} {
+ grid-template-columns: 1fr 1fr;
+ align-items: end;
+ }
+ }
+
+ /* Mobile: consistent card crop with aspect-ratio */
+ img {
+ width: 100%;
+ height: 100%;
+ aspect-ratio: 4 / 3;
+ height: auto;
+ object-fit: cover;
+ border-radius: 0;
+ display: block;
+ box-shadow: 0 6px 14px rgba(0, 0, 0, 0.08);
+ }
+
+ /* Desktop/tablet: allow flexible height again */
+ ${media.md} {
+ img {
+ aspect-ratio: auto;
+ height: clamp(260px, 40vw, 420px);
+ }
+ }
+
+ figcaption {
+ display: none;
+ }
+`;
+
+/* --- BENEFITS LIST: sharp cards with left accent bar --- */
+const List = styled.ul`
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: ${({ theme }) => theme.spacing.sm};
+`;
+
+const Item = styled.li`
+ display: grid;
+ grid-template-columns: 10px 1fr;
+ gap: ${({ theme }) => theme.spacing.md};
+ background: ${({ theme }) => theme.colors.surface};
+ border-radius: 0;
+ padding: ${({ theme }) => theme.spacing.md};
+ transition: box-shadow 0.15s ease, transform 0.15s ease,
+ border-color 0.15s ease;
+
+ /* left accent bar */
+ .bar {
+ display: block;
+ width: 8px;
+ height: 100%;
+ background: ${({ theme }) => theme.colors.brand.salmon};
+ align-self: stretch;
+ min-height: 40px;
+ }
+
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.08);
+ border-color: ${({ theme }) => theme.colors.brand.salmon};
+ }
+
+ &:focus-within {
+ outline: 2px solid ${({ theme }) => theme.colors.brand.salmon};
+ outline-offset: 2px;
+ }
+`;
+
+const ItemTitle = styled.h3`
+ margin: 0 0 6px;
+ font-family: ${({ theme }) => theme.fonts.heading};
+ font-weight: ${({ theme }) => theme.fonts.weights.bold};
+ font-size: clamp(1rem, 0.95rem + 0.3vw, 1.15rem);
+`;
+
+const ItemText = styled.p`
+ margin: 0;
+ color: ${({ theme }) => theme.colors.text.secondary};
+ line-height: 1.55;
+`;
+
+// // motion variants (only tilt on hover)
+// const cardA = {
+// rest: {
+// x: 0,
+// y: -40,
+// rotate: 0,
+// zIndex: 1,
+// boxShadow: "0 6px 14px rgba(0,0,0,.08)",
+// },
+// hover: {
+// x: -12,
+// y: -10,
+// rotate: -4,
+// zIndex: 3,
+// boxShadow: "0 12px 28px rgba(0,0,0,.18)",
+// transition: { type: "spring", stiffness: 280, damping: 22 },
+// },
+// };
+
+// const cardB = {
+// rest: {
+// x: 0,
+// y: 40,
+// rotate: 0,
+// zIndex: 2,
+// boxShadow: "0 6px 14px rgba(0,0,0,.08)",
+// },
+// hover: {
+// x: 10,
+// y: 10,
+// rotate: 3,
+// zIndex: 2,
+// boxShadow: "0 12px 28px rgba(0,0,0,.18)",
+// transition: { delay: 0.06, type: "spring", stiffness: 280, damping: 22 },
+// },
+// };
+
+
+
+// Framer wrapper + variants
+const MList = motion(List);
+const MItem = motion(Item);
+
+const itemVariants = {
+ hidden: { opacity: 0, y: 6 },
+ show: { opacity: 1, y: 0 },
+};
+
+const listVariants = {
+ hidden: { opacity: 0, y: 6 },
+ show: {
+ opacity: 1,
+ y: 0,
+ transition: { staggerChildren: 0.06, when: "beforeChildren" },
+ },
+};
+
+// tiny hook to detect ≥ md
+const useIsMdUp = () => {
+ const query = "(min-width: 768px)"
+ const getMatch = () =>
+ typeof window !== "undefined" && window.matchMedia(query).matches
+ const [isMdUp, setIsMdUp] = useState(getMatch)
+ useEffect(() => {
+ if (typeof window === "undefined") return
+ const mql = window.matchMedia(query)
+ const onChange = (e) => setIsMdUp(e.matches)
+ mql.addEventListener?.("change", onChange)
+ return () => mql.removeEventListener?.("change", onChange)
+ }, [])
+ return isMdUp
+}
+
+const Benefits = () => {
+ const prefersReduced = useReducedMotion()
+ const isMdUp = useIsMdUp() // 👈 use the hook
+ const img1 = "/images/pink-shrooms.jpg"
+ const img2 = "/images/contact.jpg"
+
+ // build variants based on breakpoint
+ const { cardA, cardB } = useMemo(() => {
+ if (!isMdUp) {
+ // mobile: no offset, no tilt (clean stack)
+ return {
+ cardA: { rest: { x: 0, y: 0, rotate: 0, zIndex: 1 },
+ hover: { x: 0, y: 0, rotate: 0, zIndex: 1 } },
+ cardB: { rest: { x: 0, y: 0, rotate: 0, zIndex: 2 },
+ hover: { x: 0, y: 0, rotate: 0, zIndex: 2 } }
+ }
+ }
+ // tablet+ : staggered look
+ return {
+ cardA: {
+ rest: { x: -6, y: -44, rotate: 0, zIndex: 1,
+ boxShadow: "0 6px 14px rgba(0,0,0,.08)" },
+ hover: { x: -12, y: -10, rotate: -4, zIndex: 3,
+ boxShadow: "0 12px 28px rgba(0,0,0,.18)",
+ transition: { type: "spring", stiffness: 280, damping: 22 } }
+ },
+ cardB: {
+ rest: { x: 6, y: 34, rotate: 0, zIndex: 2,
+ boxShadow: "0 6px 14px rgba(0,0,0,.08)" },
+ hover: { x: 10, y: 10, rotate: 3, zIndex: 2,
+ boxShadow: "0 12px 28px rgba(0,0,0,.18)",
+ transition: { delay: 0.06, type: "spring", stiffness: 280, damping: 22 } }
+ }
+ }
+ }, [isMdUp])
+
+ // only allow hover animation when md+ and user doesn’t prefer reduced motion
+ const hoverState = (!isMdUp || prefersReduced) ? "rest" : "hover"
+
+ return (
+
+
+
+ the benefits
+
+ Naturally functional fika. Real, plant-based ingredients and gentle
+ superfoods designed for steady energy and clear focus—without added
+ sugars or artificial fillers.
+
+
+
+
+ {/* image collage (hover shuffle) */}
+
+
+
+
+
+
+ Clean, plant-based ingredients.
+
+
+
+ {/* benefits list */}
+
+
+
+
+
+ functional mushrooms
+
+ Carefully chosen varieties commonly used for modern wellness
+ (e.g., lion’s mane for focus, reishi for balance, chaga for
+ antioxidants).*
+
+
+
+
+
+
+
+ gluten-free
+
+ Recipes crafted without gluten—favored by people seeking
+ lighter, everyday fika.
+
+
+
+
+
+
+
+ lactose-free
+
+ Plant-based fats replace dairy to keep things gentle and
+ satisfying.
+
+
+
+
+
+
+
+ 100% plant-based
+
+ Simple, recognizable ingredients you can feel good about.
+
+
+
+
+
+
+
+ natural ingredients & superfoods
+
+ Whole-food bases with nutrient-dense additions—crafted for
+ taste first, benefits second.
+
+
+
+
+
+
+
+ no added sugars
+
+ Sweetness comes from the recipe design—not from syrups or
+ refined sugar.
+
+
+
+
+
+
+
+ {/* small disclaimer to keep claims responsible */}
+
+ *General, educational information; not medical advice or a substitute
+ for professional guidance.
+
+
+
+ );
+};
+
+export default Benefits;
diff --git a/frontend/src/sections/CateringPartners.jsx b/frontend/src/sections/CateringPartners.jsx
new file mode 100644
index 0000000000..627e122076
--- /dev/null
+++ b/frontend/src/sections/CateringPartners.jsx
@@ -0,0 +1,159 @@
+// src/sections/CateringPartners.jsx
+import { motion, useReducedMotion } from 'framer-motion'
+import { useEffect, useMemo, useState } from 'react'
+import styled from 'styled-components'
+
+import usePartnerStore from '../stores/usePartnerStore'
+import { media } from '../styles/media'
+
+const Wrap = styled.section`
+ width: min(1100px, 92vw);
+ margin: 3rem auto 4rem;
+`
+const Heading = styled.h2`
+ font-family: ${({ theme }) => theme.fonts.heading};
+ font-weight: ${({ theme }) => theme.fonts.weights.heavy};
+ font-size: clamp(1.25rem, 1rem + 1.2vw, 1.75rem);
+ margin-bottom: ${({ theme }) => theme.spacing.md};
+ padding: ${({ theme }) => theme.spacing.md};
+ letter-spacing: .5px;
+`
+const Grid = styled.div`
+ display: grid;
+ gap: ${({ theme }) => theme.spacing.md};
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+ ${media.sm} { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ ${media.md} { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ ${media.lg} { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+`
+const Card = styled.a`
+ display: grid; place-items: center;
+ min-height: 84px;
+ padding: ${({ theme }) => theme.spacing.md};
+ border: 1px solid ${({ theme }) => theme.colors.border};
+ border-radius: 12px;
+ background: ${({ theme }) => theme.colors.surface};
+ text-decoration: none;
+ outline-offset: 3px;
+ transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
+ &:hover { transform: translateY(-2px); }
+ &:focus-visible {
+ border-color: ${({ theme }) => theme.colors.brand.salmon};
+ box-shadow: 0 6px 20px rgba(0,0,0,.08);
+ }
+`
+const LogoBox = styled.div`
+ width: 100%;
+ max-width: 220px;
+ aspect-ratio: 3 / 2;
+ display: grid; place-items: center;
+ overflow: hidden;
+ img { max-width: 100%; max-height: 100%; display: block; }
+`
+const Badge = styled.div`
+ display: grid; place-items: center;
+ width: 100%; height: 100%;
+ border-radius: 10px;
+ background: ${({ theme }) => theme.colors.brand.blush};
+ color: ${({ theme }) => theme.colors.text.primary};
+ font-weight: ${({ theme }) => theme.fonts.weights.semibold};
+ letter-spacing: .4px; text-align: center;
+ padding: 0 ${({ theme }) => theme.spacing.sm};
+`
+
+// utils
+const slug = (s='') =>
+ s.toLowerCase().replace(/\s*&\s*/g,'and').replace(/[^a-z0-9]+/g,'-').replace(/(^-|-$)/g,'')
+
+const Logo = ({ name, remoteUrl }) => {
+ const localSvg = `/partners/${slug(name)}.svg`
+ const localPng = `/partners/${slug(name)}.png`
+
+ // 👇 Start with remote if available, otherwise try the local SVG first
+ const [src, setSrc] = useState(remoteUrl || localSvg)
+ const [failed, setFailed] = useState(false)
+
+ // Reset when name/remote changes (e.g., HMR or props update)
+ useEffect(() => {
+ setFailed(false)
+ setSrc(remoteUrl || localSvg)
+ }, [name, remoteUrl, localSvg])
+
+ const onError = () => {
+ // Try SVG → PNG → Badge
+ if (src === remoteUrl && remoteUrl) {
+ setSrc(localSvg)
+ } else if (src === localSvg) {
+ setSrc(localPng)
+ } else {
+ setFailed(true)
+ }
+ }
+
+ if (failed || !src) return {name}
+
+ return (
+
+ )
+}
+
+const CateringPartners = () => {
+ const prefersReduced = useReducedMotion()
+ const { cateringPartners, fetchCateringPartners, loading } = usePartnerStore()
+
+ useEffect(() => {
+ fetchCateringPartners?.().catch(() => {})
+ }, [fetchCateringPartners])
+
+ const list = useMemo(
+ () => (Array.isArray(cateringPartners) ? cateringPartners : []),
+ [cateringPartners]
+ )
+
+ return (
+
+ Order from our wholesale & catering partners:
+
+
+ {list.map((p) => (
+
+
+
+
+
+
+
+ ))}
+
+
+ {loading && Loading…
}
+ {!loading && list.length === 0 && No partners yet.
}
+
+ )
+}
+
+export default CateringPartners
diff --git a/frontend/src/sections/CompanySettings.jsx b/frontend/src/sections/CompanySettings.jsx
new file mode 100644
index 0000000000..e771470368
--- /dev/null
+++ b/frontend/src/sections/CompanySettings.jsx
@@ -0,0 +1,37 @@
+import { Button } from '../components/Button'
+import styled from 'styled-components'
+import { media } from '../styles/media'
+
+const StyledCompanySettings = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: ${(props) => props.theme.spacing.md};
+
+ ${media.md} {
+ flex-direction: row;
+ justify-content: flex-start;
+ align-items: flex-start;
+ gap: ${(props) => props.theme.spacing.md};
+ width: 100%;
+ padding: ${(props) => props.theme.spacing.lg} 0;
+ }
+`
+
+const StyledButton = styled(Button)`
+ margin: ${(props) => props.theme.spacing.sm} 0;
+ width: 150px;
+`
+
+export const CompanySettings = () => {
+ return (
+
+
+ Update address
+
+
+ Manage subscription
+
+
+ )
+}
diff --git a/frontend/src/sections/FeaturedFika.jsx b/frontend/src/sections/FeaturedFika.jsx
new file mode 100644
index 0000000000..c3c719daf9
--- /dev/null
+++ b/frontend/src/sections/FeaturedFika.jsx
@@ -0,0 +1,188 @@
+import { useEffect, useRef } from 'react'
+import styled from 'styled-components'
+
+import { FeaturedProduct } from '../components/FeaturedProduct'
+import Reveal from '../components/Reveal'
+import useProductStore from '../stores/useProductStore'
+import { media } from '../styles/media'
+
+const StyledFeaturedFika = styled.section`
+ background: ${(props) => props.theme.colors.background || '#f9f9f9'};
+ margin-bottom: ${(props) => props.theme.spacing.xxl};
+`
+
+const StyledH2 = styled.h2`
+ font-size: ${(props) => props.theme.typography.h2.fontSize};
+ font-weight: ${(props) => props.theme.typography.h2.fontWeight};
+ line-height: ${(props) => props.theme.typography.h2.lineHeight};
+ margin-bottom: ${(props) => props.theme.spacing.md};
+`
+
+const InfoSection = styled.div`
+ text-align: left;
+ margin: ${(props) => props.theme.spacing.sm};
+
+ ${media.md} {
+ margin: ${(props) => props.theme.spacing.md};
+ }
+`
+
+const Description = styled.p`
+ text-align: left;
+ font-size: 1.1rem;
+ color: #666;
+ line-height: 1.6;
+
+ ${media.md} {
+ margin-bottom: 2rem;
+ }
+`
+
+const FeaturedGrid = styled(Reveal)`
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ margin: 0 auto;
+
+ ${media.md} {
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ }
+
+ /* Animate only the direct children (your ) */
+ & > * {
+ opacity: 0;
+ transform: translateY(15px);
+ animation: fg-card-in 420ms ease both;
+ animation-play-state: paused; /* wait until grid is in view */
+ will-change: transform;
+ }
+
+ /* Start animations when Reveal sets data-inview="true" */
+ &[data-inview='true'] > * {
+ animation-play-state: running;
+ }
+
+ /* Simple repeating stagger (works for 1–n columns) */
+ &[data-inview='true'] > *:nth-child(3n + 1) {
+ animation-delay: 0ms;
+ }
+ &[data-inview='true'] > *:nth-child(3n + 2) {
+ animation-delay: 80ms;
+ }
+ &[data-inview='true'] > *:nth-child(3n + 3) {
+ animation-delay: 160ms;
+ }
+
+ @keyframes fg-card-in {
+ to {
+ opacity: 1;
+ transform: none;
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ & > * {
+ animation: none;
+ opacity: 1;
+ transform: none;
+ }
+ }
+`
+
+const LoadingMessage = styled.div`
+ text-align: center;
+ padding: 2rem;
+ color: #666;
+`
+
+const ErrorMessage = styled.div`
+ text-align: center;
+ padding: 2rem;
+ color: #d32f2f;
+ background: #ffebee;
+ border-radius: 8px;
+ margin: 2rem auto;
+ max-width: 600px;
+`
+
+export const FeaturedFika = () => {
+ const { featuredProducts, loading, error, fetchFeaturedProducts } =
+ useProductStore()
+ const hasFetched = useRef(false)
+
+ useEffect(() => {
+ // ✅ More defensive checking
+ if (
+ !hasFetched.current &&
+ (!featuredProducts || featuredProducts.length === 0)
+ ) {
+ // ✅ Wrap in try-catch
+ try {
+ fetchFeaturedProducts()
+ hasFetched.current = true
+ } catch (err) {
+ console.error('❌ Error in FeaturedFika useEffect:', err)
+ }
+ }
+ }, [fetchFeaturedProducts]) // ✅ Add fetchFeaturedProducts to dependencies
+
+ // ✅ Loading state
+ if (loading && (!featuredProducts || featuredProducts.length === 0)) {
+ return (
+
+ Featured treats
+ Loading featured products...
+
+ )
+ }
+
+ // ✅ Error state
+ if (error) {
+ return (
+
+
+ Featured treats
+
+
+ Error loading products: {error}
+
+ {
+ hasFetched.current = false
+ fetchFeaturedProducts()
+ }}
+ >
+ Try Again
+
+
+
+ )
+ }
+
+ // ✅ Safe array check
+ const safeProducts = Array.isArray(featuredProducts) ? featuredProducts : []
+
+ return (
+
+
+ Featured treats
+
+ Every bite tells a story of wellness. Our handcrafted treats combine
+ traditional Swedish fika culture with modern superfoods.
+
+
+
+ {safeProducts.length > 0 ? (
+
+ {safeProducts.map((product) => (
+
+ ))}
+
+ ) : (
+ No featured products available.
+ )}
+
+ )
+}
diff --git a/frontend/src/sections/Footer.jsx b/frontend/src/sections/Footer.jsx
new file mode 100644
index 0000000000..604d688f60
--- /dev/null
+++ b/frontend/src/sections/Footer.jsx
@@ -0,0 +1,70 @@
+import styled from 'styled-components'
+
+import { SocialIcons } from '../components/SocialIcons'
+import { media } from '../styles/media'
+
+const StyledFooter = styled.footer`
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ padding: ${({ theme }) => theme.spacing.md};
+ margin-top: auto;
+ min-height: auto;
+ background-color: ${({ theme }) => theme.colors.brand.primary};
+ text-align: left;
+
+ ${media.md} {
+ padding: ${({ theme }) => theme.spacing.xl};
+ min-height: auto;
+ }
+
+ ${media.lg} {
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ min-height: auto;
+ }
+`
+
+const FooterContent = styled.div`
+ margin: 0 auto;
+ width: 100%;
+
+ ${media.lg} {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+`
+
+const StyledH2 = styled.h2`
+ font-size: 2rem;
+ margin-bottom: ${({ theme }) => theme.spacing.sm};
+ font-weight: 300;
+
+ ${media.md} {
+ font-size: 3rem;
+ }
+`
+
+const FooterSection = styled.div`
+ justify-content: flex-start;
+ margin-bottom: ${({ theme }) => theme.spacing.md};
+`
+
+export const Footer = () => {
+ return (
+
+
+
+ Join the community
+ © 2025 naima. All rights reserved.
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/sections/Footerv2.jsx b/frontend/src/sections/Footerv2.jsx
new file mode 100644
index 0000000000..c11f975597
--- /dev/null
+++ b/frontend/src/sections/Footerv2.jsx
@@ -0,0 +1,130 @@
+import styled from 'styled-components'
+
+import { SocialIcons } from '../components/SocialIcons'
+import { media } from '../styles/media'
+
+const StyledFooter = styled.footer`
+ position: relative;
+ margin-top: auto;
+ text-align: left;
+ color: #fff; /* white content over image */
+ min-height: 260px;
+
+ /* full-bleed background image */
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background-image: url('/images/footer-vert.jpg');
+ background-size: cover;
+ background-position: 70% 62%;
+ background-repeat: no-repeat;
+ z-index: 0;
+ }
+
+ /* overlay for legibility */
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ to top,
+ rgba(0, 0, 0, 0.55) 0%,
+ rgba(0, 0, 0, 0.4) 40%,
+ rgba(0, 0, 0, 0.25) 100%
+ );
+ z-index: 0;
+ }
+
+ /* content spacing */
+ padding: ${({ theme }) => theme.spacing.xl} ${({ theme }) => theme.spacing.md};
+ ${media.md} {
+ padding: ${({ theme }) => theme.spacing.xxl};
+ }
+
+ /* responsive crop nudges */
+ @media (max-width: 480px) {
+ &::before {
+ background-position: 65% 60%;
+ }
+ }
+ ${media.lg} {
+ &::before {
+ background-position: 75% 64%;
+ }
+ }
+`
+
+const FooterContent = styled.div`
+ position: relative;
+ z-index: 1;
+ margin: 0 auto;
+ width: 100%;
+
+ ${media.lg} {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+`
+
+const FooterSection = styled.div`
+ margin-bottom: ${({ theme }) => theme.spacing.md};
+ ${media.lg} {
+ margin-bottom: 0;
+ }
+
+ h2 {
+ font-size: 2.5rem;
+ font-family: ${({ theme }) => theme.fonts.heading};
+ font-weight: ${({ theme }) => theme.fonts.weights.heavy};
+ text-transform: lowercase;
+ color: #fff;
+ }
+ p {
+ color: rgba(255, 255, 255, 0.92);
+ }
+
+ &.footer-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: ${({ theme }) => theme.spacing.md};
+
+ p {
+ margin: 0;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 0.9rem;
+ color: rgba(255, 255, 255, 0.92);
+ transition: color 0.3s ease;
+
+ &:hover {
+ color: #fff;
+ }
+ }
+ }
+`
+
+export const Footer = () => {
+ return (
+
+
+
+ join the community
+ © 2025 naima. All rights reserved.
+
+
+ Press
+ Terms
+ Privacy
+ Cookie
+ FAQ
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/sections/Hero.jsx b/frontend/src/sections/Hero.jsx
new file mode 100644
index 0000000000..2e6b671364
--- /dev/null
+++ b/frontend/src/sections/Hero.jsx
@@ -0,0 +1,156 @@
+import styled from 'styled-components'
+
+import { Carousel } from '../components/Carousel'
+import { homeCarouselItems } from '../data/carouselData'
+import { media } from '../styles/media'
+
+const StyledHero = styled.section`
+ position: relative;
+ width: 100%;
+ height: 60vh;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ padding: 0;
+ margin: 0;
+ overflow: hidden; /* ✅ Prevent content from spilling outside */
+
+ ${media.sm} {
+ height: 70vh;
+ }
+
+ ${media.md} {
+ height: ${(props) =>
+ props.theme.layout?.heroHeight || 'calc(100vh - 80px)'};
+ padding: ${(props) => props.theme.spacing.xl};
+ }
+`
+
+/* 👇 gradient overlay for contrast */
+const Shade = styled.div`
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ z-index: 1;
+
+ /* bottom-up darkening + slight side vignette */
+ background: radial-gradient(
+ 120% 90% at 100% 50%,
+ rgba(0, 0, 0, 0.2) 0%,
+ rgba(0, 0, 0, 0) 60%
+ ),
+ radial-gradient(
+ 120% 90% at 0% 50%,
+ rgba(0, 0, 0, 0.2) 0%,
+ rgba(0, 0, 0, 0) 60%
+ ),
+ linear-gradient(
+ to top,
+ rgba(0, 0, 0, 0.55) 0%,
+ rgba(0, 0, 0, 0.3) 35%,
+ rgba(0, 0, 0, 0.1) 60%,
+ rgba(0, 0, 0, 0) 100%
+ );
+ mix-blend-mode: multiply;
+
+ @media (prefers-contrast: more) {
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.65), rgba(0, 0, 0, 0));
+ }
+`
+
+const StyledH1 = styled.h1`
+ position: absolute;
+ font-size: 2rem;
+ color: ${(props) => props.theme.colors.text.hero};
+ text-align: left;
+ z-index: 10;
+ left: ${(props) => props.theme.spacing.sm};
+ top: ${(props) => props.theme.spacing.md};
+ transform: none;
+ font-family: ${(props) => props.theme.fonts.heading};
+ font-weight: ${(props) => props.theme.fonts.weights.heavy};
+ text-shadow: 0 0.6px 1px rgba(0, 0, 0, 0.55);
+
+ ${media.sm} {
+ font-size: 3rem;
+ left: ${(props) => props.theme.spacing.md};
+ top: ${(props) => props.theme.spacing.lg};
+ }
+
+ ${media.md} {
+ font-size: 4rem;
+ left: ${(props) => props.theme.spacing.xl};
+ top: ${(props) => props.theme.spacing.xl};
+ }
+
+ ${media.lg} {
+ font-size: 4rem;
+ left: 4rem;
+ }
+
+ ${media.xl} {
+ font-size: 5rem;
+ }
+
+ ${media.xxl} {
+ font-size: 6rem;
+ }
+`
+
+const CursiveText = styled.span`
+ font-style: italic;
+`
+
+export const Hero = ({
+ title = 'Fika with benefits',
+ subtitle = null,
+ carouselItems = homeCarouselItems,
+ showCarousel = true
+}) => {
+ // ✅ Prevent rendering if no items
+ if (!carouselItems || carouselItems.length === 0) {
+ return Loading hero content...
+ }
+
+ return (
+
+ {showCarousel && (
+
+ )}
+
+
+ {title.includes('benefits') ? (
+ <>
+ Fika with benefits
+ >
+ ) : (
+ title
+ )}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {/* replace or ensure your hero image uses these attributes */}
+
+
+ )
+}
diff --git a/frontend/src/sections/InstagramGrid.jsx b/frontend/src/sections/InstagramGrid.jsx
new file mode 100644
index 0000000000..7ca2bcb186
--- /dev/null
+++ b/frontend/src/sections/InstagramGrid.jsx
@@ -0,0 +1,50 @@
+import styled from 'styled-components'
+import { useBreakpoint } from '../hooks/useBreakpoint'
+
+const StyledInstagramGrid = styled.section`
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 0;
+
+ a {
+ display: block;
+ line-height: 0;
+ }
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ aspect-ratio: 1;
+ }
+`
+
+const mockPosts = Array.from({ length: 12 }, (_, i) => ({
+ id: i + 1,
+ media_url: `https://picsum.photos/400/400?random=${i + 1}`,
+ caption: `Beautiful fika moment #${i + 1}`,
+ permalink: '#'
+}))
+
+export const InstagramGrid = () => {
+ const breakpoint = useBreakpoint()
+ let postCount = 12
+ if (breakpoint === 'tablet') postCount = 8
+ if (breakpoint === 'mobile') postCount = 4
+
+ return (
+
+ {mockPosts.slice(0, postCount).map((post) => (
+
+
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/sections/Orders.jsx b/frontend/src/sections/Orders.jsx
new file mode 100644
index 0000000000..b877b7d2ff
--- /dev/null
+++ b/frontend/src/sections/Orders.jsx
@@ -0,0 +1,56 @@
+import { useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+
+import { OrderItem } from '../components/OrderItem'
+import { useAuthStore } from '../stores/useAuthStore'
+import { useOrderStore } from '../stores/useOrderStore'
+
+export const Orders = () => {
+ const { orders, loading, error, fetchOrders } = useOrderStore()
+ const token = useAuthStore((state) => state.companyToken)
+ const isLoggedIn = useAuthStore((state) => state.isLoggedIn)
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ if (!isLoggedIn) {
+ navigate('/company/login')
+ }
+ }, [isLoggedIn, navigate])
+
+ useEffect(() => {
+ if (token) fetchOrders(token)
+ }, [token, fetchOrders])
+
+ const handleOrderClick = (orderId) => {
+ navigate(`/orders/${orderId}`)
+ }
+
+ // defensive: ensure orders is an array before reading .length / mapping
+ const getTimestamp = (o) => {
+ if (!o) return 0
+ if (o.createdAt) return new Date(o.createdAt).getTime()
+ if (o._id && typeof o._id === 'string' && o._id.length >= 8) {
+ // ObjectId first 8 chars are Unix seconds in hex
+ return parseInt(o._id.slice(0, 8), 16) * 1000
+ }
+ return 0
+ }
+
+ const list = Array.isArray(orders)
+ ? [...orders].sort((a, b) => getTimestamp(b) - getTimestamp(a))
+ : []
+
+ if (loading) return Loading orders...
+ if (error) return {error}
+ if (!list.length) return No orders found.
+
+ return (
+
+
+ {list.map((order) => (
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/sections/Products.jsx b/frontend/src/sections/Products.jsx
new file mode 100644
index 0000000000..835c80bd6a
--- /dev/null
+++ b/frontend/src/sections/Products.jsx
@@ -0,0 +1,56 @@
+import { PageTitle } from '../components/PageTitle'
+import { ProductCard } from '../components/ProductCard'
+import { media } from '../styles/media'
+import styled from 'styled-components'
+
+const StyledProducts = styled.section`
+ background-color: ${(props) => props.theme.colors.background};
+ color: ${(props) => props.theme.colors.text.primary};
+ text-align: left;
+ padding: 0 8px;
+ max-width: 1200px;
+ margin: 0 auto;
+
+ ${media.md} {
+ padding: ${(props) => props.theme.spacing.xl};
+ }
+`
+
+const StyledProductsGrid = styled.div`
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: ${(props) => props.theme.spacing.md};
+ margin: 0 auto;
+ align-items: stretch;
+ padding: 0 8px;
+
+ ${media.sm} {
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ }
+
+ ${media.md} {
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ }
+`
+
+export const Products = ({ products = [], onOrder }) => {
+ if (!products || products.length === 0) {
+ return (
+
+ Fika Selection
+ No products available at the moment.
+
+ )
+ }
+
+ return (
+
+ Fika selection
+
+ {products.map((product) => (
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/sections/SocialProof.jsx b/frontend/src/sections/SocialProof.jsx
new file mode 100644
index 0000000000..b461a7f679
--- /dev/null
+++ b/frontend/src/sections/SocialProof.jsx
@@ -0,0 +1,165 @@
+import { useEffect, useRef, useState } from 'react'
+import styled, { keyframes } from 'styled-components'
+
+import { Logo } from '../components/Logo'
+import usePartnerStore from '../stores/usePartnerStore'
+import { media } from '../styles/media'
+
+const StyledSocialProof = styled.section`
+ background: ${({ theme }) => theme.colors.brand.blush};
+ width: 100%;
+ overflow: hidden;
+ margin: 0;
+ padding: 0;
+
+ ${media.md} {
+ padding: ${(props) => props.theme.spacing.md} 0;
+ }
+`
+
+const Container = styled.div`
+ width: 100%;
+ margin: 0;
+`
+
+const SectionTitle = styled.h2`
+ font-size: 1.2rem;
+ text-align: left;
+ color: ${({ theme }) => theme.colors.text.primary};
+ font-family: ${({ theme }) => theme.fonts.heading};
+ font-weight: ${({ theme }) => theme.fonts.weights.normal};
+ margin: ${({ theme }) => theme.spacing.md};
+ text-transform: lowercase;
+ letter-spacing: 2px;
+
+ ${media.md} {
+ font-size: 1.5rem;
+ margin-left: ${({ theme }) => theme.spacing.md};
+ }
+`
+
+// Rolling animation keyframes- Animate the track by the width of one list (half of the doubled content) */
+const roll = keyframes`
+ to { transform: translateX(-50%); }
+`
+
+const LogoTrack = styled.div`
+ display: flex;
+ width: max-content;
+ animation: ${roll} 55s linear infinite; /* Adjust speed as needed */
+
+ &:hover {
+ animation-play-state: paused; /* Pause on hover */
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ animation: none;
+ }
+`
+
+const LogoGrid = styled.div`
+ display: flex;
+ width: 50%;
+ justify-content: flex-start;
+ align-items: center;
+ gap: ${({ theme }) => theme.spacing.xxl};
+`
+
+const LogoItem = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0.6;
+ transition: opacity 0.3s ease;
+ flex: 0 0 auto;
+ min-width: 120px;
+
+ &:hover {
+ opacity: 1;
+ }
+`
+
+export const SocialProof = () => {
+ const { servedAtPartners, loading, error, fetchServedAtPartners } =
+ usePartnerStore()
+ const hasFetched = useRef(false)
+ const [repeated, setRepeated] = useState([])
+
+ useEffect(() => {
+ if (!hasFetched.current) {
+ fetchServedAtPartners()
+ hasFetched.current = true
+ }
+ }, [])
+
+ // repeat partners so the track width >= 2x viewport to avoid gaps
+ useEffect(() => {
+ if (!servedAtPartners || servedAtPartners.length === 0) {
+ setRepeated([])
+ return
+ }
+
+ const createRepeated = () => {
+ const itemApproxWidth = 160 // px — adjust to match LogoItem size including gap
+ const needed = Math.ceil(
+ (window.innerWidth * 2) / (servedAtPartners.length * itemApproxWidth)
+ )
+ const arr = []
+ for (let i = 0; i < Math.max(2, needed); i++) {
+ arr.push(...servedAtPartners)
+ }
+ setRepeated(arr)
+ }
+
+ createRepeated()
+ window.addEventListener('resize', createRepeated)
+ return () => window.removeEventListener('resize', createRepeated)
+ }, [servedAtPartners])
+
+ if (loading && servedAtPartners.length === 0)
+ return Loading partners...
+ if (error) return Error loading partners: {error}
+
+ return (
+
+
+ Served at:
+
+
+ {(repeated.length ? repeated : servedAtPartners).map(
+ (partner, idx) => {
+ const base = partner?._id ?? partner?.name ?? String(idx)
+ return (
+
+
+
+ )
+ }
+ )}
+
+ {/* Duplicate grid for animation */}
+
+ {(repeated.length ? repeated : servedAtPartners).map(
+ (partner, idx) => {
+ const base = partner?._id ?? partner?.name ?? `dup-${idx}`
+ return (
+
+
+
+ )
+ }
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
new file mode 100644
index 0000000000..288b5f51ca
--- /dev/null
+++ b/frontend/src/services/api.js
@@ -0,0 +1,179 @@
+// Normalize API base: prefer env, strip trailing slash, ensure /api present when used for requests
+const rawBase = (
+ import.meta.env.VITE_API_BASE ||
+ import.meta.env.VITE_API_URL ||
+ 'http://localhost:3001'
+).trim()
+const baseNoSlash = rawBase.replace(/\/+$/, '')
+const API_BASE = baseNoSlash.includes('/api')
+ ? baseNoSlash
+ : `${baseNoSlash}/api`
+
+const request = async (path, opts = {}) => {
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`
+ const url = `${API_BASE}${normalizedPath}`
+ const res = await fetch(url, {
+ headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
+ ...opts
+ })
+ if (!res.ok) {
+ const bodyText = await res.text().catch(() => null)
+ throw new Error(`HTTP error! status: ${res.status}`)
+ }
+ const text = await res.text().catch(() => '')
+ try {
+ return text ? JSON.parse(text) : null
+ } catch (e) {
+ return text
+ }
+}
+
+// example exported helpers (adapt to your file's exports)
+export const partners = {
+ getServedAt: () => request('/partners/served-at')
+}
+
+// ✅ Add generic get method
+const apiRequest = async (url, options = {}) => {
+ const fullUrl = `${API_BASE}${url}`
+
+ try {
+ // build final options so headers/body aren't accidentally overwritten
+ const requestOptions = {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(options.headers || {})
+ }
+ }
+
+ const response = await fetch(fullUrl, requestOptions)
+
+ if (!response.ok) {
+ // attempt to parse error body for more info
+ let errBody = null
+ try {
+ errBody = await response.json()
+ } catch (e) {
+ /* ignore */
+ }
+ console.error('📡 Response error body:', errBody)
+ throw new Error(`HTTP error! status: ${response.status}`)
+ }
+
+ const data = await response.json()
+ return data
+ } catch (error) {
+ console.error(`❌ API request failed for ${url}:`, error)
+ throw error
+ }
+}
+
+export const api = {
+ // ✅ Generic get method
+ get: apiRequest,
+
+ // Products API
+ products: {
+ // Get all products with optional filters
+ getAll: async (params = {}) => {
+ const queryString = new URLSearchParams(params).toString()
+ const url = queryString ? `/products?${queryString}` : '/products'
+ return apiRequest(url)
+ },
+
+ // Get single product by ID
+ getById: async (id) => {
+ return apiRequest(`/products/${id}`)
+ },
+
+ // Get featured products
+ getFeatured: async () => {
+ return apiRequest('/products/featured/list')
+ },
+
+ // Get products by category
+ getByCategory: async (category) => {
+ return apiRequest(`/products/category/${category}`)
+ },
+
+ // Search products
+ search: async (query) => {
+ return apiRequest(`/products?search=${encodeURIComponent(query)}`)
+ }
+ },
+
+ // Partners API
+ partners: {
+ // Get all partners with optional type filter
+ getAll: async (type = null) => {
+ const url = type ? `/partners?type=${type}` : '/partners'
+ return apiRequest(url)
+ },
+
+ // Get served-at partners
+ getServedAt: async () => {
+ return apiRequest('/partners/served-at')
+ },
+
+ // Get catering partners
+ getCatering: async () => {
+ return apiRequest('/partners/catering')
+ }
+ },
+
+ // Orders API
+ orders: {
+ getAll: async (token) => {
+ return apiRequest('/orders/company', {
+ headers: { Authorization: `Bearer ${token}` }
+ })
+ },
+ getById: async (id, token) => {
+ return apiRequest(`/orders/${id}`, {
+ method: 'GET',
+ headers: { Authorization: `Bearer ${token}` }
+ })
+ },
+ submitOrder: async (orderData, token) => {
+ return apiRequest('/orders', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}` },
+ body: JSON.stringify(orderData)
+ })
+ }
+ },
+
+ // Companies API
+ companies: {
+ // Login method
+ login: async (data) => {
+ const response = await fetch(`${API_BASE}/companies/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ })
+ if (!response.ok) throw new Error('Login failed')
+ return response.json()
+ },
+ logout: async () => {
+ const response = await fetch(`${API_BASE}/companies/logout`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' }
+ })
+ if (!response.ok) throw new Error('Logout failed')
+ return response.json()
+ },
+
+ // Fetch current company profile using token
+ getProfile: async (token) => {
+ return apiRequest('/companies/me', {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ })
+ }
+ }
+}
diff --git a/frontend/src/stores/useAuthStore.js b/frontend/src/stores/useAuthStore.js
new file mode 100644
index 0000000000..360d27f271
--- /dev/null
+++ b/frontend/src/stores/useAuthStore.js
@@ -0,0 +1,28 @@
+import { create } from 'zustand'
+import { persist } from 'zustand/middleware'
+
+export const useAuthStore = create(
+ persist(
+ (set) => ({
+ companyToken: null,
+ isLoggedIn: false,
+ company: null, // persisted company/profile object
+ // setAuth accepts token and optional company object
+ setAuth: (token, company = null) =>
+ set({ companyToken: token, isLoggedIn: !!token, company }),
+ // allow updating just the company/profile
+ setCompany: (company) => set({ company }),
+ logout: () => {
+ set({ companyToken: null, isLoggedIn: false, company: null })
+ }
+ }),
+ {
+ name: 'company-auth', // key in localStorage
+ partialize: (state) => ({
+ companyToken: state.companyToken,
+ isLoggedIn: state.isLoggedIn,
+ company: state.company
+ })
+ }
+ )
+)
diff --git a/frontend/src/stores/useCartStore.js b/frontend/src/stores/useCartStore.js
new file mode 100644
index 0000000000..7a7e9a9cb8
--- /dev/null
+++ b/frontend/src/stores/useCartStore.js
@@ -0,0 +1,65 @@
+import { create } from 'zustand'
+import { persist } from 'zustand/middleware'
+
+export const useCartStore = create(
+ persist(
+ (set) => ({
+ items: [],
+ isOpen: false,
+ toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
+ closeCart: () => set({ isOpen: false }),
+ addToCart: (product, selectedSize, quantity = 1) =>
+ set((state) => {
+ const sizeId = String(
+ selectedSize._id ||
+ product.sizes.findIndex((s) => s === selectedSize)
+ )
+ const cartKey = `${product._id}-${sizeId}`
+ const existingItem = state.items.find(
+ (item) => item.cartKey === cartKey
+ )
+
+ if (existingItem) {
+ return {
+ items: state.items.map((item) =>
+ item.cartKey === cartKey
+ ? { ...item, quantity: item.quantity + quantity }
+ : item
+ )
+ }
+ } else {
+ return {
+ items: [
+ ...state.items,
+ {
+ ...product,
+ selectedSize,
+ quantity,
+ cartKey
+ }
+ ]
+ }
+ }
+ }),
+ removeFromCart: (cartKey) =>
+ set((state) => ({
+ items: state.items.filter((item) => item.cartKey !== cartKey)
+ })),
+ updateQuantity: (cartKey, quantity) =>
+ set((state) => ({
+ items: state.items.map((item) =>
+ item.cartKey === cartKey
+ ? { ...item, quantity: Math.max(1, quantity) }
+ : item
+ )
+ })),
+ clearCart: () => set({ items: [] })
+ }),
+ {
+ name: 'cart-store', // key in localStorage
+ partialize: (state) => ({
+ items: state.items
+ })
+ }
+ )
+)
diff --git a/frontend/src/stores/useMenuStore.js b/frontend/src/stores/useMenuStore.js
new file mode 100644
index 0000000000..52b309e748
--- /dev/null
+++ b/frontend/src/stores/useMenuStore.js
@@ -0,0 +1,8 @@
+import { create } from 'zustand'
+
+export const useMenuStore = create((set) => ({
+ isOpen: false,
+ openMenu: () => set({ isOpen: true }),
+ closeMenu: () => set({ isOpen: false }),
+ toggleMenu: () => set((state) => ({ isOpen: !state.isOpen }))
+}))
diff --git a/frontend/src/stores/useOrderStore.js b/frontend/src/stores/useOrderStore.js
new file mode 100644
index 0000000000..c7467499ca
--- /dev/null
+++ b/frontend/src/stores/useOrderStore.js
@@ -0,0 +1,65 @@
+import { create } from 'zustand'
+
+import { api } from '../services/api'
+
+// Zustand store for managing company orders
+export const useOrderStore = create((set) => ({
+ // Array of orders for the logged-in company
+ orders: [],
+ // Setter for orders array
+ setOrders: (orders) => set({ orders }),
+
+ // Loading state for async operations
+ loading: false,
+ // Setter for loading state
+ setLoading: (loading) => set({ loading }),
+
+ // Error state for failed requests
+ error: null,
+ // Setter for error state
+ setError: (error) => set({ error }),
+
+ // Fetch orders for the logged-in company using JWT token
+ fetchOrders: async (token) => {
+ set({ loading: true, error: null }) // Start loading, clear previous errors
+ try {
+ // Make API request to fetch company orders
+ const data = await api.get('/orders/company', {
+ headers: {
+ Authorization: `Bearer ${token}` // Pass JWT token for authentication
+ }
+ })
+
+ // Accept either an array response or { data: [...] } shape
+ const orders = Array.isArray(data)
+ ? data
+ : Array.isArray(data?.data)
+ ? data.data
+ : []
+
+ set({ orders, loading: false }) // Store orders, stop loading
+ } catch (error) {
+ set({ error: error.message || 'Failed to fetch orders', loading: false }) // Store error, stop loading
+ }
+ },
+
+ // Single order details
+ order: null,
+ // Setter for single order
+ setOrder: (order) => set({ order }),
+ // Fetch a single order by ID
+ fetchOrderById: async (orderId, token) => {
+ set({ loading: true, error: null })
+ try {
+ // use api.orders.getById to hit backend with token
+ const order = await api.orders.getById(orderId, token)
+ set({ order, loading: false })
+ } catch (err) {
+ set({
+ error: err.message || 'Failed to load order',
+ order: null,
+ loading: false
+ })
+ }
+ }
+}))
diff --git a/frontend/src/stores/usePartnerStore.js b/frontend/src/stores/usePartnerStore.js
new file mode 100644
index 0000000000..7c6a27f9c4
--- /dev/null
+++ b/frontend/src/stores/usePartnerStore.js
@@ -0,0 +1,101 @@
+import { create } from 'zustand'
+import { devtools } from 'zustand/middleware'
+
+import { api } from '../services/api'
+
+// map the local data shape → API shape
+const normalize = (rows = []) =>
+ rows.map((r) => ({
+ _id: r._id || r.id || `${r.name}-${r.website || 'local'}`,
+ name: r.name,
+ type: 'catering_partner',
+ website: r.website || r.url || '',
+ isActive: true,
+ logo: r.logo
+ ? { url: r.logo, alt: r.alt || r.name } // local file uses `logo` string
+ : { url: '', alt: r.alt || r.name }
+ }))
+
+const usePartnerStore = create(
+ devtools(
+ (set, get) => ({
+ // State
+ partners: [],
+ servedAtPartners: [],
+ cateringPartners: [],
+ loading: false,
+ error: null,
+
+ // Actions
+ setLoading: (loading) => set({ loading }),
+ setError: (error) => set({ error }),
+
+ // Fetch all partners
+ fetchPartners: async (type = null) => {
+ set({ loading: true, error: null })
+ try {
+ const data = await api.partners.getAll(type)
+ set({
+ partners: data,
+ loading: false
+ })
+ } catch (error) {
+ set({
+ error: error.message,
+ loading: false
+ })
+ console.error('Failed to fetch partners:', error)
+ }
+ },
+
+ // Fetch served-at partners
+ fetchServedAtPartners: async () => {
+ set({ loading: true, error: null })
+ try {
+ const data = await api.partners.getServedAt()
+ set({
+ servedAtPartners: data,
+ loading: false
+ })
+ } catch (error) {
+ set({
+ error: error.message,
+ loading: false
+ })
+ console.error('Failed to fetch served-at partners:', error)
+ }
+ },
+
+ // Fetch catering partners
+ fetchCateringPartners: async () => {
+ set({ loading: true, error: null })
+ try {
+ // dynamic import so it’s only bundled if needed
+ const mod = await import('../data/cateringPartners')
+
+ const local = normalize(mod.cateringPartners || [])
+ set({ cateringPartners: local, loading: false })
+ } catch (error2) {
+ set({ error: error2.message, loading: false })
+ }
+ },
+ // Clear error
+ clearError: () => set({ error: null }),
+
+ // Reset store
+ reset: () =>
+ set({
+ partners: [],
+ servedAtPartners: [],
+ cateringPartners: [],
+ loading: false,
+ error: null
+ })
+ }),
+ {
+ name: 'partner-store'
+ }
+ )
+)
+
+export default usePartnerStore
diff --git a/frontend/src/stores/useProductSelectionStore.js b/frontend/src/stores/useProductSelectionStore.js
new file mode 100644
index 0000000000..30b89380dd
--- /dev/null
+++ b/frontend/src/stores/useProductSelectionStore.js
@@ -0,0 +1,10 @@
+// stores/useProductSelectionStore.js
+import { create } from 'zustand'
+
+export const useProductSelectionStore = create((set) => ({
+ selectedSizes: {},
+ setSelectedSize: (productId, size) =>
+ set((state) => ({
+ selectedSizes: { ...state.selectedSizes, [productId]: size }
+ }))
+}))
diff --git a/frontend/src/stores/useProductStore.js b/frontend/src/stores/useProductStore.js
new file mode 100644
index 0000000000..3908994db3
--- /dev/null
+++ b/frontend/src/stores/useProductStore.js
@@ -0,0 +1,149 @@
+import { create } from 'zustand'
+import { devtools } from 'zustand/middleware'
+
+import { api } from '../services/api'
+
+const useProductStore = create(
+ devtools(
+ (set, get) => ({
+ // State
+ products: [],
+ featuredProducts: [],
+ currentProduct: null,
+ loading: false,
+ error: null,
+ filters: {},
+ selectedSizes: {},
+
+ // Actions - Add useCallback equivalent for Zustand
+ setLoading: (loading) => set({ loading }),
+ setError: (error) => set({ error }),
+ setFilters: (filters) => set({ filters }),
+ setSelectedSize: (productId, size) =>
+ set((state) => ({
+ selectedSizes: { ...state.selectedSizes, [productId]: size }
+ })),
+
+ // Fetch all products
+ fetchProducts: async (filters = {}) => {
+ set({ loading: true, error: null })
+ try {
+ const data = await api.products.getAll(filters)
+ set({
+ products: data,
+ loading: false,
+ filters
+ })
+ } catch (error) {
+ set({
+ error: error.message,
+ loading: false
+ })
+ console.error('Failed to fetch products:', error)
+ }
+ },
+
+ // Fetch featured products
+ fetchFeaturedProducts: async () => {
+ const state = get()
+
+ // Prevent multiple simultaneous calls
+ if (state.loading) {
+ return state.featuredProducts // Return existing data
+ }
+
+ if (state.featuredProducts.length > 0) {
+ return state.featuredProducts // Return existing data
+ }
+
+ set({ loading: true, error: null })
+
+ try {
+ const data = await api.products.getFeatured() // ✅ Use correct API method
+ set({ featuredProducts: data, loading: false })
+ return data
+ } catch (error) {
+ console.error('❌ Error fetching products:', error)
+ set({ error: error.message, loading: false })
+ throw error
+ }
+ },
+
+ // Fetch single product
+ fetchProduct: async (id) => {
+ set({ loading: true, error: null })
+ try {
+ const data = await api.products.getById(id)
+ set({
+ currentProduct: data,
+ loading: false
+ })
+ } catch (error) {
+ set({
+ error: error.message,
+ loading: false
+ })
+ console.error('Failed to fetch product:', error)
+ }
+ },
+
+ // Search products
+ searchProducts: async (query) => {
+ set({ loading: true, error: null })
+ try {
+ const data = await api.products.search(query)
+ set({
+ products: data,
+ loading: false
+ })
+ } catch (error) {
+ set({
+ error: error.message,
+ loading: false
+ })
+ console.error('Failed to search products:', error)
+ }
+ },
+
+ // Get products by category
+ fetchProductsByCategory: async (category) => {
+ set({ loading: true, error: null })
+ try {
+ const data = await api.products.getByCategory(category)
+ set({
+ products: data,
+ loading: false
+ })
+ } catch (error) {
+ set({
+ error: error.message,
+ loading: false
+ })
+ console.error('Failed to fetch products by category:', error)
+ }
+ },
+
+ // Clear current product
+ clearCurrentProduct: () => set({ currentProduct: null }),
+
+ // Clear error
+ clearError: () => set({ error: null }),
+
+ // Reset store
+ reset: () =>
+ set({
+ products: [],
+ featuredProducts: [],
+ currentProduct: null,
+ loading: false,
+ error: null,
+ filters: {}
+ })
+ }),
+ {
+ name: 'product-store' // name for devtools
+ }
+ )
+)
+
+export default useProductStore
diff --git a/frontend/src/styles/GlobalStyles.js b/frontend/src/styles/GlobalStyles.js
new file mode 100644
index 0000000000..d18fbf5558
--- /dev/null
+++ b/frontend/src/styles/GlobalStyles.js
@@ -0,0 +1,110 @@
+//Purpose: CSS reset using styled-components, overrides browser defaults
+
+import { createGlobalStyle } from 'styled-components'
+
+const GlobalStyles = createGlobalStyle`
+ /* Remove default styles */
+ * {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+
+ html {
+ scroll-behavior: smooth;
+ }
+
+ /* Base typography */
+ body {
+ font-family: ${({ theme }) => theme.fonts.body};
+ font-weight: ${({ theme }) => theme.fonts.weights.normal};
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+
+ /* Heading styles */
+ h1, h2, h3, h4, h5, h6 {
+ font-family: ${(props) => props.theme.fonts.heading};
+ font-weight: ${(props) => props.theme.fonts.weights.bold};
+ line-height: 1.2;
+ margin: 0;
+ }
+
+ h1 {
+ font-weight: ${(props) => props.theme.fonts.weights.heavy};
+ }
+
+ /* Body text */
+ p, span, div {
+ font-family: ${(props) => props.theme.fonts.body};
+ }
+
+ /* Links */
+ a {
+ color: ${({ theme }) => theme.colors.text.primary};
+ text-decoration: none;
+ }
+
+ a.link-underline {
+ position: relative;
+ text-decoration: none;
+ }
+
+ a.link-underline::after {
+ content: '';
+ position: absolute; left: 0; bottom: -2px;
+ width: 100%; height: 2px;
+ background: ${({ theme }) => theme.colors.brand.primary};
+ transform: scaleX(0); transform-origin: left;
+ transition: transform 180ms ease;
+ }
+ a.link-underline:hover::after,
+ a.link-underline:focus-visible::after { transform: scaleX(1); }
+
+ a.active,
+ a[aria-current="page"] {
+ color: ${({ theme }) => theme.colors.text.primary};
+ }
+
+ ::selection {
+ background: ${({ theme }) => theme.colors.brand.sky};
+ color: ${({ theme }) => theme.colors.text.primary};
+}
+
+/* strong focus ring for accessibility */
+:focus-visible {
+ outline: 3px solid ${({ theme }) => theme.colors.primary};
+ outline-offset: 2px;
+}
+
+ /* Button reset */
+ button {
+ font-family: inherit;
+ border: none;
+ background: none;
+ cursor: pointer;
+ }
+
+ /* Image optimization */
+ img {
+ max-width: 100%;
+ height: auto;
+ }
+
+ /* Motion safety */
+ @media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { animation: none !important; transition: none !important; }
+ }
+
+ @font-face {
+ font-family: 'Neuzeit';
+ src: url('/fonts/neuzeit_s_lt_std_book.woff2') format('woff2'),
+ url('/fonts/neuzeit_s_lt_std_book.woff') format('woff');
+ font-weight: 400 700;
+ font-style: normal;
+ font-display: swap; /* show fallback text immediately, swap in webfont */
+}
+`
+
+export default GlobalStyles
diff --git a/frontend/src/styles/media.js b/frontend/src/styles/media.js
new file mode 100644
index 0000000000..88ef219197
--- /dev/null
+++ b/frontend/src/styles/media.js
@@ -0,0 +1,39 @@
+//Purpose: Reusable media query helpers using theme breakpoints
+
+import theme from './theme' // Changed from { theme }
+
+export const media = {
+ xs: `@media (min-width: ${theme.breakpoints.xs})`,
+ sm: `@media (min-width: ${theme.breakpoints.sm})`,
+ md: `@media (min-width: ${theme.breakpoints.md})`,
+ lg: `@media (min-width: ${theme.breakpoints.lg})`,
+ xl: `@media (min-width: ${theme.breakpoints.xl})`,
+ xxl: `@media (min-width: ${theme.breakpoints.xxl})`,
+ xxxl: `@media (min-width: ${theme.breakpoints.xxxl})`,
+
+ // Max-width queries (for mobile-first approach)
+ maxXs: `@media (max-width: ${parseInt(theme.breakpoints.sm) - 1}px)`,
+ maxSm: `@media (max-width: ${parseInt(theme.breakpoints.md) - 1}px)`,
+ maxMd: `@media (max-width: ${parseInt(theme.breakpoints.lg) - 1}px)`,
+ maxLg: `@media (max-width: ${parseInt(theme.breakpoints.xl) - 1}px)`,
+ maxXl: `@media (max-width: ${parseInt(theme.breakpoints.xxl) - 1}px)`,
+
+ // Range queries
+ smToMd: `@media (min-width: ${theme.breakpoints.sm}) and (max-width: ${
+ parseInt(theme.breakpoints.md) - 1
+ }px)`,
+ mdToLg: `@media (min-width: ${theme.breakpoints.md}) and (max-width: ${
+ parseInt(theme.breakpoints.lg) - 1
+ }px)`,
+ lgToXl: `@media (min-width: ${theme.breakpoints.lg}) and (max-width: ${
+ parseInt(theme.breakpoints.xl) - 1
+ }px)`
+}
+
+// Alternative approach with functions for more flexibility
+export const mediaQueries = {
+ up: (size) => `@media (min-width: ${theme.breakpoints[size]})`,
+ down: (size) => `@media (max-width: ${theme.breakpoints[size]})`,
+ between: (min, max) =>
+ `@media (min-width: ${theme.breakpoints[min]}) and (max-width: ${theme.breakpoints[max]})`
+}
diff --git a/frontend/src/styles/theme.js b/frontend/src/styles/theme.js
new file mode 100644
index 0000000000..79d5c38c81
--- /dev/null
+++ b/frontend/src/styles/theme.js
@@ -0,0 +1,101 @@
+//Purpose: Centralized design tokens, accessible via props.theme
+//Maintainability: Change theme.js to update entire app
+
+const theme = {
+ colors: {
+ primary: '#2563eb',
+ secondary: '#7c3aed',
+ accent: '#f59e0b',
+ background: '#ffffff',
+ surface: '#f8fafc',
+ border: '#e2e8f0',
+ text: {
+ primary: '#1e293b',
+ secondary: '#64748b',
+ muted: '#94a3b8',
+ hero: '#ffffff'
+ },
+ error: '#ef4444',
+ success: '#10b981',
+
+ // 👇 client palette
+ brand: {
+ primary: '#BCE8C2', // CTA, active states
+ blush: '#F7CDD0', // soft highlight/hero band
+ salmon: '#F4A6A3', // info tint, tags
+ lavender: '#D0C3F1', // secondary tint, cards
+ sky: '#B3D9F3' // success tint / positive
+ }
+ },
+ fonts: {
+ // Semantic font naming
+ heading: "'Arca Majora', sans-serif", // For h1, h2, h3, etc.
+ body: "'Neuzeit S LT Std', sans-serif", // For paragraphs, text
+
+ // Alternative: Keep primary/secondary but assign semantically
+ primary: "'Arca Majora', sans-serif", // Headings
+ secondary: "'Neuzeit S LT Std', sans-serif", // Body text
+
+ weights: {
+ light: 300,
+ normal: 400,
+ medium: 500,
+ semibold: 600,
+ bold: 700,
+ heavy: 900 // For Arca Majora Heavy
+ }
+ },
+ typography: {
+ // Define typography scales
+ heading: {
+ fontFamily: "'Arca Majora', sans-serif",
+ fontWeight: 700,
+ lineHeight: 1.2
+ },
+ body: {
+ fontFamily: "'Neuzeit S LT Std', sans-serif",
+ fontWeight: 400,
+ lineHeight: 1.6
+ },
+ caption: {
+ fontFamily: "'Neuzeit S LT Std', sans-serif",
+ fontWeight: 400,
+ fontSize: '0.875rem',
+ lineHeight: 1.4
+ },
+ h1: {
+ fontSize: '3rem',
+ fontWeight: 900, // heavy weight
+ lineHeight: 1.2
+ },
+ h2: {
+ fontSize: '2rem',
+ fontWeight: 700,
+ lineHeight: 1.2
+ }
+ },
+ spacing: {
+ xs: '0.25rem',
+ sm: '0.5rem',
+ md: '1rem',
+ lg: '1.5rem',
+ xl: '2rem',
+ xxl: '3rem'
+ },
+ breakpoints: {
+ xs: '320px', // Extra small phones
+ sm: '576px', // Small phones (landscape)
+ md: '768px', // Tablets (portrait)
+ lg: '992px', // Tablets (landscape) / Small laptops
+ xl: '1200px', // Desktop
+ xxl: '1400px', // Large desktop
+ xxxl: '1920px' // Extra large desktop
+ },
+ layout: {
+ contentMax: '800px',
+ navHeight: '80px',
+ heroHeight: 'calc(100vh - 80px)'
+ }
+}
+
+export default theme
diff --git a/frontend/src/utils/cart.js b/frontend/src/utils/cart.js
new file mode 100644
index 0000000000..8ed91fea69
--- /dev/null
+++ b/frontend/src/utils/cart.js
@@ -0,0 +1,23 @@
+export const getItemQuantity = (it) => it?.quantity ?? it?.qty ?? 1
+
+export const getItemPrice = (it) =>
+ Number(it?.selectedSize?.price ?? it?.price ?? 0) || 0
+
+export const calcCartTotal = (items = []) =>
+ Array.isArray(items)
+ ? items.reduce((sum, it) => sum + getItemPrice(it) * getItemQuantity(it), 0)
+ : 0
+
+export const formatCurrency = (
+ value,
+ { locale = undefined, currency = 'USD' } = {}
+) => {
+ const v = Number(value || 0)
+ if (typeof Intl !== 'undefined' && Intl.NumberFormat) {
+ return new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency
+ }).format(v)
+ }
+ return `${currency} ${v.toFixed(2)}`
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 5a33944a9b..ec1ebaedb8 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,7 +1,13 @@
-import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+// vite.config.js
+import { defineConfig } from 'vite'
-// https://vitejs.dev/config/
export default defineConfig({
- plugins: [react()],
+ plugins: [
+ react({
+ babel: {
+ plugins: [['babel-plugin-styled-components', { displayName: true }]]
+ }
+ })
+ ]
})