From bfd93e862879e4a062a99e76aa694ddce704b351 Mon Sep 17 00:00:00 2001 From: annanyaa03 Date: Mon, 4 May 2026 00:08:43 +0530 Subject: [PATCH 1/5] fix: add missing MessagingPage import --- client/src/App.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/App.jsx b/client/src/App.jsx index f511618..e4035e9 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -25,6 +25,7 @@ import ExplorePage from './pages/ExplorePage'; import UploadPage from './pages/UploadPage'; import CreatorDashboard from './pages/CreatorDashboard'; import BrandDashboard from './pages/BrandDashboard'; +import MessagingPage from './pages/MessagingPage'; import DirectMessagePage from './pages/DirectMessagePage'; import CheckoutPage from './pages/CheckoutPage'; From 1e2f69b4b4302916818986057b65875025807ebb Mon Sep 17 00:00:00 2001 From: AayushGajbhiye9860 Date: Thu, 7 May 2026 20:35:32 +0530 Subject: [PATCH 2/5] + --- client/src/App.jsx | 8 +- client/src/hooks/useProject.js | 158 ++++++ client/src/pages/CheckoutPage.jsx | 234 +++++++-- client/src/pages/DirectMessagePage.jsx | 24 +- client/src/pages/ProjectProgressPage.jsx | 72 ++- client/src/services/projectService.js | 55 +++ server/routes/v1/index.js | 2 + server/routes/v1/projects.js | 208 ++++++++ server/schemas/projectSchemas.js | 36 ++ server/services/projectService.js | 463 ++++++++++++++++++ server/services/uploadService.js | 39 ++ .../20240007_projects_deliverables.sql | 257 ++++++++++ 12 files changed, 1491 insertions(+), 65 deletions(-) create mode 100644 client/src/hooks/useProject.js create mode 100644 client/src/services/projectService.js create mode 100644 server/routes/v1/projects.js create mode 100644 server/schemas/projectSchemas.js create mode 100644 server/services/projectService.js create mode 100644 supabase/migrations/20240007_projects_deliverables.sql diff --git a/client/src/App.jsx b/client/src/App.jsx index f511618..36238c5 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -27,6 +27,7 @@ import CreatorDashboard from './pages/CreatorDashboard'; import BrandDashboard from './pages/BrandDashboard'; import DirectMessagePage from './pages/DirectMessagePage'; +// MessagingPage removed — use DirectMessagePage at /dm/:id import CheckoutPage from './pages/CheckoutPage'; import ProjectProgressPage from './pages/ProjectProgressPage'; @@ -101,9 +102,6 @@ const AppLayout = () => { } /> {/* Protected — any logged-in user */} - - } /> } /> } /> } /> @@ -178,9 +176,7 @@ function App() { } /> {/* Protected — any logged-in user */} - - } /> + } /> } /> diff --git a/client/src/hooks/useProject.js b/client/src/hooks/useProject.js new file mode 100644 index 0000000..52c1a9c --- /dev/null +++ b/client/src/hooks/useProject.js @@ -0,0 +1,158 @@ +/** + * useProject.js + * + * Custom hook that: + * 1. Fetches a project from the API on mount. + * 2. Subscribes to real-time socket events and merges updates into local state. + * 3. Exposes action helpers (submit, requestRevision, approve, updateProgress) + * that call the API and optimistically update local state. + * + * Usage: + * const { project, loading, error, actions } = useProject(projectId); + */ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useSocket } from '../context/SocketContext'; +import * as projectService from '../services/projectService'; + +const PROJECT_EVENTS = [ + 'project_update', + 'project_progress', + 'project_submitted', + 'revision_requested', + 'project_approved', + 'project_cancelled', + 'deliverable_submitted', + 'payment_released', +]; + +export default function useProject(projectId) { + const socket = useSocket(); + const [project, setProject] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [actionPending, setActionPending] = useState(false); + const [actionError, setActionError] = useState(null); + // Prevent stale closures in socket handlers + const projectRef = useRef(project); + useEffect(() => { projectRef.current = project; }, [project]); + + // ── Fetch ────────────────────────────────────────────────────────────── + const fetchProject = useCallback(async () => { + if (!projectId) return; + try { + setLoading(true); + setError(null); + const res = await projectService.getProject(projectId); + setProject(res.data.project); + } catch (err) { + setError(err.message || 'Failed to load project'); + } finally { + setLoading(false); + } + }, [projectId]); + + useEffect(() => { fetchProject(); }, [fetchProject]); + + // ── Socket subscriptions ─────────────────────────────────────────────── + useEffect(() => { + if (!socket || !projectId) return; + + const handleUpdate = (updatedProject) => { + // Only apply if this event is for our project + if (updatedProject?.id !== projectId && updatedProject?.project?.id !== projectId) return; + const proj = updatedProject?.project ?? updatedProject; + setProject((prev) => prev ? { ...prev, ...proj } : proj); + }; + + const handleDeliverableSubmitted = ({ project: proj, deliverable }) => { + if (proj?.id !== projectId) return; + setProject((prev) => prev ? { + ...prev, + ...proj, + latest_deliverable: deliverable, + all_deliverables: [deliverable, ...(prev.all_deliverables ?? [])], + } : proj); + }; + + const handleRevisionRequested = ({ project: proj, revision }) => { + if (proj?.id !== projectId) return; + setProject((prev) => prev ? { + ...prev, + ...proj, + open_revisions: [revision, ...(prev.open_revisions ?? [])], + } : proj); + }; + + PROJECT_EVENTS.forEach((ev) => { + if (ev === 'deliverable_submitted') { + socket.on(ev, handleDeliverableSubmitted); + } else if (ev === 'revision_requested') { + socket.on(ev, handleRevisionRequested); + } else { + socket.on(ev, handleUpdate); + } + }); + + return () => { + PROJECT_EVENTS.forEach((ev) => { + socket.off(ev, handleUpdate); + socket.off(ev, handleDeliverableSubmitted); + socket.off(ev, handleRevisionRequested); + }); + }; + }, [socket, projectId]); + + // ── Action helpers ───────────────────────────────────────────────────── + const runAction = useCallback(async (fn) => { + try { + setActionPending(true); + setActionError(null); + const result = await fn(); + // Merge returned data into local state immediately + const proj = result?.data?.project ?? result?.data?.result?.project ?? null; + if (proj) { + setProject((prev) => prev ? { + ...prev, + ...proj, + // Preserve enriched sub-arrays if not returned by the action + latest_deliverable: result?.data?.deliverable ?? prev.latest_deliverable, + all_deliverables: result?.data?.deliverable + ? [result.data.deliverable, ...(prev.all_deliverables ?? [])] + : prev.all_deliverables, + open_revisions: result?.data?.revision + ? [result.data.revision, ...(prev.open_revisions ?? [])] + : proj.status !== 'revision_requested' + ? (prev.open_revisions ?? []) + : prev.open_revisions, + } : proj); + } + return result; + } catch (err) { + setActionError(err.message || 'Action failed'); + throw err; + } finally { + setActionPending(false); + } + }, []); + + const actions = { + updateProgress: (progress) => + runAction(() => projectService.updateProgress(projectId, progress)), + + submitDeliverable: (payload) => + runAction(() => projectService.submitDeliverable(projectId, payload)), + + requestRevision: (payload) => + runAction(() => projectService.requestRevision(projectId, payload)), + + approve: () => + runAction(() => projectService.approveProject(projectId)), + + cancel: (reason) => + runAction(() => projectService.cancelProject(projectId, reason)), + + refresh: fetchProject, + }; + + return { project, loading, error, actionPending, actionError, actions }; +} diff --git a/client/src/pages/CheckoutPage.jsx b/client/src/pages/CheckoutPage.jsx index b62d99b..7883cdb 100644 --- a/client/src/pages/CheckoutPage.jsx +++ b/client/src/pages/CheckoutPage.jsx @@ -1,113 +1,245 @@ +import { useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; +import { api } from '../lib/api'; +import { useAuth } from '../context/AuthContext'; +// ── helpers ─────────────────────────────────────────────────────────────────── +const InvoiceId = `DL-${Math.floor(Math.random() * 900_000) + 100_000}`; + +const CheckIcon = () => ( + + + +); + +const Spinner = () => ( + + + + +); + +// ── main component ──────────────────────────────────────────────────────────── export default function CheckoutPage() { - const location = useLocation(); - const navigate = useNavigate(); - const { pkg, creator } = location.state || {}; + const location = useLocation(); + const navigate = useNavigate(); + const { user } = useAuth(); + + const { pkg, creator, hiring_request_id } = location.state || {}; + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [agreed, setAgreed] = useState(false); + // Guard — must arrive via navigate with state if (!pkg || !creator) { return ( -
- +
+

Nothing to check out.

+
); } + const handlePay = async () => { + if (!agreed) { setError('Please agree to the Terms of Service to continue.'); return; } + setError(null); + setLoading(true); + + try { + // If a hiring_request_id was passed, create the project on the backend. + // Otherwise, navigate to a demo progress page (pre-auth / demo flow). + if (hiring_request_id && user) { + const res = await api.post('/projects', { + hiring_request_id, + package_title: pkg.title, + package_price_str: pkg.price, + // escrow_amount_paise: 0 ← wire Stripe here when ready + }); + const projectId = res.data.project.id; + navigate(`/progress/${projectId}`, { replace: true }); + } else { + // Demo / unauthenticated flow — still show the progress page with state + navigate('/progress', { state: { pkg, creator }, replace: true }); + } + } catch (err) { + setError(err.message || 'Payment failed. Please try again.'); + } finally { + setLoading(false); + } + }; + return (
- - - {/* Left Side: Receipt Breakdown */} + + {/* ── Left: Receipt ──────────────────────────────────────────── */}
-
+
-

Checkout

-

Invoice #DL-{Math.floor(Math.random() * 90000) + 10000}

+

+ Checkout +

+

+ Invoice #{InvoiceId} +

-
- +
+ + + Secure
+ {/* Creator pill */}
- {creator.name} + {creator.name
-

Booking Creator

-

{creator.name}

+

+ Booking Creator +

+

+ {creator.display_name || creator.name || creator.username} +

+ {/* Package breakdown */}
-

Package Details

+

+ Package Details +

{pkg.title}

-

{pkg.subtitle}

+ {pkg.subtitle && ( +

{pkg.subtitle}

+ )}
-

{pkg.price}

+

{pkg.price}

+ + {pkg.features?.length > 0 && ( +
    + {pkg.features.map((f, i) => ( +
  • + + + + {f} +
  • + ))} +
+ )}
-
+ {/* Line items */} +
Subtotal {pkg.price}
-
+
Platform Fee (2.5%) Included
-
+
GST (18%) Included
-
- {/* Right Side: Total & Pay */} + {/* ── Right: Pay panel ──────────────────────────────────────── */}
-
-

Total Due Today

-

{pkg.price}

-
- -
    -
  • - - Funds held securely in escrow -
  • -
  • - - Released only upon approval -
  • -
  • - - Dedicated project manager -
  • +

    + Total Due Today +

    +

    + {pkg.price} +

    +
    + +
      + {[ + 'Funds held securely in escrow', + 'Released only upon your approval', + 'Request revisions at any stage', + 'Dedicated project tracking board', + ].map((item) => ( +
    • + {item} +
    • + ))}
    - -

    - By paying, you agree to our Terms of Service + +

    + 256-bit SSL encrypted · Powered by Driplens Escrow

    -
diff --git a/client/src/pages/DirectMessagePage.jsx b/client/src/pages/DirectMessagePage.jsx index 7987c18..3adae16 100644 --- a/client/src/pages/DirectMessagePage.jsx +++ b/client/src/pages/DirectMessagePage.jsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react'; -import { useParams, Link } from 'react-router-dom'; +import { useParams, Link, useNavigate } from 'react-router-dom'; import { motion } from 'framer-motion'; const MOCK_CREATOR = { @@ -14,6 +14,7 @@ const MOCK_CREATOR = { export default function DirectMessagePage() { const { id } = useParams(); + const navigate = useNavigate(); const [messages, setMessages] = useState([ { id: 1, text: "Hey! I saw your portfolio and I love your work.", sender: 'me', time: '10:41 AM', status: 'read' }, { id: 2, text: "Are you taking on new projects? We have a commercial shoot next month.", sender: 'me', time: '10:42 AM', status: 'read' }, @@ -99,6 +100,27 @@ export default function DirectMessagePage() {

{MOCK_CREATOR.rate}

+ + {/* Action Buttons */} +
+ + + + + Start Google Meet + + +
diff --git a/client/src/pages/ProjectProgressPage.jsx b/client/src/pages/ProjectProgressPage.jsx index 1f1cb24..d3354ac 100644 --- a/client/src/pages/ProjectProgressPage.jsx +++ b/client/src/pages/ProjectProgressPage.jsx @@ -1,8 +1,12 @@ +import { useState } from 'react'; import { useLocation, Link } from 'react-router-dom'; import { motion } from 'framer-motion'; export default function ProjectProgressPage() { const location = useLocation(); + const [revisionRequested, setRevisionRequested] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [approved, setApproved] = useState(false); const { pkg, creator } = location.state || { creator: { name: 'Atharv Gadekar', img: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=500' }, pkg: { title: 'Cinematography Package' } @@ -83,14 +87,68 @@ export default function ProjectProgressPage() {
- {/* Step 4: Pending */} -
-
- 4 + {/* Step 4: Interactive Final Delivery */} +
+
+ {submitted ? ( + + ) : 4}
-
-

Final Delivery

-

Awaiting final revisions and export.

+
+

Final Delivery

+ + {/* Creator submits */} + {!submitted && ( + + )} + + {/* Brand reviews */} + {submitted && !approved && !revisionRequested && ( +
+

Project submitted. Awaiting brand approval.

+
+ + +
+
+ )} + + {/* Revision requested */} + {revisionRequested && !approved && ( +
+

Revision Requested

+

Brand has requested changes. Creator will update and resubmit.

+ +
+ )} + + {/* Approved — payment released */} + {approved && ( +
+

✅ Approved! Payment Released.

+

Funds have been released to the creator. Project complete!

+
+ )}
diff --git a/client/src/services/projectService.js b/client/src/services/projectService.js new file mode 100644 index 0000000..df8407c --- /dev/null +++ b/client/src/services/projectService.js @@ -0,0 +1,55 @@ +/** + * client/src/services/projectService.js + * + * Thin wrapper over api.js for all project-related calls. + * All functions throw ApiError on failure — callers handle UI state. + */ +import { api } from '../lib/api'; + +/** List all projects for the logged-in user */ +export const listProjects = () => api.get('/projects'); + +/** Fetch a single project with deliverables + revisions */ +export const getProject = (projectId) => api.get(`/projects/${projectId}`); + +/** + * Brand creates a project after deal is confirmed. + * @param {object} payload - { hiring_request_id, package_title, package_price_str, deadline? } + */ +export const createProject = (payload) => api.post('/projects', payload); + +/** + * Creator updates progress %. + * @param {string} projectId + * @param {number} progress 0–100 + */ +export const updateProgress = (projectId, progress) => + api.patch(`/projects/${projectId}/progress`, { progress }); + +/** + * Creator submits a deliverable via its public URL (e.g. after client-side upload). + * @param {string} projectId + * @param {{ file_url, file_name, file_size?, mime_type?, notes? }} payload + */ +export const submitDeliverable = (projectId, payload) => + api.post(`/projects/${projectId}/submit`, payload); + +/** + * Brand requests a revision. + * @param {string} projectId + * @param {{ deliverable_id, feedback }} payload + */ +export const requestRevision = (projectId, payload) => + api.post(`/projects/${projectId}/revision`, payload); + +/** + * Brand approves deliverable — releases payment. + */ +export const approveProject = (projectId) => + api.post(`/projects/${projectId}/approve`, {}); + +/** + * Either party cancels the project. + */ +export const cancelProject = (projectId, reason = '') => + api.post(`/projects/${projectId}/cancel`, { reason }); diff --git a/server/routes/v1/index.js b/server/routes/v1/index.js index a3d82f7..ac9f81a 100644 --- a/server/routes/v1/index.js +++ b/server/routes/v1/index.js @@ -4,6 +4,7 @@ import creatorRoutes from './creators.js'; import hiringRoutes from './hiring.js'; import messageRoutes from './messages.js'; import uploadRoutes from './upload.js'; +import projectRoutes from './projects.js'; const router = Router(); @@ -12,5 +13,6 @@ router.use('/creators', creatorRoutes); router.use('/hiring', hiringRoutes); router.use('/messages', messageRoutes); router.use('/upload', uploadRoutes); +router.use('/projects', projectRoutes); export default router; diff --git a/server/routes/v1/projects.js b/server/routes/v1/projects.js new file mode 100644 index 0000000..6634477 --- /dev/null +++ b/server/routes/v1/projects.js @@ -0,0 +1,208 @@ +/** + * /api/v1/projects + * + * Endpoint map: + * + * GET / → list all projects for the auth'd user + * POST / → create a project on checkout confirmation (brand) + * GET /:id → get full project detail with deliverables + revisions + * PATCH /:id/progress → creator updates progress % + * POST /:id/submit → creator submits a deliverable (file URL) + * POST /:id/revision → brand requests a revision + * POST /:id/approve → brand approves + releases payment + * POST /:id/cancel → either party cancels + */ + +import { Router } from 'express'; +import multer from 'multer'; + +import { requireAuth, requireRole } from '../../middleware/auth.js'; +import { apiLimiter, uploadLimiter } from '../../middleware/rateLimiter.js'; +import { validate } from '../../middleware/validate.js'; +import * as uploadService from '../../services/uploadService.js'; +import * as projectService from '../../services/projectService.js'; +import { + createProjectSchema, + updateProgressSchema, + submitDeliverableSchema, + requestRevisionSchema, +} from '../../schemas/projectSchemas.js'; + +const router = Router(); + +// Multer — memory storage for deliverable file uploads (max 200 MB) +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 200 * 1024 * 1024 }, +}); + +// ── List projects ───────────────────────────────────────────────────────── + +router.get('/', requireAuth, apiLimiter, async (req, res, next) => { + try { + const projects = await projectService.listProjects(req.user.id); + res.json({ success: true, data: { projects } }); + } catch (err) { next(err); } +}); + +// ── Create project (brand, on checkout confirmation) ────────────────────── + +router.post( + '/', + requireAuth, + requireRole('brand'), + apiLimiter, + validate(createProjectSchema), + async (req, res, next) => { + try { + const project = await projectService.createProject({ + ...req.body, + brand_id: req.user.id, + }); + res.status(201).json({ success: true, data: { project } }); + } catch (err) { next(err); } + }, +); + +// ── Get single project ──────────────────────────────────────────────────── + +router.get('/:id', requireAuth, apiLimiter, async (req, res, next) => { + try { + const project = await projectService.getProject(req.params.id, req.user.id); + res.json({ success: true, data: { project } }); + } catch (err) { next(err); } +}); + +// ── Update progress (creator) ───────────────────────────────────────────── + +router.patch( + '/:id/progress', + requireAuth, + requireRole('creator'), + apiLimiter, + validate(updateProgressSchema), + async (req, res, next) => { + try { + const project = await projectService.updateProgress( + req.params.id, + req.user.id, + req.body, + ); + res.json({ success: true, data: { project } }); + } catch (err) { next(err); } + }, +); + +// ── Submit deliverable via file upload (creator) ────────────────────────── +// +// Two modes: +// 1. Multipart/form-data → file uploaded to Supabase Storage via uploadService +// 2. JSON body with existing file_url (already uploaded elsewhere) +// +// Mode 1 is preferred for production; mode 2 is a convenience for testing. + +router.post( + '/:id/submit', + requireAuth, + requireRole('creator'), + uploadLimiter, + // Conditionally parse multipart (if client sends a file) or JSON (if URL provided) + (req, res, next) => { + const contentType = req.headers['content-type'] ?? ''; + if (contentType.includes('multipart/form-data')) { + return upload.single('deliverable')(req, res, next); + } + next(); + }, + async (req, res, next) => { + try { + let file_url, file_name, file_size, mime_type, storage_path, notes; + + if (req.file) { + // Mode 1: file uploaded in this request → push to Supabase Storage + const uploaded = await uploadService.uploadDeliverable( + req.user.id, + req.params.id, + req.file, + ); + file_url = uploaded.publicUrl; + file_name = req.file.originalname; + file_size = req.file.size; + mime_type = req.file.mimetype; + storage_path = uploaded.storagePath; + notes = req.body?.notes ?? null; + } else { + // Mode 2: client already has the URL (e.g. Supabase Storage SDK used client-side) + const parsed = submitDeliverableSchema.safeParse(req.body); + if (!parsed.success) { + const messages = parsed.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', '); + return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: messages } }); + } + ({ file_url, file_name, file_size, mime_type, storage_path, notes } = parsed.data); + } + + const result = await projectService.submitDeliverable( + req.params.id, + req.user.id, + { file_url, file_name, file_size, mime_type, storage_path, notes }, + ); + + res.status(201).json({ success: true, data: result }); + } catch (err) { next(err); } + }, +); + +// ── Request revision (brand) ────────────────────────────────────────────── + +router.post( + '/:id/revision', + requireAuth, + requireRole('brand'), + apiLimiter, + validate(requestRevisionSchema), + async (req, res, next) => { + try { + const result = await projectService.requestRevision( + req.params.id, + req.user.id, + req.body, + ); + res.status(201).json({ success: true, data: result }); + } catch (err) { next(err); } + }, +); + +// ── Approve + release payment (brand) ──────────────────────────────────── + +router.post( + '/:id/approve', + requireAuth, + requireRole('brand'), + apiLimiter, + async (req, res, next) => { + try { + const project = await projectService.approveProject(req.params.id, req.user.id); + res.json({ success: true, data: { project } }); + } catch (err) { next(err); } + }, +); + +// ── Cancel project ──────────────────────────────────────────────────────── + +router.post( + '/:id/cancel', + requireAuth, + apiLimiter, + async (req, res, next) => { + try { + const project = await projectService.cancelProject( + req.params.id, + req.user.id, + req.body?.reason, + ); + res.json({ success: true, data: { project } }); + } catch (err) { next(err); } + }, +); + +export default router; diff --git a/server/schemas/projectSchemas.js b/server/schemas/projectSchemas.js new file mode 100644 index 0000000..cd4248e --- /dev/null +++ b/server/schemas/projectSchemas.js @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +// ── Project ──────────────────────────────────────────────────────────────── + +export const createProjectSchema = z.object({ + hiring_request_id: z.string().uuid(), + package_title: z.string().min(1).max(100).optional(), + package_price_str: z.string().max(50).optional(), + escrow_amount_paise: z.number().int().nonnegative().optional(), + stripe_payment_intent_id: z.string().optional(), + deadline: z.string().date().optional(), // ISO date 'YYYY-MM-DD' +}); + +export const updateProgressSchema = z.object({ + progress: z.number().int().min(0).max(100), + // Optional status override; usually derived server-side but allow client hint + note: z.string().max(500).optional(), +}); + +// ── Deliverable ──────────────────────────────────────────────────────────── + +export const submitDeliverableSchema = z.object({ + file_url: z.string().url('file_url must be a valid URL'), + file_name: z.string().min(1).max(255), + file_size: z.number().int().nonnegative().optional(), + mime_type: z.string().max(100).optional(), + storage_path: z.string().optional(), + notes: z.string().max(2000).optional(), +}); + +// ── Revision Request ────────────────────────────────────────────────────── + +export const requestRevisionSchema = z.object({ + deliverable_id: z.string().uuid(), + feedback: z.string().min(10).max(3000), +}); diff --git a/server/services/projectService.js b/server/services/projectService.js new file mode 100644 index 0000000..5c8879f --- /dev/null +++ b/server/services/projectService.js @@ -0,0 +1,463 @@ +/** + * projectService.js + * + * All database interactions for the Projects, Deliverables, and + * Revision Requests tables. Designed to be called only from the + * projects route handler — never from other services directly. + * + * Service-role Supabase client is used so RLS is bypassed server-side; + * access control is enforced explicitly in every function via + * assertParty() / role checks. + */ + +import { supabase } from '../utils/supabase.js'; +import { emitToUser } from '../utils/socket.js'; +import logger from '../utils/logger.js'; +import { + badRequest, + forbidden, + notFound, + conflict, +} from '../utils/AppError.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Internal helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Fetch a project and confirm the requesting user is a party to it. + * Returns the project row. + */ +const assertParty = async (projectId, userId) => { + const { data, error } = await supabase + .from('projects') + .select(` + id, brand_id, creator_id, status, progress, + escrow_amount_paise, package_title, package_price_str, + stripe_payment_intent_id, stripe_transfer_id, deadline, + created_at, updated_at, + brand:brand_id (id, username, avatar_url), + creator:creator_id (id, username, avatar_url), + hiring_request:hiring_request_id (id, project_title, project_description) + `) + .eq('id', projectId) + .single(); + + if (error || !data) throw notFound('Project not found'); + if (data.brand_id !== userId && data.creator_id !== userId) { + throw forbidden('You are not a party to this project'); + } + return data; +}; + +/** + * Emit a project update event to BOTH parties (brand and creator), + * except to the sender themselves. + */ +const broadcastProjectUpdate = (project, senderId, event = 'project_update') => { + const recipients = [project.brand_id, project.creator_id].filter( + (id) => id && id !== senderId, + ); + recipients.forEach((recipientId) => emitToUser(recipientId, event, project)); +}; + +/** + * Fetch the latest deliverable for a project. + */ +const getLatestDeliverable = async (projectId) => { + const { data } = await supabase + .from('deliverables') + .select('*') + .eq('project_id', projectId) + .order('version', { ascending: false }) + .limit(1) + .maybeSingle(); + return data ?? null; +}; + +/** + * Fetch all open revision requests for a project. + */ +const getOpenRevisions = async (projectId) => { + const { data } = await supabase + .from('revision_requests') + .select('*') + .eq('project_id', projectId) + .eq('status', 'open') + .order('requested_at', { ascending: false }); + return data ?? []; +}; + + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Create a new project after checkout is confirmed. + * Called by: POST /api/v1/projects + * Role: brand (or service-level call on checkout confirmation) + */ +export const createProject = async ({ + hiring_request_id, + brand_id, + package_title, + package_price_str, + escrow_amount_paise = 0, + stripe_payment_intent_id, + deadline, +}) => { + // Verify the hiring request exists and belongs to the brand + const { data: hr, error: hrErr } = await supabase + .from('hiring_requests') + .select('id, brand_id, creator_id, status') + .eq('id', hiring_request_id) + .single(); + + if (hrErr || !hr) throw notFound('Hiring request not found'); + if (hr.brand_id !== brand_id) throw forbidden('This hiring request does not belong to you'); + if (!hr.creator_id) throw badRequest('Cannot start a project without a targeted creator'); + + // Prevent duplicate project for same hiring_request + const { data: existing } = await supabase + .from('projects') + .select('id') + .eq('hiring_request_id', hiring_request_id) + .maybeSingle(); + + if (existing) throw conflict('A project already exists for this hiring request'); + + const payload = { + hiring_request_id, + brand_id: hr.brand_id, + creator_id: hr.creator_id, + status: 'in_progress', + package_title, + package_price_str, + escrow_amount_paise, + ...(stripe_payment_intent_id && { stripe_payment_intent_id }), + ...(deadline && { deadline }), + }; + + const { data: project, error } = await supabase + .from('projects') + .insert(payload) + .select(` + *, + brand:brand_id (id, username, avatar_url), + creator:creator_id (id, username, avatar_url) + `) + .single(); + + if (error) throw error; + + // Notify the creator that work has started + emitToUser(project.creator_id, 'project_created', project); + + logger.info('Project created', { projectId: project.id, brandId: brand_id }); + return project; +}; + + +/** + * Fetch a project with full context (deliverables, revisions). + * Called by: GET /api/v1/projects/:id + */ +export const getProject = async (projectId, userId) => { + const project = await assertParty(projectId, userId); + + const [latestDeliverable, openRevisions, allDeliverables] = await Promise.all([ + getLatestDeliverable(projectId), + getOpenRevisions(projectId), + supabase + .from('deliverables') + .select('*') + .eq('project_id', projectId) + .order('version', { ascending: false }) + .then(({ data }) => data ?? []), + ]); + + return { + ...project, + latest_deliverable: latestDeliverable, + open_revisions: openRevisions, + all_deliverables: allDeliverables, + }; +}; + + +/** + * List all projects for the authenticated user (brand or creator). + * Called by: GET /api/v1/projects + */ +export const listProjects = async (userId) => { + const { data, error } = await supabase + .from('projects') + .select(` + id, status, progress, package_title, package_price_str, deadline, created_at, updated_at, + brand:brand_id (id, username, avatar_url), + creator:creator_id (id, username, avatar_url), + hiring_request:hiring_request_id (id, project_title) + `) + .or(`brand_id.eq.${userId},creator_id.eq.${userId}`) + .order('updated_at', { ascending: false }); + + if (error) throw error; + return data ?? []; +}; + + +/** + * Creator updates the progress percentage (and optionally leaves a note). + * Called by: PATCH /api/v1/projects/:id/progress + * Role: creator + */ +export const updateProgress = async (projectId, userId, { progress, note }) => { + const project = await assertParty(projectId, userId); + + if (project.creator_id !== userId) { + throw forbidden('Only the creator can update progress'); + } + if (['approved', 'completed', 'cancelled'].includes(project.status)) { + throw badRequest(`Cannot update progress on a project that is "${project.status}"`); + } + + const { data: updated, error } = await supabase + .from('projects') + .update({ progress }) + .eq('id', projectId) + .select(` + *, + brand:brand_id (id, username, avatar_url), + creator:creator_id (id, username, avatar_url) + `) + .single(); + + if (error) throw error; + + broadcastProjectUpdate(updated, userId, 'project_progress'); + logger.info('Progress updated', { projectId, progress, userId }); + return updated; +}; + + +/** + * Creator submits a deliverable (file already uploaded; provide URL). + * Changes project status → 'submitted'. + * Called by: POST /api/v1/projects/:id/submit + * Role: creator + */ +export const submitDeliverable = async ( + projectId, + userId, + { file_url, file_name, file_size, mime_type, storage_path, notes }, +) => { + const project = await assertParty(projectId, userId); + + if (project.creator_id !== userId) { + throw forbidden('Only the creator can submit deliverables'); + } + if (['approved', 'completed', 'cancelled'].includes(project.status)) { + throw badRequest(`Cannot submit to a project that is "${project.status}"`); + } + + // Insert deliverable (version auto-set by DB trigger) + const { data: deliverable, error: delErr } = await supabase + .from('deliverables') + .insert({ + project_id: projectId, + creator_id: userId, + file_url, + file_name, + file_size: file_size ?? null, + mime_type: mime_type ?? null, + storage_path: storage_path ?? null, + notes: notes ?? null, + }) + .select() + .single(); + + if (delErr) throw delErr; + + // If there were open revisions, mark them as addressed + await supabase + .from('revision_requests') + .update({ status: 'addressed', addressed_at: new Date().toISOString() }) + .eq('project_id', projectId) + .eq('status', 'open'); + + // Update project status → submitted + const { data: updatedProject, error: projErr } = await supabase + .from('projects') + .update({ status: 'submitted' }) + .eq('id', projectId) + .select(` + *, + brand:brand_id (id, username, avatar_url), + creator:creator_id (id, username, avatar_url) + `) + .single(); + + if (projErr) throw projErr; + + broadcastProjectUpdate(updatedProject, userId, 'project_submitted'); + emitToUser(updatedProject.brand_id, 'deliverable_submitted', { + project: updatedProject, + deliverable, + }); + + logger.info('Deliverable submitted', { + projectId, + deliverableId: deliverable.id, + version: deliverable.version, + userId, + }); + + return { project: updatedProject, deliverable }; +}; + + +/** + * Brand requests a revision on the latest deliverable. + * Changes project status → 'revision_requested'. + * Called by: POST /api/v1/projects/:id/revision + * Role: brand + */ +export const requestRevision = async ( + projectId, + userId, + { deliverable_id, feedback }, +) => { + const project = await assertParty(projectId, userId); + + if (project.brand_id !== userId) { + throw forbidden('Only the brand can request revisions'); + } + if (project.status !== 'submitted') { + throw badRequest('Revisions can only be requested after the creator submits deliverables'); + } + + // Verify deliverable belongs to this project + const { data: deliverable, error: delErr } = await supabase + .from('deliverables') + .select('id, project_id') + .eq('id', deliverable_id) + .eq('project_id', projectId) + .single(); + + if (delErr || !deliverable) throw notFound('Deliverable not found on this project'); + + const { data: revision, error: revErr } = await supabase + .from('revision_requests') + .insert({ + project_id: projectId, + deliverable_id, + brand_id: userId, + feedback, + }) + .select() + .single(); + + if (revErr) throw revErr; + + // Update project status → revision_requested + const { data: updatedProject, error: projErr } = await supabase + .from('projects') + .update({ status: 'revision_requested' }) + .eq('id', projectId) + .select(` + *, + brand:brand_id (id, username, avatar_url), + creator:creator_id (id, username, avatar_url) + `) + .single(); + + if (projErr) throw projErr; + + broadcastProjectUpdate(updatedProject, userId, 'revision_requested'); + emitToUser(updatedProject.creator_id, 'revision_requested', { + project: updatedProject, + revision, + }); + + logger.info('Revision requested', { projectId, revisionId: revision.id, userId }); + return { project: updatedProject, revision }; +}; + + +/** + * Brand approves the deliverable — project → 'approved'. + * In a real Stripe integration you'd trigger payout here. + * Called by: POST /api/v1/projects/:id/approve + * Role: brand + */ +export const approveProject = async (projectId, userId) => { + const project = await assertParty(projectId, userId); + + if (project.brand_id !== userId) { + throw forbidden('Only the brand can approve and release payment'); + } + if (project.status !== 'submitted') { + throw badRequest('Project must be in "submitted" state to be approved'); + } + + // ── Stripe payout would go here ────────────────────────────────────────── + // const transfer = await stripe.transfers.create({ ... }); + // const stripeTransferId = transfer.id; + // ───────────────────────────────────────────────────────────────────────── + + const { data: updatedProject, error } = await supabase + .from('projects') + .update({ + status: 'completed', + // stripe_transfer_id: stripeTransferId, // ← uncomment when Stripe is wired + }) + .eq('id', projectId) + .select(` + *, + brand:brand_id (id, username, avatar_url), + creator:creator_id (id, username, avatar_url) + `) + .single(); + + if (error) throw error; + + broadcastProjectUpdate(updatedProject, userId, 'project_approved'); + emitToUser(updatedProject.creator_id, 'payment_released', { + project: updatedProject, + message: 'Your payment has been released! Congratulations.', + }); + + logger.info('Project approved & payment released', { projectId, userId }); + return updatedProject; +}; + + +/** + * Either party can cancel a project that hasn't been completed yet. + * Called by: POST /api/v1/projects/:id/cancel + */ +export const cancelProject = async (projectId, userId, reason) => { + const project = await assertParty(projectId, userId); + + if (['completed', 'cancelled'].includes(project.status)) { + throw badRequest(`Cannot cancel a project that is already "${project.status}"`); + } + + const { data: updatedProject, error } = await supabase + .from('projects') + .update({ status: 'cancelled' }) + .eq('id', projectId) + .select(` + *, + brand:brand_id (id, username, avatar_url), + creator:creator_id (id, username, avatar_url) + `) + .single(); + + if (error) throw error; + + broadcastProjectUpdate(updatedProject, userId, 'project_cancelled'); + logger.warn('Project cancelled', { projectId, userId, reason }); + return updatedProject; +}; diff --git a/server/services/uploadService.js b/server/services/uploadService.js index 0ac3697..0d333dd 100644 --- a/server/services/uploadService.js +++ b/server/services/uploadService.js @@ -139,6 +139,45 @@ export const uploadProfileImage = async (userId, file, type) => { return { publicUrl, storagePath: filePath }; }; +/** + * Upload a deliverable file for a project to Supabase Storage. + * Path: deliverables/{projectId}/{userId}_{uuid}.{ext} + * Returns { publicUrl, storagePath }. + */ +export const uploadDeliverable = async (userId, projectId, file) => { + validateFile(file); + + const ext = file.originalname.split('.').pop().toLowerCase(); + const safeName = `${userId}_${uuidv4()}.${ext}`; + const filePath = `deliverables/${projectId}/${safeName}`; + + if (env.SUPABASE_URL.includes('dummy')) { + const uploadDir = path.join(process.cwd(), 'public', 'uploads', 'deliverables', projectId); + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + fs.writeFileSync(path.join(uploadDir, safeName), file.buffer); + const publicUrl = `http://localhost:${env.PORT || 5001}/uploads/deliverables/${projectId}/${safeName}`; + return { publicUrl, storagePath: filePath }; + } + + const { error: uploadError } = await supabase.storage + .from('DripLens upload') + .upload(filePath, file.buffer, { + contentType: file.mimetype, + cacheControl: '3600', + upsert: false, + }); + + if (uploadError) { + throw new AppError(uploadError.message || 'Deliverable upload failed', 500, 'STORAGE_UPLOAD_FAILED'); + } + + const { data: { publicUrl } } = supabase.storage + .from('DripLens upload') + .getPublicUrl(filePath); + + return { publicUrl, storagePath: filePath }; +}; + export const listPortfolio = async ({ page = 1, limit = 10, creator_id }) => { const pageNum = parseInt(page) || 1; const limitNum = parseInt(limit) || 10; diff --git a/supabase/migrations/20240007_projects_deliverables.sql b/supabase/migrations/20240007_projects_deliverables.sql new file mode 100644 index 0000000..ababc17 --- /dev/null +++ b/supabase/migrations/20240007_projects_deliverables.sql @@ -0,0 +1,257 @@ +-- ============================================================================= +-- DRIPLENS — Projects, Deliverables & Revision Requests +-- Migration: 20240007_projects_deliverables.sql +-- +-- This migration adds the project lifecycle tables that back the +-- full 14-step brand↔creator workflow: +-- +-- hiring_request → project → deliverable → revision_request → payout +-- +-- Run AFTER 20240002_rls_policies.sql (depends on profiles, hiring_requests). +-- Apply via: Supabase SQL editor, or `supabase db push`. +-- ============================================================================= + + +-- --------------------------------------------------------------------------- +-- 0. New ENUM types +-- --------------------------------------------------------------------------- + +create type public.project_status as enum ( + 'in_progress', -- creator is working + 'submitted', -- creator submitted deliverable, awaiting brand review + 'revision_requested', -- brand requested changes + 'approved', -- brand approved, payment queued for release + 'completed', -- payment released, project archived + 'cancelled' -- either party cancelled before completion +); + +create type public.revision_status as enum ( + 'open', -- revision request sent, creator has not addressed it yet + 'addressed' -- creator resubmitted after addressing the request +); + + +-- --------------------------------------------------------------------------- +-- 1. projects +-- One project per accepted hiring_request. Created automatically by the +-- server when the checkout (Step 8) is confirmed. +-- --------------------------------------------------------------------------- +create table public.projects ( + id uuid primary key default gen_random_uuid(), + hiring_request_id uuid not null unique references public.hiring_requests(id) on delete cascade, + brand_id uuid not null references public.profiles(id) on delete cascade, + creator_id uuid not null references public.profiles(id) on delete cascade, + + status project_status not null default 'in_progress', + progress integer not null default 0 check (progress between 0 and 100), + + -- Escrow tracking (store in paisa/cents as integer to avoid float rounding) + escrow_amount_paise bigint not null default 0 check (escrow_amount_paise >= 0), + -- Stripe PaymentIntent ID — set when checkout is confirmed + stripe_payment_intent_id text unique, + + -- Stripe Transfer ID — set when payment is released to creator + stripe_transfer_id text unique, + + package_title text, + package_price_str text, -- display string e.g. "₹15,000-₹25,000" + + deadline date, + + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +comment on table public.projects is + 'One row per active / completed brand↔creator project. Drives the ProjectProgressPage.'; +comment on column public.projects.progress is + 'Creator-reported percentage 0–100. Updated via PATCH /projects/:id/progress.'; +comment on column public.projects.escrow_amount_paise is + 'Amount in smallest currency unit (paise / cents) held in escrow. Set at checkout.'; +comment on column public.projects.stripe_payment_intent_id is + 'Stripe PaymentIntent ID used for escrow capture and release.'; + + +-- --------------------------------------------------------------------------- +-- 2. deliverables +-- Each time the creator hits "Submit Project" a new row is created +-- (versioned). Brands see the latest deliverable. +-- --------------------------------------------------------------------------- +create table public.deliverables ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references public.projects(id) on delete cascade, + creator_id uuid not null references public.profiles(id) on delete cascade, + + -- File uploaded to Supabase Storage / external URL + file_url text not null, + storage_path text, -- internal path for deletion; null for external URLs + file_name text not null, -- original filename for display + file_size bigint, -- bytes + mime_type text, + + version integer not null default 1, -- auto-incremented per project + notes text check (char_length(notes) <= 2000), + + submitted_at timestamptz not null default now() +); + +comment on table public.deliverables is + 'Versioned project deliverables submitted by creators. Each resubmission increments version.'; +comment on column public.deliverables.version is + 'Starts at 1 for first submission. Incremented on every resubmission (revision → resubmit cycle).'; + + +-- --------------------------------------------------------------------------- +-- 3. revision_requests +-- Brand-authored feedback requests on a specific deliverable. +-- --------------------------------------------------------------------------- +create table public.revision_requests ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references public.projects(id) on delete cascade, + deliverable_id uuid not null references public.deliverables(id) on delete cascade, + brand_id uuid not null references public.profiles(id) on delete cascade, + + feedback text not null check (char_length(feedback) between 10 and 3000), + status revision_status not null default 'open', + + requested_at timestamptz not null default now(), + addressed_at timestamptz -- set when creator resubmits +); + +comment on table public.revision_requests is + 'Brand-authored revision requests on a deliverable. Status flips to ''addressed'' on creator resubmission.'; + + +-- --------------------------------------------------------------------------- +-- 4. Indexes +-- --------------------------------------------------------------------------- + +-- projects +create index idx_projects_hiring_request on public.projects(hiring_request_id); +create index idx_projects_brand on public.projects(brand_id); +create index idx_projects_creator on public.projects(creator_id); +create index idx_projects_status on public.projects(status); +create index idx_projects_updated_at on public.projects(updated_at desc); + +-- deliverables +create index idx_deliverables_project on public.deliverables(project_id); +create index idx_deliverables_version on public.deliverables(project_id, version desc); + +-- revision_requests +create index idx_revisions_project on public.revision_requests(project_id); +create index idx_revisions_deliverable on public.revision_requests(deliverable_id); +create index idx_revisions_status on public.revision_requests(project_id, status); + + +-- --------------------------------------------------------------------------- +-- 5. updated_at trigger for projects +-- --------------------------------------------------------------------------- +create trigger trg_projects_updated_at + before update on public.projects + for each row execute function public.set_updated_at(); + + +-- --------------------------------------------------------------------------- +-- 6. Auto-increment deliverable version per project (database trigger) +-- Called BEFORE INSERT so `new.version` is already set correctly. +-- --------------------------------------------------------------------------- +create or replace function public.set_deliverable_version() +returns trigger +language plpgsql +as $$ +declare + max_version integer; +begin + select coalesce(max(version), 0) + into max_version + from public.deliverables + where project_id = new.project_id; + + new.version = max_version + 1; + return new; +end; +$$; + +create trigger trg_deliverable_version + before insert on public.deliverables + for each row execute function public.set_deliverable_version(); + + +-- --------------------------------------------------------------------------- +-- 7. RLS Policies +-- --------------------------------------------------------------------------- + +alter table public.projects enable row level security; +alter table public.deliverables enable row level security; +alter table public.revision_requests enable row level security; + + +-- projects: a party (brand or creator) can read their own projects +create policy "project_party_select" + on public.projects for select + using ( + auth.uid() = brand_id or auth.uid() = creator_id + ); + +-- projects: only the system (service_role) can insert (server creates project on checkout) +create policy "project_service_insert" + on public.projects for insert + with check (false); -- blocked for normal users; server uses service_role key + +-- projects: brand or creator can update their own project +create policy "project_party_update" + on public.projects for update + using ( + auth.uid() = brand_id or auth.uid() = creator_id + ); + + +-- deliverables: party can read +create policy "deliverable_party_select" + on public.deliverables for select + using ( + exists ( + select 1 from public.projects p + where p.id = deliverables.project_id + and (p.brand_id = auth.uid() or p.creator_id = auth.uid()) + ) + ); + +-- deliverables: only creator can insert +create policy "deliverable_creator_insert" + on public.deliverables for insert + with check ( + auth.uid() = creator_id + ); + + +-- revision_requests: party can read +create policy "revision_party_select" + on public.revision_requests for select + using ( + exists ( + select 1 from public.projects p + where p.id = revision_requests.project_id + and (p.brand_id = auth.uid() or p.creator_id = auth.uid()) + ) + ); + +-- revision_requests: only brand can insert +create policy "revision_brand_insert" + on public.revision_requests for insert + with check ( + auth.uid() = brand_id + ); + + +-- --------------------------------------------------------------------------- +-- 8. Realtime — publish projects so dashboards live-update status badges +-- --------------------------------------------------------------------------- +alter publication supabase_realtime add table public.projects; +alter publication supabase_realtime add table public.deliverables; +alter publication supabase_realtime add table public.revision_requests; + + +-- ============================================================================= +-- Migration complete. +-- ============================================================================= From de8d9fdb35547a90ee75d3448617a497e7e80166 Mon Sep 17 00:00:00 2001 From: annanyaa03 Date: Thu, 7 May 2026 21:22:21 +0530 Subject: [PATCH 3/5] feat: redesign pricing page and messaging fixes --- client/src/App.jsx | 15 +- client/src/components/WorkDetailCard.jsx | 189 +++++++++ client/src/index.css | 333 +++++++++++++++ client/src/pages/CreatorProfilePage.jsx | 504 ++++++++++++++--------- client/src/pages/DirectMessagePage.jsx | 104 +++-- client/src/pages/ExplorePage.jsx | 59 +-- client/src/pages/OnboardingPage.jsx | 6 +- server/routes/v1/creators.js | 1 + server/services/creatorService.js | 6 +- 9 files changed, 923 insertions(+), 294 deletions(-) create mode 100644 client/src/components/WorkDetailCard.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index e4035e9..ec476fa 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,4 +1,4 @@ -import { BrowserRouter as Router, Routes, Route, useLocation } from 'react-router-dom'; +import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { HelmetProvider } from 'react-helmet-async'; import { AuthProvider } from './context/AuthContext'; import { SocketProvider } from './context/SocketContext'; @@ -55,9 +55,20 @@ import ContactPage from './pages/ContactPage'; import PrivacyPage from './pages/PrivacyPage'; import TermsPage from './pages/TermsPage'; +import { useAuth } from './context/AuthContext'; + +/** Redirects /profile/me → /profile/:userId, or /auth if not logged in */ +function ProfileMeRedirect() { + const { user, loading } = useAuth(); + if (loading) return
; + if (!user) return ; + return ; +} + const AppLayout = () => { const location = useLocation(); const isDriplens = location.pathname.startsWith('/driplens'); + const isDM = location.pathname.startsWith('/dm'); return ( @@ -154,6 +165,7 @@ function App() { } /> } /> } /> + } /> } /> } /> @@ -182,6 +194,7 @@ function App() { } /> + } /> } /> diff --git a/client/src/components/WorkDetailCard.jsx b/client/src/components/WorkDetailCard.jsx new file mode 100644 index 0000000..32eba66 --- /dev/null +++ b/client/src/components/WorkDetailCard.jsx @@ -0,0 +1,189 @@ +import { useEffect, useRef } from 'react'; +import { Link } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; + +/** + * WorkDetailCard – redesigned modal + * + * Props: + * project – the selected content item (or null when closed) + * onClose – callback to clear selection + */ +export default function WorkDetailCard({ project, onClose }) { + const panelRef = useRef(null); + + // Close on Escape key + useEffect(() => { + const handleKey = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', handleKey); + return () => window.removeEventListener('keydown', handleKey); + }, [onClose]); + + // Lock body scroll while open + useEffect(() => { + if (project) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { document.body.style.overflow = ''; }; + }, [project]); + + // Derive initials for the avatar + const initials = (str = 'A') => + str + .split(/[\s_]/) + .slice(0, 2) + .map((w) => w[0]?.toUpperCase() ?? '') + .join(''); + + return ( + + {project && ( + /* ── Backdrop ── */ + + {/* ── Card ── */} + e.stopPropagation()} + > + {/* ─── Left: Image ─── */} +
+ {project.mediaType === 'video' ? ( +
+ + {/* ─── Divider ─── */} +
+ + {/* ─── Right: Content ─── */} +
+ {/* Close button */} + + + {/* Type label */} +

{project.category || 'Cinematography'}

+ + {/* Title */} +

{project.title}

+ + {/* Description */} +

+ {project.description || + 'A meticulously crafted visual piece delivering a cinematic narrative through careful composition, color, and movement.'} +

+ + {/* ── Divider ── */} +
+ + {/* Creator row */} +
+ {/* Avatar */} + +
+

+ {project.author?.display_name || project.author?.username || 'Anonymous'} +

+

Independent Creator

+
+
+ + {/* ── Divider ── */} +
+ + {/* Meta grid */} +
+ {[ + { label: 'Category', value: project.category || 'Creative' }, + { label: 'Source', value: project.source || 'Driplens' }, + { label: 'Format', value: project.mediaType === 'video' ? 'Video' : 'Photo' }, + { label: 'Status', value: 'Available' }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {/* ── Divider ── */} +
+ + {/* CTA */} +
+ + Hire Me + + + + + +
+
+ + + )} + + ); +} diff --git a/client/src/index.css b/client/src/index.css index c901d34..501a72f 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -107,4 +107,337 @@ ::-webkit-scrollbar-thumb:hover { background: var(--color-brand-label); +} + +/* ═══════════════════════════════════════════════ + WORK DETAIL CARD – redesigned modal component +═══════════════════════════════════════════════ */ + +/* ── Card shell ── */ +.wdc-card { + display: flex; + flex-direction: column; + width: 100%; + max-width: 980px; + max-height: 90vh; + background: #ffffff; + border-radius: 0; /* sharp edges */ + overflow: hidden; + box-shadow: 0 8px 40px rgba(2, 6, 23, 0.22), 0 2px 8px rgba(2, 6, 23, 0.08); + transition: box-shadow 0.3s ease; +} + +.wdc-card:hover { + box-shadow: 0 20px 60px rgba(2, 6, 23, 0.28), 0 4px 16px rgba(2, 6, 23, 0.12); +} + +@media (min-width: 768px) { + .wdc-card { + flex-direction: row; + } +} + +/* ── Image column ── */ +.wdc-image-col { + position: relative; + flex: 0 0 55%; + min-height: 260px; + background: #111; + overflow: hidden; +} + +/* ── Media (image / video) ── */ +.wdc-media { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform 0.55s cubic-bezier(0.22, 1, 0.36, 1), + filter 0.55s ease; +} + +.wdc-image-col:hover .wdc-media { + transform: scale(1.04); + filter: brightness(1.06); +} + +/* ── Category badge (image overlay) ── */ +.wdc-category-badge { + position: absolute; + top: 16px; + left: 16px; + background: rgba(2, 6, 23, 0.65); + backdrop-filter: blur(6px); + color: #fff; + font-family: 'Inter', sans-serif; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; + padding: 5px 10px; + border-radius: 0; +} + +/* ── Vertical divider ── */ +.wdc-divider { + display: none; + flex: 0 0 1px; + background: #e8e8e8; +} + +@media (min-width: 768px) { + .wdc-divider { + display: block; + } +} + +/* ── Content column ── */ +.wdc-content-col { + flex: 1; + padding: 36px 36px 32px; + display: flex; + flex-direction: column; + gap: 0; + overflow-y: auto; + position: relative; + background: #ffffff; +} + +/* ── Close button ── */ +.wdc-close-btn { + position: absolute; + top: 18px; + right: 18px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: 1.5px solid #e2e2e2; + background: #f9f9f9; + color: #555; + border-radius: 0; + cursor: pointer; + transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease; + flex-shrink: 0; +} + +.wdc-close-btn:hover { + background: #111; + color: #fff; + border-color: #111; +} + +.wdc-close-btn:focus-visible { + outline: 2px solid #0046FF; + outline-offset: 2px; +} + +/* ── Type label (e.g. "CINEMATOGRAPHY") ── */ +.wdc-type-label { + font-family: 'Inter', sans-serif; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.2em; + text-transform: uppercase; + color: #999; + margin-top: 4px; + margin-bottom: 10px; +} + +/* ── Title ── */ +.wdc-title { + font-family: 'Space Grotesk', sans-serif; + font-size: clamp(1.4rem, 2.5vw, 1.9rem); + font-weight: 700; + color: #0d0d0d; + line-height: 1.15; + letter-spacing: -0.02em; + margin-bottom: 14px; +} + +/* ── Description ── */ +.wdc-description { + font-family: 'Inter', sans-serif; + font-size: 13.5px; + color: #4a4a4a; + line-height: 1.75; + margin-bottom: 22px; +} + +/* ── Horizontal rule ── */ +.wdc-h-rule { + width: 100%; + height: 1px; + background: #ececec; + margin: 18px 0; + flex-shrink: 0; +} + +/* ── Creator row ── */ +.wdc-creator-row { + display: flex; + align-items: center; + gap: 14px; +} + +/* ── Avatar ── */ +.wdc-avatar { + flex-shrink: 0; + width: 44px; + height: 44px; + background: #0d0d0d; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-family: 'Inter', sans-serif; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.03em; + border-radius: 0; /* square avatar */ + overflow: hidden; +} + +.wdc-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* ── Creator name / sub ── */ +.wdc-creator-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.wdc-creator-name { + font-family: 'Inter', sans-serif; + font-size: 14px; + font-weight: 700; + color: #111; + line-height: 1.2; +} + +.wdc-creator-sub { + font-family: 'Inter', sans-serif; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #999; +} + +/* ── Meta grid ── */ +.wdc-meta-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px 24px; +} + +.wdc-meta-item { + display: flex; + flex-direction: column; + gap: 3px; +} + +.wdc-meta-label { + font-family: 'Inter', sans-serif; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: #bbb; +} + +.wdc-meta-value { + font-family: 'Inter', sans-serif; + font-size: 13px; + font-weight: 600; + color: #222; +} + +/* ── CTA area ── */ +.wdc-cta-area { + display: flex; + gap: 12px; + margin-top: 4px; +} + +/* ── HIRE ME button ── */ +.wdc-hire-btn { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 1; + justify-content: center; + background: #0046FF; + color: #fff; + border: none; + border-radius: 0; + font-family: 'Inter', sans-serif; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; + padding: 14px 20px; + cursor: pointer; + box-shadow: 0 4px 16px rgba(0, 70, 255, 0.28); + transition: background 0.22s ease, box-shadow 0.22s ease, transform 0.15s ease; + text-decoration: none; +} + +.wdc-hire-btn:hover { + background: #0031B0; + box-shadow: 0 6px 20px rgba(0, 49, 176, 0.35); + transform: translateY(-1px); +} + +.wdc-hire-btn:active { + transform: translateY(0); + box-shadow: 0 2px 8px rgba(0, 49, 176, 0.3); +} + +.wdc-hire-btn:focus-visible { + outline: 3px solid #0046FF; + outline-offset: 3px; +} + +.wdc-hire-arrow { + width: 15px; + height: 15px; + transition: transform 0.2s ease; + flex-shrink: 0; +} + +.wdc-hire-btn:hover .wdc-hire-arrow { + transform: translateX(3px); +} + +/* ── Message button ── */ +.wdc-message-btn { + border: 1.5px solid #d8d8d8; + background: transparent; + color: #333; + border-radius: 0; + font-family: 'Inter', sans-serif; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + padding: 14px 20px; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; + flex-shrink: 0; +} + +.wdc-message-btn:hover { + background: #f4f4f4; + border-color: #aaa; +} + +.wdc-message-btn:focus-visible { + outline: 2px solid #0046FF; + outline-offset: 2px; } \ No newline at end of file diff --git a/client/src/pages/CreatorProfilePage.jsx b/client/src/pages/CreatorProfilePage.jsx index b052ba1..d7b1e93 100644 --- a/client/src/pages/CreatorProfilePage.jsx +++ b/client/src/pages/CreatorProfilePage.jsx @@ -2,89 +2,66 @@ import { useState, useEffect } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { api } from '../lib/api'; +import { useAuth } from '../context/AuthContext'; -const DEMO_CREATOR = { - id: 'demo', - name: 'Atharv Gadekar', - username: 'atharvagadekar2906', - role: 'Creative Director & Cinematographer', - location: 'Mumbai / Global', - img: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=500&auto=format&fit=crop', - qualifications: ['Advanced Color Grading', 'Certified Drone Operator', '10+ Years Industry Exp.'], - pastWork: ['Nike', 'Sony', 'RedBull'], - brandDeals: ['Nike', 'Sony', 'RedBull', 'Apple'], - workflow: [ - { step: 'Discovery', description: 'Deep dive into brand guidelines and visual identity.' }, - { step: 'Pre-Production', description: 'Storyboarding, scouting, and preparation.' }, - { step: 'Production', description: 'On-set execution with top-tier equipment.' }, - { step: 'Post & Delivery', description: 'Color grading and final rendering.' } - ], - portfolio_items: [ - { id: 1, title: 'Mandai', type: 'Cinematography', img: 'https://images.unsplash.com/photo-1518131672697-613becd4fab5?w=500&auto=format&fit=crop' }, - { id: 2, title: 'Ethnicity', type: 'Direction', img: 'https://images.unsplash.com/photo-1555680202-c86f0e12f086?w=500&auto=format&fit=crop' }, - { id: 3, title: 'Urban Flow', type: 'Photography', img: 'https://images.unsplash.com/photo-1600132806370-bf17e65e942f?w=500&auto=format&fit=crop' }, - ], - packages: [ - { - title: "Social / Lifestyle", - price: "₹5,000-₹12,000", - subtitle: "Perfect for personal branding & short reels", - features: [ - "2-4 hours shoot time", - "Professional lighting setup", - "Basic color grading", - "2-3 edited short videos", - "Optimized for Instagram/TikTok", - "Web-ready delivery" - ] - }, - { - title: "Event Coverage", - price: "₹15,000-₹25,000", - subtitle: "Best choice for corporate events & parties", - features: [ - "6-8 hours event coverage", - "Dual camera setup for backup", - "Candid + staged shots", - "Advanced color correction", - "1-3 minute highlight reel", - "Raw footage available" - ], - popular: true - }, - { - title: "Commercial Film", - price: "₹35,000-₹80,000+", - subtitle: "High-end visuals for brands & businesses", - features: [ - "Full-day shoot (10-12 hours)", - "Pre-production & storyboarding", - "Cinema-grade RED/ARRI cameras", - "Professional crew & sound design", - "Cinematic color grading", - "Multiple export formats" - ] - } - ] -}; +/* ── Packages are platform-level, not per-user — keep static ── */ +const DEFAULT_PACKAGES = [ + { + title: 'Social / Lifestyle', + price: '₹5,000–₹12,000', + subtitle: 'Perfect for personal branding & short reels', + features: ['2–4 hours shoot time', 'Professional lighting setup', 'Basic color grading', '2–3 edited short videos', 'Optimised for Instagram/TikTok', 'Web-ready delivery'], + }, + { + title: 'Event Coverage', + price: '₹15,000–₹25,000', + subtitle: 'Best for corporate events & parties', + features: ['6–8 hours event coverage', 'Dual camera setup', 'Candid + staged shots', 'Advanced colour correction', '1–3 min highlight reel', 'Raw footage available'], + popular: true, + }, + { + title: 'Commercial Film', + price: '₹35,000–₹80,000+', + subtitle: 'High-end visuals for brands & businesses', + features: ['Full-day shoot (10–12 h)', 'Pre-production & storyboarding', 'Cinema-grade cameras', 'Professional crew & sound', 'Cinematic colour grading', 'Multiple export formats'], + }, +]; + +const DEFAULT_WORKFLOW = [ + { step: 'Discovery', description: 'Deep dive into brand guidelines and visual identity.' }, + { step: 'Pre-Production', description: 'Storyboarding, scouting, and preparation.' }, + { step: 'Production', description: 'On-set execution with top-tier equipment.' }, + { step: 'Post & Delivery', description: 'Colour grading and final rendering.' }, +]; + +/* Derive readable initials from a username / display_name */ +function initials(str = 'A') { + return str.split(/[\s_]/).slice(0, 2).map(w => w[0]?.toUpperCase() ?? '').join(''); +} export default function CreatorProfilePage() { - const { id } = useParams(); - const navigate = useNavigate(); - const [creator, setCreator] = useState(null); - const [error, setError] = useState(null); + const { id } = useParams(); + const navigate = useNavigate(); + const { user } = useAuth(); + + const [creator, setCreator] = useState(null); + const [error, setError] = useState(null); const [selectedMedia, setSelectedMedia] = useState(null); - const [showPricing, setShowPricing] = useState(false); + const [showPricing, setShowPricing] = useState(false); + const [portfolioFilter, setPortfolioFilter] = useState('All'); + /* Resolve 'me' → real user id, then fetch */ useEffect(() => { + const resolvedId = id === 'me' ? user?.id : id; + + if (!resolvedId) { + setError('Please log in to view your profile.'); + return; + } + const load = async () => { - if (id === 'demo') { - setCreator(DEMO_CREATOR); - window.scrollTo(0, 0); - return; - } try { - const data = await api.get(`/creators/${id}`); + const data = await api.get(`/creators/${resolvedId}`); if (data?.data?.creator) { setCreator(data.data.creator); } else { @@ -94,10 +71,24 @@ export default function CreatorProfilePage() { setError(err.message || 'Failed to load creator profile'); } }; + load(); window.scrollTo(0, 0); - }, [id]); + }, [id, user?.id]); + + const isOwnProfile = user && creator && user.id === creator.id; + + /* ── Portfolio filter ── */ + const portfolioItems = creator?.portfolio_items || []; + const filteredItems = portfolioFilter === 'All' + ? portfolioItems + : portfolioItems.filter(item => + portfolioFilter === 'Video' + ? item.media_type === 'video' + : item.media_type !== 'video' + ); + /* ── Error / loading states ── */ if (error) { return (
@@ -107,124 +98,196 @@ export default function CreatorProfilePage() { ); } - if (!creator) return
Loading...
; + if (!creator) { + return ( +
+
+
+ ); + } + + /* Derive display values from real profile fields */ + const displayName = creator.display_name || creator.username; + const roleLabel = creator.category || 'Creator'; + const locationLabel = creator.location || 'Remote'; + const bio = creator.bio || 'A multi-disciplinary creative professional with a passion for pushing visual boundaries.'; + const qualifications = creator.tags?.length ? creator.tags : ['Professional Creator']; + const pastWork = creator.platforms?.length ? creator.platforms : []; return (
- {/* Hero Section */} + + {/* ── Hero / Banner ── */}
- Banner -
- +
+ ← Back to Explore + + {isOwnProfile && ( + + Edit Profile + + )}
- - {/* Left Wing: Profile & Quick Info */} + + {/* ── LEFT: Profile card ── */}
- + {/* Avatar */}
- {creator.username} + {creator.avatar_url ? ( + {displayName} + ) : ( +
+ {initials(displayName)} +
+ )}
+ {/* Name + role */}
-

{creator.display_name || creator.username}

- Verified +

{displayName}

+ + {isOwnProfile ? 'You' : 'Verified'} +
-

{creator.role} • {creator.location || 'Remote'}

+

+ {roleLabel} • {locationLabel} +

+ {bio && ( +

{bio}

+ )}
-
- - Message - - - +
+ {isOwnProfile ? ( + + Edit Profile + + ) : ( +
+ + +
+ )}
+ {/* Skills / Tags */}
-

Qualification

-
    - {(creator.qualifications || ['Professional Creator']).map((q, i) => ( -
  • - - {q} -
  • - ))} -
+

Skills & Tags

+ {qualifications.length > 0 ? ( +
    + {qualifications.map((q, i) => ( +
  • + + {q} +
  • + ))} +
+ ) : ( +

No tags added yet.

+ )}
-
-

Past Work

-
- {(creator.past_work || creator.pastWork || ['New Member']).map((pw, i) => ( - - {pw} - - ))} + {/* Platforms */} + {pastWork.length > 0 && ( +
+

Platforms

+
+ {pastWork.map((pw, i) => ( + + {pw} + + ))} +
-
+ )} + + {/* Social links */} + {(creator.instagram || creator.twitter || creator.website) && ( +
+

Links

+
+ {creator.instagram && ( + + Instagram → @{creator.instagram} + + )} + {creator.twitter && ( + + Twitter → @{creator.twitter} + + )} + {creator.website && ( + + Website → {creator.website} + + )} +
+
+ )}
- {/* Brand Deals Wing */} - -

Trusted By

-
- {(creator.brandDeals || ['Community Partner']).map((brand, i) => ( -
- {brand} -
- ))} -
+
+
+

Availability

+

+ {creator.is_available ? 'Open to new projects' : 'Currently unavailable'} +

+
- {/* Right Wing: Main Content */} + {/* ── RIGHT: Content ── */}
- - {/* Workflow Section */} + + {/* Workflow */}

- Stable Workflow - + Workflow

- {(creator.workflow || [ - { step: 'Strategy', description: 'Aligning vision with brand goals.' }, - { step: 'Production', description: 'High-end content creation.' }, - { step: 'Review', description: 'Collaborative feedback loop.' }, - { step: 'Delivery', description: 'Final assets in all formats.' } - ]).map((item, i) => ( - ( +
- {/* Portfolio Grid */} + {/* Portfolio */}

- Portfolio - + Portfolio

- {['All', 'Video', 'Photos'].map(f => ( - - ))} + {['All', 'Video', 'Photos'].map(f => ( + + ))}
- -
- {(creator.portfolio_items || []).map((item, i) => ( - setSelectedMedia(item)} - className="break-inside-avoid relative group cursor-pointer overflow-hidden rounded-none bg-zinc-100" - > - {item.title} -
-

{item.title}

-

{item.type}

-
-
- ))} -
+ + {filteredItems.length === 0 ? ( +
+

+ {isOwnProfile ? 'Your portfolio is empty. Start by uploading your work.' : 'No portfolio items yet.'} +

+ {isOwnProfile && ( + + Upload Your First Work + + )} +
+ ) : ( +
+ {filteredItems.map((item, i) => ( + setSelectedMedia(item)} + className="break-inside-avoid relative group cursor-pointer overflow-hidden rounded-none bg-zinc-100" + > + {item.media_type === 'video' ? ( + + ))} +
+ )} + + {/* Upload CTA on own profile when items exist */} + {isOwnProfile && filteredItems.length > 0 && ( +
+ + + Add More Work + +
+ )}
-
- {/* Lightbox Modal */} + {/* ── Lightbox ── */} {selectedMedia && ( - setSelectedMedia(null)} > - e.stopPropagation()} > - - {selectedMedia.title} + {selectedMedia.media_type === 'video' ? ( + )} - {/* Pricing Modal */} + {/* ── Pricing Modal ── */} {showPricing && ( - setShowPricing(false)} > - e.stopPropagation()} >
- -

Photography

+

+ Packages +

- -
- {(creator.packages || []).map((pkg, i) => ( + +
+ {DEFAULT_PACKAGES.map((pkg, i) => (
{pkg.popular && ( ★ Popular )} -
+

{pkg.title}

{pkg.price}

{pkg.subtitle}

-
    {pkg.features.map((f, j) => (
  • -
    - -
    + + + {f}
  • ))}
- -
{/* Chat Messages */} -
+
{messages.map((msg) => ( -
+

{msg.text}

{msg.time} {msg.sender === 'me' && ( - + {msg.status !== 'sent' && } @@ -157,24 +195,24 @@ export default function DirectMessagePage() {
{/* Chat Input */} -
+
- -
+
setNewMessage(e.target.value)} placeholder="Type your message..." - className="flex-1 bg-transparent border-none text-white py-2 outline-none text-sm font-medium w-full" + className="flex-1 bg-transparent border-none text-black py-2 outline-none text-sm font-medium w-full" />
diff --git a/client/src/pages/ExplorePage.jsx b/client/src/pages/ExplorePage.jsx index 50b8884..cb748fa 100644 --- a/client/src/pages/ExplorePage.jsx +++ b/client/src/pages/ExplorePage.jsx @@ -3,6 +3,7 @@ import { motion } from 'framer-motion'; import { Link } from 'react-router-dom'; import { Helmet } from 'react-helmet-async'; import { getAllExploreContent } from '../services/externalMediaService'; +import WorkDetailCard from '../components/WorkDetailCard'; export default function ExplorePage() { const [content, setContent] = useState([]); @@ -178,60 +179,10 @@ export default function ExplorePage() { )} )} - {selectedProject && ( -
setSelectedProject(null)}> - e.stopPropagation()} - > - - -
- {selectedProject.mediaType === 'video' ? ( -
- -
-

{selectedProject.title}

-

{selectedProject.category}

- -
-

Creator

-
-
- {(selectedProject.author?.username || 'A')[0]} -
-
-

{selectedProject.author?.username || 'Anonymous'}

-

Independent

-
-
-

- {selectedProject.author?.bio || "A multi-disciplinary creative professional with a proven track record of delivering high-impact visual experiences. Passionate about pushing boundaries and setting new industry standards."} -

-
- -
- - Hire Me - - -
-
-
-
- )} + setSelectedProject(null)} + />
); } diff --git a/client/src/pages/OnboardingPage.jsx b/client/src/pages/OnboardingPage.jsx index 0e8f3a3..8af2083 100644 --- a/client/src/pages/OnboardingPage.jsx +++ b/client/src/pages/OnboardingPage.jsx @@ -407,7 +407,7 @@ function Step3({ onNext, onBack }) { /> ))} - q.trim()) || !data.past_work.some(p => p.trim())} /> +
); } @@ -494,7 +494,7 @@ function Step4({ onNext, onBack }) {

You can change all of this anytime from your dashboard.

- +
); } @@ -666,7 +666,7 @@ function Step5({ onBack, onSubmit, loading }) { />
- + +

Loading profile...

); @@ -321,342 +339,327 @@ export default function EditProfilePage() { -
- -

Edit Profile

-

- {isCreator ? 'Showcase your work and let brands find you.' : 'Tell creators about your brand and what you\'re looking for.'} -

- - {/* Banner Upload */} -
- -
bannerInputRef.current?.click()} - className={`aspect-[3/1] border-2 border-dashed transition-all cursor-pointer flex flex-col items-center justify-center p-8 relative overflow-hidden ${ - dragActiveBanner ? 'border-black bg-gray-50' : 'border-[#DDD] bg-white hover:border-black' - }`} +
+ {/* TOP HEADER BAR */} +
+
+
Profile settings
+
Manage your public profile and personal information
+
+
+
- {errors.banner &&

{errors.banner}

} + Cancel + +
+
- {/* Avatar Upload */} -
- -
avatarInputRef.current?.click()} - className={`w-32 h-32 border-2 border-dashed transition-all cursor-pointer flex items-center justify-center relative overflow-hidden rounded-lg ${ - dragActiveAvatar ? 'border-black bg-gray-50' : 'border-[#DDD] bg-white hover:border-black' - }`} - > - - {avatarPreview ? ( - Avatar Preview - ) : ( - <> - - - - - )} -
-

Square image recommended (500×500px)

- {errors.avatar &&

{errors.avatar}

} + {/* BODY */} +
+ + {/* LEFT SIDEBAR NAV */} +
+ {[['Profile', true], ['Portfolio', false], ['Availability', false], ['Notifications', false], ['Security', false]].map(([label, active]) => ( +
+ {label} +
+ ))}
- {/* Form */} - + {/* MAIN CONTENT — CARDS */} +
+ {message.text && ( {message.text} )} - {/* Bio */} -
- -