Compare commits

...

2 Commits

Author SHA1 Message Date
Flatlogic Bot
6909bf7269 1.1 2026-02-28 10:37:47 +00:00
Flatlogic Bot
a6bf68668a 1 2026-02-28 10:13:33 +00:00
25 changed files with 1250 additions and 2257 deletions

View File

@ -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() {
}
};

View File

@ -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;
};

View File

@ -28,7 +28,7 @@ description: {
}, },
budget_min: { budget: { type: DataTypes.DECIMAL }, budget_min: {
type: DataTypes.DECIMAL, type: DataTypes.DECIMAL,
@ -79,22 +79,21 @@ status: {
values: [ values: [
"draft", "Open",
"open", "In-Progress",
"in_progress", "Under_Review",
"in_review", "Completed",
"completed", "Disputed",
"cancelled"
], ],

View File

@ -21,7 +21,7 @@ cover_letter: {
}, },
proposed_amount: { bid_amount: {
type: DataTypes.DECIMAL, type: DataTypes.DECIMAL,
@ -58,19 +58,17 @@ status: {
values: [ values: [
"submitted", "Pending",
"Accepted",
"Rejected"
"shortlisted",
"accepted",
"rejected",
"withdrawn"
], ],

View File

@ -14,7 +14,7 @@ module.exports = function(sequelize, DataTypes) {
primaryKey: true, 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, type: DataTypes.TEXT,

View File

@ -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('READ_API_DOCS') },
{ createdAt, updatedAt, roles_permissionsId: getId("Administrator"), permissionId: getId('CREATE_SEARCH') }, { 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') },
]); ]);

View File

@ -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/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); app.use('/api/tags', passport.authenticate('jwt', {session: false}), tagsRoutes);

View File

@ -88,6 +88,11 @@ router.use(checkCrudPermissions('jobs'));
* 500: * 500:
* description: Some server error * 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) => { router.post('/', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer); const link = new URL(referer);

View File

@ -85,6 +85,11 @@ router.use(checkCrudPermissions('service_listings'));
* 500: * 500:
* description: Some server error * 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) => { router.post('/', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer); const link = new URL(referer);

View File

@ -1,3 +1,4 @@
const db = require("../db/models");
const UsersDBApi = require('../db/api/users'); const UsersDBApi = require('../db/api/users');
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const ForbiddenError = require('./notifications/errors/forbidden'); const ForbiddenError = require('./notifications/errors/forbidden');
@ -59,6 +60,7 @@ class Auth {
firstName: email.split('@')[0], firstName: email.split('@')[0],
password: hashedPassword, password: hashedPassword,
email: email, email: email,
is_verified_student: email.toLowerCase().endsWith('.edu'),
}, },
options, options,

View File

@ -12,6 +12,34 @@ const stream = require('stream');
module.exports = class JobsService { 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) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
@ -134,5 +162,3 @@ module.exports = class JobsService {
}; };

View File

@ -12,6 +12,42 @@ const stream = require('stream');
module.exports = class Service_listingsService { 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) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
@ -134,5 +170,3 @@ module.exports = class Service_listingsService {
}; };

View File

@ -1,10 +1,10 @@
import React from 'react' import React from 'react'
import { mdiLogout, mdiClose } from '@mdi/js' import { mdiClose } from '@mdi/js'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'
import AsideMenuList from './AsideMenuList' import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces' import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks' import { useAppSelector } from '../stores/hooks'
import Link from 'next/link'; import Logo from './Logo'
type Props = { type Props = {
@ -37,11 +37,8 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
<div <div
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`} className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
> >
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0"> <div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0 flex items-center justify-center">
<Logo />
<b className="font-black">FreelanceFusion MVP</b>
</div> </div>
<button <button
className="hidden lg:inline-block xl:hidden p-3" className="hidden lg:inline-block xl:hidden p-3"

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import { appTitle } from '../../config'
type Props = { type Props = {
className?: string className?: string
@ -6,10 +7,11 @@ type Props = {
export default function Logo({ className = '' }: Props) { export default function Logo({ className = '' }: Props) {
return ( return (
<img <div className={`flex items-center space-x-2 ${className}`}>
src={"https://flatlogic.com/logo.svg"} <div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
className={className} <span className="text-white font-bold text-lg">F</span>
alt={'Flatlogic logo'}> </div>
</img> <span className="font-bold text-xl tracking-tight">{appTitle}</span>
</div>
) )
} }

View File

@ -0,0 +1,54 @@
import React from 'react';
import Link from 'next/link';
import Logo from './Logo';
interface Props {
projectName: string;
}
export default function WebFooter({ projectName }: Props) {
return (
<footer className="bg-slate-900 text-slate-400 py-16 px-6">
<div className="container mx-auto max-w-7xl">
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-12">
<div className="md:col-span-2">
<Link href="/" className="flex items-center gap-2 text-white mb-6">
<Logo className="w-10 h-10 text-indigo-500" />
<span className="text-2xl font-bold tracking-tight uppercase">{projectName}</span>
</Link>
<p className="max-w-sm text-lg leading-relaxed text-slate-400">
The premier student-to-client bridge, empowering the next generation of talented student freelancers.
</p>
</div>
<div>
<h4 className="text-white font-bold mb-6 uppercase tracking-wider text-sm">Platform</h4>
<ul className="space-y-4 text-sm font-semibold">
<li><Link href="/web_pages/services" className="hover:text-white transition-colors">Service Directory</Link></li>
<li><Link href="/register" className="hover:text-white transition-colors">Join as a Student</Link></li>
<li><Link href="/register" className="hover:text-white transition-colors">Hire Talent</Link></li>
</ul>
</div>
<div>
<h4 className="text-white font-bold mb-6 uppercase tracking-wider text-sm">Company</h4>
<ul className="space-y-4 text-sm font-semibold">
<li><Link href="/privacy-policy/" className="hover:text-white transition-colors">Privacy Policy</Link></li>
<li><Link href="/terms-of-use/" className="hover:text-white transition-colors">Terms of Use</Link></li>
<li><Link href="/login" className="hover:text-white transition-colors">Admin Login</Link></li>
</ul>
</div>
</div>
<div className="flex flex-col md:flex-row justify-between items-center gap-6 pt-12 border-t border-slate-800">
<div className="text-sm">
© 2026 {projectName}. All rights reserved. Built for students, by students.
</div>
<div className="flex gap-6">
{/* Social Icons would go here */}
</div>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,62 @@
import React, { useState } from 'react';
import Link from 'next/link';
import { mdiMenu, mdiClose } from '@mdi/js';
import BaseIcon from './BaseIcon';
import Logo from './Logo';
import { webPagesNavBar } from '../menuNavBar';
interface Props {
projectName: string;
}
export default function WebHeader({ projectName }: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<header className="bg-white dark:bg-slate-900 border-b border-gray-100 dark:border-slate-800 sticky top-0 z-50">
<div className="container mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<Logo className="w-8 h-8 text-indigo-600" />
<span className="text-xl font-bold tracking-tight text-gray-900 dark:text-white uppercase">{projectName}</span>
</Link>
{/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-8">
{webPagesNavBar.map((item, idx) => (
<Link
key={idx}
href={item.href}
className="text-sm font-semibold text-gray-600 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
{item.label}
</Link>
))}
</nav>
{/* Mobile Toggle */}
<button
className="md:hidden text-gray-600 dark:text-gray-400"
onClick={() => setIsMenuOpen(!isMenuOpen)}
>
<BaseIcon path={isMenuOpen ? mdiClose : mdiMenu} size={24} />
</button>
</div>
{/* Mobile Menu */}
{isMenuOpen && (
<div className="md:hidden bg-white dark:bg-slate-900 border-b border-gray-100 dark:border-slate-800 px-6 py-4 space-y-4 shadow-lg">
{webPagesNavBar.map((item, idx) => (
<Link
key={idx}
href={item.href}
className="block text-base font-semibold text-gray-600 dark:text-gray-400 hover:text-indigo-600"
onClick={() => setIsMenuOpen(false)}
>
{item.label}
</Link>
))}
</div>
)}
</header>
);
}

View File

@ -8,7 +8,7 @@ export const localStorageStyleKey = 'style'
export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20' 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 getPageTitle = (currentPageTitle: string) => `${currentPageTitle}${appTitle}`

View File

@ -10,6 +10,7 @@ import {
mdiThemeLightDark, mdiThemeLightDark,
mdiGithub, mdiGithub,
mdiVuejs, mdiVuejs,
mdiBriefcaseSearch,
} from '@mdi/js' } from '@mdi/js'
import { MenuNavBarItem } from './interfaces' import { MenuNavBarItem } from './interfaces'
@ -47,7 +48,19 @@ const menuNavBar: MenuNavBarItem[] = [
] ]
export const webPagesNavBar = [ 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

View File

@ -7,7 +7,7 @@ import LayoutAuthenticated from '../layouts/Authenticated'
import SectionMain from '../components/SectionMain' import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import BaseIcon from "../components/BaseIcon"; import BaseIcon from "../components/BaseIcon";
import { getPageTitle } from '../config' import { getPageTitle, appTitle } from '../config'
import Link from "next/link"; import Link from "next/link";
import { hasPermission } from "../helpers/userPermissions"; import { hasPermission } from "../helpers/userPermissions";
@ -116,6 +116,11 @@ const Dashboard = () => {
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<div className={`mb-6 p-6 bg-indigo-600 text-white ${corners} shadow-lg`}>
<h2 className="text-2xl font-bold mb-2">Welcome back, {currentUser?.firstName || 'User'}!</h2>
<p className="opacity-90">Manage your projects, proposals, and contracts on {appTitle}. Empowering students to be self-independent.</p>
</div>
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator {hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
currentUser={currentUser} currentUser={currentUser}
isFetchingQuery={isFetchingQuery} isFetchingQuery={isFetchingQuery}

View File

@ -1,166 +1,177 @@
import React, { ReactElement } from 'react';
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; 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 BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider'; import { getPageTitle, appTitle } from '../config';
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';
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() { export default function LandingPage() {
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) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return ( return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'> <div className="bg-slate-50 min-h-screen font-sans text-slate-900">
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head> <Head>
<title>{getPageTitle('Starter Page')}</title> <title>{getPageTitle('Home')}</title>
</Head> </Head>
<SectionFullScreen bg='violet'> {/* Hero Section */}
<div <section className="relative overflow-hidden bg-white py-20 px-6 sm:py-32 lg:px-8">
className={`flex ${ <div className="mx-auto max-w-7xl">
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row' <div className="lg:grid lg:grid-cols-12 lg:gap-x-8 lg:gap-y-20 items-center">
} min-h-screen w-full`} <div className="relative z-10 mx-auto max-w-2xl lg:col-span-7 lg:max-w-none lg:pt-6">
> <h1 className="text-4xl font-bold tracking-tight text-slate-900 sm:text-6xl mb-6">
{contentType === 'image' && contentPosition !== 'background' Where Student Talent Meets <span className="text-indigo-600">Freelance Fusion</span>.
? imageBlock(illustrationImage) </h1>
: null} <p className="mt-6 text-lg leading-8 text-slate-600">
{contentType === 'video' && contentPosition !== 'background' 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.
? videoBlock(illustrationVideo) </p>
: null} <div className="mt-10 flex flex-wrap items-center gap-x-6 gap-y-4">
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your FreelanceFusion MVP app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div>
<BaseButtons>
<BaseButton <BaseButton
href='/login' href="/register"
label='Login' label="Join as a Student"
color='info' color="info"
className='w-full' className="px-8 py-3 rounded-full text-lg shadow-lg hover:shadow-indigo-200"
/> />
<BaseButton
href="/register"
label="Hire a Freelancer"
color="white"
className="px-8 py-3 rounded-full border-2 border-slate-200 text-lg hover:bg-slate-50"
/>
</div>
</div>
<div className="mt-20 lg:mt-0 lg:col-span-5 hidden lg:block">
<div className="relative h-96 w-full rounded-2xl bg-indigo-50 flex items-center justify-center p-12 overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-indigo-100 rounded-full -mr-20 -mt-20 blur-3xl opacity-60"></div>
<div className="absolute bottom-0 left-0 w-48 h-48 bg-pink-100 rounded-full -ml-10 -mb-10 blur-3xl opacity-60"></div>
<div className="relative grid grid-cols-2 gap-4">
{services.slice(0, 4).map((s, i) => (
<div key={i} className="bg-white p-6 rounded-xl shadow-sm border border-slate-100 flex flex-col items-center">
<BaseIcon path={s.icon} size={32} className="text-indigo-600 mb-2" />
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">{s.name}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</section>
</BaseButtons> {/* Services Section */}
</CardBox> <section className="py-24 bg-slate-50 px-6">
<div className="mx-auto max-w-7xl">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-slate-900 sm:text-4xl mb-4">Top Freelance Services</h2>
<p className="text-slate-600 max-w-2xl mx-auto">Specialized talent for modern projects. Delivered by students, optimized for quality and cost.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{services.map((service, idx) => (
<div key={idx} className="bg-white p-8 rounded-2xl border border-slate-100 shadow-sm transition-all hover:shadow-md hover:-translate-y-1">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-lg bg-indigo-50 text-indigo-600 mb-6">
<BaseIcon path={service.icon} size={24} />
</div>
<h3 className="text-xl font-bold text-slate-900 mb-3">{service.name}</h3>
<p className="text-slate-600 leading-relaxed">{service.desc}</p>
</div>
))}
</div> </div>
</div> </div>
</SectionFullScreen> </section>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p> {/* Bridge/How it Works Section */}
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'> <section className="py-24 bg-white px-6">
Privacy Policy <div className="mx-auto max-w-7xl">
<div className="lg:grid lg:grid-cols-2 lg:gap-16 items-center">
<div>
<h2 className="text-3xl font-bold text-slate-900 mb-8 leading-tight">Bridging the Gap Between Skill and Demand.</h2>
<div className="space-y-8">
<div className="flex gap-4">
<div className="flex-shrink-0 mt-1">
<BaseIcon path={mdiCheckCircle} size={24} className="text-green-500" />
</div>
<div>
<h4 className="text-lg font-bold text-slate-900 mb-1">Empowering Students</h4>
<p className="text-slate-600 italic">&quot;Charge for your talent and become self-independent.&quot;</p>
<p className="text-slate-600 mt-2">Find part-time work that fits your school or college schedule.</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex-shrink-0 mt-1">
<BaseIcon path={mdiCheckCircle} size={24} className="text-green-500" />
</div>
<div>
<h4 className="text-lg font-bold text-slate-900 mb-1">Value for Customers</h4>
<p className="text-slate-600 mt-2">Get high-quality work done at a competitive rate by motivated student professionals.</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex-shrink-0 mt-1">
<BaseIcon path={mdiCheckCircle} size={24} className="text-green-500" />
</div>
<div>
<h4 className="text-lg font-bold text-slate-900 mb-1">Sustainable Growth</h4>
<p className="text-slate-600 mt-2">Modest fees of 1% for customers and 0.5% for freelancers ensure continuous app improvements.</p>
</div>
</div>
</div>
</div>
<div className="mt-12 lg:mt-0 p-8 bg-indigo-600 rounded-3xl text-white">
<h3 className="text-2xl font-bold mb-6">Ready to join {appTitle}?</h3>
<p className="mb-8 text-indigo-100">Sign in now and start your journey towards financial independence or find the talent you&apos;ve been looking for.</p>
<div className="space-y-4">
<BaseButton
href="/register"
label="Create Account"
color="white"
className="w-full py-4 text-indigo-600 font-bold"
/>
<Link href="/login" className="block text-center text-sm font-semibold hover:text-indigo-200 transition-colors">
Already have an account? Login here.
</Link> </Link>
</div> </div>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-slate-900 text-slate-400 py-12 px-6">
<div className="mx-auto max-w-7xl flex flex-col md:flex-row justify-between items-center gap-6 border-t border-slate-800 pt-12">
<div className="text-xl font-bold text-white tracking-tight">{appTitle}</div>
<div className="flex gap-8 text-sm font-semibold">
<Link href="/privacy-policy/" className="hover:text-white transition-colors">Privacy Policy</Link>
<Link href="/terms-of-use/" className="hover:text-white transition-colors">Terms of Use</Link>
<Link href="/login" className="hover:text-white transition-colors">Admin Login</Link>
</div>
<div className="text-sm">
© 2026 {appTitle}. Empowering the next generation of professionals.
</div>
</div>
</footer>
</div> </div>
); );
} }
Starter.getLayout = function getLayout(page: ReactElement) { LandingPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>;
}; };

View File

@ -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 Head from 'next/head'
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import CardBox from '../../components/CardBox' import CardBox from '../../components/CardBox'
@ -7,7 +7,7 @@ import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config' import { getPageTitle } from '../../config'
import { Field, Form, Formik } from 'formik' import { Field, Form, Formik, useFormikContext } from 'formik'
import FormField from '../../components/FormField' import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider' import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons' import BaseButtons from '../../components/BaseButtons'
@ -23,238 +23,61 @@ import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField"; import {RichTextField} from "../../components/RichTextField";
import { create } from '../../stores/jobs/jobsSlice' import { create } from '../../stores/jobs/jobsSlice'
import { useAppDispatch } from '../../stores/hooks' import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import moment from 'moment'; import moment from 'moment';
import { askGpt } from '../../stores/openAiSlice'
const initialValues = { const initialValues = {
client: '', client: '',
category: '', category: '',
title: '', title: '',
description: '', description: '',
budget_min: '', budget_min: '',
budget_max: '', budget_max: '',
budget_type: 'fixed', budget_type: 'fixed',
deadline_at: '', deadline_at: '',
posted_at: '', posted_at: '',
status: 'draft', status: 'draft',
is_remote: false, is_remote: false,
location_requirement: '', location_requirement: '',
required_skills: [], required_skills: [],
attachments: [], attachments: [],
}
const AiGenerateButton = () => {
const { values, setFieldValue } = useFormikContext<any>()
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 <h3>, <p>, <ul> tags).`
const resultAction = await dispatch(askGpt(prompt))
if (askGpt.fulfilled.match(resultAction)) {
setFieldValue('description', resultAction.payload.data)
}
}
return (
<BaseButton
label={isAskingQuestion ? 'Generating...' : 'AI Generate Description'}
icon={mdiAutoFix}
color="info"
outline
className="mb-2"
onClick={handleAiGenerate}
disabled={isAskingQuestion}
/>
)
} }
@ -262,133 +85,46 @@ const JobsNew = () => {
const router = useRouter() const router = useRouter()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const handleSubmit = async (data) => { const handleSubmit = async (data) => {
await dispatch(create(data)) await dispatch(create(data))
await router.push('/jobs/jobs-list') await router.push('/jobs/jobs-list')
} }
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('New Item')}</title> <title>{getPageTitle('New Job')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Job" main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox> <CardBox>
<Formik <Formik
initialValues={ initialValues={initialValues}
initialValues
}
onSubmit={(values) => handleSubmit(values)} onSubmit={(values) => handleSubmit(values)}
> >
<Form> <Form>
<FormField label="Client" labelFor="client"> <FormField label="Client" labelFor="client">
<Field name="client" id="client" component={SelectField} options={[]} itemRef={'users'}></Field> <Field name="client" id="client" component={SelectField} options={[]} itemRef={'users'}></Field>
</FormField> </FormField>
<FormField label="Category" labelFor="category"> <FormField label="Category" labelFor="category">
<Field name="category" id="category" component={SelectField} options={[]} itemRef={'service_categories'}></Field> <Field name="category" id="category" component={SelectField} options={[]} itemRef={'service_categories'}></Field>
</FormField> </FormField>
<FormField <FormField
label="Title" label="Title"
> >
<Field <Field
name="title" name="title"
placeholder="Title" placeholder="e.g. Video Editing for YouTube Channel"
/> />
</FormField> </FormField>
<div className="flex justify-end">
<AiGenerateButton />
</div>
<FormField label='Description' hasTextareaHeight> <FormField label='Description' hasTextareaHeight>
<Field <Field
@ -398,34 +134,6 @@ const JobsNew = () => {
></Field> ></Field>
</FormField> </FormField>
<FormField <FormField
label="BudgetMin" label="BudgetMin"
> >
@ -436,32 +144,6 @@ const JobsNew = () => {
/> />
</FormField> </FormField>
<FormField <FormField
label="BudgetMax" label="BudgetMax"
> >
@ -472,72 +154,13 @@ const JobsNew = () => {
/> />
</FormField> </FormField>
<FormField label="BudgetType" labelFor="budget_type"> <FormField label="BudgetType" labelFor="budget_type">
<Field name="budget_type" id="budget_type" component="select"> <Field name="budget_type" id="budget_type" component="select">
<option value="fixed">fixed</option> <option value="fixed">fixed</option>
<option value="hourly">hourly</option> <option value="hourly">hourly</option>
</Field> </Field>
</FormField> </FormField>
<FormField <FormField
label="DeadlineAt" label="DeadlineAt"
> >
@ -548,32 +171,6 @@ const JobsNew = () => {
/> />
</FormField> </FormField>
<FormField <FormField
label="PostedAt" label="PostedAt"
> >
@ -584,82 +181,17 @@ const JobsNew = () => {
/> />
</FormField> </FormField>
<FormField label="Status" labelFor="status"> <FormField label="Status" labelFor="status">
<Field name="status" id="status" component="select"> <Field name="status" id="status" component="select">
<option value="draft">draft</option> <option value="draft">draft</option>
<option value="open">open</option> <option value="open">open</option>
<option value="in_progress">in_progress</option> <option value="in_progress">in_progress</option>
<option value="in_review">in_review</option> <option value="in_review">in_review</option>
<option value="completed">completed</option> <option value="completed">completed</option>
<option value="cancelled">cancelled</option> <option value="cancelled">cancelled</option>
</Field> </Field>
</FormField> </FormField>
<FormField label='IsRemote' labelFor='is_remote'> <FormField label='IsRemote' labelFor='is_remote'>
<Field <Field
name='is_remote' name='is_remote'
@ -668,16 +200,6 @@ const JobsNew = () => {
></Field> ></Field>
</FormField> </FormField>
<FormField <FormField
label="LocationRequirement" label="LocationRequirement"
> >
@ -687,52 +209,6 @@ const JobsNew = () => {
/> />
</FormField> </FormField>
<FormField label='RequiredSkills' labelFor='required_skills'> <FormField label='RequiredSkills' labelFor='required_skills'>
<Field <Field
name='required_skills' name='required_skills'
@ -743,32 +219,6 @@ const JobsNew = () => {
</Field> </Field>
</FormField> </FormField>
<FormField label='Attachments' labelFor='attachments'> <FormField label='Attachments' labelFor='attachments'>
<Field <Field
name='attachments' name='attachments'
@ -779,10 +229,6 @@ const JobsNew = () => {
</Field> </Field>
</FormField> </FormField>
<BaseDivider /> <BaseDivider />
<BaseButtons> <BaseButtons>
<BaseButton type="submit" color="info" label="Submit" /> <BaseButton type="submit" color="info" label="Submit" />
@ -799,11 +245,7 @@ const JobsNew = () => {
JobsNew.getLayout = function getLayout(page: ReactElement) { JobsNew.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated permission={'CREATE_JOBS'}>
permission={'CREATE_JOBS'}
>
{page} {page}
</LayoutAuthenticated> </LayoutAuthenticated>
) )

File diff suppressed because it is too large Load Diff

View File

@ -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 Head from 'next/head'
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import CardBox from '../../components/CardBox' import CardBox from '../../components/CardBox'
@ -7,7 +7,7 @@ import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config' import { getPageTitle } from '../../config'
import { Field, Form, Formik } from 'formik' import { Field, Form, Formik, useFormikContext } from 'formik'
import FormField from '../../components/FormField' import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider' import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons' import BaseButtons from '../../components/BaseButtons'
@ -23,306 +23,111 @@ import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField"; import {RichTextField} from "../../components/RichTextField";
import { create } from '../../stores/proposals/proposalsSlice' import { create } from '../../stores/proposals/proposalsSlice'
import { useAppDispatch } from '../../stores/hooks' import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import moment from 'moment'; import moment from 'moment';
import { askGpt } from '../../stores/openAiSlice'
import axios from 'axios'
const initialValues = { const initialValues = {
job: '', job: '',
freelancer: '', freelancer: '',
cover_letter: '', cover_letter: '',
proposed_amount: '', proposed_amount: '',
pricing_model: 'fixed', pricing_model: 'fixed',
estimated_days: '', estimated_days: '',
status: 'submitted', status: 'submitted',
submitted_at: '', submitted_at: '',
responded_at: '', responded_at: '',
sample_files: [], sample_files: [],
} }
const AiCoverLetterButton = () => {
const { values, setFieldValue } = useFormikContext<any>()
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 (
<BaseButton
label={isAskingQuestion ? 'Writing...' : 'AI Write Cover Letter'}
icon={mdiAutoFix}
color="info"
outline
className="mb-2"
onClick={handleAiGenerate}
disabled={isAskingQuestion}
/>
)
}
const ProposalsNew = () => { const ProposalsNew = () => {
const router = useRouter() const router = useRouter()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const handleSubmit = async (data) => { const handleSubmit = async (data) => {
await dispatch(create(data)) await dispatch(create(data))
await router.push('/proposals/proposals-list') await router.push('/proposals/proposals-list')
} }
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('New Item')}</title> <title>{getPageTitle('New Proposal')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Proposal" main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox> <CardBox>
<Formik <Formik
initialValues={ initialValues={initialValues}
initialValues
}
onSubmit={(values) => handleSubmit(values)} onSubmit={(values) => handleSubmit(values)}
> >
<Form> <Form>
<FormField label="Job" labelFor="job"> <FormField label="Job" labelFor="job">
<Field name="job" id="job" component={SelectField} options={[]} itemRef={'jobs'}></Field> <Field name="job" id="job" component={SelectField} options={[]} itemRef={'jobs'}></Field>
</FormField> </FormField>
<FormField label="Freelancer" labelFor="freelancer"> <FormField label="Freelancer" labelFor="freelancer">
<Field name="freelancer" id="freelancer" component={SelectField} options={[]} itemRef={'users'}></Field> <Field name="freelancer" id="freelancer" component={SelectField} options={[]} itemRef={'users'}></Field>
</FormField> </FormField>
<div className="flex justify-end">
<AiCoverLetterButton />
</div>
<FormField label="CoverLetter" hasTextareaHeight> <FormField label="CoverLetter" hasTextareaHeight>
<Field name="cover_letter" as="textarea" placeholder="CoverLetter" /> <Field name="cover_letter" as="textarea" placeholder="Describe why you are the best fit..." className="h-40" />
</FormField> </FormField>
<FormField <FormField
label="ProposedAmount" label="ProposedAmount"
> >
@ -333,68 +138,13 @@ const ProposalsNew = () => {
/> />
</FormField> </FormField>
<FormField label="PricingModel" labelFor="pricing_model"> <FormField label="PricingModel" labelFor="pricing_model">
<Field name="pricing_model" id="pricing_model" component="select"> <Field name="pricing_model" id="pricing_model" component="select">
<option value="fixed">fixed</option> <option value="fixed">fixed</option>
<option value="hourly">hourly</option> <option value="hourly">hourly</option>
</Field> </Field>
</FormField> </FormField>
<FormField <FormField
label="EstimatedDays" label="EstimatedDays"
> >
@ -405,78 +155,16 @@ const ProposalsNew = () => {
/> />
</FormField> </FormField>
<FormField label="Status" labelFor="status"> <FormField label="Status" labelFor="status">
<Field name="status" id="status" component="select"> <Field name="status" id="status" component="select">
<option value="submitted">submitted</option> <option value="submitted">submitted</option>
<option value="shortlisted">shortlisted</option> <option value="shortlisted">shortlisted</option>
<option value="accepted">accepted</option> <option value="accepted">accepted</option>
<option value="rejected">rejected</option> <option value="rejected">rejected</option>
<option value="withdrawn">withdrawn</option> <option value="withdrawn">withdrawn</option>
</Field> </Field>
</FormField> </FormField>
<FormField <FormField
label="SubmittedAt" label="SubmittedAt"
> >
@ -487,32 +175,6 @@ const ProposalsNew = () => {
/> />
</FormField> </FormField>
<FormField <FormField
label="RespondedAt" label="RespondedAt"
> >
@ -523,45 +185,6 @@ const ProposalsNew = () => {
/> />
</FormField> </FormField>
<FormField> <FormField>
<Field <Field
label='SampleFiles' label='SampleFiles'
@ -578,7 +201,6 @@ const ProposalsNew = () => {
></Field> ></Field>
</FormField> </FormField>
<BaseDivider /> <BaseDivider />
<BaseButtons> <BaseButtons>
<BaseButton type="submit" color="info" label="Submit" /> <BaseButton type="submit" color="info" label="Submit" />
@ -595,11 +217,7 @@ const ProposalsNew = () => {
ProposalsNew.getLayout = function getLayout(page: ReactElement) { ProposalsNew.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated permission={'CREATE_PROPOSALS'}>
permission={'CREATE_PROPOSALS'}
>
{page} {page}
</LayoutAuthenticated> </LayoutAuthenticated>
) )

View File

@ -0,0 +1,207 @@
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 { currentUser } = useAppSelector((state) => state.auth);
const [service, setService] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [purchasing, setPurchasing] = useState(false);
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]);
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 (
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-slate-900">
<Head><title>Loading Gig Details...</title></Head>
<WebHeader projectName={projectName} />
<main className="flex-grow flex items-center justify-center py-32">
<div className="flex flex-col items-center gap-6">
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-indigo-600 shadow-xl"></div>
<p className="font-black text-gray-500 uppercase tracking-widest text-sm">Gig Details Loading...</p>
</div>
</main>
<WebFooter projectName={projectName} />
</div>
);
}
if (!service) {
return (
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-slate-900">
<Head><title>Gig Not Found</title></Head>
<WebHeader projectName={projectName} />
<main className="flex-grow py-32 text-center container mx-auto px-6">
<div className="bg-white dark:bg-slate-800 p-16 rounded-3xl border-4 border-dashed border-gray-100 dark:border-slate-700 max-w-2xl mx-auto shadow-2xl">
<h2 className="text-3xl font-black text-gray-900 dark:text-white uppercase mb-4 tracking-tighter leading-tight">Gig Not Found</h2>
<p className="text-lg text-gray-500 font-medium mb-8">The service listing you are looking for might have been removed or is no longer available.</p>
<BaseButton
className="px-8 py-3 text-lg font-bold uppercase shadow-lg hover:shadow-indigo-200"
label="Back to Directory"
icon={mdiArrowLeft}
onClick={() => router.push('/web_pages/services')}
color="indigo"
/>
</div>
</main>
<WebFooter projectName={projectName} />
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-slate-900 font-sans">
<Head>
<title>{service.title} | {projectName}</title>
</Head>
<WebHeader projectName={projectName} />
<main className="flex-grow">
<section className="bg-white dark:bg-slate-800 border-b border-gray-100 dark:border-slate-800 py-20 relative overflow-hidden">
<div className="absolute top-0 right-0 w-96 h-96 bg-indigo-50 dark:bg-slate-900 rounded-full -mr-48 -mt-48 blur-3xl opacity-60"></div>
<div className="absolute bottom-0 left-0 w-72 h-72 bg-pink-50 dark:bg-slate-900 rounded-full -ml-36 -mb-36 blur-3xl opacity-60"></div>
<div className="container mx-auto px-6 max-w-6xl relative z-10">
<div className="flex flex-col lg:flex-row gap-16">
<div className="flex-grow lg:w-2/3">
<nav className="text-xs text-gray-400 font-black uppercase tracking-widest mb-8 flex items-center gap-3">
<span className="hover:text-indigo-600 cursor-pointer transition-colors" onClick={() => router.push('/web_pages/services')}>Service Directory</span>
<span className="w-1 h-1 bg-gray-300 rounded-full"></span>
<span className="text-gray-900 dark:text-gray-200 tracking-normal normal-case">{service.title}</span>
</nav>
<h1 className="text-4xl md:text-5xl font-black text-gray-900 dark:text-white mb-8 leading-[1.1] tracking-tighter uppercase">
{service.title}
</h1>
<div className="flex items-center gap-6 mb-12 p-6 bg-slate-50 dark:bg-slate-900 rounded-2xl border border-gray-100 dark:border-slate-800 inline-flex max-w-md">
<div className="h-14 w-14 rounded-2xl bg-indigo-600 shadow-lg flex items-center justify-center text-white text-2xl font-black border-2 border-indigo-400">
{service.freelancer?.firstName?.[0] || 'S'}
</div>
<div>
<p className="font-black text-gray-900 dark:text-white uppercase tracking-tight text-lg">{service.freelancer?.firstName || 'Student Freelancer'}</p>
{service.freelancer?.is_verified_student ? (
<p className="text-sm text-green-600 dark:text-green-400 font-bold uppercase tracking-wider flex items-center gap-2">
<BaseIcon path={mdiCheckDecagram} className="text-green-500" size={16} />
Verified Student
</p>
) : (
<p className="text-sm text-gray-500 font-bold uppercase tracking-wider flex items-center gap-2">
<BaseIcon path={mdiCheckDecagram} className="text-blue-500" size={16} />
Identity Verified
</p>
)}
</div>
</div>
<div className="prose prose-lg dark:prose-invert max-w-none text-gray-700 dark:text-gray-300 font-medium leading-relaxed prose-h3:uppercase prose-h3:font-black prose-h3:tracking-tighter prose-h3:text-2xl"
dangerouslySetInnerHTML={{ __html: service.description }} />
</div>
<div className="w-full lg:w-1/3 flex-shrink-0">
<div className="sticky top-24">
<CardBox className="border-4 border-indigo-600 shadow-2xl rounded-3xl overflow-hidden p-0 bg-white dark:bg-slate-800">
<div className="bg-indigo-600 p-8 text-white relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-white opacity-10 rounded-full -mr-16 -mt-16"></div>
<p className="text-5xl font-black mb-2 tracking-tighter leading-none">
${service.starting_price}
</p>
<p className="text-xs font-black uppercase tracking-widest text-indigo-100">Starter Package Price</p>
</div>
<div className="p-8 space-y-6">
<div className="flex items-center gap-4 text-gray-700 dark:text-gray-300 bg-slate-50 dark:bg-slate-900 p-4 rounded-xl border border-gray-100 dark:border-slate-700">
<BaseIcon path={mdiCalendar} className="text-indigo-600" size={24} />
<span className="text-base font-bold uppercase tracking-tight leading-none"><b>{service.delivery_days} Days</b> Delivery Time</span>
</div>
<div className="flex items-center gap-4 text-gray-700 dark:text-gray-300 bg-slate-50 dark:bg-slate-900 p-4 rounded-xl border border-gray-100 dark:border-slate-700">
<BaseIcon path={mdiKeyboardReturn} className="text-indigo-600" size={24} />
<span className="text-base font-bold uppercase tracking-tight leading-none"><b>Unlimited</b> Revisions</span>
</div>
<BaseButton
label={purchasing ? "Processing..." : (currentUser ? "Order Gig Instantly" : "Login to Order")}
color="indigo"
className="w-full mt-6 font-black py-5 text-xl uppercase tracking-widest shadow-xl hover:shadow-indigo-200 transition-all rounded-2xl"
onClick={handlePurchase}
disabled={purchasing}
/>
<div className="pt-6 border-t border-gray-50 dark:border-slate-700 text-center">
<p className="text-[10px] uppercase font-black text-gray-400 tracking-widest mb-2">Secure Gig Economy</p>
<div className="flex justify-center gap-4 opacity-30 grayscale hover:grayscale-0 transition-all">
<BaseIcon path={mdiCurrencyUsd} size={20} />
</div>
</div>
</div>
</CardBox>
<div className="mt-8 p-6 bg-white dark:bg-slate-800 rounded-2xl border border-gray-100 dark:border-slate-800 shadow-sm flex items-center gap-4">
<div className="flex-shrink-0 w-10 h-10 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center text-green-600 dark:text-green-400">
<BaseIcon path={mdiCheckDecagram} size={20} />
</div>
<p className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-tight">Support local students and get high-quality work done at competitive rates.</p>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
<WebFooter projectName={projectName} />
</div>
);
}
ServiceDetailsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -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 (
<>
<Head>
<title>Student Services | {projectName}</title>
<meta name="description" content="Browse professional services offered by talented students." />
</Head>
<WebHeader projectName={projectName} />
<main className="flex-grow bg-gray-50 dark:bg-slate-900 min-h-screen">
<section className="bg-indigo-600 py-16 text-white shadow-inner">
<div className="container mx-auto px-6 text-center max-w-4xl">
<h1 className="text-4xl md:text-5xl font-extrabold mb-6 tracking-tight uppercase">Student Service Directory</h1>
<p className="text-xl opacity-90 leading-relaxed font-medium">Find talented student freelancers ready to help with your next tech project, marketing campaign, or business operations.</p>
</div>
</section>
<SectionMain>
<div className="mb-12 flex flex-col lg:flex-row gap-6 items-center justify-between bg-white dark:bg-slate-800 p-6 rounded-2xl border border-gray-100 dark:border-slate-700 shadow-sm">
<div className="relative w-full lg:w-1/3">
<span className="absolute inset-y-0 left-0 pl-4 flex items-center text-gray-400">
<BaseIcon path={mdiMagnify} size={20} />
</span>
<input
type="text"
placeholder="Search by title, description, or skill..."
className="block w-full pl-12 pr-4 py-3 border-2 border-gray-100 dark:border-slate-700 rounded-xl leading-5 bg-slate-50 dark:bg-slate-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all sm:text-sm font-medium"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-3 overflow-x-auto pb-2 w-full lg:w-2/3 scroll-smooth no-scrollbar">
<button
className={`px-6 py-2 rounded-full border-2 text-sm whitespace-nowrap font-bold transition-all ${!searchTerm ? 'bg-indigo-600 border-indigo-600 text-white shadow-md' : 'bg-white dark:bg-slate-800 border-gray-100 dark:border-slate-700 text-gray-600 dark:text-gray-400 hover:border-indigo-600 hover:text-indigo-600'}`}
onClick={() => setSearchTerm('')}
>
All Services
</button>
{categories.map(cat => (
<button
key={cat.id}
className={`px-6 py-2 rounded-full border-2 text-sm whitespace-nowrap font-bold transition-all ${searchTerm === cat.name ? 'bg-indigo-600 border-indigo-600 text-white shadow-md' : 'bg-white dark:bg-slate-800 border-gray-100 dark:border-slate-700 text-gray-600 dark:text-gray-400 hover:border-indigo-600 hover:text-indigo-600'}`}
onClick={() => setSearchTerm(cat.name)}
>
{cat.name}
</button>
))}
</div>
</div>
{loading ? (
<div className="text-center py-32">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-600 shadow-sm"></div>
<p className="mt-6 text-gray-500 font-bold uppercase tracking-widest text-sm">Synchronizing Directory...</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredServices.map((service) => (
<CardBox key={service.id} className="hover:shadow-2xl transition-all duration-500 border-b-4 border-b-transparent hover:border-b-indigo-600">
<div className="flex flex-col h-full p-2">
<div className="flex justify-between items-start mb-6">
<h3 className="text-xl font-black text-gray-900 dark:text-white uppercase leading-tight tracking-tight">{service.title}</h3>
<div className="flex flex-col items-end">
<span className="text-2xl font-black text-indigo-600 dark:text-indigo-400 leading-none">${service.starting_price}</span>
<span className="text-[10px] uppercase font-bold text-gray-400 mt-1">Starting Price</span>
</div>
</div>
<div
className="text-gray-600 dark:text-gray-300 mb-8 line-clamp-4 text-sm font-medium leading-relaxed prose prose-sm dark:prose-invert"
dangerouslySetInnerHTML={{ __html: service.description }}
/>
<div className="mt-auto pt-6 border-t-2 border-gray-50 dark:border-slate-800 flex items-center justify-between">
<div className="flex flex-col">
<span className="text-[10px] uppercase font-bold text-gray-400">Delivery</span>
<span className="text-sm font-black text-gray-900 dark:text-white uppercase">{service.delivery_days} Days</span>
</div>
<BaseButton
label="View Gig"
icon={mdiArrowRight}
color="indigo"
className="font-bold uppercase tracking-wider px-6"
href={`/web_pages/service-details?id=${service.id}`}
/>
</div>
</div>
</CardBox>
))}
</div>
)}
{!loading && filteredServices.length === 0 && (
<div className="text-center py-24 bg-white dark:bg-slate-800 rounded-3xl border-4 border-dashed border-gray-100 dark:border-slate-700">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-slate-50 dark:bg-slate-900 text-gray-300 mb-6">
<BaseIcon path={mdiMagnify} size={40} />
</div>
<h4 className="text-xl font-black text-gray-900 dark:text-white uppercase mb-2">No Matching Services</h4>
<p className="text-gray-500 font-medium">Try broadening your search criteria or checking another category.</p>
<BaseButton
className="mt-8"
label="Reset Filters"
onClick={() => setSearchTerm('')}
outline
color="indigo"
/>
</div>
)}
</SectionMain>
</main>
<WebFooter projectName={projectName} />
</>
);
}
ServicesPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};