almost complete
This commit is contained in:
parent
111693ae6e
commit
ff6518916f
@ -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",
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
}
|
||||
};
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
|
||||
@ -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">"{review.text}"</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">"{review.response}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!business.reviews_business?.length && (
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
185
frontend/src/pages/web_pages/about.tsx
Normal file
185
frontend/src/pages/web_pages/about.tsx
Normal 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">
|
||||
"We help local professionals focus on what they do best, while we handle the discovery and matching."
|
||||
</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">
|
||||
"Find the perfect help for your home without the stress of unverified listings or endless phone calls."
|
||||
</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>;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user