This commit is contained in:
Flatlogic Bot 2026-02-28 10:13:33 +00:00
parent 8a5f885928
commit a6bf68668a
19 changed files with 1103 additions and 1433 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,
@ -79,22 +79,21 @@ status: {
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,
@ -58,19 +58,17 @@ status: {
values: [
"submitted",
"Pending",
"Accepted",
"Rejected"
"shortlisted",
"accepted",
"rejected",
"withdrawn"
],

View File

@ -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,

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('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/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);

View File

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

View File

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

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,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 || ''

View File

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

View File

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

View File

@ -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">&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>
</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>;
};

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 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

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 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

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

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