diff --git a/backend/src/routes/jobs.js b/backend/src/routes/jobs.js index e0ec332..2cf830b 100644 --- a/backend/src/routes/jobs.js +++ b/backend/src/routes/jobs.js @@ -88,6 +88,11 @@ router.use(checkCrudPermissions('jobs')); * 500: * description: Some server error */ +router.post('/:id/dispute', wrapAsync(async (req, res) => { + const result = await JobsService.dispute(req.params.id, req.currentUser); + res.status(200).send(result); +})); + router.post('/', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); diff --git a/backend/src/routes/service_listings.js b/backend/src/routes/service_listings.js index 88c3ae9..ab792ad 100644 --- a/backend/src/routes/service_listings.js +++ b/backend/src/routes/service_listings.js @@ -85,6 +85,11 @@ router.use(checkCrudPermissions('service_listings')); * 500: * description: Some server error */ +router.post('/:id/purchase', wrapAsync(async (req, res) => { + const result = await Service_listingsService.purchase(req.params.id, req.currentUser); + res.status(200).send(result); +})); + router.post('/', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js index 2862da4..79332a9 100644 --- a/backend/src/services/auth.js +++ b/backend/src/services/auth.js @@ -1,3 +1,4 @@ +const db = require("../db/models"); const UsersDBApi = require('../db/api/users'); const ValidationError = require('./notifications/errors/validation'); const ForbiddenError = require('./notifications/errors/forbidden'); @@ -59,6 +60,7 @@ class Auth { firstName: email.split('@')[0], password: hashedPassword, email: email, + is_verified_student: email.toLowerCase().endsWith('.edu'), }, options, diff --git a/backend/src/services/jobs.js b/backend/src/services/jobs.js index 9908989..ad185bc 100644 --- a/backend/src/services/jobs.js +++ b/backend/src/services/jobs.js @@ -12,6 +12,34 @@ const stream = require('stream'); module.exports = class JobsService { + static async dispute(id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + const job = await JobsDBApi.findBy({ id }, { transaction }); + if (!job) throw new Error('Job not found'); + + await db.jobs.update({ status: 'Disputed' }, { where: { id }, transaction }); + + if (db.escrow_transactions) { + await db.escrow_transactions.update({ status: 'Held_in_Escrow' }, { where: { jobId: id }, transaction }); + } + + await db.disputes.create({ + opened_byId: currentUser.id, + subject: `Dispute for Job: ${job.title || id}`, + description: `Dispute raised by ${currentUser.email} for Job ${id}`, + status: 'open', + opened_at: new Date() + }, { transaction }); + + await transaction.commit(); + return { success: true }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); try { @@ -133,6 +161,4 @@ module.exports = class JobsService { } -}; - - +}; \ No newline at end of file diff --git a/backend/src/services/service_listings.js b/backend/src/services/service_listings.js index 40ddcba..edbe0cf 100644 --- a/backend/src/services/service_listings.js +++ b/backend/src/services/service_listings.js @@ -12,6 +12,42 @@ const stream = require('stream'); module.exports = class Service_listingsService { + static async purchase(id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + const service = await Service_listingsDBApi.findBy({ id }, { transaction }); + if (!service) throw new Error('Service not found'); + + const job = await db.jobs.create({ + clientId: currentUser.id, + title: `Order: ${service.title}`, + description: service.description, + budget: service.starting_price, + status: 'In-Progress' + }, { transaction }); + + const client_fee = Number(service.starting_price) * 0.01; + const freelancer_fee = Number(service.starting_price) * 0.005; + const total_amount = Number(service.starting_price) + client_fee; + + const escrow = await db.escrow_transactions.create({ + jobId: job.id, + clientId: currentUser.id, + freelancerId: service.freelancerId, + total_amount: total_amount, + client_fee: client_fee, + freelancer_fee: freelancer_fee, + status: 'Held_in_Escrow' + }, { transaction }); + + await transaction.commit(); + return { job, escrow }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); try { @@ -133,6 +169,4 @@ module.exports = class Service_listingsService { } -}; - - +}; \ No newline at end of file diff --git a/frontend/src/pages/jobs/jobs-view.tsx b/frontend/src/pages/jobs/jobs-view.tsx index e59d458..3c7b761 100644 --- a/frontend/src/pages/jobs/jobs-view.tsx +++ b/frontend/src/pages/jobs/jobs-view.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useEffect } from 'react'; +import React, { ReactElement, useEffect, useState } from 'react'; import Head from 'next/head' import DatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; @@ -16,16 +16,17 @@ import SectionMain from "../../components/SectionMain"; import CardBox from "../../components/CardBox"; import BaseButton from "../../components/BaseButton"; import BaseDivider from "../../components/BaseDivider"; -import {mdiChartTimelineVariant} from "@mdi/js"; +import {mdiChartTimelineVariant, mdiAlertCircle} from "@mdi/js"; import {SwitchField} from "../../components/SwitchField"; import FormField from "../../components/FormField"; +import axios from 'axios'; const JobsView = () => { const router = useRouter() const dispatch = useAppDispatch() const { jobs } = useAppSelector((state) => state.jobs) - + const [disputing, setDisputing] = useState(false); const { id } = router.query; @@ -35,9 +36,26 @@ const JobsView = () => { } useEffect(() => { - dispatch(fetch({ id })); + if(id) { + dispatch(fetch({ id })); + } }, [dispatch, id]); + const handleDispute = async () => { + if (!confirm('Are you sure you want to raise a dispute? This will pause escrow release and alert the admins.')) return; + setDisputing(true); + try { + await axios.post(`/jobs/${id}/dispute`); + alert('Dispute raised successfully. Escrow is paused.'); + dispatch(fetch({ id })); // Refresh job details + } catch (error) { + console.error('Error raising dispute:', error); + alert('Failed to raise dispute.'); + } finally { + setDisputing(false); + } + }; + return ( <> @@ -46,224 +64,43 @@ const JobsView = () => { - +
+ {jobs?.status !== 'Disputed' && ( + + )} + +
- - - - - - - - - - - - - - - - - - - -

Client

- - -

{jobs?.client?.firstName ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +

{jobs?.client?.firstName ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - - -

Category

- - - - - - - - - - -

{jobs?.category?.name ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +

{jobs?.category?.name ?? 'No data'}

- - - - - - - - -

Title

{jobs?.title}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Description

{jobs.description @@ -272,135 +109,21 @@ const JobsView = () => { }
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -

BudgetMin

{jobs?.budget_min || 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - -

BudgetMax

{jobs?.budget_max || 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

BudgetType

{jobs?.budget_type ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - {jobs.deadline_at ? { /> :

No DeadlineAt

}
- - - - - - - - - - - - - - - - - - - - - - - - - - - {jobs.posted_at ? { /> :

No PostedAt

}
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Status

-

{jobs?.status ?? 'No data'}

+

{jobs?.status ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - { /> - - - - - - - - - - -

LocationRequirement

{jobs?.location_requirement}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - <>

RequiredSkills

{ - - - - - Name - - - Category - - - Description - - - IsActive - - - - - - - - - - - - - - - - - - - - - - - - - {jobs.required_skills && Array.isArray(jobs.required_skills) && jobs.required_skills.map((item: any) => ( router.push(`/skills/skills-view/?id=${item.id}`)}> - - - - - - { item.name } - - - { item.category } - - - { item.description } - - - { dataFormatter.booleanFormatter(item.is_active) } - - - - - - - - - - - - - - - - - - - - - - - - - ))} @@ -710,34 +209,7 @@ const JobsView = () => { {!jobs?.required_skills?.length &&
No data
}
- - - - - - - - - - - - - - - - - - - - - - - - - - - <>

Attachments

{ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {jobs.attachments && Array.isArray(jobs.attachments) && jobs.attachments.map((item: any) => ( router.push(`/job_attachments/job_attachments-view/?id=${item.id}`)}> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ))} @@ -847,22 +243,6 @@ const JobsView = () => { - - - - - - - - - - - - - - - - <>

Job_attachments Job

{
CaptionUploadedAt
{ item.caption } { dataFormatter.dateTimeFormatter(item.uploaded_at) }
- - - - - - - - - - - {jobs.job_attachments_job && Array.isArray(jobs.job_attachments_job) && jobs.job_attachments_job.map((item: any) => ( router.push(`/job_attachments/job_attachments-view/?id=${item.id}`)}> - - - - - - - - - - - ))} @@ -918,7 +276,6 @@ const JobsView = () => { - <>

Proposals Job

{
CaptionUploadedAt
{ item.caption } { dataFormatter.dateTimeFormatter(item.uploaded_at) }
- - - - - - - - - - - - - - - - - - - - - - - - - - - - {jobs.proposals_job && Array.isArray(jobs.proposals_job) && jobs.proposals_job.map((item: any) => ( router.push(`/proposals/proposals-view/?id=${item.id}`)}> - - - - - - - - - - - - - - - - - - - - - - - - - - - - ))} @@ -1028,7 +329,6 @@ const JobsView = () => { - <>

Contracts Job

{
CoverLetterProposedAmountPricingModelEstimatedDaysStatusSubmittedAtRespondedAt
{ item.cover_letter } { item.proposed_amount } { item.pricing_model } { item.estimated_days } { item.status } { dataFormatter.dateTimeFormatter(item.submitted_at) } { dataFormatter.dateTimeFormatter(item.responded_at) }
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {jobs.contracts_job && Array.isArray(jobs.contracts_job) && jobs.contracts_job.map((item: any) => ( router.push(`/contracts/contracts-view/?id=${item.id}`)}> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ))} @@ -1141,21 +381,6 @@ const JobsView = () => { {!jobs?.contracts_job?.length &&
No data
} - - - - - - - - - - - - - - - @@ -1173,9 +398,7 @@ const JobsView = () => { JobsView.getLayout = function getLayout(page: ReactElement) { return ( {page} diff --git a/frontend/src/pages/web_pages/service-details.tsx b/frontend/src/pages/web_pages/service-details.tsx index 3dccc70..6c8363d 100644 --- a/frontend/src/pages/web_pages/service-details.tsx +++ b/frontend/src/pages/web_pages/service-details.tsx @@ -17,8 +17,10 @@ export default function ServiceDetailsPage() { const router = useRouter(); const { id } = router.query; const projectName = useAppSelector((state) => state.style.projectName) || 'FreelanceFusion'; + const { currentUser } = useAppSelector((state) => state.auth); const [service, setService] = useState(null); const [loading, setLoading] = useState(true); + const [purchasing, setPurchasing] = useState(false); useEffect(() => { if (id && typeof id === 'string') { @@ -36,6 +38,25 @@ export default function ServiceDetailsPage() { } }, [id]); + const handlePurchase = async () => { + if (!currentUser) { + router.push('/login'); + return; + } + + setPurchasing(true); + try { + await axios.post(`/service_listings/${id}/purchase`); + alert('Gig purchased successfully! Your funds are held safely in escrow.'); + router.push('/jobs/jobs-list'); + } catch (err: any) { + console.error('Failed to purchase gig:', err); + alert('Failed to purchase gig. Please try again.'); + } finally { + setPurchasing(false); + } + }; + if (loading) { return (
@@ -65,7 +86,7 @@ export default function ServiceDetailsPage() { className="px-8 py-3 text-lg font-bold uppercase shadow-lg hover:shadow-indigo-200" label="Back to Directory" icon={mdiArrowLeft} - href="/web_pages/services" + onClick={() => router.push('/web_pages/services')} color="indigo" />
@@ -106,11 +127,18 @@ export default function ServiceDetailsPage() { {service.freelancer?.firstName?.[0] || 'S'}
-

Student Freelancer

-

- - Identity Verified -

+

{service.freelancer?.firstName || 'Student Freelancer'}

+ {service.freelancer?.is_verified_student ? ( +

+ + Verified Student +

+ ) : ( +

+ + Identity Verified +

+ )}
@@ -140,16 +168,16 @@ export default function ServiceDetailsPage() {

Secure Gig Economy

- {/* Payment icons could go here */}
StatusAgreedAmountPricingModelStartedAtCompletedAtTermsRevisionLimit
{ item.status } { item.agreed_amount } { item.pricing_model } { dataFormatter.dateTimeFormatter(item.started_at) } { dataFormatter.dateTimeFormatter(item.completed_at) } { item.terms } { item.revision_limit }