final assignment

This commit is contained in:
Sayed Jawad Hussaini 2025-05-07 05:43:28 +02:00
parent c7786bb9df
commit 9b27289a25
128 changed files with 5318 additions and 0 deletions

11
sk1/.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
# Secrets and environment variables
k8s/mongo-secret.yml
.env
*/.env
**/.env
.env.*
# Node modules and build artifacts
node_modules/
dist/
build/

67
sk1/README.md Executable file
View File

@ -0,0 +1,67 @@
# Nudges.works — GKE Deployment on GCP
## What it does
A service marketplace application that connects users with service providers. Users can search for professionals like plumbers, electricians, or gardeners in their area and view their profiles, including contact information and rates.
## Architecture
- GKE cluster (3 nodes) running on Google Cloud
- Google-managed TLS certificate for HTTPS
- Ingress controller for traffic routing
- 2 Deployments & Services: `frontend` (React/Vite), `backend` (Express)
- MongoDB hosted on Atlas (external persistent database)
- Docker images stored in Google Container Registry (GCR)
## Files
- `backend/Dockerfile` - Containerizes the Express backend
- `frontend/Dockerfile` - Containerizes the React frontend
- `frontend/nginx.conf` - Nginx configuration for serving frontend and proxying API requests
- `k8s/` - Kubernetes manifests:
- `mongo-secret.yml` - Secret for MongoDB credentials and API keys
- `backend-deployment.yml.tpl` - Backend deployment template
- `backend-service.yml` - Backend service
- `backend-config.yml` - Health check configuration for backend
- `frontend-deployment.yml.tpl` - Frontend deployment template
- `frontend-service.yml` - Frontend service
- `managed-cert.yml` - GCP-managed TLS certificate
- `ingress.yml` - Ingress configuration for routing
- `prepare-app.sh` - Script to deploy everything
- `remove-app.sh` - Script to tear down the deployment
- `README.md` - This documentation
## Prerequisites
- Google Cloud SDK + billing enabled
- Install the GKE auth plugin:
```bash
gcloud components install gke-gcloud-auth-plugin
```
- Ensure your shell sees the Linux `gcloud`, `kubectl` and `npm`, not Windows versions
- `gcloud init` & set project
- Enable APIs: Container, Compute, Cloud Build
## DNS setup
1. After `./prepare-app.sh` finishes you'll see the ingress external IP
2. In your DNS provider, set A-record for `nudges.works` → your GKE Ingress EXTERNAL-IP.
3. (Optional) CNAME `www``nudges.works`.
## Deploy
```bash
chmod +x prepare-app.sh remove-app.sh
./prepare-app.sh
```
Wait until `ManagedCertificate``Active`. Then browse
https://nudges.works
## Note on Certificate Provisioning
- The managed certificate can take 30-60 minutes to provision
- Your application will be accessible via HTTP immediately, but HTTPS requires waiting for certificate activation
- You can check certificate status with: `kubectl get managedcertificates -n sk1`
## Teardown
```bash
./remove-app.sh
```
## External Sources
- GKE ManagedCertificate guide
- MongoDB Atlas docs
- Generated with GitHub Copilot

13
sk1/backend/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 4000
CMD ["node", "index.js"]

11
sk1/backend/config/db.js Normal file
View File

@ -0,0 +1,11 @@
import mongoose from "mongoose";
export async function connectDB() {
try {
await mongoose.connect(process.env.MONGO_URI);
console.log("✅ MongoDB connected");
} catch (err) {
console.error("❌ MongoDB connection failed:", err);
process.exit(1);
}
}

30
sk1/backend/index.js Normal file
View File

@ -0,0 +1,30 @@
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
import { connectDB } from "./config/db.js";
import authRoutes from "./routes/auth.js";
import profilesRoutes from "./routes/profiles.js";
import servicesRoutes from "./routes/services.js";
import autocompleteRoutes from "./routes/autocomplete.js";
dotenv.config();
await connectDB();
const app = express();
app.use(cors());
app.use(express.json());
app.use("/api/auth", authRoutes);
app.use("/api/profiles", profilesRoutes);
app.use("/api/services", servicesRoutes);
app.use("/api/autocomplete", autocompleteRoutes);
// Add a health check endpoint for Kubernetes
app.get("/api", (req, res) => {
res.status(200).json({ status: "ok", message: "API is healthy" });
});
const PORT = process.env.PORT || 4000;
app.listen(PORT, '0.0.0.0', () =>
console.log(`🚀 Server running on http://0.0.0.0:${PORT}`)
);

View File

@ -0,0 +1,28 @@
import jwt from "jsonwebtoken";
// require a valid JWT in Authorization: Bearer <token>
export function requireAuth(req, res, next) {
const auth = req.headers.authorization || "";
const [scheme, token] = auth.split(" ");
if (scheme !== "Bearer" || !token) {
return res.status(401).json({ error: "Not authenticated" });
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = { id: payload.sub, role: payload.role };
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
}
// ensure the user has a specific role
export function requireRole(role) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: "Not authenticated" });
if (req.user.role !== role) {
return res.status(403).json({ error: "Forbidden" });
}
next();
};
}

View File

@ -0,0 +1,40 @@
import mongoose from "mongoose";
import bcrypt from "bcrypt";
const profileSchema = new mongoose.Schema({
firstName: String,
lastName: String,
phone: String,
field: String,
rate: Number,
address: String,
city: String,
location: {
type: { type: String, enum: ["Point"], default: "Point" },
coordinates: { type: [Number], index: "2dsphere" },
},
published: { type: Boolean, default: false },
description: String, // ← new
picture: String, // ← new (URL or upload path)
});
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
role: { type: String, enum: ["normal", "pro"], default: "normal" },
profile: { type: profileSchema, default: {} },
});
// hash password before save
userSchema.pre("save", async function () {
if (!this.isModified("password")) return;
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
});
// helper to compare passwords
userSchema.methods.comparePassword = function (plain) {
return bcrypt.compare(plain, this.password);
};
export const User = mongoose.model("User", userSchema);

27
sk1/backend/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "backend",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"scripts": {
"dev": "nodemon index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"bcrypt": "^5.1.1",
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.14.1",
"node-fetch": "^3.3.2"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}

View File

@ -0,0 +1,45 @@
import express from "express";
import jwt from "jsonwebtoken";
import { User } from "../models/User.js";
const router = express.Router();
// POST /api/auth/register
router.post("/register", async (req, res) => {
const { email, password, role } = req.body;
try {
const exists = await User.findOne({ email });
if (exists) return res.status(409).json({ error: "Email in use" });
const user = new User({ email, password, role });
await user.save();
const token = jwt.sign(
{ sub: user._id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
res.json({ token });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/auth/login
router.post("/login", async (req, res) => {
const { email, password } = req.body;
try {
const user = await User.findOne({ email });
if (!user || !(await user.comparePassword(password))) {
return res.status(401).json({ error: "Invalid credentials" });
}
const token = jwt.sign(
{ sub: user._id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
res.json({ token });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

View File

@ -0,0 +1,26 @@
import express from "express";
import fetch from "node-fetch";
const router = express.Router();
// GET /api/autocomplete?input=...
router.get("/", async (req, res) => {
const { input } = req.query;
if (!input) return res.status(400).json({ predictions: [] });
const url =
`https://maps.googleapis.com/maps/api/place/autocomplete/json` +
`?input=${encodeURIComponent(input)}` +
`&key=${process.env.GOOGLE_PLACES_API_KEY}` +
`&types=address`;
try {
const r = await fetch(url);
const data = await r.json();
res.json(data);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

View File

@ -0,0 +1,186 @@
import express from "express";
import fetch from "node-fetch";
import bcrypt from "bcryptjs";
import { User } from "../models/User.js";
import { requireAuth } from "../middleware/auth.js";
const router = express.Router();
// GET /api/profiles/me
router.get("/me", requireAuth, async (req, res) => {
const user = await User.findById(req.user.id).select("-password -__v");
if (!user) return res.status(404).json({ error: "User not found" });
res.json({
email: user.email,
role: user.role,
profile: user.profile,
});
});
// NEW: GET any profile by its ID
router.get("/:id", async (req, res) => {
try {
const user = await User.findById(req.params.id).select("-password -__v");
if (!user) return res.status(404).json({ error: "Profile not found" });
return res.json({
email: user.email,
role: user.role,
profile: user.profile,
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// POST /api/profiles (create or update profile)
// normal user: only name/address/password
// pro user: full profile (field, rate, address, lat, lon, published)
router.post("/", requireAuth, async (req, res) => {
try {
const user = await User.findById(req.user.id);
if (req.user.role === "pro") {
const {
firstName,
lastName,
phone,
field,
rate,
address,
description,
picture,
published,
} = req.body;
// pure publish toggle?
if (
req.body.hasOwnProperty("published") &&
Object.keys(req.body).length === 1
) {
// disallow publishing unless all fields are set
if (published) {
const pf = (await User.findById(req.user.id)).profile;
if (
!pf.firstName ||
!pf.lastName ||
!pf.phone ||
!pf.field ||
pf.rate == null ||
!pf.address ||
!pf.description || // ← require description
!pf.picture // ← require picture
) {
return res.status(400).json({
error:
"Complete firstName, lastName, phone, field, rate, address, description & picture before publishing.",
});
}
}
user.profile.published = !!published;
await user.save();
return res.json({ success: true });
}
// full update → geocode + persist all fields
const geoRes = await fetch(
`https://maps.googleapis.com/maps/api/geocode/json` +
`?address=${encodeURIComponent(address)}` +
`&key=${process.env.GOOGLE_PLACES_API_KEY}`
);
const geoData = await geoRes.json();
console.log(
"🗺️ geoData.results:",
JSON.stringify(geoData.results, null, 2)
);
if (geoData.status !== "OK" || !geoData.results.length) {
return res.status(400).json({ error: "Invalid address" });
}
// prefer a true citylevel result, then region, then country, then anything
const place =
geoData.results.find((r) => r.types.includes("locality")) ||
geoData.results.find((r) =>
r.types.includes("administrative_area_level_2")
) ||
geoData.results.find((r) =>
r.types.includes("administrative_area_level_1")
) ||
geoData.results[0];
const { lat, lng } = place.geometry.location;
// first try the locality component
const cityComp = place.address_components.find((c) =>
c.types.includes("locality")
);
let rawCity;
if (cityComp) {
rawCity = cityComp.long_name;
} else {
// use the penultimate segment of formatted_address, e.g.
// ["Hlavná","Old Town","Košice","Slovakia"] → "Košice"
const parts = (place.formatted_address || "")
.split(",")
.map((s) => s.trim());
rawCity = parts.length > 1 ? parts[parts.length - 2] : parts[0];
}
// normalize: strip accents + lowercase
const city = rawCity
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
// now persist everything
user.profile.firstName = firstName;
user.profile.lastName = lastName;
user.profile.phone = phone;
user.profile.field = field;
user.profile.rate = rate;
user.profile.address = address;
user.profile.description = description; // ← save description
user.profile.picture = picture; // ← save picture
user.profile.city = city;
user.profile.location = {
type: "Point",
coordinates: [lng, lat],
};
user.profile.published = !!published;
} else {
// ─── Normal user profile update ──────────────────────────────────────
// Allow them to save firstName, lastName, phone, and (optionally) password
const { firstName, lastName, phone, password } = req.body;
const u = await User.findById(req.user.id);
if (!u) return res.status(404).json({ error: "User not found" });
if (firstName !== undefined) u.profile.firstName = firstName;
if (lastName !== undefined) u.profile.lastName = lastName;
if (phone !== undefined) u.profile.phone = phone;
// if they submitted a new password, hash + save it
if (password) {
u.password = await bcrypt.hash(password, 10);
}
await u.save();
return res.json({ success: true, profile: u.profile });
}
await user.save();
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/profiles/me → unpublish pro or delete normal account
router.delete("/me", requireAuth, async (req, res) => {
try {
// Delete the entire user account
await User.findByIdAndDelete(req.user.id);
return res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

View File

@ -0,0 +1,90 @@
import express from "express";
import { User } from "../models/User.js";
const router = express.Router();
router.get("/:field", async (req, res) => {
const { field } = req.params;
const lat = parseFloat(req.query.lat);
const lon = parseFloat(req.query.lon);
if (isNaN(lat) || isNaN(lon)) {
return res.status(400).json({ error: "Must provide lat & lon" });
}
const rawRadius = parseFloat(req.query.radius);
const radiusKm = isNaN(rawRadius) || rawRadius < 0 ? 50 : rawRadius;
const maxDistance = radiusKm * 1000;
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = 6; // now 6 per page
const skip = (page - 1) * limit;
const sortBy = req.query.sortBy === "rate" ? "rate" : "distance";
const order = req.query.order === "desc" ? -1 : 1;
try {
// pipeline to get paged docs
const pipeline = [
{
$geoNear: {
near: { type: "Point", coordinates: [lon, lat] },
distanceField: "distanceMeters",
maxDistance,
spherical: true,
},
},
{
$match: {
"profile.field": field,
"profile.published": true,
},
},
{
$sort:
sortBy === "rate"
? { "profile.rate": order }
: { distanceMeters: order },
},
{ $skip: skip },
{ $limit: limit },
];
// pipeline to count total matching
const countPipeline = [
{
$geoNear: {
near: { type: "Point", coordinates: [lon, lat] },
distanceField: "distanceMeters",
maxDistance,
spherical: true,
},
},
{
$match: {
"profile.field": field,
"profile.published": true,
},
},
{ $count: "count" },
];
const [docs, countRes] = await Promise.all([
User.aggregate(pipeline),
User.aggregate(countPipeline),
]);
const total = countRes[0]?.count || 0;
const totalPages = Math.ceil(total / limit);
const pros = docs.map((u) => ({
_id: u._id,
profile: u.profile,
distanceKm: u.distanceMeters / 1000,
}));
return res.json({ pros, page, totalPages });
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
export default router;

63
sk1/fixed-backend.sh Executable file
View File

@ -0,0 +1,63 @@
# Create a fixed deployment file
cat > fixed-backend.yml << EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: sk1
spec:
replicas: 2
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: gcr.io/$(gcloud config get-value project)/backend:latest
resources:
limits:
cpu: "500m"
memory: "512Mi"
requests:
cpu: "200m"
memory: "256Mi"
ports:
- containerPort: 4000
env:
- name: PORT
value: "4000"
- name: MONGO_URI
valueFrom:
secretKeyRef:
name: mongodb-secret
key: MONGO_URI
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: mongodb-secret
key: JWT_SECRET
- name: GOOGLE_PLACES_API_KEY
valueFrom:
secretKeyRef:
name: mongodb-secret
key: GOOGLE_PLACES_API_KEY
livenessProbe:
httpGet:
path: /api
port: 4000
initialDelaySeconds: 30
periodSeconds: 20
readinessProbe:
httpGet:
path: /api
port: 4000
initialDelaySeconds: 15
periodSeconds: 10
EOF
# Apply the fixed deployment
kubectl apply -f fixed-backend.yml

57
sk1/fixed-backend.yml Normal file
View File

@ -0,0 +1,57 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: sk1
spec:
replicas: 2
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: gcr.io/leafy-racer-458817-c0/backend:latest
resources:
limits:
cpu: "500m"
memory: "512Mi"
requests:
cpu: "200m"
memory: "256Mi"
ports:
- containerPort: 4000
env:
- name: PORT
value: "4000"
- name: MONGO_URI
valueFrom:
secretKeyRef:
name: mongodb-secret
key: MONGO_URI
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: mongodb-secret
key: JWT_SECRET
- name: GOOGLE_PLACES_API_KEY
valueFrom:
secretKeyRef:
name: mongodb-secret
key: GOOGLE_PLACES_API_KEY
livenessProbe:
httpGet:
path: /api
port: 4000
initialDelaySeconds: 30
periodSeconds: 20
readinessProbe:
httpGet:
path: /api
port: 4000
initialDelaySeconds: 15
periodSeconds: 10

24
sk1/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

26
sk1/frontend/Dockerfile Executable file
View File

@ -0,0 +1,26 @@
FROM node:20-alpine as build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
# Copy built files to nginx
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx config - keep only this line, remove the duplicate below
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
# REMOVE THIS DUPLICATE LINE
# COPY nginx/default.conf /etc/nginx/conf.d/default.conf
CMD ["nginx", "-g", "daemon off;"]

12
sk1/frontend/README.md Executable file
View File

@ -0,0 +1,12 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

33
sk1/frontend/eslint.config.js Executable file
View File

@ -0,0 +1,33 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

27
sk1/frontend/fix-config.sh Executable file
View File

@ -0,0 +1,27 @@
mkdir -p frontend/nginx
# Create a proper nginx.conf file for frontend
cat > frontend/nginx/default.conf << 'EOF'
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# API requests should be forwarded to the backend service
location /api/ {
proxy_pass http://backend-service:4000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
}
# All other requests go to the React app
location / {
try_files $uri $uri/ /index.html;
}
}
EOF

View File

@ -0,0 +1,22 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# API requests should be forwarded to the backend service
location /api/ {
proxy_pass http://backend-service:4000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
}
# All other requests go to the React app
location / {
try_files $uri $uri/ /index.html;
}
}

12
sk1/frontend/index.html Executable file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Nudge Job Portal</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

22
sk1/frontend/nginx.conf Executable file
View File

@ -0,0 +1,22 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# API requests should be forwarded to the backend service
location /api/ {
proxy_pass http://backend-service.sk1.svc.cluster.local:4000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
}
# All other requests go to the React app
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -0,0 +1,22 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# API requests should be forwarded to the backend service
location /api/ {
proxy_pass http://backend-service:4000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
}
# All other requests go to the React app
location / {
try_files $uri $uri/ /index.html;
}
}

28
sk1/frontend/package.json Executable file
View File

@ -0,0 +1,28 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.5.3"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"vite": "^6.3.1"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

6
sk1/frontend/someshit.sh Executable file
View File

@ -0,0 +1,6 @@
# Update Dockerfile to use the nginx.conf
if [ -f frontend/Dockerfile ]; then
if ! grep -q "nginx.conf" frontend/Dockerfile; then
sed -i '/CMD/i COPY nginx.conf /etc/nginx/conf.d/default.conf' frontend/Dockerfile
fi
fi

42
sk1/frontend/src/App.css Normal file
View File

@ -0,0 +1,42 @@
/* #root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
} */

34
sk1/frontend/src/App.jsx Normal file
View File

@ -0,0 +1,34 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Nav from "./components/Nav";
import Footer from "./components/Footer";
import Home from "./pages/Home";
import LoginPage from "./pages/LoginPage";
import SignUpPage from "./pages/SignUpPage";
import ServicePage from "./pages/ServicePage";
import ProfilePage from "./pages/ProfilePage";
import PublishProfilePage from "./pages/PublishProfilePage";
import ProtectedRoute from "./components/ProtectedRoute";
export default function App() {
return (
<BrowserRouter>
<Nav />
<Routes>
<Route path="/" element={<Home />} />
<Route path="login" element={<LoginPage />} />
<Route path="signup" element={<SignUpPage />} />
<Route path="services/:serviceId" element={<ServicePage />} />
<Route path="profile" element={<ProfilePage />} />
<Route
path="publish"
element={
<ProtectedRoute role="pro">
<PublishProfilePage />
</ProtectedRoute>
}
/>
</Routes>
<Footer />
</BrowserRouter>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

View File

@ -0,0 +1,4 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://www.google.com/
HostUrl=https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Instagram_icon.png/768px-Instagram_icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=about:internet

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -0,0 +1,4 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://www.flaticon.com/free-icon/mop_2946701?term=cleaning&page=1&position=5&origin=search&related_id=2946701
HostUrl=https://www.flaticon.com/download/icon/2946701?icon_id=2946701&author=159&team=159&keyword=Mop&pack=2946600&style=Lineal&style_id=26&format=png&color=%23000000&colored=1&size=512&selection=1&type=standard&search=cleaning

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=about:internet

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=about:internet

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,4 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://www.flaticon.com/free-icon/home-repair_3084913?term=repair+house&page=1&position=4&origin=search&related_id=3084913
HostUrl=https://www.flaticon.com/download/icon/3084913?icon_id=3084913&author=510&team=510&keyword=Home+repair&pack=3084910&style=Detailed+Outline&style_id=1219&format=png&color=%23000000&colored=1&size=512&selection=1&type=standard&search=repair+house

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@ -0,0 +1,4 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://www.flaticon.com/free-icon/desktop_5975099?term=mount&related_id=5975099
HostUrl=about:internet

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,4 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://www.flaticon.com/free-icon/moving-truck_4258917?term=moving&page=1&position=5&origin=search&related_id=4258917
HostUrl=about:internet

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=about:internet

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -0,0 +1,4 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://www.google.com/
HostUrl=https://freepnglogo.com/images/all_img/1725374683twitter-x-logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,4 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://www.google.com/
HostUrl=https://upload.wikimedia.org/wikipedia/commons/e/ef/Youtube_logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,4 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://www.google.com/
HostUrl=https://upload.wikimedia.org/wikipedia/commons/7/7c/Profile_avatar_placeholder_large.png?20150327203541

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 B

View File

@ -0,0 +1,67 @@
import React, { useState, useEffect } from "react";
export default function AddressAutocomplete({ onSelect }) {
const [input, setInput] = useState("");
const [options, setOptions] = useState([]);
useEffect(() => {
if (input.length < 3) {
setOptions([]);
return;
}
const ctrl = new AbortController();
fetch(`/api/autocomplete?input=${encodeURIComponent(input)}`, {
signal: ctrl.signal,
})
.then((res) => res.json())
.then((json) => setOptions(json.predictions || []))
.catch(() => {})
.finally(() => {});
return () => ctrl.abort();
}, [input]);
return (
<div className="address-autocomplete" style={{ position: "relative" }}>
<input
type="text"
placeholder="Start typing address…"
value={input}
onChange={(e) => setInput(e.target.value)}
style={{ width: "100%", padding: "0.5rem", boxSizing: "border-box" }}
/>
{options.length > 0 && (
<ul
className="autocomplete-list"
style={{
listStyle: "none",
margin: 0,
padding: "0.5rem",
position: "absolute",
top: "100%",
left: 0,
right: 0,
background: "#fff",
border: "1px solid #ccc",
maxHeight: "200px",
overflowY: "auto",
zIndex: 1000,
}}
>
{options.map((opt) => (
<li
key={opt.place_id}
onClick={() => {
setInput(opt.description);
setOptions([]);
onSelect(opt.description);
}}
style={{ padding: "0.25rem 0", cursor: "pointer" }}
>
{opt.description}
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -0,0 +1,11 @@
import { Link } from "react-router-dom";
export default function Card({ name, iconImg, slug }) {
return (
<li>
<Link className="service-card" to={`/services/${slug}`}>
<img className="icon-img" src={iconImg} alt={name} />
<p>{name}</p>
</Link>
</li>
);
}

View File

@ -0,0 +1,32 @@
import Card from "./Card";
import assemblyImg from "../assets/icons/assembly.png";
import mountingImg from "../assets/icons/mounting.png";
import homeRepairImg from "../assets/icons/home-repair.png";
import plumberImg from "../assets/icons/plumbering.png";
import electricianImg from "../assets/icons/electrician.png";
import gardeningImg from "../assets/icons/gardening.png";
import cleaningImg from "../assets/icons/cleaning.png";
import movingImg from "../assets/icons/moving.png";
export default function Cards() {
const services = [
{ name: "Assembly", slug: "assembly", icon: assemblyImg },
{ name: "Mounting", slug: "mounting", icon: mountingImg },
{ name: "Home Repairs", slug: "home-repairs", icon: homeRepairImg },
{ name: "Plumber", slug: "plumber", icon: plumberImg },
{ name: "Electrician", slug: "electrician", icon: electricianImg },
{ name: "Gardening", slug: "gardening", icon: gardeningImg },
{ name: "Moving", slug: "moving", icon: movingImg },
{ name: "Cleaning", slug: "cleaning", icon: cleaningImg },
];
return (
<section className="service-cards">
<ul>
{services.map((s) => (
<Card key={s.slug} name={s.name} slug={s.slug} iconImg={s.icon} />
))}
</ul>
</section>
);
}

View File

@ -0,0 +1,33 @@
import twitterImg from "../assets/icons/twitter-x-logo.png";
import instaImg from "../assets/icons/Instagram_icon.png";
import youtubeImg from "../assets/icons/youtube-img.png";
import mainIcon from "../assets/main-section-logo.png";
export default function Header() {
return (
<footer>
<div className="footer-content">
<div className="logo-social-media">
<img className="company-logo" src={mainIcon} alt="footer-logo" />
<p>&#169; 2025 All rights reserved</p>
<ul>
<li>
<a href="">
<img src={youtubeImg} alt="link to youtube" />
</a>
</li>
<li>
<a href="">
<img src={instaImg} alt="link to Instagram" />
</a>
</li>
<li>
<a href="">
<img src={twitterImg} alt="link to Twitter" />
</a>
</li>
</ul>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,39 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import headerLogo from "../assets/main-section-logo.png";
import searchIcon from "../assets/search-icon.png";
export default function Header() {
const [query, setQuery] = useState("");
const navigate = useNavigate();
const handleSubmit = (e) => {
e.preventDefault();
const term = query.trim().toLowerCase().replace(/\s+/g, "-");
if (term) {
navigate(`/services/${term}`);
setQuery("");
}
};
return (
<section className="main-header-section">
<div className="main-logo-img-container">
<img className="main-logo" src={headerLogo} alt="main header logo" />
<form onSubmit={handleSubmit}>
<div className="search-bar">
<input
type="search"
value={query}
placeholder="Search services…"
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit">
<img src={searchIcon} alt="search icon" id="search-icon" />
</button>
</div>
</form>
</div>
</section>
);
}

View File

@ -0,0 +1,31 @@
export default function HowItWorks() {
return (
<section className="how-works">
<div className="header">
<h2 className="how-title">How It Works</h2>
</div>
<div className="step">
<h3>1. Choose a Category</h3>
<p>
Set your location and browse through our skilled service providers.
Review their hourly rates and select the one that fits your needs
best.
</p>
</div>
<div className="step">
<h3>2. Contact a Service Provider</h3>
<p>
Sign up and send a request to your chosen provider, specifying the
date and time for the service you require.
</p>
</div>
<div className="step">
<h3>3. Get the Job Done</h3>
<p>
On the scheduled date, your selected provider will arrive and complete
the job with professionalism and skill.
</p>
</div>
</section>
);
}

View File

@ -0,0 +1,14 @@
import React from "react";
export default function Modal({ children, onClose }) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button className="btn close-btn" onClick={onClose}>
×
</button>
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
import React from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import navLogo from "../assets/Nudge-logo-nav.png";
export default function Nav() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate("/");
};
return (
<header>
<nav className="main-nav">
<div className="in-nav">
<div className="nav-logo">
<Link id="img-link" to="/">
<img src={navLogo} alt="logo" />
</Link>
</div>
<ul className="nav-menu">
<Link to="/">Home</Link>
{!user ? (
<>
<li className="nav-item">
<Link to="/login">Login</Link>
</li>
<li className="nav-item">
<Link to="/signup">Sign Up</Link>
</li>
</>
) : (
<>
<li className="nav-item">
<Link to="/profile">Profile</Link>
</li>
<li className="nav-item">
<button
className="select-seller-button"
onClick={handleLogout}
>
Logout
</button>
</li>
</>
)}
</ul>
</div>
</nav>
</header>
);
}

View File

@ -0,0 +1,35 @@
import React from "react";
import placeholder from "../assets/placeholder.png";
export default function ProCard({ pro, onClick }) {
const distNum =
typeof pro.distanceKm === "number"
? pro.distanceKm
: parseFloat(pro.distanceKm) || 0;
return (
<div className="seller-card" onClick={onClick}>
<div className="name-and-profile">
<h3 className="seller-name">
{pro.profile.firstName} {pro.profile.lastName}
</h3>
{pro.distanceKm != null && (
<p className="seller-distance">{distNum.toFixed(1)} KM Away</p>
)}
<p className="seller-tag">{pro.profile.field}</p>
<img
src={pro.profile.picture || placeholder}
className="seller-picture"
alt={`${pro.profile.firstName}'s profile`}
/>
</div>
<div className="profile-description">
<p>
<b>How I Can Help:</b>
</p>
<p>{pro.profile.description || "No description provided."}</p>
</div>
<button className="select-seller-button">Select</button>
</div>
);
}

View File

@ -0,0 +1,189 @@
import React from "react";
import AddressAutocomplete from "./AddressAutocomplete";
export default function ProfileForm({
form,
handleChange,
handleFile,
editMode,
user,
onSubmit,
onCancel,
togglePublish,
pubError,
remove,
}) {
return (
<form onSubmit={onSubmit} className="profile-form">
{editMode && user.role === "pro" && (
<>
{form.picture && (
<img
src={form.picture}
alt="Preview"
className="profile-picture profile-picture-edit"
/>
)}
<label>
Upload Picture
<input type="file" accept="image/*" onChange={handleFile} />
</label>
</>
)}
{/* First/Last */}
{editMode ? (
<>
<label>
First Name
<input
name="firstName"
value={form.firstName}
onChange={handleChange}
required
/>
</label>
<label>
Last Name
<input
name="lastName"
value={form.lastName}
onChange={handleChange}
required
/>
</label>
</>
) : (
<>
<div className="field-group">
<strong>First Name:</strong> {form.firstName}
</div>
<div className="field-group">
<strong>Last Name:</strong> {form.lastName}
</div>
</>
)}
{/* Phone */}
{editMode ? (
<label>
Phone
<input
name="phone"
value={form.phone}
onChange={handleChange}
type="tel"
/>
</label>
) : (
<div className="field-group">
<strong>Phone:</strong> {form.phone || "—"}
</div>
)}
{/* Address */}
{editMode ? (
<label>
Address
<AddressAutocomplete
value={form.address}
onSelect={(a) =>
handleChange({ target: { name: "address", value: a } })
}
onChange={handleChange}
/>
</label>
) : (
<div className="field-group">
<strong>Address:</strong> {form.address}
</div>
)}
{/* Pro-only */}
{user.role === "pro" && (
<>
{editMode ? (
<label>
Service Field
<select
name="field"
value={form.field}
onChange={handleChange}
required
>
<option value="">Select a field</option>
{/* ...options... */}
</select>
</label>
) : (
<div className="field-group">
<strong>Service Field:</strong>{" "}
{form.field.charAt(0).toUpperCase() + form.field.slice(1)}
</div>
)}
{editMode ? (
<label>
Rate ($/hr)
<input
name="rate"
type="number"
value={form.rate}
onChange={handleChange}
required
/>
</label>
) : (
<div className="field-group">
<strong>Rate:</strong> ${form.rate}/hr
</div>
)}
{/* Publish toggle */}
{!editMode && (
<>
<button type="button" className="btn" onClick={togglePublish}>
{form.published ? "Unpublish" : "Publish"}
</button>
{pubError && <p className="error">{pubError}</p>}
</>
)}
{/* Description */}
{editMode ? (
<label>
Description
<textarea
name="description"
value={form.description}
onChange={handleChange}
required
/>
</label>
) : (
<div className="field-group">
<strong>Description:</strong> {form.description || "—"}
</div>
)}
</>
)}
{/* Edit buttons */}
{editMode ? (
<div className="form-buttons">
<button type="submit">Save</button>
<button type="button" onClick={onCancel}>
Cancel
</button>
</div>
) : null}
{/* Delete */}
{!editMode && remove && (
<button type="button" className="danger" onClick={remove}>
Delete
</button>
)}
</form>
);
}

View File

@ -0,0 +1,18 @@
import React from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
export default function ProtectedRoute({ children, role }) {
const { user } = useAuth();
const location = useLocation();
// Not logged in redirect to login
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
// Role mismatch redirect home
if (role && user.role !== role) {
return <Navigate to="/" replace />;
}
return children;
}

View File

@ -0,0 +1,58 @@
import React, { createContext, useState, useEffect, useContext } from "react";
function parseJWT(token) {
try {
const payload = token.split(".")[1];
return JSON.parse(atob(payload));
} catch {
return null;
}
}
const AuthContext = createContext({
user: null,
token: null,
login: () => {},
logout: () => {},
});
export function AuthProvider({ children }) {
const [token, setToken] = useState(null);
const [user, setUser] = useState(null);
useEffect(() => {
const t = localStorage.getItem("token");
if (t) {
const data = parseJWT(t);
if (data) {
setToken(t);
setUser({ id: data.sub, role: data.role });
}
}
}, []);
const login = (newToken) => {
const data = parseJWT(newToken);
if (data) {
localStorage.setItem("token", newToken);
setToken(newToken);
setUser({ id: data.sub, role: data.role });
}
};
const logout = () => {
localStorage.removeItem("token");
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ user, token, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

787
sk1/frontend/src/index.css Normal file
View File

@ -0,0 +1,787 @@
@import url("https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Raleway:ital,wght@0,100..900;1,100..900&display=swap");
html {
font-size: 16px;
background-color: #f7f8fb;
box-sizing: border-box;
height: 100%;
margin: 0;
}
*,
*::after,
*::before {
box-sizing: inherit;
}
body {
font-family: "Raleway", sans-serif;
margin: 0;
padding: 0;
min-width: 460px;
height: 100%;
}
#root {
display: flex;
flex-direction: column;
height: 100%;
}
main {
max-width: 1200px;
margin: 0 auto;
flex: 1;
}
h2 {
font-size: 2rem;
}
h1 {
font-size: 3rem;
}
h3 {
font-size: 1.5rem;
}
/* Nav section */
nav {
/* border-bottom: 1px solid rgba(139, 139, 139, 0.5); */
background-color: #f7f8fb;
min-height: 60px;
position: sticky;
top: 0;
right: 0;
padding: 8px 0 8px 0;
box-shadow: rgba(0, 0, 0, 0.15) 2.4px 2.4px 3.2px;
}
.humburger {
display: none;
cursor: pointer;
}
.bar {
display: block;
width: 25px;
height: 3px;
margin: 5px auto;
background-color: #000000;
}
.in-nav {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
}
.in-nav img {
width: 158px;
}
.nav-menu {
list-style-type: none;
display: flex;
flex-flow: row nowrap;
align-items: center;
margin: 0;
padding: 0;
justify-content: space-between;
}
.nav-logo {
flex-basis: 60%;
flex-shrink: 1;
}
.nav-item {
flex-shrink: 0;
}
.in-nav a {
text-decoration: none;
color: rgba(0, 0, 0, 0.5);
padding: 16px 22px;
}
#img-link {
padding: 0;
}
.in-nav a:hover {
color: rgb(0, 0, 0);
}
.nav-button a {
border: 1px solid #323232;
border-radius: 10px;
color: #323232;
}
.nav-button a:hover {
background-color: #323232;
color: #ffffff;
}
/* main section */
/* header section */
.main-header-section {
height: 358px;
background-color: #323232;
margin: 60px auto 20px auto;
border-radius: 10px;
display: flex;
flex-flow: column nowrap;
justify-content: center;
padding: 30px;
}
.main-logo {
width: 389px;
height: 132px;
display: block;
margin: 0 auto;
}
.main-logo-img-container .search-bar {
background: #ffffff;
max-width: 660px;
height: 60px;
margin: 60px auto 0 auto;
border-radius: 10px;
}
.search-bar input {
width: 90%;
height: 100%;
border-radius: 10px;
border: none;
font-size: 1.9rem;
}
.search-bar input:focus {
outline: none;
}
.search-bar button {
width: 9%;
height: 51px;
border-radius: 10px;
border: none;
background-color: #323232;
}
.search-bar button:hover {
background-color: #3d3d3d;
}
/* card section */
.service-cards {
margin: 0 auto 60px auto;
}
.service-cards .icon-img {
width: 70px;
}
.service-cards ul {
margin: 0;
padding: 0;
list-style-type: none;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
row-gap: 15px;
}
.service-cards .service-card {
display: block;
width: 116px;
height: 116px;
box-shadow: 0 4px 13.8px 0 rgb(0 0 0 / 25%);
border-radius: 10px;
text-decoration: none;
color: rgba(0, 0, 0, 0.5);
display: flex;
flex-flow: column nowrap;
align-items: center;
justify-content: center;
background-color: #ffffff;
}
.service-cards .service-card:hover {
background-color: rgba(141, 141, 141, 0.13);
}
.service-card p {
font-size: 15px;
margin: 13px 0 0 0;
color: #000000;
}
/* how it works section */
.how-works {
background-color: #e2e2e2;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: auto;
column-gap: 60px;
padding: 24px;
border-radius: 10px;
margin-bottom: 60px;
}
.how-works .header {
grid-column: 1/4;
text-align: center;
margin-bottom: 40px;
}
.how-works p,
h3 {
text-align: center;
}
/* footer */
footer {
background-color: #000000;
color: #ffffff;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 40px 0;
}
footer ul img {
width: 32px;
}
footer .company-logo {
width: 140px;
}
footer .line {
height: 1px;
background-color: rgba(255, 255, 255, 0.5);
margin: 40px 0;
}
.logo-social-media {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: center;
justify-items: auto;
justify-content: space-between;
}
.logo-social-media ul {
list-style-type: none;
display: flex;
align-items: center;
column-gap: 32px;
margin: 0 0 0 auto;
text-align: right;
}
footer .logo-social-media p {
text-align: center;
}
footer .all-links {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: auto;
}
.all-links ul {
padding: 0;
list-style-type: none;
}
footer .all-links a {
color: rgba(255, 255, 255, 0.5);
}
footer .all-links a:hover {
color: rgba(255, 255, 255);
}
.all-links li {
margin-bottom: 24px;
}
/* --- login & signup form styles --- */
.form-page {
width: 100%;
max-width: 400px;
margin: 2rem auto;
padding: 2rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.form-page h1 {
margin-bottom: 1.5rem;
font-size: 1.75rem;
text-align: center;
color: #333;
}
.form-page form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-page label {
display: flex;
flex-direction: column;
font-size: 0.9rem;
color: #555;
}
.form-page input[type="email"],
.form-page input[type="password"] {
margin-top: 0.25rem;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.form-page fieldset {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0.75rem;
position: relative;
padding-right: 1rem;
}
.form-page fieldset legend {
margin-bottom: 0.5rem;
background: #fff;
padding: 0 0.5rem;
position: absolute;
top: -0.65em;
left: 1rem;
font-size: 0.9rem;
color: #333;
}
.form-page fieldset label {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.25rem;
margin: 0;
}
.form-page legend {
font-size: 0.9rem;
color: #333;
padding: 0 0.5rem;
}
.form-page input[type="radio"] {
margin-right: 0.5rem;
}
.form-page button {
padding: 0.75rem;
background: transparent;
border: 1px solid #323232;
border-radius: 10px;
color: #323232;
cursor: pointer;
margin-top: 0.5rem;
}
.form-page button:hover {
background-color: #323232;
color: #ffffff;
}
.form-page .error {
color: #e74c3c;
font-size: 0.9rem;
text-align: center;
}
/* Profile seller cards */
.seller-section {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: auto;
row-gap: 40px;
column-gap: 60px;
margin: 60px 0;
}
.seller-card {
width: 360px;
background-color: #ffffff;
padding: 23px 30px;
border-radius: 10px;
box-shadow: 3.07px 3.07px 25.39px 0 rgb(0 0 0 / 25%);
}
.seller-card:hover {
background-color: rgba(141, 141, 141, 0.13);
}
.seller-card p,
h3 {
margin: 0;
margin-bottom: 12.5px;
}
.seller-card .seller-picture {
width: 107px;
height: 107px;
border-radius: 50%;
object-fit: cover;
}
.seller-card .name-and-profile {
display: grid;
grid-template-columns: 2fr 1fr;
grid-template-rows: repeat(3, 0.1fr);
margin-bottom: 18px;
}
.seller-name {
text-align: left;
grid-column: 1/2;
grid-row: 1/2;
}
.seller-distance {
grid-column: 1/2;
grid-row: 2/3;
}
.seller-picture {
grid-column: 2/3;
grid-row: 1/4;
}
.seller-tag {
background-color: #cbcbcb;
width: fit-content;
padding: 3px;
border-radius: 10px;
}
.seller-tag p {
margin-bottom: 0;
}
.profile-description {
margin-bottom: 18px;
}
.select-seller-button {
width: 123px;
padding: 12.27px 16.86px;
border: none;
border-radius: 10px;
background-color: #323232;
color: #ffffff;
}
.select-seller-button:hover {
background-color: #3d3d3d;
cursor: pointer;
}
/* form layout */
.form-page {
max-width: 600px;
margin: 2rem auto;
padding: 1rem;
}
.profile-form label {
display: block;
margin-bottom: 1rem;
}
.profile-form input,
.profile-form select {
width: 100%;
padding: 0.5rem;
margin-top: 0.25rem;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
.checkbox-label {
display: flex;
align-items: center;
}
.checkbox-label input {
margin-right: 0.5rem;
}
.form-buttons {
margin-top: 1.5rem;
}
.form-buttons button {
background: #323232;
color: #fff;
border: none;
padding: 0.6rem 1.2rem;
border-radius: 4px;
cursor: pointer;
}
.form-buttons button:hover {
background: #3d3d3d;
}
.form-buttons .danger {
margin-left: 1rem;
background: #c0392b;
}
.form-buttons .danger:hover {
background: #e74c3c;
}
.field-group {
margin-bottom: 1rem;
font-size: 1rem;
}
/* Controls container */
.service-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin: 1rem 0;
width: 1200px;
}
/* Left: radius filter */
.filter-group {
display: flex;
align-items: center;
}
.filter-group label {
margin-right: 0.5rem;
font-weight: 500;
}
.filter-group input {
width: 8ch; /* increased width for visibility */
min-width: 80px; /* ensure it never collapses */
padding: 0.5rem 1rem;
border: 1px solid #323232;
border-radius: 4px;
background: transparent;
font-size: 0.9rem;
margin: 0 0.5rem;
}
.filter-group input:focus {
outline: none;
}
/* Right: sort buttons */
.sort-group {
display: flex;
align-items: center;
}
.sort-btn {
margin-left: 0.5rem;
}
/* Shared button styles */
.btn {
background: transparent;
border: 1px solid #323232; /* same dark color as your “select-seller-button” */
color: #323232;
border-radius: 4px;
padding: 0.5rem 1rem;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s, color 0.2s;
}
.btn:hover {
background-color: #323232;
color: #ffffff;
}
/* Page title */
.service-title {
text-transform: capitalize;
margin-bottom: 0.5rem;
}
/* Login prompt button */
.login-button {
background-color: #e53e3e;
}
.login-button:hover {
background-color: #c53030;
}
/* ProfilePage buttons (save, cancel, edit) */
.save-button {
margin-right: 0.5rem;
}
.cancel-button {
margin-left: 0.5rem;
}
.edit-button {
margin-top: 1rem;
display: inline-block;
padding: 0.5rem 1rem;
}
/* reuse your .select-seller-button theme for logout/nav */
.select-seller-button {
background-color: #323232;
color: #ffffff;
border: none;
border-radius: 10px;
padding: 0.5rem 1rem;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.select-seller-button:hover {
background-color: #3d3d3d;
}
/* Pagination container */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin: 2rem 0;
}
/* Pagination buttons match seller style but smaller */
.pagination-btn {
background-color: #323232;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.4rem 0.8rem;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.2s;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: default;
}
.pagination-btn:hover:not(:disabled) {
background-color: #3d3d3d;
}
/* ─── Edit-Profile button: match .btn theme and right-align ─────────────── */
.form-page > .edit-button {
display: block;
margin: 1rem 0 1rem auto; /* push it to the right */
background: transparent;
border: 1px solid #323232;
color: #323232;
border-radius: 4px;
padding: 0.5rem 1rem;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s, color 0.2s;
}
.form-page > .edit-button:hover {
background-color: #323232;
color: #ffffff;
}
.fullpage-message {
text-align: center;
}
.profile-picture {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 50%;
display: block;
margin-top: 0.5rem;
}
/* 3) Make all textareas match inputs */
.profile-form textarea {
width: 100%;
min-height: 3em; /* ~3 lines high */
padding: 0.5rem 1rem;
border: 1px solid #323232;
border-radius: 4px;
font-size: 0.9rem;
font-family: inherit;
box-sizing: border-box;
resize: vertical;
margin-top: 0.5rem;
}
/* 2b) Tweak the edit-mode preview if you like */
.profile-picture-edit {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 50%;
display: block;
margin: 0.5rem 0;
}
/* 4) Add space between Save & Cancel */
.form-buttons > button + button {
margin-left: 0.5rem;
}
/* Modal Overlay */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
animation: fadeIn 0.3s forwards;
}
/* ─── Modal Content ────────────────────────────────────────────────── */
.modal-overlay .modal-content {
background: #fff;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.25);
max-width: 600px;
width: 90%;
max-height: 90%;
overflow-y: auto;
animation: slideDown 0.4s ease-out both;
width: 80vw;
max-width: 800px;
}
/* ─── Close Button ────────────────────────────────────────────────── */
.modal-content .close-btn {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: transparent;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: #333;
}
.modal-content .close-btn:hover {
color: #000;
}
/* ─── Animations ─────────────────────────────────────────────────── */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideDown {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* ensure the overlay fade animation still works */
.modal-overlay {
animation: fadeIn 0.3s forwards;
}

13
sk1/frontend/src/main.jsx Normal file
View File

@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { AuthProvider } from "./contexts/AuthContext";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,13 @@
import Header from "../components/Header";
import Cards from "../components/Cards";
import HowItWorks from "../components/HowItWorks";
export default function Home() {
return (
<main>
<Header />
<Cards />
<HowItWorks />
</main>
);
}

View File

@ -0,0 +1,57 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const navigate = useNavigate();
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Login failed");
login(data.token); // store token & user in context
navigate("/"); // redirect on success
} catch (err) {
setError(err.message);
}
};
return (
<main className="form-page">
<h1>Sign In</h1>
<form onSubmit={handleSubmit}>
{error && <p className="error">{error}</p>}
<label>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
<button type="submit">Log In</button>
</form>
</main>
);
}

View File

@ -0,0 +1,141 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import ProfileForm from "../components/ProfileForm";
import { useAuth } from "../contexts/AuthContext";
export default function ProfilePage() {
const { user, token, logout } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState({
firstName: "",
lastName: "",
phone: "",
address: "",
password: "",
field: "",
rate: "",
published: false,
description: "",
picture: "",
});
const [initial, setInitial] = useState(form);
const [editMode, setEditMode] = useState(false);
const [pubError, setPubError] = useState("");
useEffect(() => {
fetch("/api/profiles/me", {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((data) => {
const p = data.profile || {};
const loaded = {
firstName: p.firstName || "",
lastName: p.lastName || "",
phone: p.phone || "",
address: p.address || "",
password: "",
field: p.field || "",
rate: p.rate || "",
published: p.published || false,
description: p.description || "",
picture: p.picture || "",
};
setForm(loaded);
setInitial(loaded);
});
}, [token]);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setForm((f) => ({ ...f, [name]: type === "checkbox" ? checked : value }));
};
const handleFile = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () =>
setForm((f) => ({ ...f, picture: reader.result }));
reader.readAsDataURL(file);
}
};
const save = async () => {
await fetch("/api/profiles", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(form),
});
setInitial(form);
setEditMode(false);
};
const togglePublish = async () => {
setPubError("");
try {
const res = await fetch("/api/profiles", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ published: !form.published }),
});
const data = await res.json();
if (!res.ok) {
setPubError(data.error || "Failed to update publish status");
return;
}
setForm((f) => ({ ...f, published: !f.published }));
setInitial((i) => ({ ...i, published: !i.published }));
} catch (err) {
console.error(err);
setPubError("Network error, try again");
}
};
const remove = async () => {
if (!window.confirm("Delete account?")) return;
await fetch("/api/profiles/me", {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
logout();
navigate("/");
};
return (
<main className="form-page">
<h1>My Profile</h1>
{!editMode && (
<button className="btn edit-button" onClick={() => setEditMode(true)}>
Edit Profile
</button>
)}
<ProfileForm
form={form}
handleChange={handleChange}
handleFile={handleFile}
editMode={editMode}
user={user}
onSubmit={(e) => {
e.preventDefault();
save();
}}
onCancel={() => {
setForm(initial);
setEditMode(false);
}}
togglePublish={togglePublish}
pubError={pubError}
remove={remove}
/>
</main>
);
}

View File

@ -0,0 +1,99 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import ProfileForm from "../components/ProfileForm";
export default function PublishProfilePage() {
const { token } = useAuth();
const nav = useNavigate();
const [form, setForm] = useState({
firstName: "",
lastName: "",
phone: "",
address: "",
description: "",
picture: "",
field: "",
rate: "",
published: true,
});
const [error, setError] = useState("");
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setForm((f) => ({
...f,
[name]: type === "checkbox" ? checked : value,
}));
};
const handleFile = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => setForm((f) => ({ ...f, picture: reader.result }));
reader.readAsDataURL(file);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
// require all fields
const {
firstName,
lastName,
phone,
address,
description,
picture,
field,
rate,
} = form;
if (
!firstName ||
!lastName ||
!phone ||
!address ||
!description ||
!picture ||
!field ||
rate === ""
) {
return setError(
"Please complete all fields (including picture & description) before publishing."
);
}
try {
const res = await fetch("/api/profiles", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(form),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Publish failed");
nav("/profile");
} catch (err) {
setError(err.message);
}
};
return (
<main className="form-page">
<h1>Publish Your Seller Profile</h1>
{error && <p className="error">{error}</p>}
<ProfileForm
form={form}
handleChange={handleChange}
handleFile={handleFile}
editMode={true}
user={{ role: "pro" }}
onSubmit={handleSubmit}
onCancel={null}
/>
</main>
);
}

View File

@ -0,0 +1,225 @@
import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import ProCard from "../components/ProCard";
import Modal from "../components/Modal";
export default function ServicePage() {
const { serviceId } = useParams();
const { user, token } = useAuth();
const navigate = useNavigate();
const [pros, setPros] = useState([]);
const [loading, setLoading] = useState(true);
const [radius, setRadius] = useState(10);
const [coords, setCoords] = useState({ lat: null, lon: null });
const [sortBy, setSortBy] = useState("distance");
const [order, setOrder] = useState("asc");
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loginPromptOpen, setLoginPromptOpen] = useState(false);
const [selectedPro, setSelectedPro] = useState(null);
const [profileData, setProfileData] = useState(null);
useEffect(() => {
const defaultLocation = { latitude: null, longitude: null };
function onSuccess(pos) {
const { latitude, longitude } = pos?.coords ?? defaultLocation;
setCoords({ lat: latitude, lon: longitude });
fetchList(latitude, longitude, radius, sortBy, order, page);
}
function onError(err) {
console.warn("Geolocation unavailable:", err);
setCoords({ lat: null, lon: null });
// still fetch without coords
fetchList(null, null, radius, sortBy, order, page);
}
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(onSuccess, onError, {
enableHighAccuracy: true,
timeout: 5000,
maximumAge: 0,
});
} else {
onError(new Error("Geolocation not supported"));
}
// eslint-disable-next-line
}, [serviceId]);
function fetchList(lat, lon, rad, sort, ord, pg) {
setLoading(true);
let q = `?radius=${rad}&page=${pg}&sortBy=${sort}&order=${ord}`;
// only append if both are real numbers
if (typeof lat === "number" && typeof lon === "number") {
q += `&lat=${lat}&lon=${lon}`;
}
fetch(`/api/services/${serviceId}${q}`)
.then((r) => r.json())
.then((data) => {
setPros(data.pros || []);
setPage(data.page || 1);
setTotalPages(data.totalPages || 1);
})
.catch(console.error)
.finally(() => setLoading(false));
}
function applyFilters() {
setPage(1);
fetchList(coords.lat, coords.lon, radius, sortBy, order, 1);
}
function toggleSort(field) {
const nextOrder = order === "asc" ? "desc" : "asc";
setSortBy(field);
setOrder(nextOrder);
fetchList(coords.lat, coords.lon, radius, field, nextOrder, 1);
}
function gotoPage(p) {
fetchList(coords.lat, coords.lon, radius, sortBy, order, p);
}
function handleCardClick(pro) {
if (!user) return setLoginPromptOpen(true);
fetch(`/api/profiles/${pro._id}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then(({ profile }) => {
setSelectedPro(pro);
setProfileData(profile);
})
.catch(console.error);
}
if (loading) {
return (
<main>
<section className="fullpage-message loading">
<p>Loading {serviceId}</p>
</section>
</main>
);
}
return (
<main>
<h1 className="service-title">{serviceId}</h1>
{/* Controls */}
<div className="service-controls">
<div className="filter-group">
<label>
Radius (km):
<input
type="number"
min="0"
value={radius}
onChange={(e) => {
const v = Number(e.target.value);
// ignore invalid or negative
setRadius(isNaN(v) ? 0 : Math.max(0, v));
}}
/>
</label>
<button className="btn apply-btn" onClick={applyFilters}>
Apply
</button>
</div>
<div className="sort-group">
<button
className="btn sort-btn"
onClick={() => toggleSort("distance")}
>
Distance{" "}
{sortBy === "distance" ? (order === "asc" ? "↑" : "↓") : ""}
</button>
<button className="btn sort-btn" onClick={() => toggleSort("rate")}>
Rate {sortBy === "rate" ? (order === "asc" ? "↑" : "↓") : ""}
</button>
</div>
</div>
{/* Listing or “No providers” */}
{pros.length > 0 ? (
<section className="seller-section">
{pros.map((p) => (
<ProCard key={p._id} pro={p} onClick={() => handleCardClick(p)} />
))}
</section>
) : (
<section className="fullpage-message">
<p>No providers found in this radius.</p>
</section>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="pagination">
<button
className="pagination-btn"
onClick={() => gotoPage(page - 1)}
disabled={page <= 1}
>
Prev
</button>
<span>
Page {page} of {totalPages}
</span>
<button
className="pagination-btn"
onClick={() => gotoPage(page + 1)}
disabled={page >= totalPages}
>
Next
</button>
</div>
)}
{loginPromptOpen && (
<Modal onClose={() => setLoginPromptOpen(false)}>
<h2>Please Sign In</h2>
<p>You must be logged in to view provider details.</p>
<button className="btn" onClick={() => navigate("/login")}>
Go to Login
</button>
</Modal>
)}
{selectedPro && profileData && (
<Modal onClose={() => setSelectedPro(null)}>
<h2>
{profileData.firstName} {profileData.lastName}
</h2>
<p>
<strong>Field:</strong> {profileData.field}
</p>
<p>
<strong>Rate:</strong> ${profileData.rate}/hr
</p>
<p>
<strong>Phone:</strong> {profileData.phone || "N/A"}
</p>{" "}
{/* ← show phone */}
<p>
<strong>Address:</strong>{" "}
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(
profileData.address
)}`}
target="_blank"
rel="noopener noreferrer"
>
{profileData.address}
</a>
</p>
</Modal>
)}
</main>
);
}

View File

@ -0,0 +1,82 @@
import { useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
export default function SignUpPage() {
const [searchParams] = useSearchParams();
const defaultRole = searchParams.get("role") === "pro" ? "pro" : "normal";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState(defaultRole);
const [error, setError] = useState("");
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, role }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Sign up failed");
navigate("/login");
} catch (err) {
setError(err.message);
}
};
return (
<main className="form-page">
<h1>Sign Up</h1>
<form onSubmit={handleSubmit}>
{error && <p className="error">{error}</p>}
<label>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
<fieldset>
<legend>Account Type</legend>
<label>
<input
type="radio"
value="normal"
checked={role === "normal"}
onChange={(e) => setRole(e.target.value)}
/>
Normal
</label>
<label>
<input
type="radio"
value="pro"
checked={role === "pro"}
onChange={(e) => setRole(e.target.value)}
/>
Pro (Seller)
</label>
</fieldset>
<button type="submit">Create Account</button>
</form>
</main>
);
}

22
sk1/frontend/vite.config.js Executable file
View File

@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
root: '.', // force project root
build: {
outDir: 'dist',
rollupOptions: {
input: 'index.html' // explicit entry
}
},
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://localhost:4000",
changeOrigin: true,
secure: false,
},
},
},
})

View File

@ -0,0 +1,14 @@
apiVersion: cloud.google.com/v1
kind: BackendConfig
metadata:
name: backend-health
namespace: sk1
spec:
healthCheck:
checkIntervalSec: 15
timeoutSec: 5
healthyThreshold: 1
unhealthyThreshold: 2
type: HTTP
requestPath: /api
port: 4000

View File

@ -0,0 +1,58 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: sk1
spec:
replicas: 2
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: gcr.io/${PROJECT}/backend:latest
resources:
limits:
cpu: "500m"
memory: "512Mi"
requests:
cpu: "200m"
memory: "256Mi"
ports:
- containerPort: 4000
env:
- name: PORT
value: "4000"
- name: MONGO_URI
valueFrom:
secretKeyRef:
name: mongodb-secret
key: MONGO_URI
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: mongodb-secret
key: JWT_SECRET
- name: GOOGLE_PLACES_API_KEY
valueFrom:
secretKeyRef:
name: mongodb-secret
key: GOOGLE_PLACES_API_KEY
# Change probes to use /api endpoint which exists
livenessProbe:
httpGet:
path: /api
port: 4000
initialDelaySeconds: 30
periodSeconds: 20
readinessProbe:
httpGet:
path: /api
port: 4000
initialDelaySeconds: 15
periodSeconds: 10

14
sk1/k8s/backend-service.yml Executable file
View File

@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: backend-service
namespace: sk1
annotations:
cloud.google.com/backend-config: '{"default": "backend-health"}'
spec:
selector:
app: backend
ports:
- port: 4000
targetPort: 4000
type: ClusterIP

View File

@ -0,0 +1,39 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: sk1
spec:
replicas: 2
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: gcr.io/${PROJECT}/frontend:latest
resources:
limits:
cpu: "0.3"
memory: "256Mi"
requests:
cpu: "0.1"
memory: "128Mi"
ports:
- containerPort: 80
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 20
periodSeconds: 15

10
sk1/k8s/frontend-service.yml Executable file
View File

@ -0,0 +1,10 @@
apiVersion: v1
kind: Service
metadata:
name: frontend-service
spec:
selector:
app: frontend
ports:
- port: 80
targetPort: 80

28
sk1/k8s/ingress.yml Executable file
View File

@ -0,0 +1,28 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sk1-ingress
namespace: sk1
annotations:
kubernetes.io/ingress.global-static-ip-name: sk1-static-ip
networking.gke.io/managed-certificates: sk1-certificate
kubernetes.io/ingress.class: "gce"
spec:
rules:
- host: nudges.works
http:
paths:
- path: /api/
pathType: Prefix
backend:
service:
name: backend-service
port:
number: 4000
- path: /
pathType: Prefix
backend:
service:
name: frontend-service
port:
number: 80

7
sk1/k8s/managed-cert.yml Executable file
View File

@ -0,0 +1,7 @@
apiVersion: networking.gke.io/v1
kind: ManagedCertificate
metadata:
name: sk1-certificate
spec:
domains:
- nudges.works

1
sk1/not usable Executable file
View File

@ -0,0 +1 @@
frontend/Dockerfile verify-service.sh update-nginx-config.sh update-frontend.sh test-routes.sh test-network.sh test-connection.sh test-backend.sh test-api.sh simple-backend.sh resilient-deploy.sh refresh-connection.sh rebuild-backend.sh fixed-nginx-conf.yaml sk1-ingress.yaml fix-service-routes.sh fix-react-config.sh fix-ingress.sh fix-frontend.sh fix-frontend-nginx.sh fix-deployments.sh fix-backend-service.sh fix-backend-health.sh fix-add-routes.sh deploy-fixed-backend.sh debug-instructions.txt create-ingress.sh complete-ingress.yaml complete-ingress-setup.sh complete-fix.sh certificate.yaml backend-fixed-deployment.yaml

68
sk1/not use/add-routes.sh Executable file
View File

@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
# Create a ConfigMap with route patches for the backend
cat > /tmp/route-patches.js << 'EOF'
// Root route
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the API' });
});
// Health check endpoint
app.get('/api', (req, res) => {
res.json({ status: 'ok', message: 'API is healthy' });
});
// Update existing routes if needed
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
EOF
kubectl create configmap route-patches -n sk1 --from-file=routes.js=/tmp/route-patches.js --dry-run=client -o yaml | kubectl apply -f -
# Create a patch for the backend deployment to inject these routes
cat > /tmp/backend-patch-routes.yaml << EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: sk1
spec:
template:
spec:
containers:
- name: backend
env:
- name: DEBUG
value: "express:*"
# Add script to update routes on startup
lifecycle:
postStart:
exec:
command:
- sh
- -c
- |
echo "Patching routes..."
cat /routes/routes.js >> /app/index.js
# Force Express to reload routes
kill -SIGHUP 1
volumeMounts:
- name: route-patches
mountPath: /routes
volumes:
- name: route-patches
configMap:
name: route-patches
EOF
# Apply the patch
kubectl apply -f /tmp/backend-patch-routes.yaml
# Restart backend to apply changes
kubectl rollout restart deployment backend -n sk1
echo "Added missing API routes to backend"
echo "Waiting for backend to restart..."
kubectl rollout status deployment backend -n sk1

View File

@ -0,0 +1,42 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend-fixed
namespace: sk1
spec:
replicas: 1
selector:
matchLabels:
app: backend-fixed
template:
metadata:
labels:
app: backend-fixed
spec:
containers:
- name: backend
image: gcr.io/leafy-racer-458817-c0/backend-fixed:latest
env:
- name: PORT
value: "4000"
- name: HOST
value: "0.0.0.0"
- name: MONGO_URI
valueFrom:
secretKeyRef:
name: mongodb-secret
key: MONGO_URI
ports:
- containerPort: 4000
---
apiVersion: v1
kind: Service
metadata:
name: backend-service
namespace: sk1
spec:
selector:
app: backend-fixed
ports:
- port: 4000
targetPort: 4000

8
sk1/not use/certificate.yaml Executable file
View File

@ -0,0 +1,8 @@
apiVersion: networking.gke.io/v1
kind: ManagedCertificate
metadata:
name: sk1-cert
namespace: sk1
spec:
domains:
- nudges.works

352
sk1/not use/complete-fix.sh Executable file
View File

@ -0,0 +1,352 @@
#!/usr/bin/env bash
set -euo pipefail
# Get project ID
PROJECT=$(gcloud config get-value project)
echo "🔍 Checking current status..."
kubectl get pods -n sk1
echo "🧹 Cleaning up any broken configurations..."
# Delete the debug pods we created earlier
kubectl delete pod debug-react -n sk1 --ignore-not-found
kubectl delete pod test-route -n sk1 --ignore-not-found
echo "🔄 Creating a complete backend with all required routes..."
# Create a temporary file with our complete backend app
cat > /tmp/complete-backend.js << 'EOF'
const express = require('express');
const cors = require('cors');
const mongoose = require('mongoose');
const app = express();
const port = process.env.PORT || 4000;
const host = process.env.HOST || '0.0.0.0';
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Root route
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the API' });
});
// Health check endpoint
app.get('/api', (req, res) => {
res.json({ status: 'ok', message: 'API is healthy' });
});
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
// Service routes
app.get('/api/services/:serviceType', (req, res) => {
const serviceType = req.params.serviceType;
const { radius, page, sortBy, order, lat, lon } = req.query;
console.log(`Received request for ${serviceType} services`);
console.log(`Query params: radius=${radius}, page=${page}, sortBy=${sortBy}, order=${order}, lat=${lat}, lon=${lon}`);
// Return mock data for any service type
res.json({
success: true,
services: [
{
id: 1,
name: `${serviceType} Service 1`,
description: `Professional ${serviceType} service`,
price: Math.floor(Math.random() * 50) + 20,
distance: parseFloat((Math.random() * 5).toFixed(1)),
rating: parseFloat((Math.random() * 2 + 3).toFixed(1))
},
{
id: 2,
name: `${serviceType} Service 2`,
description: `Expert ${serviceType} provider`,
price: Math.floor(Math.random() * 50) + 20,
distance: parseFloat((Math.random() * 5).toFixed(1)),
rating: parseFloat((Math.random() * 2 + 3).toFixed(1))
}
],
pagination: {
currentPage: parseInt(page) || 1,
totalPages: 3,
totalResults: 6
}
});
});
// Authentication endpoints
app.post('/api/auth/login', (req, res) => {
console.log('Login attempt:', req.body);
res.json({
success: true,
token: "mock-jwt-token",
user: {
id: 1,
name: "Test User",
email: req.body.email || "test@example.com"
}
});
});
app.post('/api/auth/register', (req, res) => {
console.log('Registration attempt:', req.body);
res.json({
success: true,
message: "User registered successfully",
user: {
id: new Date().getTime(),
name: req.body.name || "New User",
email: req.body.email || "new@example.com"
}
});
});
// MongoDB connection
const MONGO_URI = process.env.MONGO_URI;
if (MONGO_URI) {
mongoose.connect(MONGO_URI)
.then(() => console.log('✅ MongoDB connected'))
.catch(err => console.error('MongoDB connection error:', err));
}
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Error:', err);
res.status(500).json({
success: false,
message: 'Server error',
error: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error'
});
});
// Handle 404s
app.use((req, res) => {
console.log(`404: ${req.method} ${req.path}`);
res.status(404).json({
success: false,
message: 'API endpoint not found'
});
});
// Start server
app.listen(port, host, () => {
console.log(`🚀 Server running on http://${host}:${port}`);
});
EOF
# Create a package.json file
cat > /tmp/package.json << 'EOF'
{
"name": "backend",
"version": "1.0.0",
"description": "Backend for Nudges Works application",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"mongoose": "^7.0.3"
}
}
EOF
# Create a Dockerfile
cat > /tmp/Dockerfile << 'EOF'
FROM node:20-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY index.js .
EXPOSE 4000
CMD ["node", "index.js"]
EOF
# Create a temporary directory and copy files
rm -rf /tmp/backend-build || true
mkdir -p /tmp/backend-build
cp /tmp/complete-backend.js /tmp/backend-build/index.js
cp /tmp/package.json /tmp/backend-build/package.json
cp /tmp/Dockerfile /tmp/backend-build/Dockerfile
# Build and push the image
echo "🏗️ Building and pushing new backend image..."
cd /tmp/backend-build
gcloud builds submit --tag gcr.io/${PROJECT}/backend-complete:latest
# Create a deployment with the new image
cat > /tmp/backend-complete.yaml << EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: sk1
spec:
replicas: 1
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: gcr.io/${PROJECT}/backend-complete:latest
resources:
limits:
cpu: "0.5"
memory: "512Mi"
requests:
cpu: "0.2"
memory: "256Mi"
ports:
- containerPort: 4000
env:
- name: PORT
value: "4000"
- name: HOST
value: "0.0.0.0"
- name: MONGO_URI
valueFrom:
secretKeyRef:
name: mongodb-secret
key: MONGO_URI
---
apiVersion: v1
kind: Service
metadata:
name: backend-service
namespace: sk1
spec:
selector:
app: backend
ports:
- port: 4000
targetPort: 4000
EOF
# Apply the deployment
echo "📦 Applying new backend deployment..."
kubectl apply -f /tmp/backend-complete.yaml
# Update the frontend nginx config to ensure it's working correctly
cat > /tmp/fixed-nginx.conf << 'EOF'
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Fix for API routing - explicit location block for various endpoints
location /api/services/ {
proxy_pass http://backend-service:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
}
location /api/auth/ {
proxy_pass http://backend-service:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
}
# General API requests
location /api {
proxy_pass http://backend-service:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
}
# All other requests go to the React app
location / {
try_files $uri $uri/ /index.html;
}
}
EOF
# Create a ConfigMap for the new nginx configuration
kubectl create configmap fixed-nginx-conf -n sk1 --from-file=nginx.conf=/tmp/fixed-nginx.conf -o yaml --dry-run=client | kubectl apply -f -
# Update the frontend deployment to use the new ConfigMap
kubectl patch deployment frontend -n sk1 --type=strategic --patch '
{
"spec": {
"template": {
"spec": {
"containers": [
{
"name": "frontend",
"volumeMounts": [
{
"name": "nginx-config",
"mountPath": "/etc/nginx/conf.d/default.conf",
"subPath": "nginx.conf"
}
]
}
],
"volumes": [
{
"name": "nginx-config",
"configMap": {
"name": "fixed-nginx-conf"
}
}
]
}
}
}
}
'
# Restart frontend
kubectl rollout restart deployment frontend -n sk1
echo "⏱️ Waiting for deployments to be ready..."
kubectl rollout status deployment backend -n sk1
kubectl rollout status deployment frontend -n sk1
echo "✅ Deployment complete!"
echo "Testing backend API directly..."
kubectl run api-test --image=curlimages/curl -n sk1 --rm -it -- sh -c '
echo "Testing /api endpoint:"
curl -s http://backend-service:4000/api
echo ""
echo "Testing /api/services/assembly endpoint:"
curl -s http://backend-service:4000/api/services/assembly
echo ""
echo "Testing /api/auth/login endpoint:"
curl -s -X POST -H "Content-Type: application/json" -d '\''{"email":"test@example.com","password":"password"}'\'' http://backend-service:4000/api/auth/login
echo ""
'
echo "🎉 Your application should now be working properly at https://nudges.works"
echo "If you're still seeing issues, try clearing your browser cache or using a private/incognito window"

View File

@ -0,0 +1,167 @@
#!/usr/bin/env bash
set -euo pipefail
# Get project ID
PROJECT=$(gcloud config get-value project)
echo "🔍 Checking if certificate exists..."
if ! kubectl get managedcertificate sk1-cert -n sk1 2>/dev/null; then
echo "🔒 Creating managed certificate..."
# Create certificate
cat > certificate.yaml << EOF
apiVersion: networking.gke.io/v1
kind: ManagedCertificate
metadata:
name: sk1-cert
namespace: sk1
spec:
domains:
- nudges.works
EOF
kubectl apply -f certificate.yaml
echo "✅ Certificate created"
else
echo "✅ Certificate already exists"
fi
echo "🔍 Checking if static IP exists..."
if ! gcloud compute addresses describe sk1-static-ip --global &>/dev/null; then
echo "🌐 Creating static IP..."
# Create static IP
gcloud compute addresses create sk1-static-ip --global
echo "✅ Static IP created"
else
echo "✅ Static IP already exists"
fi
# Get the static IP
IP=$(gcloud compute addresses describe sk1-static-ip --global --format="value(address)")
echo "📝 Using static IP: $IP"
echo "🏗️ Creating ingress resource..."
# Create ingress resource
cat > complete-ingress.yaml << EOF
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sk1-ingress
namespace: sk1
annotations:
kubernetes.io/ingress.global-static-ip-name: sk1-static-ip
networking.gke.io/managed-certificates: sk1-cert
kubernetes.io/ingress.class: "gce"
spec:
rules:
- host: nudges.works
http:
paths:
- path: /api/
pathType: Prefix
backend:
service:
name: backend-service
port:
number: 4000
- path: /
pathType: Prefix
backend:
service:
name: frontend-service
port:
number: 80
EOF
# Apply the ingress configuration
kubectl apply -f complete-ingress.yaml
echo "⏱️ Waiting for ingress to initialize..."
sleep 10
echo "🔍 Checking ingress status..."
kubectl get ingress -n sk1
# Create a special nginx configuration for frontend to handle direct requests to backend
echo "📝 Updating frontend nginx configuration to correctly handle API requests..."
cat > fixed-nginx-conf.yaml << EOF
apiVersion: v1
kind: ConfigMap
metadata:
name: fixed-nginx-conf
namespace: sk1
data:
nginx.conf: |
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# All API requests should go to backend service
location /api/ {
proxy_pass http://backend-service:4000/;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_cache_bypass \$http_upgrade;
}
# All other requests go to the React app
location / {
try_files \$uri \$uri/ /index.html;
}
}
EOF
kubectl apply -f fixed-nginx-conf.yaml
# Update frontend to use this config
kubectl patch deployment frontend -n sk1 --type=strategic --patch '
{
"spec": {
"template": {
"spec": {
"containers": [
{
"name": "frontend",
"volumeMounts": [
{
"name": "nginx-config",
"mountPath": "/etc/nginx/conf.d/default.conf",
"subPath": "nginx.conf"
}
]
}
],
"volumes": [
{
"name": "nginx-config",
"configMap": {
"name": "fixed-nginx-conf"
}
}
]
}
}
}
}
'
echo "🔄 Restarting pods to ensure clean configuration..."
kubectl rollout restart deployment frontend -n sk1
kubectl rollout restart deployment backend -n sk1
echo "⏱️ Waiting for deployments to restart..."
kubectl rollout status deployment frontend -n sk1
kubectl rollout status deployment backend -n sk1
echo "✅ Setup complete!"
echo ""
echo "Your application should now be accessible at: https://nudges.works"
echo "Note: It may take 5-10 minutes for the DNS and certificate to propagate completely."
echo ""
echo "You can test the API directly with: curl https://nudges.works/api"
echo ""
echo "If you still encounter issues, try clearing your browser cache or using an incognito window."

View File

@ -0,0 +1,28 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sk1-ingress
namespace: sk1
annotations:
kubernetes.io/ingress.global-static-ip-name: sk1-static-ip
networking.gke.io/managed-certificates: sk1-cert
kubernetes.io/ingress.class: "gce"
spec:
rules:
- host: nudges.works
http:
paths:
- path: /api/
pathType: Prefix
backend:
service:
name: backend-service
port:
number: 4000
- path: /
pathType: Prefix
backend:
service:
name: frontend-service
port:
number: 80

Some files were not shown because too many files have changed in this diff Show More