almost complete
This commit is contained in:
parent
111693ae6e
commit
ff6518916f
@ -85,7 +85,7 @@ module.exports = {
|
|||||||
// BUSINESS Permissions (Clients)
|
// BUSINESS Permissions (Clients)
|
||||||
const businessPerms = [
|
const businessPerms = [
|
||||||
...publicPerms,
|
...publicPerms,
|
||||||
"READ_REVIEWS",
|
"READ_REVIEWS", "UPDATE_REVIEWS", // Added UPDATE_REVIEWS for responding to reviews
|
||||||
"READ_LEADS", "UPDATE_LEADS",
|
"READ_LEADS", "UPDATE_LEADS",
|
||||||
"READ_LEAD_PHOTOS",
|
"READ_LEAD_PHOTOS",
|
||||||
"CREATE_MESSAGES", "READ_MESSAGES",
|
"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/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);
|
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 passport = require('passport');
|
||||||
const router = express.Router();
|
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) => {
|
router.get('/business-metrics', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => {
|
||||||
const payload = await DashboardService.getBusinessMetrics(req.currentUser);
|
const payload = await DashboardService.getBusinessMetrics(req.currentUser);
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
@ -28,4 +47,4 @@ router.post('/record-event', (req, res, next) => {
|
|||||||
res.status(200).send(event);
|
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
|
// 1. Get businesses owned by current user
|
||||||
const businesses = await db.businesses.findAll({
|
const businesses = await db.businesses.findAll({
|
||||||
where: { owner_userId: currentUser.id },
|
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' }]
|
include: [{ model: db.plans, as: 'plan' }]
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!businesses.length) {
|
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);
|
const businessIds = businesses.map(b => b.id);
|
||||||
@ -76,10 +83,9 @@ module.exports = class DashboardService {
|
|||||||
updatedAt: { [Op.gte]: last30d }
|
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 ---
|
// --- Recent Messages ---
|
||||||
// Join messages with lead_matches to ensure they belong to this business
|
|
||||||
const recentMessages = await db.messages.findAll({
|
const recentMessages = await db.messages.findAll({
|
||||||
where: {
|
where: {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@ -114,7 +120,7 @@ module.exports = class DashboardService {
|
|||||||
const website30d = await getEventCount('WEBSITE_CLICK', last30d);
|
const website30d = await getEventCount('WEBSITE_CLICK', last30d);
|
||||||
|
|
||||||
const totalClicks30d = calls30d + website30d;
|
const totalClicks30d = calls30d + website30d;
|
||||||
const conversionRate = views30d > 0 ? (totalClicks30d / views30d) * 100 : 0;
|
const viewConversionRate = views30d > 0 ? Math.round((totalClicks30d / views30d) * 100) : 0;
|
||||||
|
|
||||||
// --- Health Score ---
|
// --- Health Score ---
|
||||||
const firstBusiness = businesses[0];
|
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 } });
|
const photoCount = await db.business_photos.count({ where: { businessId: firstBusiness.id } });
|
||||||
if (photoCount > 0) healthScore += 15; else missingFields.push('Photos');
|
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 } });
|
const priceCount = await db.service_prices.count({ where: { businessId: firstBusiness.id } });
|
||||||
if (priceCount > 0) healthScore += 5; else missingFields.push('Service Prices');
|
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 {
|
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,
|
businesses,
|
||||||
action_queue: {
|
action_queue: {
|
||||||
newLeads24h,
|
newLeads24h,
|
||||||
@ -171,9 +199,30 @@ module.exports = class DashboardService {
|
|||||||
calls30d,
|
calls30d,
|
||||||
website7d,
|
website7d,
|
||||||
website30d,
|
website30d,
|
||||||
conversionRate
|
conversionRate: winRate30d
|
||||||
},
|
},
|
||||||
healthScore: Math.min(healthScore, 100)
|
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 output = process.env.NODE_ENV === 'production' ? 'export' : 'standalone';
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/about',
|
||||||
|
destination: '/web_pages/about',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
trailingSlash: true,
|
trailingSlash: true,
|
||||||
distDir: 'build',
|
distDir: 'build',
|
||||||
output,
|
output,
|
||||||
|
|||||||
@ -96,10 +96,8 @@ export default function LayoutAuthenticated({
|
|||||||
'/leads/leads-list',
|
'/leads/leads-list',
|
||||||
'/reviews/reviews-list',
|
'/reviews/reviews-list',
|
||||||
'/messages/messages-list',
|
'/messages/messages-list',
|
||||||
'/verification_submissions/verification_submissions-list',
|
|
||||||
'/profile',
|
'/profile',
|
||||||
'/billing',
|
'/billing',
|
||||||
'/team'
|
|
||||||
];
|
];
|
||||||
return allowedPaths.includes(item.href);
|
return allowedPaths.includes(item.href);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,10 +67,17 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
|
|
||||||
{
|
{
|
||||||
href: '/leads/leads-list',
|
href: '/leads/leads-list',
|
||||||
label: 'Client Bookings',
|
label: 'Service Requests',
|
||||||
icon: icon.mdiCalendarHeart,
|
icon: icon.mdiCalendarHeart,
|
||||||
permissions: 'READ_LEADS',
|
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',
|
permissions: 'READ_VERIFICATION_SUBMISSIONS',
|
||||||
roles: ['Administrator', 'Platform Owner', 'Trust & Safety Lead']
|
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
|
// Placeholder for Billing and Team
|
||||||
{
|
{
|
||||||
@ -123,12 +123,6 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiFinance,
|
icon: icon.mdiFinance,
|
||||||
roles: ['Administrator', 'Platform Owner']
|
roles: ['Administrator', 'Platform Owner']
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: '/team',
|
|
||||||
label: 'Studio Team',
|
|
||||||
icon: icon.mdiAccountGroupOutline,
|
|
||||||
roles: ['Verified Business Owner']
|
|
||||||
},
|
|
||||||
|
|
||||||
// Moderator
|
// Moderator
|
||||||
{
|
{
|
||||||
|
|||||||
@ -48,8 +48,8 @@ const menuNavBar: MenuNavBarItem[] = [
|
|||||||
|
|
||||||
export const webPagesNavBar = [
|
export const webPagesNavBar = [
|
||||||
{ href: '/search', label: 'Find Services' },
|
{ 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 [dashboardData, setDashboardData] = useState<any>(null)
|
||||||
const [isFetching, setIsFetching] = useState(false)
|
const [isFetching, setIsFetching] = useState(false)
|
||||||
|
|
||||||
const isBusinessOwner = currentUser?.role === 'Verified Business Owner'
|
const isBusinessOwner = currentUser?.app_role?.name === 'Verified Business Owner'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDashboard = async () => {
|
const fetchDashboard = async () => {
|
||||||
@ -197,28 +197,32 @@ const Dashboard = () => {
|
|||||||
<div className="space-y-6">
|
<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 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="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" />
|
<BaseIcon path={mdiShieldCheck} size={20} className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-sm">Verification Status</h4>
|
<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>
|
||||||
</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>
|
||||||
|
|
||||||
<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 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="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" />
|
<BaseIcon path={mdiChartTimelineVariant} size={20} className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-sm">Account Standing</h4>
|
<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>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|||||||
@ -13,7 +13,9 @@ import {
|
|||||||
mdiPowerPlug,
|
mdiPowerPlug,
|
||||||
mdiAirConditioner,
|
mdiAirConditioner,
|
||||||
mdiBrush,
|
mdiBrush,
|
||||||
mdiFormatPaint
|
mdiFormatPaint,
|
||||||
|
mdiClipboardTextOutline,
|
||||||
|
mdiCheckCircleOutline
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import BaseIcon from '../components/BaseIcon';
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
@ -135,6 +137,62 @@ export default function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 */}
|
{/* Trust Features */}
|
||||||
<section className="py-24 bg-slate-900 text-white overflow-hidden relative">
|
<section className="py-24 bg-slate-900 text-white overflow-hidden relative">
|
||||||
<div className="container mx-auto px-6 relative z-10">
|
<div className="container mx-auto px-6 relative z-10">
|
||||||
|
|||||||
@ -52,7 +52,8 @@ const LeadsTablesPage = () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_LEADS');
|
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 addFilter = () => {
|
||||||
const newItem = {
|
const newItem = {
|
||||||
@ -94,10 +95,10 @@ const LeadsTablesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Leads')}</title>
|
<title>{getPageTitle(pageTitle)}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Leads" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={pageTitle} main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
<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,
|
mdiCheckCircle,
|
||||||
mdiPlus,
|
mdiPlus,
|
||||||
mdiMagnify,
|
mdiMagnify,
|
||||||
mdiPencil
|
mdiPencil,
|
||||||
|
mdiCamera,
|
||||||
|
mdiDelete,
|
||||||
|
mdiUpload
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
@ -20,6 +23,9 @@ import BaseIcon from '../components/BaseIcon';
|
|||||||
import LoadingSpinner from '../components/LoadingSpinner';
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import { useAppSelector } from '../stores/hooks';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
|
import { Form, Formik, Field } from 'formik';
|
||||||
|
import FormField from '../components/FormField';
|
||||||
|
import FormImagePicker from '../components/FormImagePicker';
|
||||||
|
|
||||||
const MyListingPage = () => {
|
const MyListingPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -27,6 +33,7 @@ const MyListingPage = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [myBusiness, setMyBusiness] = useState<any>(null);
|
const [myBusiness, setMyBusiness] = useState<any>(null);
|
||||||
const [pendingClaim, setPendingClaim] = useState<any>(null);
|
const [pendingClaim, setPendingClaim] = useState<any>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
@ -46,7 +53,8 @@ const MyListingPage = () => {
|
|||||||
// Search by owner_userId if businessId is not set on user record yet
|
// Search by owner_userId if businessId is not set on user record yet
|
||||||
const res = await axios.get('/businesses', { params: { owner_userId: currentUser.id } });
|
const res = await axios.get('/businesses', { params: { owner_userId: currentUser.id } });
|
||||||
if (res.data.rows && res.data.rows.length > 0) {
|
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);
|
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>;
|
if (loading) return <SectionMain><LoadingSpinner /></SectionMain>;
|
||||||
|
|
||||||
// STATE 1: Owns a business
|
// STATE 1: Owns a business
|
||||||
if (myBusiness) {
|
if (myBusiness) {
|
||||||
|
const allPhotos = myBusiness.business_photos_business?.flatMap((bp: any) =>
|
||||||
|
bp.photos?.map((p: any) => ({ ...p, bpId: bp.id }))
|
||||||
|
) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<Head>
|
<Head>
|
||||||
@ -85,8 +160,16 @@ const MyListingPage = () => {
|
|||||||
|
|
||||||
<CardBox className="mb-6">
|
<CardBox className="mb-6">
|
||||||
<div className="flex flex-col md:flex-row items-center gap-8 p-4">
|
<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">
|
<div className="w-32 h-32 bg-slate-100 rounded-3xl overflow-hidden flex items-center justify-center text-slate-400">
|
||||||
<BaseIcon path={mdiStorefront} size={48} />
|
{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>
|
||||||
<div className="flex-grow text-center md:text-left">
|
<div className="flex-grow text-center md:text-left">
|
||||||
<h2 className="text-3xl font-bold mb-2">{myBusiness.name}</h2>
|
<h2 className="text-3xl font-bold mb-2">{myBusiness.name}</h2>
|
||||||
@ -107,19 +190,98 @@ const MyListingPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|
||||||
{/* Placeholder for stats or recent bookings */}
|
{/* Performance & Gallery Section */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||||
<CardBox className="p-6 text-center">
|
{/* Stats */}
|
||||||
<div className="text-slate-400 text-xs font-bold uppercase tracking-widest mb-2">Total Love Letters</div>
|
<div className="lg:col-span-1 space-y-6">
|
||||||
<div className="text-3xl font-bold">{myBusiness.reviews_business?.length || 0}</div>
|
<CardBox className="p-6 text-center">
|
||||||
</CardBox>
|
<div className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-2">Total Love Letters</div>
|
||||||
<CardBox className="p-6 text-center">
|
<div className="text-3xl font-bold">{myBusiness.reviews_business?.length || 0}</div>
|
||||||
<div className="text-slate-400 text-xs font-bold uppercase tracking-widest mb-2">Avg Rating</div>
|
</CardBox>
|
||||||
<div className="text-3xl font-bold">{myBusiness.rating || 'New'}</div>
|
<CardBox className="p-6 text-center">
|
||||||
</CardBox>
|
<div className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-2">Avg Rating</div>
|
||||||
<CardBox className="p-6 text-center">
|
<div className="text-3xl font-bold">{myBusiness.rating ? Number(myBusiness.rating).toFixed(1) : 'New'}</div>
|
||||||
<div className="text-slate-400 text-xs font-bold uppercase tracking-widest mb-2">Service Bookings</div>
|
</CardBox>
|
||||||
<div className="text-3xl font-bold">0</div>
|
<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>
|
</CardBox>
|
||||||
</div>
|
</div>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
|
|||||||
@ -12,7 +12,8 @@ import {
|
|||||||
mdiCurrencyUsd,
|
mdiCurrencyUsd,
|
||||||
mdiCheckDecagram,
|
mdiCheckDecagram,
|
||||||
mdiMessageDraw,
|
mdiMessageDraw,
|
||||||
mdiAccount
|
mdiAccount,
|
||||||
|
mdiReply
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import LayoutGuest from '../../layouts/Guest';
|
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 = () => {
|
const getBusinessImage = () => {
|
||||||
if (business && business.business_photos_business && business.business_photos_business.length > 0) {
|
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];
|
const photo = business.business_photos_business[0].photos && business.business_photos_business[0].photos[0];
|
||||||
if (photo && photo.publicUrl) {
|
if (photo && photo.publicUrl) {
|
||||||
return `/api/file/download?privateUrl=${photo.publicUrl}`;
|
return formatImageUrl(photo.publicUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
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">
|
<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() ? (
|
{getBusinessImage() ? (
|
||||||
<img
|
<img
|
||||||
src={getBusinessImage()}
|
src={getBusinessImage()!}
|
||||||
alt={business.name}
|
alt={business.name}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
@ -221,7 +230,7 @@ const BusinessDetailsPublic = () => {
|
|||||||
bp.photos?.map((p: any) => (
|
bp.photos?.map((p: any) => (
|
||||||
<div key={p.id} className="aspect-square rounded-2xl overflow-hidden bg-slate-100">
|
<div key={p.id} className="aspect-square rounded-2xl overflow-hidden bg-slate-100">
|
||||||
<img
|
<img
|
||||||
src={`/api/file/download?privateUrl=${p.publicUrl}`}
|
src={formatImageUrl(p.publicUrl)!}
|
||||||
alt="Business"
|
alt="Business"
|
||||||
className="w-full h-full object-cover hover:scale-110 transition-transform duration-500"
|
className="w-full h-full object-cover hover:scale-110 transition-transform duration-500"
|
||||||
/>
|
/>
|
||||||
@ -285,7 +294,7 @@ const BusinessDetailsPublic = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-700 leading-relaxed mb-4 italic text-lg">"{review.text}"</p>
|
<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="flex items-center gap-2">
|
||||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-slate-400">
|
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-slate-400">
|
||||||
<BaseIcon path={mdiAccount} size={18} />
|
<BaseIcon path={mdiAccount} size={18} />
|
||||||
@ -301,6 +310,22 @@ const BusinessDetailsPublic = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!business.reviews_business?.length && (
|
{!business.reviews_business?.length && (
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import {
|
|||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { Formik, Form, Field } from 'formik';
|
import { Formik, Form, Field } from 'formik';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
import LayoutGuest from '../../layouts/Guest';
|
||||||
import BaseIcon from '../../components/BaseIcon';
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
import LoadingSpinner from '../../components/LoadingSpinner';
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
import FormField from '../../components/FormField';
|
import FormField from '../../components/FormField';
|
||||||
@ -49,12 +49,19 @@ const RequestServicePage = () => {
|
|||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
businessId,
|
businessId,
|
||||||
user: currentUser?.id
|
user: currentUser?.id || null
|
||||||
};
|
};
|
||||||
await dispatch(createLead(payload));
|
await dispatch(createLead(payload)).unwrap();
|
||||||
router.push('/leads/leads-list'); // Redirect to their leads tracker
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('Lead creation error:', error);
|
console.error('Lead creation error:', error);
|
||||||
|
alert('There was an error sending your request. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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>;
|
if (!business && businessId) return <div className="min-h-screen flex items-center justify-center bg-slate-50"><LoadingSpinner /></div>;
|
||||||
|
|
||||||
return (
|
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>
|
<Head>
|
||||||
<title>Request Service | Fix-It-Local™</title>
|
<title>Request Service | Fix-It-Local™</title>
|
||||||
</Head>
|
</Head>
|
||||||
@ -105,6 +112,7 @@ const RequestServicePage = () => {
|
|||||||
name="keyword"
|
name="keyword"
|
||||||
placeholder="e.g. Leaking faucet in kitchen"
|
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"
|
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>
|
</FormField>
|
||||||
|
|
||||||
@ -129,6 +137,7 @@ const RequestServicePage = () => {
|
|||||||
rows={4}
|
rows={4}
|
||||||
placeholder="Please describe the problem in detail so the professional can give you an accurate estimate."
|
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"
|
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>
|
</FormField>
|
||||||
|
|
||||||
@ -140,29 +149,29 @@ const RequestServicePage = () => {
|
|||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
<FormField label="Your Name" labelFor="contact_name">
|
<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>
|
||||||
<FormField label="Email" labelFor="contact_email">
|
<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>
|
||||||
<FormField label="Phone" labelFor="contact_phone">
|
<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>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<FormField label="Service Address" labelFor="address">
|
<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>
|
</FormField>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<FormField label="City" labelFor="city">
|
<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>
|
||||||
<FormField label="State" labelFor="state">
|
<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>
|
||||||
<FormField label="ZIP" labelFor="zip">
|
<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>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -193,10 +202,10 @@ const RequestServicePage = () => {
|
|||||||
|
|
||||||
RequestServicePage.getLayout = function getLayout(page: ReactElement) {
|
RequestServicePage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated permission={'CREATE_LEADS'}>
|
<LayoutGuest>
|
||||||
{page}
|
{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) {
|
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];
|
const photo = biz.business_photos_business[0].photos && biz.business_photos_business[0].photos[0];
|
||||||
if (photo && photo.publicUrl) {
|
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;
|
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