1
This commit is contained in:
parent
8a5f885928
commit
a6bf68668a
47
backend/src/db/migrations/1780000000000.js
Normal file
47
backend/src/db/migrations/1780000000000.js
Normal 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() {
|
||||
}
|
||||
};
|
||||
77
backend/src/db/models/escrow_transactions.js
Normal file
77
backend/src/db/models/escrow_transactions.js
Normal 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;
|
||||
};
|
||||
@ -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"
|
||||
|
||||
],
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
],
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
|
||||
@ -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') },
|
||||
]);
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
<div
|
||||
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">
|
||||
|
||||
<b className="font-black">FreelanceFusion MVP</b>
|
||||
|
||||
|
||||
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0 flex items-center justify-center">
|
||||
<Logo />
|
||||
</div>
|
||||
<button
|
||||
className="hidden lg:inline-block xl:hidden p-3"
|
||||
@ -60,4 +57,4 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { appTitle } from '../../config'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
@ -6,10 +7,11 @@ type Props = {
|
||||
|
||||
export default function Logo({ className = '' }: Props) {
|
||||
return (
|
||||
<img
|
||||
src={"https://flatlogic.com/logo.svg"}
|
||||
className={className}
|
||||
alt={'Flatlogic logo'}>
|
||||
</img>
|
||||
<div className={`flex items-center space-x-2 ${className}`}>
|
||||
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg">F</span>
|
||||
</div>
|
||||
<span className="font-bold text-xl tracking-tight">{appTitle}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
54
frontend/src/components/WebFooter.tsx
Normal file
54
frontend/src/components/WebFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
frontend/src/components/WebHeader.tsx
Normal file
62
frontend/src/components/WebHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 || ''
|
||||
@ -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
|
||||
@ -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>
|
||||
{''}
|
||||
</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
|
||||
currentUser={currentUser}
|
||||
@ -871,4 +876,4 @@ Dashboard.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
export default Dashboard
|
||||
@ -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) => (
|
||||
<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 (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<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>)
|
||||
}
|
||||
};
|
||||
|
||||
export default function LandingPage() {
|
||||
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',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<div className="bg-slate-50 min-h-screen font-sans text-slate-900">
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Home')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<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>
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden bg-white py-20 px-6 sm:py-32 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="lg:grid lg:grid-cols-12 lg:gap-x-8 lg:gap-y-20 items-center">
|
||||
<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">
|
||||
Where Student Talent Meets <span className="text-indigo-600">Freelance Fusion</span>.
|
||||
</h1>
|
||||
<p className="mt-6 text-lg leading-8 text-slate-600">
|
||||
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.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-x-6 gap-y-4">
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
href="/register"
|
||||
label="Join as a Student"
|
||||
color="info"
|
||||
className="px-8 py-3 rounded-full text-lg shadow-lg hover:shadow-indigo-200"
|
||||
/>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
<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>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<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>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Services Section */}
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* Bridge/How it Works Section */}
|
||||
<section className="py-24 bg-white px-6">
|
||||
<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">"Charge for your talent and become self-independent."</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'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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
LandingPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
@ -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<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,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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('New Item')}</title>
|
||||
<title>{getPageTitle('New Job')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Job" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<Formik
|
||||
initialValues={
|
||||
|
||||
initialValues
|
||||
|
||||
}
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Client" labelFor="client">
|
||||
<Field name="client" id="client" component={SelectField} options={[]} itemRef={'users'}></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Category" labelFor="category">
|
||||
<Field name="category" id="category" component={SelectField} options={[]} itemRef={'service_categories'}></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="Title"
|
||||
>
|
||||
<Field
|
||||
name="title"
|
||||
placeholder="Title"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label='Description' hasTextareaHeight>
|
||||
<Field
|
||||
name='description'
|
||||
id='description'
|
||||
component={RichTextField}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="BudgetMin"
|
||||
>
|
||||
<Field
|
||||
type="number"
|
||||
name="budget_min"
|
||||
placeholder="BudgetMin"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="BudgetMax"
|
||||
>
|
||||
<Field
|
||||
type="number"
|
||||
name="budget_max"
|
||||
placeholder="BudgetMax"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="BudgetType" labelFor="budget_type">
|
||||
<Field name="budget_type" id="budget_type" component="select">
|
||||
|
||||
<option value="fixed">fixed</option>
|
||||
|
||||
<option value="hourly">hourly</option>
|
||||
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="DeadlineAt"
|
||||
>
|
||||
<Field
|
||||
type="datetime-local"
|
||||
name="deadline_at"
|
||||
placeholder="DeadlineAt"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="PostedAt"
|
||||
>
|
||||
<Field
|
||||
type="datetime-local"
|
||||
name="posted_at"
|
||||
placeholder="PostedAt"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Status" labelFor="status">
|
||||
<Field name="status" id="status" component="select">
|
||||
|
||||
<option value="draft">draft</option>
|
||||
|
||||
<option value="open">open</option>
|
||||
|
||||
<option value="in_progress">in_progress</option>
|
||||
|
||||
<option value="in_review">in_review</option>
|
||||
|
||||
<option value="completed">completed</option>
|
||||
|
||||
<option value="cancelled">cancelled</option>
|
||||
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label='IsRemote' labelFor='is_remote'>
|
||||
<Field
|
||||
name='is_remote'
|
||||
id='is_remote'
|
||||
component={SwitchField}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="LocationRequirement"
|
||||
>
|
||||
<Field
|
||||
name="location_requirement"
|
||||
placeholder="LocationRequirement"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label='RequiredSkills' labelFor='required_skills'>
|
||||
<Field
|
||||
name='required_skills'
|
||||
id='required_skills'
|
||||
itemRef={'skills'}
|
||||
options={[]}
|
||||
component={SelectFieldMany}>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label='Attachments' labelFor='attachments'>
|
||||
<Field
|
||||
name='attachments'
|
||||
id='attachments'
|
||||
itemRef={'job_attachments'}
|
||||
options={[]}
|
||||
component={SelectFieldMany}>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Client" labelFor="client">
|
||||
<Field name="client" id="client" component={SelectField} options={[]} itemRef={'users'}></Field>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Category" labelFor="category">
|
||||
<Field name="category" id="category" component={SelectField} options={[]} itemRef={'service_categories'}></Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Title"
|
||||
>
|
||||
<Field
|
||||
name="title"
|
||||
placeholder="e.g. Video Editing for YouTube Channel"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<AiGenerateButton />
|
||||
</div>
|
||||
|
||||
<FormField label='Description' hasTextareaHeight>
|
||||
<Field
|
||||
name='description'
|
||||
id='description'
|
||||
component={RichTextField}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="BudgetMin"
|
||||
>
|
||||
<Field
|
||||
type="number"
|
||||
name="budget_min"
|
||||
placeholder="BudgetMin"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="BudgetMax"
|
||||
>
|
||||
<Field
|
||||
type="number"
|
||||
name="budget_max"
|
||||
placeholder="BudgetMax"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="BudgetType" labelFor="budget_type">
|
||||
<Field name="budget_type" id="budget_type" component="select">
|
||||
<option value="fixed">fixed</option>
|
||||
<option value="hourly">hourly</option>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="DeadlineAt"
|
||||
>
|
||||
<Field
|
||||
type="datetime-local"
|
||||
name="deadline_at"
|
||||
placeholder="DeadlineAt"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="PostedAt"
|
||||
>
|
||||
<Field
|
||||
type="datetime-local"
|
||||
name="posted_at"
|
||||
placeholder="PostedAt"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Status" labelFor="status">
|
||||
<Field name="status" id="status" component="select">
|
||||
<option value="draft">draft</option>
|
||||
<option value="open">open</option>
|
||||
<option value="in_progress">in_progress</option>
|
||||
<option value="in_review">in_review</option>
|
||||
<option value="completed">completed</option>
|
||||
<option value="cancelled">cancelled</option>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<FormField label='IsRemote' labelFor='is_remote'>
|
||||
<Field
|
||||
name='is_remote'
|
||||
id='is_remote'
|
||||
component={SwitchField}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="LocationRequirement"
|
||||
>
|
||||
<Field
|
||||
name="location_requirement"
|
||||
placeholder="LocationRequirement"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='RequiredSkills' labelFor='required_skills'>
|
||||
<Field
|
||||
name='required_skills'
|
||||
id='required_skills'
|
||||
itemRef={'skills'}
|
||||
options={[]}
|
||||
component={SelectFieldMany}>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Attachments' labelFor='attachments'>
|
||||
<Field
|
||||
name='attachments'
|
||||
id='attachments'
|
||||
itemRef={'job_attachments'}
|
||||
options={[]}
|
||||
component={SelectFieldMany}>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
@ -799,14 +245,10 @@ const JobsNew = () => {
|
||||
|
||||
JobsNew.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
|
||||
permission={'CREATE_JOBS'}
|
||||
|
||||
>
|
||||
<LayoutAuthenticated permission={'CREATE_JOBS'}>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
)
|
||||
}
|
||||
|
||||
export default JobsNew
|
||||
export default JobsNew
|
||||
@ -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<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 router = useRouter()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
|
||||
|
||||
|
||||
const handleSubmit = async (data) => {
|
||||
await dispatch(create(data))
|
||||
await router.push('/proposals/proposals-list')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('New Item')}</title>
|
||||
<title>{getPageTitle('New Proposal')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Proposal" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<Formik
|
||||
initialValues={
|
||||
|
||||
initialValues
|
||||
|
||||
}
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Job" labelFor="job">
|
||||
<Field name="job" id="job" component={SelectField} options={[]} itemRef={'jobs'}></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Freelancer" labelFor="freelancer">
|
||||
<Field name="freelancer" id="freelancer" component={SelectField} options={[]} itemRef={'users'}></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="CoverLetter" hasTextareaHeight>
|
||||
<Field name="cover_letter" as="textarea" placeholder="CoverLetter" />
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="ProposedAmount"
|
||||
>
|
||||
<Field
|
||||
type="number"
|
||||
name="proposed_amount"
|
||||
placeholder="ProposedAmount"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="PricingModel" labelFor="pricing_model">
|
||||
<Field name="pricing_model" id="pricing_model" component="select">
|
||||
|
||||
<option value="fixed">fixed</option>
|
||||
|
||||
<option value="hourly">hourly</option>
|
||||
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="EstimatedDays"
|
||||
>
|
||||
<Field
|
||||
type="number"
|
||||
name="estimated_days"
|
||||
placeholder="EstimatedDays"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Status" labelFor="status">
|
||||
<Field name="status" id="status" component="select">
|
||||
|
||||
<option value="submitted">submitted</option>
|
||||
|
||||
<option value="shortlisted">shortlisted</option>
|
||||
|
||||
<option value="accepted">accepted</option>
|
||||
|
||||
<option value="rejected">rejected</option>
|
||||
|
||||
<option value="withdrawn">withdrawn</option>
|
||||
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="SubmittedAt"
|
||||
>
|
||||
<Field
|
||||
type="datetime-local"
|
||||
name="submitted_at"
|
||||
placeholder="SubmittedAt"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="RespondedAt"
|
||||
>
|
||||
<Field
|
||||
type="datetime-local"
|
||||
name="responded_at"
|
||||
placeholder="RespondedAt"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField>
|
||||
<Field
|
||||
label='SampleFiles'
|
||||
color='info'
|
||||
icon={mdiUpload}
|
||||
path={'proposals/sample_files'}
|
||||
name='sample_files'
|
||||
id='sample_files'
|
||||
schema={{
|
||||
size: undefined,
|
||||
formats: undefined,
|
||||
}}
|
||||
component={FormFilePicker}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Job" labelFor="job">
|
||||
<Field name="job" id="job" component={SelectField} options={[]} itemRef={'jobs'}></Field>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Freelancer" labelFor="freelancer">
|
||||
<Field name="freelancer" id="freelancer" component={SelectField} options={[]} itemRef={'users'}></Field>
|
||||
</FormField>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<AiCoverLetterButton />
|
||||
</div>
|
||||
|
||||
<FormField label="CoverLetter" hasTextareaHeight>
|
||||
<Field name="cover_letter" as="textarea" placeholder="Describe why you are the best fit..." className="h-40" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="ProposedAmount"
|
||||
>
|
||||
<Field
|
||||
type="number"
|
||||
name="proposed_amount"
|
||||
placeholder="ProposedAmount"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="PricingModel" labelFor="pricing_model">
|
||||
<Field name="pricing_model" id="pricing_model" component="select">
|
||||
<option value="fixed">fixed</option>
|
||||
<option value="hourly">hourly</option>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="EstimatedDays"
|
||||
>
|
||||
<Field
|
||||
type="number"
|
||||
name="estimated_days"
|
||||
placeholder="EstimatedDays"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Status" labelFor="status">
|
||||
<Field name="status" id="status" component="select">
|
||||
<option value="submitted">submitted</option>
|
||||
<option value="shortlisted">shortlisted</option>
|
||||
<option value="accepted">accepted</option>
|
||||
<option value="rejected">rejected</option>
|
||||
<option value="withdrawn">withdrawn</option>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="SubmittedAt"
|
||||
>
|
||||
<Field
|
||||
type="datetime-local"
|
||||
name="submitted_at"
|
||||
placeholder="SubmittedAt"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="RespondedAt"
|
||||
>
|
||||
<Field
|
||||
type="datetime-local"
|
||||
name="responded_at"
|
||||
placeholder="RespondedAt"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Field
|
||||
label='SampleFiles'
|
||||
color='info'
|
||||
icon={mdiUpload}
|
||||
path={'proposals/sample_files'}
|
||||
name='sample_files'
|
||||
id='sample_files'
|
||||
schema={{
|
||||
size: undefined,
|
||||
formats: undefined,
|
||||
}}
|
||||
component={FormFilePicker}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
@ -595,14 +217,10 @@ const ProposalsNew = () => {
|
||||
|
||||
ProposalsNew.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
|
||||
permission={'CREATE_PROPOSALS'}
|
||||
|
||||
>
|
||||
<LayoutAuthenticated permission={'CREATE_PROPOSALS'}>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProposalsNew
|
||||
export default ProposalsNew
|
||||
179
frontend/src/pages/web_pages/service-details.tsx
Normal file
179
frontend/src/pages/web_pages/service-details.tsx
Normal file
@ -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<any>(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 (
|
||||
<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}
|
||||
href="/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">Student Freelancer</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="Order Gig"
|
||||
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"
|
||||
href="/login"
|
||||
/>
|
||||
|
||||
<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">
|
||||
{/* Payment icons could go here */}
|
||||
<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>;
|
||||
};
|
||||
164
frontend/src/pages/web_pages/services.tsx
Normal file
164
frontend/src/pages/web_pages/services.tsx
Normal 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>;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user