almost complete

This commit is contained in:
Flatlogic Bot 2026-02-18 16:31:33 +00:00
parent 111693ae6e
commit ff6518916f
19 changed files with 714 additions and 80 deletions

View File

@ -85,7 +85,7 @@ module.exports = {
// BUSINESS Permissions (Clients)
const businessPerms = [
...publicPerms,
"READ_REVIEWS",
"READ_REVIEWS", "UPDATE_REVIEWS", // Added UPDATE_REVIEWS for responding to reviews
"READ_LEADS", "UPDATE_LEADS",
"READ_LEAD_PHOTOS",
"CREATE_MESSAGES", "READ_MESSAGES",

View File

@ -0,0 +1,42 @@
const { v4: uuid } = require("uuid");
module.exports = {
async up(queryInterface) {
const createdAt = new Date();
const updatedAt = new Date();
const roles = await queryInterface.sequelize.query(
`SELECT id, name FROM "roles";`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
const permissions = await queryInterface.sequelize.query(
`SELECT id, name FROM "permissions";`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
const getRoleId = (name) => roles.find(r => r.name === name)?.id;
const getPermId = (name) => permissions.find(p => p.name === name)?.id;
const vboRoleId = getRoleId("Verified Business Owner");
const createDisputesPermId = getPermId("CREATE_DISPUTES");
if (vboRoleId && createDisputesPermId) {
const existing = await queryInterface.sequelize.query(
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${vboRoleId}' AND "permissionId" = '${createDisputesPermId}';`,
{ type: queryInterface.sequelize.QueryTypes.SELECT }
);
if (existing.length === 0) {
await queryInterface.bulkInsert("rolesPermissionsPermissions", [{
createdAt,
updatedAt,
roles_permissionsId: vboRoleId,
permissionId: createDisputesPermId
}]);
}
}
},
async down(queryInterface) {
}
};

View File

@ -0,0 +1,41 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
const createdAt = new Date();
const updatedAt = new Date();
const [roles] = await queryInterface.sequelize.query(
`SELECT id FROM roles WHERE name = 'Verified Business Owner';`
);
const [permissions] = await queryInterface.sequelize.query(
`SELECT id FROM permissions WHERE name = 'DELETE_BUSINESS_PHOTOS';`
);
if (roles.length > 0 && permissions.length > 0) {
const roleId = roles[0].id;
const permissionId = permissions[0].id;
// Check if it already exists to avoid duplicates
const [existing] = await queryInterface.sequelize.query(
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${roleId}' AND "permissionId" = '${permissionId}';`
);
if (existing.length === 0) {
await queryInterface.bulkInsert('rolesPermissionsPermissions', [
{
createdAt,
updatedAt,
roles_permissionsId: roleId,
permissionId: permissionId,
},
]);
}
}
},
down: async (queryInterface, Sequelize) => {
// Logic to remove the permission if needed
}
};

View File

@ -0,0 +1,36 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
const createdAt = new Date();
const updatedAt = new Date();
const [publicRole] = await queryInterface.sequelize.query(
"SELECT id FROM roles WHERE name = 'Public' LIMIT 1"
);
const [createLeadsPermission] = await queryInterface.sequelize.query(
"SELECT id FROM permissions WHERE name = 'CREATE_LEADS' LIMIT 1"
);
if (publicRole.length && createLeadsPermission.length) {
// Check if already exists
const [existing] = await queryInterface.sequelize.query(
`SELECT * FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${publicRole[0].id}' AND "permissionId" = '${createLeadsPermission[0].id}'`
);
if (!existing.length) {
await queryInterface.bulkInsert('rolesPermissionsPermissions', [{
createdAt,
updatedAt,
roles_permissionsId: publicRole[0].id,
permissionId: createLeadsPermission[0].id,
}]);
}
}
},
down: async (queryInterface, Sequelize) => {
// Logic to revert if needed
}
};

View File

@ -163,7 +163,7 @@ app.use('/api/verification_evidences', passport.authenticate('jwt', {session: fa
app.use('/api/claim_requests', passport.authenticate('jwt', {session: false}), claim_requestsRoutes);
app.use('/api/leads', passport.authenticate('jwt', {session: false}), leadsRoutes);
app.use('/api/leads', optionalAuth, leadsRoutes);
app.use('/api/lead_photos', passport.authenticate('jwt', {session: false}), lead_photosRoutes);

View File

@ -5,6 +5,25 @@ const db = require('../db/models');
const passport = require('passport');
const router = express.Router();
router.get('/', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => {
const role = req.currentUser.app_role ? req.currentUser.app_role.name : null;
if (role === 'Verified Business Owner') {
const payload = await DashboardService.getBusinessMetrics(req.currentUser);
res.status(200).send(payload);
} else if (role === 'Administrator' || role === 'Platform Owner') {
const payload = await DashboardService.getAdminMetrics();
res.status(200).send(payload);
} else {
// Default or other roles
res.status(200).send({
totalViews: 0,
activeLeads: 0,
conversionRate: 0,
});
}
}));
router.get('/business-metrics', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => {
const payload = await DashboardService.getBusinessMetrics(req.currentUser);
res.status(200).send(payload);
@ -28,4 +47,4 @@ router.post('/record-event', (req, res, next) => {
res.status(200).send(event);
}));
module.exports = router;
module.exports = router;

View File

@ -7,12 +7,19 @@ module.exports = class DashboardService {
// 1. Get businesses owned by current user
const businesses = await db.businesses.findAll({
where: { owner_userId: currentUser.id },
attributes: ['id', 'name', 'planId', 'renewal_date', 'reliability_score', 'description', 'phone', 'website', 'address', 'hours_json'],
attributes: ['id', 'name', 'planId', 'renewal_date', 'reliability_score', 'description', 'phone', 'website', 'address', 'hours_json', 'is_active'],
include: [{ model: db.plans, as: 'plan' }]
});
if (!businesses.length) {
return { no_business: true };
return {
no_business: true,
totalViews: 0,
activeLeads: 0,
conversionRate: 0,
verificationStatus: 'N/A',
accountStanding: 'N/A'
};
}
const businessIds = businesses.map(b => b.id);
@ -76,10 +83,9 @@ module.exports = class DashboardService {
updatedAt: { [Op.gte]: last30d }
}
});
const winRate30d = (won30d + lost30d) > 0 ? (won30d / (won30d + lost30d)) * 100 : 0;
const winRate30d = (won30d + lost30d) > 0 ? Math.round((won30d / (won30d + lost30d)) * 100) : 0;
// --- Recent Messages ---
// Join messages with lead_matches to ensure they belong to this business
const recentMessages = await db.messages.findAll({
where: {
[Op.or]: [
@ -114,7 +120,7 @@ module.exports = class DashboardService {
const website30d = await getEventCount('WEBSITE_CLICK', last30d);
const totalClicks30d = calls30d + website30d;
const conversionRate = views30d > 0 ? (totalClicks30d / views30d) * 100 : 0;
const viewConversionRate = views30d > 0 ? Math.round((totalClicks30d / views30d) * 100) : 0;
// --- Health Score ---
const firstBusiness = businesses[0];
@ -137,7 +143,6 @@ module.exports = class DashboardService {
}
});
// Add weights for photos/categories/prices
const photoCount = await db.business_photos.count({ where: { businessId: firstBusiness.id } });
if (photoCount > 0) healthScore += 15; else missingFields.push('Photos');
@ -147,7 +152,30 @@ module.exports = class DashboardService {
const priceCount = await db.service_prices.count({ where: { businessId: firstBusiness.id } });
if (priceCount > 0) healthScore += 5; else missingFields.push('Service Prices');
// --- Verification & Standing ---
const lastSubmission = await db.verification_submissions.findOne({
where: { businessId: { [Op.in]: businessIds } },
order: [['createdAt', 'DESC']]
});
let verificationStatus = 'Not Started';
if (lastSubmission) {
verificationStatus = lastSubmission.status.charAt(0).toUpperCase() + lastSubmission.status.slice(1);
}
let accountStanding = 'Good';
if (firstBusiness.reliability_score < 70) accountStanding = 'Reviewing';
if (firstBusiness.reliability_score < 40) accountStanding = 'At Risk';
return {
totalViews: views30d,
activeLeads: newLeads24h,
conversionRate: winRate30d,
viewConversionRate,
verificationStatus,
verificationSubtext: lastSubmission?.status === 'APPROVED' ? 'Identity & Business verified' : 'Complete verification to build trust',
accountStanding,
accountStandingSubtext: accountStanding === 'Good' ? 'Perfect track record' : 'Contact support for details',
businesses,
action_queue: {
newLeads24h,
@ -171,9 +199,30 @@ module.exports = class DashboardService {
calls30d,
website7d,
website30d,
conversionRate
conversionRate: winRate30d
},
healthScore: Math.min(healthScore, 100)
};
}
static async getAdminMetrics() {
const totalUsers = await db.users.count();
const totalBusinesses = await db.businesses.count();
// Revenue as sum of prices of plans currently active on businesses
const businessesWithPlans = await db.businesses.findAll({
where: { planId: { [Op.ne]: null } },
include: [{ model: db.plans, as: 'plan' }]
});
const totalRevenue = businessesWithPlans.reduce((acc, curr) => {
return acc + (curr.plan ? parseFloat(curr.plan.price) : 0);
}, 0);
return {
totalUsers,
totalBusinesses,
totalRevenue
};
}
};

View File

@ -4,6 +4,14 @@
const output = process.env.NODE_ENV === 'production' ? 'export' : 'standalone';
const nextConfig = {
async rewrites() {
return [
{
source: '/about',
destination: '/web_pages/about',
},
];
},
trailingSlash: true,
distDir: 'build',
output,

View File

@ -96,10 +96,8 @@ export default function LayoutAuthenticated({
'/leads/leads-list',
'/reviews/reviews-list',
'/messages/messages-list',
'/verification_submissions/verification_submissions-list',
'/profile',
'/billing',
'/team'
];
return allowedPaths.includes(item.href);
}

View File

@ -67,10 +67,17 @@ const menuAside: MenuAsideItem[] = [
{
href: '/leads/leads-list',
label: 'Client Bookings',
label: 'Service Requests',
icon: icon.mdiCalendarHeart,
permissions: 'READ_LEADS',
roles: ['Administrator', 'Platform Owner', 'Verified Business Owner']
roles: ['Verified Business Owner']
},
{
href: '/leads/leads-list',
label: 'Leads',
icon: icon.mdiCalendarHeart,
permissions: 'READ_LEADS',
roles: ['Administrator', 'Platform Owner']
},
{
@ -102,13 +109,6 @@ const menuAside: MenuAsideItem[] = [
permissions: 'READ_VERIFICATION_SUBMISSIONS',
roles: ['Administrator', 'Platform Owner', 'Trust & Safety Lead']
},
{
href: '/verification_submissions/verification_submissions-list',
label: 'Safety Badge',
icon: icon.mdiShieldCheck,
permissions: 'READ_VERIFICATION_SUBMISSIONS',
roles: ['Verified Business Owner']
},
// Placeholder for Billing and Team
{
@ -123,12 +123,6 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiFinance,
roles: ['Administrator', 'Platform Owner']
},
{
href: '/team',
label: 'Studio Team',
icon: icon.mdiAccountGroupOutline,
roles: ['Verified Business Owner']
},
// Moderator
{

View File

@ -48,8 +48,8 @@ const menuNavBar: MenuNavBarItem[] = [
export const webPagesNavBar = [
{ href: '/search', label: 'Find Services' },
{ href: '/register', label: 'List Business' }
{ href: '/register', label: 'List Business' },
{ href: '/about', label: 'About Us' }
];
export default menuNavBar
export default menuNavBar

View File

@ -58,7 +58,7 @@ const Dashboard = () => {
const [dashboardData, setDashboardData] = useState<any>(null)
const [isFetching, setIsFetching] = useState(false)
const isBusinessOwner = currentUser?.role === 'Verified Business Owner'
const isBusinessOwner = currentUser?.app_role?.name === 'Verified Business Owner'
useEffect(() => {
const fetchDashboard = async () => {
@ -197,28 +197,32 @@ const Dashboard = () => {
<div className="space-y-6">
<div className="flex items-center justify-between p-4 rounded-2xl bg-slate-50 dark:bg-slate-800 border border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-emerald-500 flex items-center justify-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${dashboardData?.verificationStatus === 'Approved' ? 'bg-emerald-500' : 'bg-amber-500'}`}>
<BaseIcon path={mdiShieldCheck} size={20} className="text-white" />
</div>
<div>
<h4 className="font-bold text-sm">Verification Status</h4>
<p className="text-xs text-slate-500">Identity & Business verified</p>
<p className="text-xs text-slate-500">{dashboardData?.verificationSubtext || 'Complete verification to build trust'}</p>
</div>
</div>
<span className="px-3 py-1 bg-emerald-100 text-emerald-600 text-[10px] font-bold uppercase tracking-wider rounded-full">Active</span>
<span className={`px-3 py-1 text-[10px] font-bold uppercase tracking-wider rounded-full ${dashboardData?.verificationStatus === 'Approved' ? 'bg-emerald-100 text-emerald-600' : 'bg-amber-100 text-amber-600'}`}>
{dashboardData?.verificationStatus || 'N/A'}
</span>
</div>
<div className="flex items-center justify-between p-4 rounded-2xl bg-slate-50 dark:bg-slate-800 border border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${dashboardData?.accountStanding === 'Good' ? 'bg-blue-500' : 'bg-red-500'}`}>
<BaseIcon path={mdiChartTimelineVariant} size={20} className="text-white" />
</div>
<div>
<h4 className="font-bold text-sm">Account Standing</h4>
<p className="text-xs text-slate-500">Perfect track record</p>
<p className="text-xs text-slate-500">{dashboardData?.accountStandingSubtext || 'Perfect track record'}</p>
</div>
</div>
<span className="px-3 py-1 bg-blue-100 text-blue-600 text-[10px] font-bold uppercase tracking-wider rounded-full">Good</span>
<span className={`px-3 py-1 text-[10px] font-bold uppercase tracking-wider rounded-full ${dashboardData?.accountStanding === 'Good' ? 'bg-blue-100 text-blue-600' : 'bg-red-100 text-red-600'}`}>
{dashboardData?.accountStanding || 'N/A'}
</span>
</div>
</div>
</CardBox>

View File

@ -13,7 +13,9 @@ import {
mdiPowerPlug,
mdiAirConditioner,
mdiBrush,
mdiFormatPaint
mdiFormatPaint,
mdiClipboardTextOutline,
mdiCheckCircleOutline
} from '@mdi/js';
import BaseIcon from '../components/BaseIcon';
import LayoutGuest from '../layouts/Guest';
@ -135,6 +137,62 @@ export default function LandingPage() {
</div>
</section>
{/* How it Works Section */}
<section className="py-24 bg-white border-y border-slate-200 overflow-hidden">
<div className="container mx-auto px-6">
<div className="text-center max-w-3xl mx-auto mb-20">
<h2 className="text-4xl font-bold mb-6 text-slate-900 leading-tight">Simplified Discovery. Trusted Connections.</h2>
<p className="text-lg text-slate-500 leading-relaxed">
Fix-It-Local connects homeowners and businesses with verified professionals through a transparent, AI-powered process.
</p>
</div>
<div className="grid md:grid-cols-4 gap-12 relative">
{/* Connector line for desktop */}
<div className="hidden lg:block absolute top-1/4 left-[10%] right-[10%] h-px bg-slate-200 -z-0"></div>
<div className="relative z-10 text-center flex flex-col items-center">
<div className="w-20 h-20 bg-emerald-500 text-white rounded-3xl flex items-center justify-center mb-8 shadow-xl shadow-emerald-500/20 transform hover:scale-110 transition-transform">
<BaseIcon path={mdiMagnify} size={36} />
</div>
<h4 className="font-bold text-xl mb-4">1. Find a Pro</h4>
<p className="text-slate-500 text-sm leading-relaxed max-w-[200px]">Browse categories or search for specific verified services near you.</p>
</div>
<div className="relative z-10 text-center flex flex-col items-center">
<div className="w-20 h-20 bg-blue-500 text-white rounded-3xl flex items-center justify-center mb-8 shadow-xl shadow-blue-500/20 transform hover:scale-110 transition-transform">
<BaseIcon path={mdiClipboardTextOutline} size={36} />
</div>
<h4 className="font-bold text-xl mb-4">2. Request Service</h4>
<p className="text-slate-500 text-sm leading-relaxed max-w-[200px]">Submit details about your job. No signup required for initial requests.</p>
</div>
<div className="relative z-10 text-center flex flex-col items-center">
<div className="w-20 h-20 bg-amber-500 text-white rounded-3xl flex items-center justify-center mb-8 shadow-xl shadow-amber-500/20 transform hover:scale-110 transition-transform">
<BaseIcon path={mdiFlash} size={36} />
</div>
<h4 className="font-bold text-xl mb-4">3. AI Smart Match</h4>
<p className="text-slate-500 text-sm leading-relaxed max-w-[200px]">Our engine matches your request with the best available verified professional.</p>
</div>
<div className="relative z-10 text-center flex flex-col items-center">
<div className="w-20 h-20 bg-indigo-500 text-white rounded-3xl flex items-center justify-center mb-8 shadow-xl shadow-indigo-500/20 transform hover:scale-110 transition-transform">
<BaseIcon path={mdiCheckCircleOutline} size={36} />
</div>
<h4 className="font-bold text-xl mb-4">4. Get it Done</h4>
<p className="text-slate-500 text-sm leading-relaxed max-w-[200px]">Connect with your pro, review job history, and enjoy quality results.</p>
</div>
</div>
<div className="text-center mt-20">
<Link href="/about" className="inline-flex items-center text-emerald-600 font-bold text-lg hover:underline group">
Learn more about our mission
<BaseIcon path={mdiFlash} size={20} className="ml-2 group-hover:translate-x-1 transition-transform" />
</Link>
</div>
</div>
</section>
{/* Trust Features */}
<section className="py-24 bg-slate-900 text-white overflow-hidden relative">
<div className="container mx-auto px-6 relative z-10">

View File

@ -52,7 +52,8 @@ const LeadsTablesPage = () => {
]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_LEADS');
const isBusinessOwner = currentUser?.app_role?.name === 'Verified Business Owner';
const pageTitle = isBusinessOwner ? 'Service Requests' : 'Leads';
const addFilter = () => {
const newItem = {
@ -94,10 +95,10 @@ const LeadsTablesPage = () => {
return (
<>
<Head>
<title>{getPageTitle('Leads')}</title>
<title>{getPageTitle(pageTitle)}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Leads" main>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={pageTitle} main>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
@ -169,4 +170,4 @@ LeadsTablesPage.getLayout = function getLayout(page: ReactElement) {
)
}
export default LeadsTablesPage
export default LeadsTablesPage

View File

@ -8,7 +8,10 @@ import {
mdiCheckCircle,
mdiPlus,
mdiMagnify,
mdiPencil
mdiPencil,
mdiCamera,
mdiDelete,
mdiUpload
} from '@mdi/js';
import axios from 'axios';
import LayoutAuthenticated from '../layouts/Authenticated';
@ -20,6 +23,9 @@ import BaseIcon from '../components/BaseIcon';
import LoadingSpinner from '../components/LoadingSpinner';
import { useAppSelector } from '../stores/hooks';
import { getPageTitle } from '../config';
import { Form, Formik, Field } from 'formik';
import FormField from '../components/FormField';
import FormImagePicker from '../components/FormImagePicker';
const MyListingPage = () => {
const router = useRouter();
@ -27,6 +33,7 @@ const MyListingPage = () => {
const [loading, setLoading] = useState(true);
const [myBusiness, setMyBusiness] = useState<any>(null);
const [pendingClaim, setPendingClaim] = useState<any>(null);
const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
if (currentUser) {
@ -46,7 +53,8 @@ const MyListingPage = () => {
// Search by owner_userId if businessId is not set on user record yet
const res = await axios.get('/businesses', { params: { owner_userId: currentUser.id } });
if (res.data.rows && res.data.rows.length > 0) {
business = res.data.rows[0];
const resById = await axios.get(`/businesses/${res.data.rows[0].id}`);
business = resById.data;
}
}
setMyBusiness(business);
@ -65,10 +73,77 @@ const MyListingPage = () => {
}
};
const handlePhotoUpload = async (values: any, { resetForm }: any) => {
if (!myBusiness) return;
setIsUploading(true);
try {
// Check if we already have a business_photos record
const existingPhotosRecord = myBusiness.business_photos_business && myBusiness.business_photos_business[0];
if (existingPhotosRecord) {
// Update existing record with NEW photos (append)
await axios.put(`/business_photos/${existingPhotosRecord.id}`, {
id: existingPhotosRecord.id,
data: {
photos: [...(existingPhotosRecord.photos || []), ...values.photos]
}
});
} else {
// Create new record
await axios.post('/business_photos', {
data: {
business: myBusiness.id,
photos: values.photos
}
});
}
// Refresh data
await fetchData();
resetForm();
} catch (error) {
console.error('Error uploading photos:', error);
} finally {
setIsUploading(false);
}
};
const removePhoto = async (photoId: string, businessPhotosRecordId: string) => {
if (!window.confirm('Are you sure you want to remove this photo?')) return;
try {
const record = myBusiness.business_photos_business.find((r: any) => r.id === businessPhotosRecordId);
if (!record) return;
const newPhotos = record.photos.filter((p: any) => p.id !== photoId);
await axios.put(`/business_photos/${businessPhotosRecordId}`, {
id: businessPhotosRecordId,
data: {
photos: newPhotos
}
});
await fetchData();
} catch (error) {
console.error('Error removing photo:', error);
}
};
const formatImageUrl = (url: string) => {
if (!url) return null;
if (url.startsWith('http') || url.startsWith('/')) {
return url;
}
return `${axios.defaults.baseURL}/file/download?privateUrl=${url}`;
};
if (loading) return <SectionMain><LoadingSpinner /></SectionMain>;
// STATE 1: Owns a business
if (myBusiness) {
const allPhotos = myBusiness.business_photos_business?.flatMap((bp: any) =>
bp.photos?.map((p: any) => ({ ...p, bpId: bp.id }))
) || [];
return (
<SectionMain>
<Head>
@ -85,8 +160,16 @@ const MyListingPage = () => {
<CardBox className="mb-6">
<div className="flex flex-col md:flex-row items-center gap-8 p-4">
<div className="w-32 h-32 bg-slate-100 rounded-3xl flex items-center justify-center text-slate-400">
<BaseIcon path={mdiStorefront} size={48} />
<div className="w-32 h-32 bg-slate-100 rounded-3xl overflow-hidden flex items-center justify-center text-slate-400">
{allPhotos.length > 0 ? (
<img
src={formatImageUrl(allPhotos[0].publicUrl)!}
className="w-full h-full object-cover"
alt="Business"
/>
) : (
<BaseIcon path={mdiStorefront} size={48} />
)}
</div>
<div className="flex-grow text-center md:text-left">
<h2 className="text-3xl font-bold mb-2">{myBusiness.name}</h2>
@ -107,19 +190,98 @@ const MyListingPage = () => {
</div>
</CardBox>
{/* Placeholder for stats or recent bookings */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<CardBox className="p-6 text-center">
<div className="text-slate-400 text-xs font-bold uppercase tracking-widest mb-2">Total Love Letters</div>
<div className="text-3xl font-bold">{myBusiness.reviews_business?.length || 0}</div>
</CardBox>
<CardBox className="p-6 text-center">
<div className="text-slate-400 text-xs font-bold uppercase tracking-widest mb-2">Avg Rating</div>
<div className="text-3xl font-bold">{myBusiness.rating || 'New'}</div>
</CardBox>
<CardBox className="p-6 text-center">
<div className="text-slate-400 text-xs font-bold uppercase tracking-widest mb-2">Service Bookings</div>
<div className="text-3xl font-bold">0</div>
{/* Performance & Gallery Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
{/* Stats */}
<div className="lg:col-span-1 space-y-6">
<CardBox className="p-6 text-center">
<div className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-2">Total Love Letters</div>
<div className="text-3xl font-bold">{myBusiness.reviews_business?.length || 0}</div>
</CardBox>
<CardBox className="p-6 text-center">
<div className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-2">Avg Rating</div>
<div className="text-3xl font-bold">{myBusiness.rating ? Number(myBusiness.rating).toFixed(1) : 'New'}</div>
</CardBox>
<CardBox className="p-10 bg-slate-900 text-white flex flex-col items-center justify-center text-center rounded-[2.5rem]">
<div className="w-12 h-12 bg-emerald-500 rounded-2xl flex items-center justify-center mb-4">
<BaseIcon path={mdiShieldCheck} size={24} />
</div>
<h4 className="font-bold mb-1">Reliability Score</h4>
<div className="text-4xl font-black text-emerald-400">{myBusiness.reliability_score || 0}%</div>
</CardBox>
</div>
{/* Gallery Management */}
<CardBox className="lg:col-span-2 p-8 rounded-[3rem]">
<div className="flex items-center justify-between mb-8">
<h3 className="text-2xl font-bold flex items-center">
<BaseIcon path={mdiCamera} size={28} className="mr-3 text-emerald-500" />
Portfolio Gallery
</h3>
<span className="text-slate-400 text-sm font-medium">{allPhotos.length} Pictures</span>
</div>
{/* Upload Form */}
<div className="mb-8 p-6 bg-slate-50 rounded-3xl border border-dashed border-slate-200">
<Formik
initialValues={{ photos: [] }}
onSubmit={handlePhotoUpload}
>
<Form className="flex flex-col md:flex-row items-end gap-4">
<div className="flex-grow w-full">
<FormField label="Add new pictures to your listing" help="Show clients your best work. High-quality photos increase bookings.">
<Field
label='Choose Photos'
color='info'
icon={mdiUpload}
path={'business_photos/photos'}
name='photos'
id='photos'
schema={{
size: undefined,
formats: undefined,
}}
component={FormImagePicker}
/>
</FormField>
</div>
<BaseButton
type="submit"
color="info"
label={isUploading ? "Uploading..." : "Add to Gallery"}
disabled={isUploading}
className="mb-4"
/>
</Form>
</Formik>
</div>
{/* Current Photos Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{allPhotos.map((photo: any) => (
<div key={photo.id} className="group relative aspect-square rounded-2xl overflow-hidden bg-slate-100 border border-slate-200">
<img
src={formatImageUrl(photo.publicUrl)!}
alt="Business"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
onClick={() => removePhoto(photo.id, photo.bpId)}
className="bg-white/20 hover:bg-red-500 text-white p-3 rounded-xl backdrop-blur-md transition-all"
title="Remove Photo"
>
<BaseIcon path={mdiDelete} size={20} />
</button>
</div>
</div>
))}
{allPhotos.length === 0 && (
<div className="col-span-full py-12 text-center text-slate-400 italic">
No photos in your gallery yet. Add some to stand out!
</div>
)}
</div>
</CardBox>
</div>
</SectionMain>

View File

@ -12,7 +12,8 @@ import {
mdiCurrencyUsd,
mdiCheckDecagram,
mdiMessageDraw,
mdiAccount
mdiAccount,
mdiReply
} from '@mdi/js';
import axios from 'axios';
import LayoutGuest from '../../layouts/Guest';
@ -77,11 +78,19 @@ const BusinessDetailsPublic = () => {
}
};
const formatImageUrl = (url: string) => {
if (!url) return null;
if (url.startsWith('http') || url.startsWith('/')) {
return url;
}
return `${axios.defaults.baseURL}/file/download?privateUrl=${url}`;
};
const getBusinessImage = () => {
if (business && business.business_photos_business && business.business_photos_business.length > 0) {
const photo = business.business_photos_business[0].photos && business.business_photos_business[0].photos[0];
if (photo && photo.publicUrl) {
return `/api/file/download?privateUrl=${photo.publicUrl}`;
return formatImageUrl(photo.publicUrl);
}
}
return null;
@ -117,7 +126,7 @@ const BusinessDetailsPublic = () => {
<div className="w-32 h-32 lg:w-48 lg:h-48 bg-slate-100 rounded-[2.5rem] overflow-hidden flex items-center justify-center shadow-inner relative flex-shrink-0">
{getBusinessImage() ? (
<img
src={getBusinessImage()}
src={getBusinessImage()!}
alt={business.name}
className="w-full h-full object-cover"
/>
@ -221,7 +230,7 @@ const BusinessDetailsPublic = () => {
bp.photos?.map((p: any) => (
<div key={p.id} className="aspect-square rounded-2xl overflow-hidden bg-slate-100">
<img
src={`/api/file/download?privateUrl=${p.publicUrl}`}
src={formatImageUrl(p.publicUrl)!}
alt="Business"
className="w-full h-full object-cover hover:scale-110 transition-transform duration-500"
/>
@ -285,7 +294,7 @@ const BusinessDetailsPublic = () => {
</span>
</div>
<p className="text-slate-700 leading-relaxed mb-4 italic text-lg">&quot;{review.text}&quot;</p>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-slate-400">
<BaseIcon path={mdiAccount} size={18} />
@ -301,6 +310,22 @@ const BusinessDetailsPublic = () => {
</div>
)}
</div>
{/* Business Owner Response */}
{review.response && (
<div className="mt-6 ml-4 pl-6 border-l-4 border-emerald-500 bg-slate-50 p-6 rounded-r-2xl relative">
<div className="absolute -left-3 top-0 bg-emerald-500 text-white rounded-full p-1 shadow-lg">
<BaseIcon path={mdiReply} size={16} />
</div>
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-black uppercase tracking-widest text-emerald-600">Response from the business</span>
<span className="text-[10px] text-slate-400 font-medium">
{dataFormatter.dateFormatter(review.response_at_ts)}
</span>
</div>
<p className="text-slate-600 italic">&quot;{review.response}&quot;</p>
</div>
)}
</div>
))}
{!business.reviews_business?.length && (

View File

@ -12,7 +12,7 @@ import {
} from '@mdi/js';
import { Formik, Form, Field } from 'formik';
import axios from 'axios';
import LayoutAuthenticated from '../../layouts/Authenticated';
import LayoutGuest from '../../layouts/Guest';
import BaseIcon from '../../components/BaseIcon';
import LoadingSpinner from '../../components/LoadingSpinner';
import FormField from '../../components/FormField';
@ -49,12 +49,19 @@ const RequestServicePage = () => {
const payload = {
...values,
businessId,
user: currentUser?.id
user: currentUser?.id || null
};
await dispatch(createLead(payload));
router.push('/leads/leads-list'); // Redirect to their leads tracker
await dispatch(createLead(payload)).unwrap();
if (currentUser) {
router.push('/leads/leads-list'); // Redirect to their leads tracker if logged in
} else {
alert('Your request has been sent! The professional will contact you soon.');
router.push(`/public/businesses-details?id=${businessId}`);
}
} catch (error) {
console.error('Lead creation error:', error);
alert('There was an error sending your request. Please try again.');
} finally {
setLoading(false);
}
@ -63,7 +70,7 @@ const RequestServicePage = () => {
if (!business && businessId) return <div className="min-h-screen flex items-center justify-center bg-slate-50"><LoadingSpinner /></div>;
return (
<div className="min-h-screen bg-slate-50 pb-20 pt-20">
<div className="min-h-screen bg-slate-50 pb-20 pt-28">
<Head>
<title>Request Service | Fix-It-Local</title>
</Head>
@ -105,6 +112,7 @@ const RequestServicePage = () => {
name="keyword"
placeholder="e.g. Leaking faucet in kitchen"
className="w-full bg-slate-50 border-slate-200 rounded-2xl py-4 px-6 focus:ring-emerald-500 focus:border-emerald-500"
required
/>
</FormField>
@ -129,6 +137,7 @@ const RequestServicePage = () => {
rows={4}
placeholder="Please describe the problem in detail so the professional can give you an accurate estimate."
className="w-full bg-slate-50 border-slate-200 rounded-2xl py-4 px-6 focus:ring-emerald-500 focus:border-emerald-500"
required
/>
</FormField>
@ -140,29 +149,29 @@ const RequestServicePage = () => {
<div className="grid md:grid-cols-3 gap-6">
<FormField label="Your Name" labelFor="contact_name">
<Field name="contact_name" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" />
<Field name="contact_name" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
</FormField>
<FormField label="Email" labelFor="contact_email">
<Field name="contact_email" type="email" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" />
<Field name="contact_email" type="email" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
</FormField>
<FormField label="Phone" labelFor="contact_phone">
<Field name="contact_phone" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" />
<Field name="contact_phone" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
</FormField>
</div>
<div className="grid md:grid-cols-2 gap-6">
<FormField label="Service Address" labelFor="address">
<Field name="address" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" />
<Field name="address" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
</FormField>
<div className="grid grid-cols-3 gap-4">
<FormField label="City" labelFor="city">
<Field name="city" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" />
<Field name="city" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
</FormField>
<FormField label="State" labelFor="state">
<Field name="state" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" />
<Field name="state" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
</FormField>
<FormField label="ZIP" labelFor="zip">
<Field name="zip" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" />
<Field name="zip" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" required />
</FormField>
</div>
</div>
@ -193,10 +202,10 @@ const RequestServicePage = () => {
RequestServicePage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'CREATE_LEADS'}>
<LayoutGuest>
{page}
</LayoutAuthenticated>
</LayoutGuest>
);
};
export default RequestServicePage;
export default RequestServicePage;

View File

@ -64,7 +64,10 @@ const SearchView = () => {
if (biz.business_photos_business && biz.business_photos_business.length > 0) {
const photo = biz.business_photos_business[0].photos && biz.business_photos_business[0].photos[0];
if (photo && photo.publicUrl) {
return `/api/file/download?privateUrl=${photo.publicUrl}`;
if (photo.publicUrl.startsWith('http') || photo.publicUrl.startsWith('/')) {
return photo.publicUrl;
}
return `${axios.defaults.baseURL}/file/download?privateUrl=${photo.publicUrl}`;
}
}
return null;

View File

@ -0,0 +1,185 @@
import React from 'react';
import type { ReactElement } from 'react';
import NextHead from 'next/head';
import {
mdiShieldCheck,
mdiTools,
mdiFlash,
mdiInformationOutline,
mdiBriefcaseAccountOutline,
mdiAccountGroupOutline
} from '@mdi/js';
import BaseIcon from '../../components/BaseIcon';
import LayoutGuest from '../../layouts/Guest';
import { getPageTitle } from '../../config';
export default function AboutPage() {
const projectName = 'Fix-It-Local';
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<NextHead>
<title>{getPageTitle('About Us')}</title>
<meta name="description" content={`Learn more about ${projectName} and how we help businesses and customers connect.`} />
</NextHead>
{/* Hero Section */}
<section className="bg-slate-900 text-white py-20 lg:py-32 relative overflow-hidden">
<div className="absolute inset-0 opacity-10">
<div className="absolute top-0 -left-4 w-96 h-96 bg-emerald-500 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob"></div>
<div className="absolute bottom-0 -right-4 w-96 h-96 bg-blue-500 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-4000"></div>
</div>
<div className="container mx-auto px-6 relative z-10 text-center">
<h1 className="text-4xl lg:text-6xl font-bold mb-6">Connecting Local Communities</h1>
<p className="text-xl text-slate-400 max-w-3xl mx-auto leading-relaxed">
{projectName} is a modern service directory built on trust, transparency, and AI-powered efficiency. We bridge the gap between quality service professionals and the customers who need them.
</p>
</div>
</section>
{/* Our Mission */}
<section className="py-24 container mx-auto px-6 max-w-5xl">
<div className="grid md:grid-cols-2 gap-16 items-center">
<div>
<div className="inline-flex items-center px-4 py-2 rounded-full bg-emerald-100 text-emerald-700 text-sm font-semibold mb-6">
<BaseIcon path={mdiInformationOutline} size={18} className="mr-2" />
Our Mission
</div>
<h2 className="text-3xl lg:text-4xl font-bold mb-6 text-slate-900 leading-tight">Empowering Quality Businesses & Serving Homeowners</h2>
<p className="text-lg text-slate-600 mb-6 leading-relaxed">
We started {projectName} because we saw how difficult it was for homeowners to find reliable, verified help, and how hard it was for skilled professionals to stand out in a sea of unverified listings.
</p>
<p className="text-lg text-slate-600 leading-relaxed">
Our platform uses advanced verification evidence and AI-driven matching to ensure that every connection made is based on quality, reliability, and fair pricing.
</p>
</div>
<div className="bg-white p-8 rounded-3xl shadow-xl border border-slate-100 relative">
<div className="absolute -top-6 -right-6 w-24 h-24 bg-emerald-500 rounded-2xl flex items-center justify-center shadow-lg transform rotate-12">
<BaseIcon path={mdiShieldCheck} size={48} className="text-white" />
</div>
<div className="space-y-6">
<div className="flex items-start">
<div className="bg-emerald-50 p-3 rounded-xl mr-4">
<BaseIcon path={mdiShieldCheck} size={24} className="text-emerald-500" />
</div>
<div>
<h4 className="font-bold text-slate-900 mb-1">Trust First</h4>
<p className="text-slate-500 text-sm">Every business is verified with real evidence.</p>
</div>
</div>
<div className="flex items-start">
<div className="bg-blue-50 p-3 rounded-xl mr-4">
<BaseIcon path={mdiFlash} size={24} className="text-blue-500" />
</div>
<div>
<h4 className="font-bold text-slate-900 mb-1">AI Efficiency</h4>
<p className="text-slate-500 text-sm">Find the right pro in seconds, not hours.</p>
</div>
</div>
<div className="flex items-start">
<div className="bg-purple-50 p-3 rounded-xl mr-4">
<BaseIcon path={mdiTools} size={24} className="text-purple-500" />
</div>
<div>
<h4 className="font-bold text-slate-900 mb-1">Quality Guaranteed</h4>
<p className="text-slate-500 text-sm">Focusing on high-rated local professionals.</p>
</div>
</div>
</div>
</div>
</div>
</section>
{/* How We Help */}
<section className="py-24 bg-slate-100">
<div className="container mx-auto px-6">
<h2 className="text-3xl lg:text-5xl font-bold text-center mb-16">How We Help</h2>
<div className="grid md:grid-cols-2 gap-12 max-w-6xl mx-auto">
{/* For Businesses */}
<div className="bg-white p-12 rounded-[2rem] shadow-sm border border-slate-200">
<div className="w-16 h-16 bg-blue-500 rounded-2xl flex items-center justify-center mb-8 shadow-lg shadow-blue-500/20">
<BaseIcon path={mdiBriefcaseAccountOutline} size={32} className="text-white" />
</div>
<h3 className="text-3xl font-bold mb-6 text-slate-900">For Businesses</h3>
<ul className="space-y-4 mb-8">
<li className="flex items-center text-slate-600">
<BaseIcon path={mdiShieldCheck} size={20} className="text-blue-500 mr-3 shrink-0" />
Build trust with verified badges
</li>
<li className="flex items-center text-slate-600">
<BaseIcon path={mdiShieldCheck} size={20} className="text-blue-500 mr-3 shrink-0" />
Receive high-quality, matched leads
</li>
<li className="flex items-center text-slate-600">
<BaseIcon path={mdiShieldCheck} size={20} className="text-blue-500 mr-3 shrink-0" />
Showcase reviews and past work
</li>
<li className="flex items-center text-slate-600">
<BaseIcon path={mdiShieldCheck} size={20} className="text-blue-500 mr-3 shrink-0" />
No lead-spam or irrelevant requests
</li>
</ul>
<p className="text-slate-500 italic border-t border-slate-100 pt-6">
&quot;We help local professionals focus on what they do best, while we handle the discovery and matching.&quot;
</p>
</div>
{/* For Customers */}
<div className="bg-white p-12 rounded-[2rem] shadow-sm border border-slate-200">
<div className="w-16 h-16 bg-emerald-500 rounded-2xl flex items-center justify-center mb-8 shadow-lg shadow-emerald-500/20">
<BaseIcon path={mdiAccountGroupOutline} size={32} className="text-white" />
</div>
<h3 className="text-3xl font-bold mb-6 text-slate-900">For Customers</h3>
<ul className="space-y-4 mb-8">
<li className="flex items-center text-slate-600">
<BaseIcon path={mdiShieldCheck} size={20} className="text-emerald-500 mr-3 shrink-0" />
Find verified, background-checked pros
</li>
<li className="flex items-center text-slate-600">
<BaseIcon path={mdiShieldCheck} size={20} className="text-emerald-500 mr-3 shrink-0" />
Transparent pricing and job histories
</li>
<li className="flex items-center text-slate-600">
<BaseIcon path={mdiShieldCheck} size={20} className="text-emerald-500 mr-3 shrink-0" />
Free and fast matching with AI
</li>
<li className="flex items-center text-slate-600">
<BaseIcon path={mdiShieldCheck} size={20} className="text-emerald-500 mr-3 shrink-0" />
Secure communication and history
</li>
</ul>
<p className="text-slate-500 italic border-t border-slate-100 pt-6">
&quot;Find the perfect help for your home without the stress of unverified listings or endless phone calls.&quot;
</p>
</div>
</div>
</div>
</section>
{/* The Process */}
<section className="py-24 container mx-auto px-6 max-w-5xl">
<h2 className="text-3xl lg:text-5xl font-bold text-center mb-16">The Process</h2>
<div className="grid md:grid-cols-4 gap-8">
{[
{ title: 'Search', desc: 'Find categories or specific services.', icon: mdiTools },
{ title: 'Request', desc: 'Describe your job needs in detail.', icon: mdiInformationOutline },
{ title: 'Match', desc: 'AI finds the best pro for you.', icon: mdiFlash },
{ title: 'Resolve', desc: 'Job completed with transparency.', icon: mdiShieldCheck }
].map((step, i) => (
<div key={i} className="text-center group">
<div className="w-16 h-16 bg-slate-900 text-white rounded-2xl flex items-center justify-center mb-6 mx-auto group-hover:bg-emerald-500 transition-colors duration-300">
<BaseIcon path={step.icon} size={32} />
</div>
<h4 className="font-bold text-xl mb-2">{i+1}. {step.title}</h4>
<p className="text-slate-500 text-sm leading-relaxed">{step.desc}</p>
</div>
))}
</div>
</section>
</div>
);
}
AboutPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};