diff --git a/backend/src/db/migrations/1780000000000.js b/backend/src/db/migrations/1780000000000.js new file mode 100644 index 0000000..a3c0b2c --- /dev/null +++ b/backend/src/db/migrations/1780000000000.js @@ -0,0 +1,47 @@ +module.exports = { + async up(queryInterface, Sequelize) { + try { + // 1. Users + await queryInterface.addColumn('users', 'role', { type: Sequelize.DataTypes.ENUM('Student_Freelancer', 'Client', 'Admin') }).catch(console.error); + await queryInterface.addColumn('users', 'name', { type: Sequelize.DataTypes.STRING }).catch(console.error); + await queryInterface.addColumn('users', 'is_verified_student', { type: Sequelize.DataTypes.BOOLEAN, defaultValue: false }).catch(console.error); + await queryInterface.addColumn('users', 'wallet_balance', { type: Sequelize.DataTypes.DECIMAL, defaultValue: 0.0 }).catch(console.error); + await queryInterface.addColumn('users', 'skills', { type: Sequelize.DataTypes.ARRAY(Sequelize.DataTypes.STRING) }).catch(console.error); + await queryInterface.addColumn('users', 'rating', { type: Sequelize.DataTypes.DECIMAL, defaultValue: 0.0 }).catch(console.error); + + // 2. Jobs + await queryInterface.addColumn('jobs', 'budget', { type: Sequelize.DataTypes.DECIMAL }).catch(console.error); + await queryInterface.removeColumn('jobs', 'status').catch(console.error); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_jobs_status" CASCADE;').catch(console.error); + await queryInterface.addColumn('jobs', 'status', { type: Sequelize.DataTypes.ENUM('Open', 'In-Progress', 'Under_Review', 'Completed', 'Disputed') }).catch(console.error); + + // 3. Proposals + await queryInterface.renameColumn('proposals', 'proposed_amount', 'bid_amount').catch(console.error); + await queryInterface.removeColumn('proposals', 'status').catch(console.error); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_proposals_status" CASCADE;').catch(console.error); + await queryInterface.addColumn('proposals', 'status', { type: Sequelize.DataTypes.ENUM('Pending', 'Accepted', 'Rejected') }).catch(console.error); + + // 4. Escrow Transactions + await queryInterface.createTable('escrow_transactions', { + id: { type: Sequelize.DataTypes.UUID, defaultValue: Sequelize.DataTypes.UUIDV4, primaryKey: true }, + jobId: { type: Sequelize.DataTypes.UUID, references: { model: 'jobs', key: 'id' } }, + clientId: { type: Sequelize.DataTypes.UUID, references: { model: 'users', key: 'id' } }, + freelancerId: { type: Sequelize.DataTypes.UUID, references: { model: 'users', key: 'id' } }, + total_amount: { type: Sequelize.DataTypes.DECIMAL }, + client_fee: { type: Sequelize.DataTypes.DECIMAL }, + freelancer_fee: { type: Sequelize.DataTypes.DECIMAL }, + status: { type: Sequelize.DataTypes.ENUM('Held_in_Escrow', 'Released_to_Freelancer', 'Refunded') }, + importHash: { type: Sequelize.DataTypes.STRING(255), allowNull: true, unique: true }, + createdAt: { type: Sequelize.DataTypes.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DataTypes.DATE, allowNull: false }, + deletedAt: { type: Sequelize.DataTypes.DATE } + }).catch(console.error); + + } catch (err) { + console.error(err); + } + }, + + async down() { + } +}; \ No newline at end of file diff --git a/backend/src/db/models/escrow_transactions.js b/backend/src/db/models/escrow_transactions.js new file mode 100644 index 0000000..3383fc3 --- /dev/null +++ b/backend/src/db/models/escrow_transactions.js @@ -0,0 +1,77 @@ + + +module.exports = function(sequelize, DataTypes) { + const escrow_transactions = sequelize.define( + 'escrow_transactions', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + total_amount: { + type: DataTypes.DECIMAL, + }, + client_fee: { + type: DataTypes.DECIMAL, + }, + freelancer_fee: { + type: DataTypes.DECIMAL, + }, + status: { + type: DataTypes.ENUM, + values: [ + "Held_in_Escrow", + "Released_to_Freelancer", + "Refunded" + ], + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + escrow_transactions.associate = (db) => { + db.escrow_transactions.belongsTo(db.jobs, { + as: 'job', + foreignKey: { + name: 'jobId', + }, + constraints: false, + }); + + db.escrow_transactions.belongsTo(db.users, { + as: 'client', + foreignKey: { + name: 'clientId', + }, + constraints: false, + }); + + db.escrow_transactions.belongsTo(db.users, { + as: 'freelancer', + foreignKey: { + name: 'freelancerId', + }, + constraints: false, + }); + + db.escrow_transactions.belongsTo(db.users, { + as: 'createdBy', + }); + + db.escrow_transactions.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return escrow_transactions; +}; \ No newline at end of file diff --git a/backend/src/db/models/jobs.js b/backend/src/db/models/jobs.js index 771b086..4f51d3b 100644 --- a/backend/src/db/models/jobs.js +++ b/backend/src/db/models/jobs.js @@ -28,7 +28,7 @@ description: { }, -budget_min: { +budget: { type: DataTypes.DECIMAL }, budget_min: { type: DataTypes.DECIMAL, @@ -79,22 +79,21 @@ status: { values: [ -"draft", +"Open", -"open", +"In-Progress", -"in_progress", +"Under_Review", -"in_review", +"Completed", -"completed", +"Disputed", -"cancelled" ], diff --git a/backend/src/db/models/proposals.js b/backend/src/db/models/proposals.js index b1dc234..cca54ef 100644 --- a/backend/src/db/models/proposals.js +++ b/backend/src/db/models/proposals.js @@ -21,7 +21,7 @@ cover_letter: { }, -proposed_amount: { +bid_amount: { type: DataTypes.DECIMAL, @@ -58,19 +58,17 @@ status: { values: [ -"submitted", +"Pending", +"Accepted", +"Rejected" -"shortlisted", -"accepted", -"rejected", -"withdrawn" ], diff --git a/backend/src/db/models/users.js b/backend/src/db/models/users.js index ac0dcf8..3c3db49 100644 --- a/backend/src/db/models/users.js +++ b/backend/src/db/models/users.js @@ -14,7 +14,7 @@ module.exports = function(sequelize, DataTypes) { primaryKey: true, }, -firstName: { +role: { type: DataTypes.ENUM, values: ["Student_Freelancer", "Client", "Admin"] }, name: { type: DataTypes.STRING }, is_verified_student: { type: DataTypes.BOOLEAN, defaultValue: false }, wallet_balance: { type: DataTypes.DECIMAL, defaultValue: 0.0 }, skills: { type: DataTypes.ARRAY(DataTypes.STRING) }, rating: { type: DataTypes.DECIMAL, defaultValue: 0.0 }, firstName: { type: DataTypes.TEXT, diff --git a/backend/src/db/seeders/20200430130760-user-roles.js b/backend/src/db/seeders/20200430130760-user-roles.js index 978db65..414e9b9 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.js +++ b/backend/src/db/seeders/20200430130760-user-roles.js @@ -2492,6 +2492,8 @@ await queryInterface.bulkInsert("rolesPermissionsPermissions", [ { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('READ_API_DOCS') }, { createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_SEARCH') }, + { createdAt, updatedAt, roles_permissionsId: getId("Public"), permissionId: getId('READ_SERVICE_LISTINGS') }, + { createdAt, updatedAt, roles_permissionsId: getId("Public"), permissionId: getId('READ_SERVICE_CATEGORIES') }, ]); diff --git a/backend/src/index.js b/backend/src/index.js index f5be12e..8547a18 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -137,9 +137,9 @@ app.use('/api/permissions', passport.authenticate('jwt', {session: false}), perm app.use('/api/skills', passport.authenticate('jwt', {session: false}), skillsRoutes); -app.use('/api/service_categories', passport.authenticate('jwt', {session: false}), service_categoriesRoutes); +app.use('/api/service_categories', service_categoriesRoutes); -app.use('/api/service_listings', passport.authenticate('jwt', {session: false}), service_listingsRoutes); +app.use('/api/service_listings', service_listingsRoutes); app.use('/api/tags', passport.authenticate('jwt', {session: false}), tagsRoutes); diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index af59df8..9f8fc3b 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -1,10 +1,10 @@ import React from 'react' -import { mdiLogout, mdiClose } from '@mdi/js' +import { mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' import { useAppSelector } from '../stores/hooks' -import Link from 'next/link'; +import Logo from './Logo' type Props = { @@ -37,11 +37,8 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
-
- - FreelanceFusion MVP - - +
+
+
+ + {/* Mobile Menu */} + {isMenuOpen && ( +
+ {webPagesNavBar.map((item, idx) => ( + setIsMenuOpen(false)} + > + {item.label} + + ))} +
+ )} + + ); +} diff --git a/frontend/src/config.ts b/frontend/src/config.ts index a9783c8..03cdb0e 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -8,8 +8,8 @@ export const localStorageStyleKey = 'style' export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20' -export const appTitle = 'created by Flatlogic generator!' +export const appTitle = 'FreelanceFusion' export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle} — ${appTitle}` -export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || '' +export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || '' \ No newline at end of file diff --git a/frontend/src/menuNavBar.ts b/frontend/src/menuNavBar.ts index a5dd956..84c5391 100644 --- a/frontend/src/menuNavBar.ts +++ b/frontend/src/menuNavBar.ts @@ -10,6 +10,7 @@ import { mdiThemeLightDark, mdiGithub, mdiVuejs, + mdiBriefcaseSearch, } from '@mdi/js' import { MenuNavBarItem } from './interfaces' @@ -47,7 +48,19 @@ const menuNavBar: MenuNavBarItem[] = [ ] export const webPagesNavBar = [ - + { + href: '/web_pages/services', + label: 'Student Services', + icon: mdiBriefcaseSearch, + }, + { + href: '/login', + label: 'Login', + }, + { + href: '/register', + label: 'Sign Up', + } ]; -export default menuNavBar +export default menuNavBar \ No newline at end of file diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 98d6161..a2a25b9 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -7,7 +7,7 @@ import LayoutAuthenticated from '../layouts/Authenticated' import SectionMain from '../components/SectionMain' import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' import BaseIcon from "../components/BaseIcon"; -import { getPageTitle } from '../config' +import { getPageTitle, appTitle } from '../config' import Link from "next/link"; import { hasPermission } from "../helpers/userPermissions"; @@ -115,6 +115,11 @@ const Dashboard = () => { main> {''} + +
+

Welcome back, {currentUser?.firstName || 'User'}!

+

Manage your projects, proposals, and contracts on {appTitle}. Empowering students to be self-independent.

+
{hasPermission(currentUser, 'CREATE_ROLES') && {page} } -export default Dashboard +export default Dashboard \ No newline at end of file diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index f5d87f2..ed91b55 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,177 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; +import React, { ReactElement } from 'react'; import Head from 'next/head'; import Link from 'next/link'; +import { + mdiVideo, + mdiRocketLaunch, + mdiWeb, + mdiApplicationBraces, + mdiPalette, + mdiBrush, + mdiAccountGroup, + mdiCheckCircle +} from '@mdi/js'; +import BaseIcon from '../components/BaseIcon'; import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; -import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +import { getPageTitle, appTitle } from '../config'; +const services = [ + { name: 'Video Editing', icon: mdiVideo, desc: 'Professional editing for your vlogs, ads, and social media.' }, + { name: 'App Deployment', icon: mdiApplicationBraces, desc: 'Launching your mobile apps to iOS and Android stores.' }, + { name: 'Algorithm Deployment', icon: mdiRocketLaunch, desc: 'Deploying complex ML models and algorithms to production.' }, + { name: 'Website Deployment', icon: mdiWeb, desc: 'Modern hosting and deployment for your web applications.' }, + { name: 'Logo Design', icon: mdiBrush, desc: 'Unique branding and visual identity for your business.' }, + { name: 'Photo Editing', icon: mdiPalette, desc: 'High-end retouching and graphic design services.' }, + { name: 'Account Management', icon: mdiAccountGroup, desc: 'Handling your social media and business accounts professionally.' }, +]; -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('right'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'FreelanceFusion MVP' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; - +export default function LandingPage() { return ( -
+
- {getPageTitle('Starter Page')} + {getPageTitle('Home')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - + {/* Hero Section */} +
+
+
+
+

+ Where Student Talent Meets Freelance Fusion. +

+

+ The ultimate workspace for students to find part-time jobs and for customers to hire top-tier, affordable talent. From video editing to app deployment, we bridge the gap. +

+
- - - + +
+
+
+
+
+
+
+ {services.slice(0, 4).map((s, i) => ( +
+ + {s.name} +
+ ))} +
+
+
+
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+ + {/* Services Section */} +
+
+
+

Top Freelance Services

+

Specialized talent for modern projects. Delivered by students, optimized for quality and cost.

+
+
+ {services.map((service, idx) => ( +
+
+ +
+

{service.name}

+

{service.desc}

+
+ ))} +
+
+
+ + {/* Bridge/How it Works Section */} +
+
+
+
+

Bridging the Gap Between Skill and Demand.

+
+
+
+ +
+
+

Empowering Students

+

"Charge for your talent and become self-independent."

+

Find part-time work that fits your school or college schedule.

+
+
+
+
+ +
+
+

Value for Customers

+

Get high-quality work done at a competitive rate by motivated student professionals.

+
+
+
+
+ +
+
+

Sustainable Growth

+

Modest fees of 1% for customers and 0.5% for freelancers ensure continuous app improvements.

+
+
+
+
+
+

Ready to join {appTitle}?

+

Sign in now and start your journey towards financial independence or find the talent you've been looking for.

+
+ + + Already have an account? Login here. + +
+
+
+
+
+ + {/* Footer */} +
+
+
{appTitle}
+
+ Privacy Policy + Terms of Use + Admin Login +
+
+ © 2026 {appTitle}. Empowering the next generation of professionals. +
+
+
); } -Starter.getLayout = function getLayout(page: ReactElement) { +LandingPage.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/jobs/jobs-new.tsx b/frontend/src/pages/jobs/jobs-new.tsx index 94adcf4..2ecb7ba 100644 --- a/frontend/src/pages/jobs/jobs-new.tsx +++ b/frontend/src/pages/jobs/jobs-new.tsx @@ -1,4 +1,4 @@ -import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' +import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload, mdiAutoFix } from '@mdi/js' import Head from 'next/head' import React, { ReactElement } from 'react' import CardBox from '../../components/CardBox' @@ -7,7 +7,7 @@ import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import { getPageTitle } from '../../config' -import { Field, Form, Formik } from 'formik' +import { Field, Form, Formik, useFormikContext } from 'formik' import FormField from '../../components/FormField' import BaseDivider from '../../components/BaseDivider' import BaseButtons from '../../components/BaseButtons' @@ -23,238 +23,61 @@ import { SelectFieldMany } from "../../components/SelectFieldMany"; import {RichTextField} from "../../components/RichTextField"; import { create } from '../../stores/jobs/jobsSlice' -import { useAppDispatch } from '../../stores/hooks' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' import { useRouter } from 'next/router' import moment from 'moment'; +import { askGpt } from '../../stores/openAiSlice' const initialValues = { - - - - - - - - - - - - - client: '', - - - - - - - - - - - - - - - category: '', - - - - title: '', - - - - - - - - - - - - - - - - - description: '', - - - - - - - - - - - - - budget_min: '', - - - - - - - - - - - - - - - budget_max: '', - - - - - - - - - - - - - - - - - - - - - - - - budget_type: 'fixed', - - - - - - - - - - - - deadline_at: '', - - - - - - - - - - - - - - - posted_at: '', - - - - - - - - - - - - - - - - - - - status: 'draft', - - - - - - - - - - - - - is_remote: false, - - - - - - - - - location_requirement: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - required_skills: [], - - - - - - - - - - - - - - - attachments: [], +} + +const AiGenerateButton = () => { + const { values, setFieldValue } = useFormikContext() + const dispatch = useAppDispatch() + const { isAskingQuestion } = useAppSelector((state) => state.openAi) + + const handleAiGenerate = async () => { + if (!values.title) { + alert('Please enter a title first') + return + } + const prompt = `Write a professional and detailed job description for a freelance platform called "FreelanceFusion". + The platform connects students with clients. + The job title is: "${values.title}". + The description should be structured, clear, and encouraging for student freelancers. + Use HTML format for the response (with

,

,

    tags).` - + const resultAction = await dispatch(askGpt(prompt)) + if (askGpt.fulfilled.match(resultAction)) { + setFieldValue('description', resultAction.payload.data) + } + } + + return ( + + ) } @@ -262,526 +85,149 @@ const JobsNew = () => { const router = useRouter() const dispatch = useAppDispatch() - - - const handleSubmit = async (data) => { await dispatch(create(data)) await router.push('/jobs/jobs-list') } + return ( <> - {getPageTitle('New Item')} + {getPageTitle('New Job')} - + {''} handleSubmit(values)} >
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -799,14 +245,10 @@ const JobsNew = () => { JobsNew.getLayout = function getLayout(page: ReactElement) { return ( - + {page} ) } -export default JobsNew +export default JobsNew \ No newline at end of file diff --git a/frontend/src/pages/proposals/proposals-new.tsx b/frontend/src/pages/proposals/proposals-new.tsx index 76d053b..b3f9513 100644 --- a/frontend/src/pages/proposals/proposals-new.tsx +++ b/frontend/src/pages/proposals/proposals-new.tsx @@ -1,4 +1,4 @@ -import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' +import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload, mdiAutoFix } from '@mdi/js' import Head from 'next/head' import React, { ReactElement } from 'react' import CardBox from '../../components/CardBox' @@ -7,7 +7,7 @@ import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import { getPageTitle } from '../../config' -import { Field, Form, Formik } from 'formik' +import { Field, Form, Formik, useFormikContext } from 'formik' import FormField from '../../components/FormField' import BaseDivider from '../../components/BaseDivider' import BaseButtons from '../../components/BaseButtons' @@ -23,561 +23,183 @@ import { SelectFieldMany } from "../../components/SelectFieldMany"; import {RichTextField} from "../../components/RichTextField"; import { create } from '../../stores/proposals/proposalsSlice' -import { useAppDispatch } from '../../stores/hooks' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' import { useRouter } from 'next/router' import moment from 'moment'; +import { askGpt } from '../../stores/openAiSlice' +import axios from 'axios' const initialValues = { - - - - - - - - - - - - - job: '', - - - - - - - - - - - - - - - freelancer: '', - - - - - cover_letter: '', - - - - - - - - - - - - - - proposed_amount: '', - - - - - - - - - - - - - - - - - - - - - - - - pricing_model: 'fixed', - - - - - - - - - - estimated_days: '', - - - - - - - - - - - - - - - - - - - - - status: 'submitted', - - - - - - - - - - - - submitted_at: '', - - - - - - - - - - - - - - - responded_at: '', - - - - - - - - - - - - - - - - - - - sample_files: [], - - - - - } +const AiCoverLetterButton = () => { + const { values, setFieldValue } = useFormikContext() + const dispatch = useAppDispatch() + const { isAskingQuestion } = useAppSelector((state) => state.openAi) + + const handleAiGenerate = async () => { + if (!values.job) { + alert('Please select a job first') + return + } + + try { + // Fetch job details to get context for cover letter + const jobResponse = await axios.get(`/jobs/${values.job}`) + const job = jobResponse.data + + const prompt = `Write a professional and compelling cover letter for a student freelancer applying for a job on "FreelanceFusion". + Job Title: "${job.title}" + Job Description: "${job.description}" + + The cover letter should highlight the student's enthusiasm, relevant (hypothetical) skills, and professional tone. + Keep it around 150-200 words.` + + const resultAction = await dispatch(askGpt(prompt)) + if (askGpt.fulfilled.match(resultAction)) { + setFieldValue('cover_letter', resultAction.payload.data) + } + } catch (error) { + console.error('Failed to fetch job or generate cover letter', error) + alert('Failed to generate cover letter. Please try again.') + } + } + + return ( + + ) +} const ProposalsNew = () => { const router = useRouter() const dispatch = useAppDispatch() - - - const handleSubmit = async (data) => { await dispatch(create(data)) await router.push('/proposals/proposals-list') } + return ( <> - {getPageTitle('New Item')} + {getPageTitle('New Proposal')} - + {''} handleSubmit(values)} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -595,14 +217,10 @@ const ProposalsNew = () => { ProposalsNew.getLayout = function getLayout(page: ReactElement) { return ( - + {page} ) } -export default ProposalsNew +export default ProposalsNew \ No newline at end of file diff --git a/frontend/src/pages/web_pages/service-details.tsx b/frontend/src/pages/web_pages/service-details.tsx new file mode 100644 index 0000000..3dccc70 --- /dev/null +++ b/frontend/src/pages/web_pages/service-details.tsx @@ -0,0 +1,179 @@ +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import axios from 'axios'; +import LayoutGuest from '../../layouts/Guest'; +import WebHeader from '../../components/WebHeader'; +import WebFooter from '../../components/WebFooter'; +import { useAppSelector } from '../../stores/hooks'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import { mdiCalendar, mdiCheckDecagram, mdiCurrencyUsd, mdiKeyboardReturn, mdiArrowLeft } from '@mdi/js'; +import BaseIcon from '../../components/BaseIcon'; + +export default function ServiceDetailsPage() { + const router = useRouter(); + const { id } = router.query; + const projectName = useAppSelector((state) => state.style.projectName) || 'FreelanceFusion'; + const [service, setService] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (id && typeof id === 'string') { + const fetchService = async () => { + try { + const response = await axios.get(`/service_listings/${id}`); + setService(response.data); + } catch (err) { + console.error('Failed to fetch service details:', err); + } finally { + setLoading(false); + } + }; + fetchService(); + } + }, [id]); + + if (loading) { + return ( +
    + Loading Gig Details... + +
    +
    +
    +

    Gig Details Loading...

    +
    +
    + +
    + ); + } + + if (!service) { + return ( +
    + Gig Not Found + +
    +
    +

    Gig Not Found

    +

    The service listing you are looking for might have been removed or is no longer available.

    + +
    +
    + +
    + ); + } + + return ( +
    + + {service.title} | {projectName} + + + + +
    +
    +
    +
    + +
    +
    +
    + + +

    + {service.title} +

    + +
    +
    + {service.freelancer?.firstName?.[0] || 'S'} +
    +
    +

    Student Freelancer

    +

    + + Identity Verified +

    +
    +
    + +
    +
    + +
    +
    + +
    +
    +

    + ${service.starting_price} +

    +

    Starter Package Price

    +
    + +
    +
    + + {service.delivery_days} Days Delivery Time +
    +
    + + Unlimited Revisions +
    + + + +
    +

    Secure Gig Economy

    +
    + {/* Payment icons could go here */} + +
    +
    +
    +
    + +
    +
    + +
    +

    Support local students and get high-quality work done at competitive rates.

    +
    +
    +
    +
    +
    +
    +
    + + +
    + ); +} + +ServiceDetailsPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; \ No newline at end of file diff --git a/frontend/src/pages/web_pages/services.tsx b/frontend/src/pages/web_pages/services.tsx new file mode 100644 index 0000000..be9507d --- /dev/null +++ b/frontend/src/pages/web_pages/services.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import LayoutGuest from '../../layouts/Guest'; +import WebHeader from '../../components/WebHeader'; +import WebFooter from '../../components/WebFooter'; +import axios from 'axios'; +import { useAppSelector } from '../../stores/hooks'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import { mdiArrowRight, mdiMagnify } from '@mdi/js'; +import BaseIcon from '../../components/BaseIcon'; + +export default function ServicesPage() { + const projectName = useAppSelector((state) => state.style.projectName) || 'FreelanceFusion'; + const [services, setServices] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + const fetchData = async () => { + try { + const [servicesRes, categoriesRes] = await Promise.all([ + axios.get('/service_listings'), + axios.get('/service_categories') + ]); + setServices(servicesRes.data.rows || []); + setCategories(categoriesRes.data.rows || []); + } catch (err) { + console.error('Failed to fetch public services:', err); + } finally { + setLoading(false); + } + }; + fetchData(); + }, []); + + const filteredServices = services.filter(service => + service.title.toLowerCase().includes(searchTerm.toLowerCase()) || + service.description.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( + <> + + Student Services | {projectName} + + + + + +
    +
    +
    +

    Student Service Directory

    +

    Find talented student freelancers ready to help with your next tech project, marketing campaign, or business operations.

    +
    +
    + + +
    +
    + + + + setSearchTerm(e.target.value)} + /> +
    + +
    + + {categories.map(cat => ( + + ))} +
    +
    + + {loading ? ( +
    +
    +

    Synchronizing Directory...

    +
    + ) : ( +
    + {filteredServices.map((service) => ( + +
    +
    +

    {service.title}

    +
    + ${service.starting_price} + Starting Price +
    +
    + +
    + +
    +
    + Delivery + {service.delivery_days} Days +
    + +
    +
    + + ))} +
    + )} + + {!loading && filteredServices.length === 0 && ( +
    +
    + +
    +

    No Matching Services

    +

    Try broadening your search criteria or checking another category.

    + setSearchTerm('')} + outline + color="indigo" + /> +
    + )} + +
    + + + + ); +} + +ServicesPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; \ No newline at end of file