Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from app.models.booking import Booking
from app.models.payment import Payment
from app.models.review import Review
from app.models.portfolio import PortfolioItem
from app.models.portfolio import Portfolio

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/v1/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

api_router = APIRouter()

# Include health endpoint
# Include endpoint routers
api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router, tags=["auth"])
api_router.include_router(user.router, tags=["users"])
Expand Down
1 change: 1 addition & 0 deletions backend/app/api/v1/endpoints/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import admin, artisan, auth, booking, health, payments, stats, user
42 changes: 29 additions & 13 deletions backend/app/api/v1/endpoints/artisan.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
)

# Import correct dependencies
from app.db.session import get_db # Or use app.db.database depending on your setup
from app.db.session import get_db
from app.models.artisan import Artisan
from app.models.portfolio import Portfolio
from app.models.user import User
from app.models.booking import Booking
from app.schemas.artisan import (
ArtisanLocationUpdate,
ArtisanOut,
Expand Down Expand Up @@ -227,18 +228,29 @@ async def geocode_address(
return geo_result


@router.put("/availability")
def update_availability(
availability_data: dict, # Consider defining a proper schema
@router.put("/availability", response_model=ArtisanOut)
async def update_availability(
availability_data: ArtisanProfileUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_artisan),
):
"""Update artisan availability - artisan only"""
return {
"message": "Availability updated successfully",
"artisan_id": current_user.id,
"availability": availability_data,
}
service = ArtisanService(db)
artisan = service.get_artisan_by_user_id(current_user.id)
if not artisan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Artisan profile not found"
)

updated_artisan = await service.update_artisan_profile(
artisan.id, ArtisanProfileUpdate(is_available=availability_data.is_available)
)
if not updated_artisan:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update availability",
)
return updated_artisan


@router.get("/my-portfolio")
Expand Down Expand Up @@ -341,8 +353,6 @@ def get_artisan_profile(artisan_id: int, db: Session = Depends(get_db)):
specialty_str = None
if artisan.specialties:
try:
import json

specs = json.loads(artisan.specialties)
if isinstance(specs, list):
# Take the first one as primary or join them
Expand All @@ -368,13 +378,19 @@ def get_artisan_profile(artisan_id: int, db: Session = Depends(get_db)):


@router.delete("/{artisan_id}")
def delete_artisan(
async def delete_artisan(
artisan_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""Delete artisan account - admin only"""
service = ArtisanService(db)
success = await service.delete_artisan(artisan_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Artisan not found"
)
return {
"message": f"Artisan {artisan_id} deleted by admin {current_user.id}",
"message": f"Artisan {artisan_id} deleted successfully",
"deleted_by": current_user.full_name,
}
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ async def lifespan(app: FastAPI):
if settings.BACKEND_CORS_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
allow_origins=[str(origin).rstrip("/") for origin in settings.BACKEND_CORS_ORIGINS],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
Expand Down
4 changes: 2 additions & 2 deletions backend/app/schemas/artisan.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class ArtisanItem(BaseModel):
id: int
business_name: str | None = None
description: str | None = None
specialties: str | None = None
specialties: list[str] | None = None
experience_years: int | None = None
hourly_rate: float | None = None
location: str | None = None
Expand Down Expand Up @@ -155,7 +155,7 @@ class NearbyArtisansRequest(BaseModel):
..., ge=-180, le=180, description="Search center longitude"
)
radius_km: float | None = Field(
10.0, ge=0.1, le=100, description="Search radius in kilometers"
10.0, ge=0.1, le=200, description="Search radius in kilometers"
)
specialties: list[str] | None = Field(None, description="Filter by specialties")
min_rating: float | None = Field(
Expand Down
4 changes: 2 additions & 2 deletions backend/app/schemas/user.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from __future__ import annotations

import re
from enum import StrEnum
from enum import Enum

from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator


class RoleEnum(StrEnum):
class RoleEnum(str, Enum):
client = "client"
artisan = "artisan"
admin = "admin"
Expand Down
2 changes: 1 addition & 1 deletion backend/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U stellarts"]
interval: 30s
Expand Down
46 changes: 46 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# StellArts Frontend

This is the fully responsive and authenticated Next.js frontend application for the **StellArts Platform**, integrating advanced functionality like Role-based Dashboard Access, JWT Authentication, and a Stellar testnet-powered Smart Contract Escrow System for booking payments.

## 🚀 Features & Architecture

- **Next.js 14 App Router**: Utilizes the modern React architecture for layout structures, nested routes, and optimal rendering.
- **Role-Aware Dashboard (/dashboard)**:
- Authenticated layout strictly routes users based on JWT validity.
- Distinguishes visually and functionally between `client` and `artisan` accounts.
- **Booking Management (/dashboard/bookings)**:
- Supports colorful status badges.
- Granular interactive filter tabs (`All`, `Pending`, `Active`, `Completed`).
- Optimistic UI updates ensure instantaneous user feedback when changing a booking status before the backend fully syncs.
- **Stellar Escrow Payment Flow (/dashboard/payments)**:
- Features a seamless Freighter Wallet integration via `@creit.tech/stellar-wallets-kit`.
- Full automated payment modal workflow: `Prepare XDR` → `Sign locally via Wallet UI` → `Submit Signed Transaction to Backend` → `Link to Stellar Explorer`.
- **UI & UX System (`Stellar Midnight`)**: Built completely with Tailwind CSS, `radix-ui`, `lucide-react` icons, and standard modular components (Card, Dialog, Navbar). Elements are crafted to be fully-width, airy, and dynamically responsive across all viewport sizes.

## 🛠️ Setup & Local Development

1. **Install dependencies**:
```bash
npm install
```
2. **Setup environment variables** (`.env.local`):
Ensure your `.env.local` points to your backend instance:
```bash
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
```
3. **Run the development server**:
```bash
npm run dev
```
*The app uses Tailwind for live-reloading UI styling. The site will be available on `http://localhost:3000`.*

---

## 🎨 Design Philosophy (`Stellar Midnight`)

The design does not constrain heroes or component layouts aggressively to the center. Instead, components dynamically populate across the full width of the view boundaries on desktops and intelligently wrap and stack natively on mobile devices.
Styling leverages slate blues, midnight undertones, amber warnings (for pending actions), and emerald successes (for confirmations or active flows) to seamlessly inform the user about the application state intuitively.

## 🧪 Validations
- `npm run lint`: Enforces ESLint standards across all TS/TSX contexts.
- `npm run typecheck`: Validates all TypeScript types using strict rules avoiding runtime exceptions.
150 changes: 81 additions & 69 deletions frontend/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,10 @@ import { useState, Suspense } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { motion } from "framer-motion";
import { api } from "../../../lib/api";
import { useAuth } from "../../../context/AuthContext";
import { Lock, Shield } from "lucide-react";

function LoginForm() {
const router = useRouter();
Expand Down Expand Up @@ -42,75 +37,92 @@ function LoginForm() {
}

return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Sign in</CardTitle>
<CardDescription>
Sign in to your StellArts account to book artisans or manage your
profile.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<p className="text-sm text-red-600 bg-red-50 p-3 rounded-md">
{error}
</p>
)}
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email
</label>
<input
id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-600 focus:outline-none focus:ring-1 focus:ring-blue-600"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
id="password"
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-600 focus:outline-none focus:ring-1 focus:ring-blue-600"
/>
<div className="min-h-screen flex items-center justify-center relative overflow-hidden px-4">
{/* Background Glows */}
<div className="absolute top-0 right-0 w-96 h-96 bg-blue-600/10 rounded-full blur-[128px] -z-10" />
<div className="absolute bottom-0 left-0 w-96 h-96 bg-indigo-600/10 rounded-full blur-[128px] -z-10" />

<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-lg glass-card p-8 md:p-12 rounded-[2rem] shadow-2xl relative"
>
<div className="mb-10 text-center">
<div className="w-16 h-16 bg-primary/10 rounded-2xl flex items-center justify-center mx-auto mb-6">
<Lock className="w-8 h-8 text-primary" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">Welcome Back</h1>
<p className="text-slate-400">Sign in to your StellArts account.</p>
</div>

<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm"
>
{error}
</motion.div>
)}

<div className="space-y-2">
<label className="text-sm font-medium text-slate-300 ml-1">Email Address</label>
<input
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
className="w-full h-12 bg-white/5 border border-white/10 rounded-xl px-4 text-white placeholder:text-slate-500 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-all"
/>
</div>

<div className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-sm font-medium text-slate-300">Password</label>
<Link href="#" className="text-xs text-primary hover:text-primary/80 transition-colors">Forgot password?</Link>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in…" : "Sign in"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-gray-600">
Don&apos;t have an account?{" "}
<Link href="/register" className="text-blue-600 hover:underline">
Register
</Link>
</p>
</CardContent>
</Card>
<input
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full h-12 bg-white/5 border border-white/10 rounded-xl px-4 text-white placeholder:text-slate-500 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-all"
/>
</div>

<Button
type="submit"
className="w-full h-12 rounded-xl bg-primary hover:bg-primary/90 text-white font-bold shadow-[0_0_20px_rgba(59,130,246,0.3)] mt-2"
disabled={loading}
>
{loading ? "Signing in…" : "Sign In"}
</Button>
</form>

<p className="mt-8 text-center text-slate-400 text-sm">
Don&apos;t have an account?{" "}
<Link href="/register" className="text-primary hover:text-primary/80 font-semibold transition-colors">
Register now
</Link>
</p>

<div className="mt-8 pt-8 border-t border-white/5 flex items-center justify-center space-x-2 text-slate-500">
<Shield className="w-4 h-4" />
<span className="text-xs uppercase tracking-widest font-medium">Secured by Stellar</span>
</div>
</motion.div>
</div>
);
}

export default function LoginPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-gray-50">Loading...</div>}>
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-background text-white">Loading...</div>}>
<LoginForm />
</Suspense>
);
Expand Down
Loading
Loading