final assignment
11
sk1/.gitignore
vendored
Normal 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
@ -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
@ -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
@ -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
@ -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}`)
|
||||
);
|
28
sk1/backend/middleware/auth.js
Normal 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();
|
||||
};
|
||||
}
|
40
sk1/backend/models/User.js
Normal 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
@ -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"
|
||||
}
|
||||
}
|
45
sk1/backend/routes/auth.js
Normal 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;
|
26
sk1/backend/routes/autocomplete.js
Normal 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;
|
186
sk1/backend/routes/profiles.js
Normal 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 city‐level 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;
|
90
sk1/backend/routes/services.js
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
|
22
sk1/frontend/frontend/nginx/default.conf
Normal 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
@ -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
@ -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;
|
||||
}
|
||||
}
|
22
sk1/frontend/nginx/default.conf
Normal 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
@ -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"
|
||||
}
|
||||
}
|
1
sk1/frontend/public/vite.svg
Normal 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
@ -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
@ -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
@ -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>
|
||||
);
|
||||
}
|
BIN
sk1/frontend/src/assets/Nudge-logo-nav.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
sk1/frontend/src/assets/icons/5 Starts.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
sk1/frontend/src/assets/icons/Instagram_icon.png
Normal file
After Width: | Height: | Size: 296 KiB |
@ -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
|
BIN
sk1/frontend/src/assets/icons/assembly.png
Normal file
After Width: | Height: | Size: 13 KiB |
@ -0,0 +1,3 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
HostUrl=about:internet
|
BIN
sk1/frontend/src/assets/icons/cleaning.png
Normal file
After Width: | Height: | Size: 22 KiB |
@ -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
|
BIN
sk1/frontend/src/assets/icons/electrician.png
Normal file
After Width: | Height: | Size: 18 KiB |
@ -0,0 +1,3 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
HostUrl=about:internet
|
BIN
sk1/frontend/src/assets/icons/gardening.png
Normal file
After Width: | Height: | Size: 24 KiB |
@ -0,0 +1,3 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
HostUrl=about:internet
|
BIN
sk1/frontend/src/assets/icons/home-repair.png
Normal file
After Width: | Height: | Size: 17 KiB |
@ -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
|
BIN
sk1/frontend/src/assets/icons/mounting.png
Normal file
After Width: | Height: | Size: 9.5 KiB |
@ -0,0 +1,4 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
ReferrerUrl=https://www.flaticon.com/free-icon/desktop_5975099?term=mount&related_id=5975099
|
||||
HostUrl=about:internet
|
BIN
sk1/frontend/src/assets/icons/moving.png
Normal file
After Width: | Height: | Size: 16 KiB |
4
sk1/frontend/src/assets/icons/moving.png:Zone.Identifier
Normal 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
|
BIN
sk1/frontend/src/assets/icons/plumbering.png
Normal file
After Width: | Height: | Size: 11 KiB |
@ -0,0 +1,3 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
HostUrl=about:internet
|
BIN
sk1/frontend/src/assets/icons/twitter-x-logo.png
Normal file
After Width: | Height: | Size: 22 KiB |
@ -0,0 +1,4 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
ReferrerUrl=https://www.google.com/
|
||||
HostUrl=https://freepnglogo.com/images/all_img/1725374683twitter-x-logo.png
|
BIN
sk1/frontend/src/assets/icons/w/contribution copy.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
sk1/frontend/src/assets/icons/w/desktop.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
sk1/frontend/src/assets/icons/w/electrician.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
sk1/frontend/src/assets/icons/w/home-repair.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
sk1/frontend/src/assets/icons/w/leak.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
sk1/frontend/src/assets/icons/w/mop.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
sk1/frontend/src/assets/icons/w/moving-truck.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
sk1/frontend/src/assets/icons/w/park.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
sk1/frontend/src/assets/icons/youtube-img.png
Normal file
After Width: | Height: | Size: 9.4 KiB |
@ -0,0 +1,4 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
ReferrerUrl=https://www.google.com/
|
||||
HostUrl=https://upload.wikimedia.org/wikipedia/commons/e/ef/Youtube_logo.png
|
BIN
sk1/frontend/src/assets/main-section-logo.png
Normal file
After Width: | Height: | Size: 27 KiB |
BIN
sk1/frontend/src/assets/placeholder.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
4
sk1/frontend/src/assets/placeholder.png:Zone.Identifier
Normal 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
|
1
sk1/frontend/src/assets/react.svg
Normal 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 |
BIN
sk1/frontend/src/assets/search-icon.png
Normal file
After Width: | Height: | Size: 421 B |
67
sk1/frontend/src/components/AddressAutocomplete.jsx
Normal 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>
|
||||
);
|
||||
}
|
11
sk1/frontend/src/components/Card.jsx
Normal 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>
|
||||
);
|
||||
}
|
32
sk1/frontend/src/components/Cards.jsx
Normal 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>
|
||||
);
|
||||
}
|
33
sk1/frontend/src/components/Footer.jsx
Normal 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>© 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>
|
||||
);
|
||||
}
|
39
sk1/frontend/src/components/Header.jsx
Normal 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>
|
||||
);
|
||||
}
|
31
sk1/frontend/src/components/HowItWorks.jsx
Normal 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>
|
||||
);
|
||||
}
|
14
sk1/frontend/src/components/Modal.jsx
Normal 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>
|
||||
);
|
||||
}
|
55
sk1/frontend/src/components/Nav.jsx
Normal 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>
|
||||
);
|
||||
}
|
35
sk1/frontend/src/components/ProCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
189
sk1/frontend/src/components/ProfileForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
18
sk1/frontend/src/components/ProtectedRoute.jsx
Normal 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;
|
||||
}
|
58
sk1/frontend/src/contexts/AuthContext.jsx
Normal 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
@ -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
@ -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>
|
||||
);
|
13
sk1/frontend/src/pages/Home.jsx
Normal 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>
|
||||
);
|
||||
}
|
57
sk1/frontend/src/pages/LoginPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
141
sk1/frontend/src/pages/ProfilePage.jsx
Normal 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>
|
||||
);
|
||||
}
|
99
sk1/frontend/src/pages/PublishProfilePage.jsx
Normal 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>
|
||||
);
|
||||
}
|
225
sk1/frontend/src/pages/ServicePage.jsx
Normal 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>
|
||||
);
|
||||
}
|
82
sk1/frontend/src/pages/SignUpPage.jsx
Normal 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
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
14
sk1/k8s/backend-config.yml
Normal 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
|
58
sk1/k8s/backend-deployment.yml.tpl
Executable 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
@ -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
|
39
sk1/k8s/frontend-deployment.yml.tpl
Executable 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
@ -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
@ -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
@ -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
@ -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
@ -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
|
42
sk1/not use/backend-fixed-deployment.yaml
Executable 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
@ -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
@ -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"
|
167
sk1/not use/complete-ingress-setup.sh
Executable 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."
|
28
sk1/not use/complete-ingress.yaml
Executable 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
|