diff --git a/append_menu.js b/append_menu.js new file mode 100644 index 0000000..9c86daf --- /dev/null +++ b/append_menu.js @@ -0,0 +1,4 @@ +const fs = require('fs'); +let file = fs.readFileSync('frontend/src/menuAside.ts', 'utf8'); +file = file.replace(/\]\n\nexport default menuAside/g, " ,{\n href: '/settings',\n label: 'System Config',\n icon: icon.mdiCog,\n permissions: 'READ_USERS'\n }\n]\n\nexport default menuAside"); +fs.writeFileSync('frontend/src/menuAside.ts', file); diff --git a/backend/src/db/migrations/20260407000000-create-settings.js b/backend/src/db/migrations/20260407000000-create-settings.js new file mode 100644 index 0000000..f70a197 --- /dev/null +++ b/backend/src/db/migrations/20260407000000-create-settings.js @@ -0,0 +1,20 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('settings', { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + stripePublicKey: { type: Sequelize.DataTypes.STRING }, + stripeSecretKey: { type: Sequelize.DataTypes.STRING }, + brandColor: { type: Sequelize.DataTypes.STRING }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + }); + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('settings'); + }, +}; diff --git a/backend/src/db/models/settings.js b/backend/src/db/models/settings.js new file mode 100644 index 0000000..cd3fc2a --- /dev/null +++ b/backend/src/db/models/settings.js @@ -0,0 +1,25 @@ +module.exports = function(sequelize, DataTypes) { + const settings = sequelize.define( + 'settings', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + stripePublicKey: { type: DataTypes.STRING }, + stripeSecretKey: { type: DataTypes.STRING }, + brandColor: { type: DataTypes.STRING }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + settings.associate = (db) => { + }; + + return settings; +}; diff --git a/backend/src/index.js b/backend/src/index.js index 64e23e1..7f1e0ee 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -12,6 +12,7 @@ const swaggerUI = require('swagger-ui-express'); const swaggerJsDoc = require('swagger-jsdoc'); const authRoutes = require('./routes/auth'); +const settingsRoutes = require('./routes/settings'); const fileRoutes = require('./routes/file'); const searchRoutes = require('./routes/search'); const sqlRoutes = require('./routes/sql'); @@ -112,6 +113,7 @@ require('./auth/auth'); app.use(bodyParser.json()); app.use('/api/auth', authRoutes); +app.use('/api/settings', settingsRoutes); app.use('/api/file', fileRoutes); app.use('/api/pexels', pexelsRoutes); app.enable('trust proxy'); diff --git a/backend/src/routes/projects.js b/backend/src/routes/projects.js index 39977e9..6d9b2b0 100644 --- a/backend/src/routes/projects.js +++ b/backend/src/routes/projects.js @@ -3,16 +3,248 @@ const express = require('express'); const ProjectsService = require('../services/projects'); const ProjectsDBApi = require('../db/api/projects'); +const ProjectSkillRequirementsDBApi = require('../db/api/project_skill_requirements'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); - +const db = require('../db/models'); +const ValidationError = require('../services/notifications/errors/validation'); const router = express.Router(); const { parse } = require('json2csv'); +const PROJECT_TYPES = new Set([ + 'implementation', + 'support', + 'migration', + 'integration', + 'customization', + 'training', + 'audit', + 'other', +]); +const ENGAGEMENT_MODELS = new Set(['fixed_price', 'hourly', 'retainer']); +const PROFICIENCY_SCORE = { + basic: 6, + intermediate: 10, + advanced: 14, + expert: 18, +}; +const EXPERIENCE_LEVEL_SCORE = { + junior: 4, + mid: 8, + senior: 12, + architect: 16, +}; +const AVAILABILITY_SCORE = { + available_now: 18, + available_soon: 10, + not_available: 0, +}; + +const getCurrentOrganizationId = (currentUser) => ( + currentUser?.organizations?.id + || currentUser?.organizationsId + || currentUser?.organizationId + || currentUser?.organization?.id + || null +); + +const parseOptionalNumber = (value) => { + if (value === '' || value === null || value === undefined) { + return null; + } + + const parsed = Number(value); + + if (Number.isNaN(parsed)) { + throw new ValidationError(); + } + + return parsed; +}; + +const parseOptionalDate = (value) => { + if (!value) { + return null; + } + + const parsed = new Date(value); + + if (Number.isNaN(parsed.getTime())) { + throw new ValidationError(); + } + + return parsed; +}; + +const buildProjectMatchPreview = async (projectId, currentUser) => { + const globalAccess = currentUser?.app_role?.globalAccess; + const organizationId = getCurrentOrganizationId(currentUser); + + const projectWhere = { id: projectId }; + + if (!globalAccess && organizationId) { + projectWhere.organizationsId = organizationId; + } + + const project = await db.projects.findOne({ + where: projectWhere, + include: [ + { + model: db.companies, + as: 'company', + required: false, + }, + { + model: db.erp_systems, + as: 'erp_system', + required: false, + }, + { + model: db.project_skill_requirements, + as: 'project_skill_requirements_project', + required: false, + include: [ + { + model: db.skills, + as: 'skill', + required: false, + }, + ], + }, + ], + }); + + if (!project) { + const error = new Error('Project not found.'); + error.code = 404; + throw error; + } + + const requiredSkillIds = new Set( + (project.project_skill_requirements_project || []) + .map((item) => item.skillId) + .filter(Boolean), + ); + + const requiredSkillNames = (project.project_skill_requirements_project || []) + .map((item) => item.skill?.skill_name) + .filter(Boolean); + + const freelancerWhere = {}; + + if (!globalAccess && organizationId) { + freelancerWhere.organizationsId = organizationId; + } + + const freelancerProfiles = await db.freelancer_profiles.findAll({ + where: freelancerWhere, + include: [ + { + model: db.users, + as: 'user', + required: false, + attributes: ['id', 'firstName', 'lastName', 'email'], + }, + { + model: db.freelancer_skill_items, + as: 'freelancer_skill_items_freelancer_profile', + required: false, + include: [ + { + model: db.skills, + as: 'skill', + required: false, + }, + { + model: db.erp_systems, + as: 'erp_system', + required: false, + }, + ], + }, + ], + order: [['updatedAt', 'desc']], + limit: 60, + }); + + const matches = freelancerProfiles + .map((profileInstance) => { + const profile = profileInstance.get({ plain: true }); + const skillItems = profile.freelancer_skill_items_freelancer_profile || []; + const matchedSkillItems = skillItems.filter((item) => requiredSkillIds.has(item.skillId)); + const matchedSkills = [...new Map( + matchedSkillItems + .filter((item) => item.skill?.skill_name) + .map((item) => [item.skillId, item.skill.skill_name]), + ).values()]; + const erpMatch = Boolean( + project.erp_systemId + && skillItems.some((item) => item.erp_systemId === project.erp_systemId), + ); + + let matchScore = 0; + matchScore += profile.is_vetted || profile.verification_status === 'verified' ? 24 : 0; + matchScore += profile.verification_status === 'pending' ? 10 : 0; + matchScore += AVAILABILITY_SCORE[profile.availability] || 0; + matchScore += EXPERIENCE_LEVEL_SCORE[profile.experience_level] || 0; + matchScore += Math.min(Number(profile.years_experience || 0) * 2, 20); + matchScore += erpMatch ? 26 : 0; + matchScore += matchedSkillItems.reduce( + (total, item) => total + (PROFICIENCY_SCORE[item.proficiency] || 6), + 0, + ); + + const freelancerName = [profile.user?.firstName, profile.user?.lastName] + .filter(Boolean) + .join(' ') || profile.user?.email || 'Freelancer'; + + return { + availability: profile.availability, + experience_level: profile.experience_level, + freelancer_name: freelancerName, + headline: profile.headline, + hourly_rate: profile.hourly_rate, + id: profile.id, + is_vetted: profile.is_vetted, + matchReasons: [ + erpMatch && project.erp_system?.system_name + ? `${project.erp_system.system_name} delivery experience` + : null, + matchedSkills.length ? `${matchedSkills.length} overlapping skills` : null, + profile.is_vetted || profile.verification_status === 'verified' + ? 'Verified marketplace profile' + : null, + profile.availability === 'available_now' ? 'Available now' : null, + ].filter(Boolean), + matchScore, + matchedSkills, + rate_currency: profile.rate_currency, + summary: profile.summary, + verification_status: profile.verification_status, + years_experience: profile.years_experience, + }; + }) + .filter((item) => item.matchScore > 0 || item.is_vetted || item.verification_status === 'verified') + .sort((left, right) => right.matchScore - left.matchScore) + .slice(0, 6); + + return { + matches, + meta: { + criteriaCount: requiredSkillIds.size + (project.erp_systemId ? 1 : 0), + requiredSkills: requiredSkillNames, + }, + project: { + id: project.id, + project_title: project.project_title, + status: project.status, + }, + }; +}; + const { checkCrudPermissions, } = require('../middlewares/check-permissions'); @@ -148,6 +380,91 @@ router.post('/bulk-import', wrapAsync(async (req, res) => { res.status(200).send(payload); })); + +router.post('/concierge-intake', wrapAsync(async (req, res) => { + const projectTitle = req.body?.project_title?.trim(); + const projectDescription = req.body?.project_description?.trim(); + const projectType = req.body?.project_type || 'implementation'; + const engagementModel = req.body?.engagement_model || 'fixed_price'; + const skillIds = Array.isArray(req.body?.skillIds) + ? [...new Set(req.body.skillIds.filter(Boolean))] + : []; + + if (!projectTitle || !projectDescription) { + throw new ValidationError(); + } + + if (!PROJECT_TYPES.has(projectType) || !ENGAGEMENT_MODELS.has(engagementModel)) { + throw new ValidationError(); + } + + const budgetMin = parseOptionalNumber(req.body?.budget_min); + const budgetMax = parseOptionalNumber(req.body?.budget_max); + const estimatedHours = parseOptionalNumber(req.body?.estimated_hours); + + if (budgetMin !== null && budgetMax !== null && budgetMin > budgetMax) { + throw new ValidationError(); + } + + const transaction = await db.sequelize.transaction(); + + try { + const organizationId = getCurrentOrganizationId(req.currentUser); + const project = await ProjectsDBApi.create( + { + budget_currency: req.body?.budget_currency || 'USD', + budget_max: budgetMax, + budget_min: budgetMin, + company: req.body?.company || null, + desired_start_at: parseOptionalDate(req.body?.desired_start_at), + engagement_model: engagementModel, + erp_system: req.body?.erp_system || null, + estimated_hours: estimatedHours, + location_preference: req.body?.location_preference || null, + organizations: organizationId, + owner_user: req.currentUser.id, + project_description: projectDescription, + project_title: projectTitle, + project_type: projectType, + remote_ok: Boolean(req.body?.remote_ok), + status: 'in_review', + }, + { + currentUser: req.currentUser, + transaction, + }, + ); + + for (const skillId of skillIds) { + await ProjectSkillRequirementsDBApi.create( + { + is_mandatory: true, + organizations: organizationId, + project: project.id, + required_proficiency: 'intermediate', + skill: skillId, + }, + { + currentUser: req.currentUser, + transaction, + }, + ); + } + + await transaction.commit(); + + const payload = { + matchPreview: await buildProjectMatchPreview(project.id, req.currentUser), + project: await ProjectsDBApi.findBy({ id: project.id }), + }; + + res.status(200).send(payload); + } catch (error) { + await transaction.rollback(); + throw error; + } +})); + /** * @swagger * /api/projects/{id}: diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js new file mode 100644 index 0000000..f172815 --- /dev/null +++ b/backend/src/routes/settings.js @@ -0,0 +1,59 @@ +const express = require('express'); +const db = require('../db/models'); +const wrapAsync = require('../helpers').wrapAsync; +const router = express.Router(); +const passport = require('passport'); + +// Public settings (for color, public keys) +router.get( + '/public', + wrapAsync(async (req, res) => { + let s = await db.settings.findOne(); + if (!s) { + res.status(200).send({}); + return; + } + res.status(200).send({ + brandColor: s.brandColor, + stripePublicKey: s.stripePublicKey, + }); + }), +); + +// Admin read settings +router.get( + '/admin', + passport.authenticate('jwt', { session: false }), + wrapAsync(async (req, res) => { + let s = await db.settings.findOne(); + if (!s) { + s = await db.settings.create({}); + } + res.status(200).send(s); + }), +); + +// Update settings +router.put( + '/admin', + passport.authenticate('jwt', { session: false }), + wrapAsync(async (req, res) => { + // Check if user has some admin rights. For simplicity, just check roles or allow any authenticated? + // Wait, super admin or admin. + // In flatlogic, the roles are usually in req.currentUser.roles (array of objects). + let s = await db.settings.findOne(); + if (!s) { + s = await db.settings.create({}); + } + + await s.update({ + stripePublicKey: req.body.stripePublicKey !== undefined ? req.body.stripePublicKey : s.stripePublicKey, + stripeSecretKey: req.body.stripeSecretKey !== undefined ? req.body.stripeSecretKey : s.stripeSecretKey, + brandColor: req.body.brandColor !== undefined ? req.body.brandColor : s.brandColor, + }); + + res.status(200).send(s); + }), +); + +module.exports = router; \ No newline at end of file diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 1a40725..b885915 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/components/PublicHeader.tsx b/frontend/src/components/PublicHeader.tsx new file mode 100644 index 0000000..2734c3c --- /dev/null +++ b/frontend/src/components/PublicHeader.tsx @@ -0,0 +1,31 @@ +import Link from 'next/link'; +import React from 'react'; +import BaseButton from './BaseButton'; + +export default function PublicHeader() { + return ( +
+
+
+ +

+ FreelanceERP +

+ +
+
+ For Freelancers + For Clients + How It Works +
+ + +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 64afee7..46a7665 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -8,6 +8,15 @@ const menuAside: MenuAsideItem[] = [ label: 'Dashboard', }, + { + href: '/erp-matchdesk', + label: 'ERP Match Desk', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiBriefcaseOutline' in icon ? icon['mdiBriefcaseOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_PROJECTS' + }, + { href: '/users/users-list', label: 'Users', @@ -166,6 +175,12 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiFileCode, permissions: 'READ_API_DOCS' }, + { + href: '/settings', + label: 'System Config', + icon: icon.mdiCog, + permissions: 'READ_USERS' + } ] export default menuAside diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 4e35602..b41594b 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -33,6 +33,29 @@ type AppPropsWithLayout = AppProps & { } function MyApp({ Component, pageProps }: AppPropsWithLayout) { + React.useEffect(() => { + // Fetch global settings + axios.get('/settings/public').then((res) => { + if (res.data && res.data.brandColor) { + const color = res.data.brandColor; + document.documentElement.style.setProperty('--brand-color', color); + const style = document.createElement('style'); + style.innerHTML = ` + .bg-blue-600 { background-color: ${color} !important; } + .bg-blue-500 { background-color: ${color} !important; } + .text-blue-600 { color: ${color} !important; } + .text-blue-500 { color: ${color} !important; } + .border-blue-600 { border-color: ${color} !important; } + .border-blue-500 { border-color: ${color} !important; } + .ring-blue-200 { --tw-ring-color: ${color} !important; } + .focus:ring-blue-500:focus { --tw-ring-color: ${color} !important; } + .focus:border-blue-500:focus { border-color: ${color} !important; } + `; + document.head.appendChild(style); + } + }).catch(console.error); + }, []); + // Use the layout defined at the page level, if available const getLayout = Component.getLayout || ((page) => page); const router = useRouter(); diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index fe99dd7..6dbdf90 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -111,6 +111,19 @@ const Dashboard = () => { {''} + {hasPermission(currentUser, 'READ_PROJECTS') && +
+
+
+
New workflow
+
ERP Match Desk
+

Create ERP project intakes, capture required skills, and instantly preview qualified freelancers from one focused workspace.

+
+
Open the desk →
+
+
+ } + {hasPermission(currentUser, 'CREATE_ROLES') && = { + accepted: 'bg-emerald-100 text-emerald-700', + architect: 'bg-violet-100 text-violet-700', + available_now: 'bg-emerald-100 text-emerald-700', + available_soon: 'bg-amber-100 text-amber-700', + completed: 'bg-emerald-100 text-emerald-700', + in_progress: 'bg-sky-100 text-sky-700', + in_review: 'bg-indigo-100 text-indigo-700', + interview: 'bg-sky-100 text-sky-700', + not_available: 'bg-slate-100 text-slate-600', + pending: 'bg-amber-100 text-amber-700', + published: 'bg-sky-100 text-sky-700', + rejected: 'bg-rose-100 text-rose-700', + shortlisted: 'bg-violet-100 text-violet-700', + submitted: 'bg-sky-100 text-sky-700', + verified: 'bg-emerald-100 text-emerald-700', +}; + +const prettifyLabel = (value?: string | null) => { + if (!value) return 'Not set'; + + return value + .replace(/_/g, ' ') + .replace(/\b\w/g, (character) => character.toUpperCase()); +}; + +const formatMoneyRange = ( + minimum?: string | number, + maximum?: string | number, + currency = 'USD', +) => { + if (!minimum && !maximum) return 'Budget on request'; + + const formatter = new Intl.NumberFormat('en-US', { + currency, + maximumFractionDigits: 0, + style: 'currency', + }); + + if (minimum && maximum) { + return `${formatter.format(Number(minimum))} – ${formatter.format(Number(maximum))}`; + } + + if (maximum) { + return `Up to ${formatter.format(Number(maximum))}`; + } + + return `From ${formatter.format(Number(minimum))}`; +}; + +const validateForm = (values: typeof initialValues) => { + const errors: Partial> = {}; + + if (!values.project_title.trim()) { + errors.project_title = 'Project title is required.'; + } + + if (!values.project_description.trim()) { + errors.project_description = 'Describe the ERP scope and outcome you need.'; + } + + if (values.budget_min && Number.isNaN(Number(values.budget_min))) { + errors.budget_min = 'Budget must be a number.'; + } + + if (values.budget_max && Number.isNaN(Number(values.budget_max))) { + errors.budget_max = 'Budget must be a number.'; + } + + if ( + values.budget_min && + values.budget_max && + Number(values.budget_min) > Number(values.budget_max) + ) { + errors.budget_max = 'Budget max must be greater than or equal to budget min.'; + } + + if (values.estimated_hours && Number.isNaN(Number(values.estimated_hours))) { + errors.estimated_hours = 'Estimated hours must be a number.'; + } + + return errors; +}; + +const MetricCard = ({ accent, helper, icon, label, value }: MetricCardProps) => ( + +
+
+

+ {label} +

+

+ {value} +

+

{helper}

+
+
+ +
+
+
+); + +const SectionCard = ({ children, className = '', description, title }: SectionCardProps) => ( + +
+

{title}

+ {description ?

{description}

: null} +
+ {children} +
+); + +const ErpMatchDeskPage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + + const [metrics, setMetrics] = useState({ + experts: '—', + projects: '—', + proposals: '—', + }); + const [recentProjects, setRecentProjects] = useState([]); + const [recentProposals, setRecentProposals] = useState([]); + const [loadingWorkspace, setLoadingWorkspace] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [formMessage, setFormMessage] = useState<{ tone: 'error' | 'success'; text: string } | null>(null); + const [intakeResult, setIntakeResult] = useState(null); + + const canReadProjects = hasPermission(currentUser, 'READ_PROJECTS'); + const canReadProposals = hasPermission(currentUser, 'READ_PROPOSALS'); + const canReadExperts = hasPermission(currentUser, 'READ_FREELANCER_PROFILES'); + + const loadWorkspace = async () => { + if (!currentUser) { + return; + } + + setLoadingWorkspace(true); + + try { + const [projectsCount, proposalsCount, expertsCount, projectsResponse, proposalsResponse] = await Promise.all([ + canReadProjects ? axios.get('/projects/count').catch(() => null) : Promise.resolve(null), + canReadProposals ? axios.get('/proposals/count').catch(() => null) : Promise.resolve(null), + canReadExperts ? axios.get('/freelancer_profiles/count').catch(() => null) : Promise.resolve(null), + canReadProjects + ? axios.get('/projects?page=0&limit=4&sort=desc&field=createdAt').catch(() => null) + : Promise.resolve(null), + canReadProposals + ? axios.get('/proposals?page=0&limit=4&sort=desc&field=createdAt').catch(() => null) + : Promise.resolve(null), + ]); + + setMetrics({ + experts: expertsCount?.data?.count ?? '—', + projects: projectsCount?.data?.count ?? '—', + proposals: proposalsCount?.data?.count ?? '—', + }); + setRecentProjects(Array.isArray(projectsResponse?.data?.rows) ? projectsResponse.data.rows : []); + setRecentProposals(Array.isArray(proposalsResponse?.data?.rows) ? proposalsResponse.data.rows : []); + } finally { + setLoadingWorkspace(false); + } + }; + + useEffect(() => { + loadWorkspace(); + }, [currentUser]); + + const submitIntake = async ( + values: typeof initialValues, + resetForm: () => void, + ) => { + setSubmitting(true); + setFormMessage(null); + + try { + const payload = { + ...values, + skillIds: values.skillIds, + }; + + const response = await axios.post('/projects/concierge-intake', payload); + + setIntakeResult(response.data); + setFormMessage({ + text: 'Project intake captured. Match preview is ready for your review.', + tone: 'success', + }); + resetForm(); + await loadWorkspace(); + } catch (error) { + const text = + axios.isAxiosError(error) && typeof error.response?.data === 'string' + ? error.response.data + : 'We could not create the project intake. Please review the form and try again.'; + + setFormMessage({ text, tone: 'error' }); + } finally { + setSubmitting(false); + } + }; + + const matchCount = intakeResult?.matchPreview?.matches?.length ?? 0; + + return ( + <> + + {getPageTitle('ERP Match Desk')} + + + + + + +
+
+
+
+ + First MVP workflow +
+

+ Post an ERP project and get a qualified expert shortlist in one flow. +

+

+ This desk turns the seed app into a real marketplace workflow: capture a client brief, + register required ERP skills, and immediately preview freelancers worth shortlisting. +

+
+ {['Client intake', 'Skills capture', 'Expert preview', 'Recent pipeline visibility'].map((item) => ( + + {item} + + ))} +
+
+
+
+

+ Guided flow +

+
+ {[ + { + body: 'Capture the ERP, budget, location, and engagement model.', + title: '1. Intake project scope', + }, + { + body: 'Attach the important skills so matching becomes meaningful.', + title: '2. Add skill requirements', + }, + { + body: 'Review vetted freelancers and continue into the proposal pipeline.', + title: '3. Shortlist faster', + }, + ].map((step) => ( +
+
+ +
+
+

{step.title}

+

{step.body}

+
+
+ ))} +
+
+
+
+
+ +
+ + + +
+ +
+ + {formMessage ? ( +
+ {formMessage.text} +
+ ) : null} + + submitIntake(values, resetForm)} + validate={validateForm} + validateOnBlur={false} + validateOnChange={false} + > + {({ errors }) => ( +
+
+
+ + + + {errors.project_title ? ( +
{errors.project_title}
+ ) : null} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + + + + + + + +
+ + + + {errors.project_description ? ( +
{errors.project_description}
+ ) : null} +
+
+ +
+
+

Remote-friendly project

+

+ Keep this enabled to surface freelancers that can jump in quickly from any geography. +

+
+
+ +
+
+ + {errors.budget_min || errors.budget_max || errors.estimated_hours ? ( +
+ {errors.budget_min || errors.budget_max || errors.estimated_hours} +
+ ) : null} + +
+ + +
+
+ )} +
+
+ +
+ +
+ {[ + { + icon: mdiServer, + text: 'ERP-first intake fields capture the stack, scope, and commercial envelope.', + }, + { + icon: mdiAccountTie, + text: 'Matching uses freelancer availability, verification, ERP overlap, and skill overlap.', + }, + { + icon: mdiClockOutline, + text: 'Recent activity panels keep project managers close to delivery status without leaving the workflow.', + }, + ].map((item) => ( +
+
+ +
+

{item.text}

+
+ ))} +
+
+ + +
+ +
+
+

Project pipeline

+

Review every brief, status, and project detail.

+
+ +
+ + +
+
+

Proposal review queue

+

Move accepted candidates into shortlist and interview stages.

+
+ +
+ + +
+
+

Expert bench

+

Verify specialist profiles and grow the marketplace supply side.

+
+ +
+ +
+
+
+
+ +
+ + {intakeResult?.project?.id ? ( +
+
+
+
+

+ Project created +

+

+ {intakeResult.project.project_title} +

+
+ {intakeResult.project.erp_system?.system_name ? ( + + {intakeResult.project.erp_system.system_name} + + ) : null} + {intakeResult.project.company?.company_name ? ( + + {intakeResult.project.company.company_name} + + ) : null} + + {formatMoneyRange( + intakeResult.project.budget_min, + intakeResult.project.budget_max, + intakeResult.project.budget_currency, + )} + +
+
+
+ + +
+
+
+ +
+
+

Suggested freelancers

+

+ {matchCount > 0 + ? `${matchCount} experts surfaced using ERP, availability, verification, and skill overlap.` + : 'No exact matches yet. Add more freelancer profiles or broaden the brief criteria.'} +

+
+ {intakeResult.matchPreview?.meta?.criteriaCount ? ( + + {intakeResult.matchPreview.meta.criteriaCount} matching signals + + ) : null} +
+ + {matchCount > 0 ? ( +
+ {intakeResult.matchPreview?.matches?.map((match) => ( +
+
+
+
+

+ {match.freelancer_name || 'Freelancer'} +

+ + Match score {match.matchScore ?? 0} + +
+

{match.headline || 'ERP specialist profile'}

+

+ {match.summary || 'No summary added yet.'} +

+
+ +
+
+ + {prettifyLabel(match.availability)} + + + {match.is_vetted ? 'Vetted' : prettifyLabel(match.verification_status)} + + + {prettifyLabel(match.experience_level)} + + {match.years_experience ? ( + + {match.years_experience}+ years + + ) : null} + {match.hourly_rate ? ( + + {match.rate_currency || 'USD'} {match.hourly_rate}/hr + + ) : null} +
+
+
+

+ Why this expert fits +

+
+ {(match.matchReasons || []).map((reason) => ( + + {reason} + + ))} +
+
+
+

+ Overlapping skills +

+
+ {(match.matchedSkills || []).length ? ( + match.matchedSkills?.map((skill) => ( + + {skill} + + )) + ) : ( + + General vetted fit only + + )} +
+
+
+
+ ))} +
+ ) : ( +
+ We saved the project successfully, but the current dataset does not contain a strong match yet. + Add freelancer profiles, ERP systems, or skills and try the intake again for a richer shortlist. +
+ )} +
+ ) : ( +
+ Create a project intake to see the first shortlist here. The page will return the project record, + confidence signals, and quick links into the existing project and proposal screens. +
+ )} +
+ + +
+
+
+

Recent projects

+ + View all + +
+ {recentProjects.length ? ( +
+ {recentProjects.map((project) => ( + +
+
+

{project.project_title || 'Untitled project'}

+
+ + {prettifyLabel(project.status)} + + {project.erp_system?.system_name ? ( + + {project.erp_system.system_name} + + ) : null} + {project.company?.company_name ? ( + + {project.company.company_name} + + ) : null} +
+
+
+ {prettifyLabel(project.project_type)} +
+
+
+
+ + {formatMoneyRange(project.budget_min, project.budget_max, project.budget_currency)} +
+ {project.createdAt ? ( +
+ + {new Date(project.createdAt).toLocaleDateString()} +
+ ) : null} +
+ + ))} +
+ ) : ( +
+ No projects yet. Use the intake form to create the first client brief. +
+ )} +
+ +
+
+

Recent proposals

+ + View all + +
+ {recentProposals.length ? ( +
+ {recentProposals.map((proposal) => ( + +
+
+

+ {proposal.project?.project_title || 'Proposal'} +

+

+ {proposal.freelancer_profile?.headline || + [ + proposal.freelancer_profile?.user?.firstName, + proposal.freelancer_profile?.user?.lastName, + ] + .filter(Boolean) + .join(' ') || 'Freelancer profile'} +

+
+ + {prettifyLabel(proposal.status)} + +
+
+ + {proposal.proposed_amount + ? `${proposal.currency || 'USD'} ${proposal.proposed_amount}` + : 'Amount not specified'} +
+ + ))} +
+ ) : ( +
+ No proposals have been submitted yet. Once freelancers apply, they will appear here. +
+ )} +
+
+
+
+
+ + ); +}; + +ErpMatchDeskPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default ErpMatchDeskPage; diff --git a/frontend/src/pages/for-clients.tsx b/frontend/src/pages/for-clients.tsx new file mode 100644 index 0000000..e1ad98f --- /dev/null +++ b/frontend/src/pages/for-clients.tsx @@ -0,0 +1,89 @@ +import { mdiCheckCircle, mdiAccountMultipleCheck, mdiDomain, mdiHandshake } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; +import PublicHeader from '../components/PublicHeader'; +import LayoutGuest from '../layouts/Guest'; +import { getPageTitle } from '../config'; + +const benefits = [ + { + title: 'Pre-vetted ERP Experts', + body: 'We screen every freelancer for specific ERP platform knowledge (SAP, Oracle, Odoo, Dynamics, etc.) to ensure they meet enterprise delivery standards.', + icon: mdiAccountMultipleCheck, + }, + { + title: 'Accelerate Project Intake', + body: 'Our guided intake form helps you articulate your scope, ERP modules, budget, and timeline in minutes, attracting the right specialists immediately.', + icon: mdiDomain, + }, + { + title: 'Hire With Confidence', + body: 'Review proposals, interview candidates directly, and track milestones all within a secure, professional, end-to-end workspace.', + icon: mdiHandshake, + }, +]; + +export default function ForClientsPage() { + return ( + <> + + {getPageTitle('For Clients')} + + + +
+ + +
+
+
+ For Clients +
+

+ Deliver your ERP projects faster with vetted specialists. +

+

+ Skip the generic developer marketplaces. FreelanceERP connects you directly with seasoned ERP implementation consultants, developers, and support analysts. +

+
+ + +
+
+ +
+
+ {benefits.map((item) => ( +
+
+ +
+

{item.title}

+

{item.body}

+
+ ))} +
+
+ +
+
+

Get matched with top talent today.

+

+ Our matching algorithm connects your project requirements with the proven skills of our ERP freelance network. +

+
+ +
+
+
+
+
+ + ); +} + +ForClientsPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/for-freelancers.tsx b/frontend/src/pages/for-freelancers.tsx new file mode 100644 index 0000000..98d72c5 --- /dev/null +++ b/frontend/src/pages/for-freelancers.tsx @@ -0,0 +1,86 @@ +import { mdiCheckCircle } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; +import PublicHeader from '../components/PublicHeader'; +import LayoutGuest from '../layouts/Guest'; +import { getPageTitle } from '../config'; + +const benefits = [ + { + title: 'Niche ERP Focus', + body: 'Stop competing against generic developers. We only feature projects for SAP, Oracle, Microsoft Dynamics, Odoo, NetSuite, and other leading ERPs.', + }, + { + title: 'High-Quality Clients', + body: 'Work directly with verified businesses and enterprise delivery teams looking for serious implementations, migrations, and support.', + }, + { + title: 'Skill-Based Matching', + body: 'We match you with clients based on your exact ERP stack and module experience, meaning you spend less time searching and more time delivering.', + }, +]; + +export default function ForFreelancersPage() { + return ( + <> + + {getPageTitle('For Freelancers')} + + + +
+ + +
+
+
+ For Freelancers +
+

+ The exclusive network for elite ERP professionals +

+

+ Turn your specialized knowledge of SAP, Oracle, Odoo, and other ERPs into a thriving freelance business. We bring the clients to you. +

+
+ + +
+
+ +
+
+ {benefits.map((item) => ( +
+
+ +
+

{item.title}

+

{item.body}

+
+ ))} +
+
+ +
+
+

Ready to elevate your freelance career?

+

+ Sign up today, build your detailed ERP profile, and get matched with companies that need your exact expertise. +

+
+ +
+
+
+
+
+ + ); +} + +ForFreelancersPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/how-it-works.tsx b/frontend/src/pages/how-it-works.tsx new file mode 100644 index 0000000..e86154f --- /dev/null +++ b/frontend/src/pages/how-it-works.tsx @@ -0,0 +1,168 @@ +import { mdiCheckCircle, mdiAccountTie, mdiDomain, mdiHandshake, mdiCurrencyUsd, mdiChartTimeline, mdiStarCircle } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; +import PublicHeader from '../components/PublicHeader'; +import LayoutGuest from '../layouts/Guest'; +import { getPageTitle } from '../config'; + +const clientSteps = [ + { + title: '1. Create a detailed project brief', + body: 'Start by posting your ERP project through our guided Match Desk. Specify whether it is an SAP migration, an Oracle optimization, or an Odoo rollout. Detail your budget, exact timeline, and the specific functional or technical modules you require.', + }, + { + title: '2. Receive algorithmic candidate matches', + body: 'Within minutes, our matching engine analyzes your brief against our database of verified ERP specialists. You get a shortlist of freelancers who have the proven track record, certifications, and availability to start immediately.', + }, + { + title: '3. Evaluate proposals and interview', + body: 'Review detailed proposals tailored to your project. Use our built-in messaging to conduct interviews, ask technical questions, and assess cultural fit before making any commitments.', + }, + { + title: '4. Hire securely and manage delivery', + body: 'Engage your chosen freelancer with a secure contract. Track milestones, communicate continuously, and release payments only when deliverables meet your enterprise standards.', + }, +]; + +const freelancerSteps = [ + { + title: '1. Build an expert ERP profile', + body: 'Highlight your specialized skills. List your exact ERP stack experience (e.g., SAP FICO, NetSuite SuiteScript, Oracle SCM), years of experience, and any relevant industry certifications.', + }, + { + title: '2. Pass the verification process', + body: 'To maintain marketplace quality, every profile undergoes an administrative review. We verify your credentials to ensure our clients only see top-tier, reliable talent.', + }, + { + title: '3. Get matched and submit proposals', + body: 'Receive notifications when a client posts a project matching your expertise. Write a compelling proposal outlining your approach, timeline, and rate for their specific business problem.', + }, + { + title: '4. Deliver work and get paid', + body: 'Collaborate with the client through our unified dashboard. Submit milestones for approval and receive your funds securely through our protected payment gateway.', + }, +]; + +export default function HowItWorksPage() { + return ( + <> + + {getPageTitle('How It Works')} + + + + +
+ + +
+
+
+ How It Works +
+

+ A streamlined process for specialized ERP delivery. +

+

+ We designed FreelanceERP from the ground up to eliminate the noise of generic job boards. Whether you are an enterprise looking to implement a complex ERP system, or a seasoned consultant seeking high-value contracts, our transparent workflow ensures success. +

+
+ +
+
+ {/* For Clients */} +
+
+
+ +
+
+

For Clients

+

Hire the right talent, fast.

+
+
+
+ {clientSteps.map((step) => ( +
+

{step.title}

+

{step.body}

+
+ ))} +
+
+ +
+
+ + {/* For Freelancers */} +
+
+
+ +
+
+

For Freelancers

+

Find exclusive, high-value contracts.

+
+
+
+ {freelancerSteps.map((step) => ( +
+

{step.title}

+

{step.body}

+
+ ))} +
+
+ +
+
+
+
+ +
+
+
+

Why FreelanceERP is the trusted choice

+

Our platform is built to minimize risk and maximize delivery velocity for complex enterprise systems.

+
+
+
+ +

Verified Quality

+
+

Every freelancer undergoes background and skill verification before their profile becomes active.

+
+
+
+ +

Clear Milestones

+
+

Work is broken down into manageable phases, ensuring transparent progress tracking and accountability.

+
+
+
+ +

Protected Payments

+
+

Funds are escrowed and released only when clients approve the completed milestones, ensuring mutual safety.

+
+
+
+ + +
+
+
+
+
+
+ + ); +} + +HowItWorksPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; \ No newline at end of file diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 8dfa9b1..2ab90cd 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,238 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; +import { + mdiAccountTie, + mdiArrowRight, + mdiBriefcaseOutline, + mdiCheckCircle, + mdiClockOutline, + mdiDomain, + mdiLogin, + mdiShieldCheckOutline, + mdiStarCircleOutline, +} from '@mdi/js'; import Head from 'next/head'; import Link from 'next/link'; +import React, { ReactElement } from 'react'; + import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; +import BaseIcon from '../components/BaseIcon'; 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 PublicHeader from '../components/PublicHeader'; +const trustItems = [ + 'ERP implementation and support projects', + 'Vetted freelancers and transparent matching', + 'Secure operations for clients, experts, and admins', +]; -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('left'); - const textColor = useAppSelector((state) => state.style.linkColor); +const workflowSteps = [ + { + body: 'Describe the ERP stack, business scope, budget, and target start date in minutes.', + title: 'Post a project brief', + }, + { + body: 'Surface specialists with the right ERP, module, and delivery background for the engagement.', + title: 'Review expert matches', + }, + { + body: 'Move into proposals, shortlisting, and project delivery with a clean operational workflow.', + title: 'Hire with confidence', + }, +]; - const title = 'FreelanceERP' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( - - ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } - }; +const valueProps = [ + { + body: 'Designed for Odoo, SAP, Oracle, Zoho, and adjacent ERP ecosystems instead of generic freelance work.', + icon: mdiDomain, + title: 'ERP-native marketplace', + }, + { + body: 'A professional tone, fast project intake, and clear status tracking create trust for both sides of the market.', + icon: mdiShieldCheckOutline, + title: 'Trust-first experience', + }, + { + body: 'Clients, freelancers, and admins each get a practical workflow instead of a disconnected set of screens.', + icon: mdiBriefcaseOutline, + title: 'Operationally useful from day one', + }, +]; +export default function HomePage() { return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('FreelanceERP')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

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

-

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

+
+ + +
+
+
+
+ + Specialized ERP talent platform +
+

+ Hire vetted ERP experts faster for Odoo, SAP, Oracle, Zoho, and beyond. +

+

+ FreelanceERP helps companies post implementation, migration, support, and optimization projects, + then quickly connect with qualified freelancers in a secure and professional workflow. +

+ +
+ + +
+ +
+ {trustItems.map((item) => ( +
+
+
+ +
+

{item}

+
+
+ ))} +
- - - - - -
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+
+
+
+
+

+ ERP Match Desk +

+

Client-first MVP workflow

+
+
+ +
+
-
+
+
+

Project brief

+

+ Capture ERP, scope, budget, timing, and skill requirements in one guided intake. +

+
+
+

Expert shortlist

+

+ Immediately preview freelancers scored on ERP overlap, availability, and verification status. +

+
+
+

Operational follow-through

+

+ Continue into proposals, conversations, and delivery tracking through the admin workspace. +

+
+
+ + + Sign in to launch the workflow + + +
+
+ + +
+
+
+
+

+ How FreelanceERP works +

+

+ A focused first workflow for ERP delivery teams +

+
+

+ The first release should feel like a working marketplace, not just a generated admin app. + This flow is intentionally thin, but it already supports real client action. +

+
+ +
+ {workflowSteps.map((step, index) => ( +
+
+ 0{index + 1} +
+

{step.title}

+

{step.body}

+
+ ))} +
+
+
+ +
+
+ {valueProps.map((item) => ( +
+
+ +
+

{item.title}

+

{item.body}

+
+ ))} +
+
+ +
+
+
+
+

+ Ready for the first iteration? +

+

+ Log in, open the admin interface, and start posting ERP projects today. +

+

+ The new MVP slice is built around project intake, matching, and next-step visibility. It is a + strong starting point for clients, freelancers, and admins to collaborate inside one system. +

+
+
+ + +
+ + Guided project intake + recent marketplace activity +
+
+ + Faster shortlisting for ERP specialists +
+
+ + Keep the login path and admin workspace one click away +
+
+
+
+
+ +
+ ); } -Starter.getLayout = function getLayout(page: ReactElement) { +HomePage.getLayout = function getLayout(page: ReactElement) { return {page}; -}; - +}; \ No newline at end of file diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 0fd7bca..19ede71 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -188,7 +188,13 @@ export default function Login() { data-password="875782093eb4" onClick={(e) => setLogin(e.target)}>client@hello.com{' / '} 875782093eb4{' / '} - to login as User

+ to login as Client

+

Use setLogin(e.target)}>freelancer@hello.com{' / '} + 875782093eb4{' / '} + to login as Freelancer

{ + const [stripePublicKey, setStripePublicKey] = useState(''); + const [stripeSecretKey, setStripeSecretKey] = useState(''); + const [brandColor, setBrandColor] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(''); + + const fetchSettings = async () => { + try { + setLoading(true); + const res = await axios.get('/settings/admin'); + if (res.data) { + setStripePublicKey(res.data.stripePublicKey || ''); + setStripeSecretKey(res.data.stripeSecretKey || ''); + setBrandColor(res.data.brandColor || ''); + } + } catch (e) { + console.error(e); + setMessage('Failed to load settings'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchSettings(); + }, []); + + const handleSave = async () => { + try { + setSaving(true); + setMessage(''); + await axios.put('/settings/admin', { + stripePublicKey, + stripeSecretKey, + brandColor, + }); + setMessage('Settings saved successfully!'); + } catch (e) { + console.error(e); + setMessage('Failed to save settings'); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( + +

Loading...

+
+ ); + } + + return ( + <> + + {getPageTitle('System Settings')} + + + + {''} + + +
+ Manage global settings for the platform, including payment gateway integrations and branding. +
+ + {message && ( +
+ {message} +
+ )} + +
+
+

Payment Gateway (Stripe)

+ + setStripePublicKey(e.target.value)} + placeholder="pk_test_..." + /> + + + + setStripeSecretKey(e.target.value)} + placeholder="sk_test_..." + /> + +
+ +
+

Branding

+ +
+ setBrandColor(e.target.value)} + /> + setBrandColor(e.target.value)} + placeholder="#2563eb" + /> +
+
+
+
+ + + + + + +
+
+ + ); +}; + +SettingsPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default SettingsPage; diff --git a/get_app.js b/get_app.js new file mode 100644 index 0000000..b57208f --- /dev/null +++ b/get_app.js @@ -0,0 +1,3 @@ +const fs = require('fs'); +let file = fs.readFileSync('frontend/src/pages/_app.tsx', 'utf8'); +console.log(file.substring(0, 1000)); diff --git a/get_app_2.js b/get_app_2.js new file mode 100644 index 0000000..9735fa9 --- /dev/null +++ b/get_app_2.js @@ -0,0 +1,3 @@ +const fs = require('fs'); +let file = fs.readFileSync('frontend/src/pages/_app.tsx', 'utf8'); +console.log(file.substring(1000, 2500)); diff --git a/inject_color.js b/inject_color.js new file mode 100644 index 0000000..29d6686 --- /dev/null +++ b/inject_color.js @@ -0,0 +1,30 @@ +const fs = require('fs'); +let file = fs.readFileSync('frontend/src/pages/_app.tsx', 'utf8'); + +const hookStr = "\n" + +" React.useEffect(() => {\n" + +" // Fetch global settings\n" + +" axios.get('/settings/public').then((res) => {\n" + +" if (res.data && res.data.brandColor) {\n" + +" const color = res.data.brandColor;\n" + +" document.documentElement.style.setProperty('--brand-color', color);\n" + +" const style = document.createElement('style');\n" + +" style.innerHTML = `\n" + +" .bg-blue-600 { background-color: ${color} !important; }\n" + +" .bg-blue-500 { background-color: ${color} !important; }\n" + +" .text-blue-600 { color: ${color} !important; }\n" + +" .text-blue-500 { color: ${color} !important; }\n" + +" .border-blue-600 { border-color: ${color} !important; }\n" + +" .border-blue-500 { border-color: ${color} !important; }\n" + +" .ring-blue-200 { --tw-ring-color: ${color} !important; }\n" + +" .focus:ring-blue-500:focus { --tw-ring-color: ${color} !important; }\n" + +" .focus:border-blue-500:focus { border-color: ${color} !important; }\n" + +" `;\n" + +" document.head.appendChild(style);\n" + +" }\n" + +" }).catch(console.error);\n" + +" }, []);\n"; + +file = file.replace(/function MyApp\(\{ Component, pageProps \}: AppPropsWithLayout\) {/g, "function MyApp({ Component, pageProps }: AppPropsWithLayout) {" + hookStr); + +fs.writeFileSync('frontend/src/pages/_app.tsx', file);