From ff6518916f8782439112a1718e612036a0ecab9f Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 18 Feb 2026 16:31:33 +0000 Subject: [PATCH] almost complete --- .../db/seeders/20200430130760-user-roles.js | 2 +- ...0260218050000-grant-create-disputes-vbo.js | 42 ++++ .../20260218060000-grant-delete-photos-vbo.js | 41 ++++ ...0260218070000-grant-create-leads-public.js | 36 ++++ backend/src/index.js | 2 +- backend/src/routes/dashboard.js | 21 +- backend/src/services/dashboard.js | 63 +++++- frontend/next.config.mjs | 8 + frontend/src/layouts/Authenticated.tsx | 2 - frontend/src/menuAside.ts | 24 +-- frontend/src/menuNavBar.ts | 6 +- frontend/src/pages/dashboard.tsx | 18 +- frontend/src/pages/index.tsx | 60 +++++- frontend/src/pages/leads/leads-list.tsx | 9 +- frontend/src/pages/my-listing.tsx | 196 ++++++++++++++++-- .../src/pages/public/businesses-details.tsx | 35 +++- frontend/src/pages/public/request-service.tsx | 39 ++-- frontend/src/pages/search.tsx | 5 +- frontend/src/pages/web_pages/about.tsx | 185 +++++++++++++++++ 19 files changed, 714 insertions(+), 80 deletions(-) create mode 100644 backend/src/db/seeders/20260218050000-grant-create-disputes-vbo.js create mode 100644 backend/src/db/seeders/20260218060000-grant-delete-photos-vbo.js create mode 100644 backend/src/db/seeders/20260218070000-grant-create-leads-public.js create mode 100644 frontend/src/pages/web_pages/about.tsx diff --git a/backend/src/db/seeders/20200430130760-user-roles.js b/backend/src/db/seeders/20200430130760-user-roles.js index f1710a9..2ab9003 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.js +++ b/backend/src/db/seeders/20200430130760-user-roles.js @@ -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", diff --git a/backend/src/db/seeders/20260218050000-grant-create-disputes-vbo.js b/backend/src/db/seeders/20260218050000-grant-create-disputes-vbo.js new file mode 100644 index 0000000..f2e898d --- /dev/null +++ b/backend/src/db/seeders/20260218050000-grant-create-disputes-vbo.js @@ -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) { + } +}; diff --git a/backend/src/db/seeders/20260218060000-grant-delete-photos-vbo.js b/backend/src/db/seeders/20260218060000-grant-delete-photos-vbo.js new file mode 100644 index 0000000..0f125c1 --- /dev/null +++ b/backend/src/db/seeders/20260218060000-grant-delete-photos-vbo.js @@ -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 + } +}; diff --git a/backend/src/db/seeders/20260218070000-grant-create-leads-public.js b/backend/src/db/seeders/20260218070000-grant-create-leads-public.js new file mode 100644 index 0000000..2700264 --- /dev/null +++ b/backend/src/db/seeders/20260218070000-grant-create-leads-public.js @@ -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 + } +}; diff --git a/backend/src/index.js b/backend/src/index.js index 0187e55..4eca62a 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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); diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js index 74bff3d..118a4b8 100644 --- a/backend/src/routes/dashboard.js +++ b/backend/src/routes/dashboard.js @@ -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; \ No newline at end of file diff --git a/backend/src/services/dashboard.js b/backend/src/services/dashboard.js index 95eac82..585030a 100644 --- a/backend/src/services/dashboard.js +++ b/backend/src/services/dashboard.js @@ -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 + }; + } }; \ No newline at end of file diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 89767ec..2a8424d 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -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, diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 64ac999..3d833b0 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -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); } diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 999f12c..a5b785b 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -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 { diff --git a/frontend/src/menuNavBar.ts b/frontend/src/menuNavBar.ts index ce90a25..81bda08 100644 --- a/frontend/src/menuNavBar.ts +++ b/frontend/src/menuNavBar.ts @@ -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 \ No newline at end of file diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 8a3e4fb..7e3c19a 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -58,7 +58,7 @@ const Dashboard = () => { const [dashboardData, setDashboardData] = useState(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 = () => {
-
+

Verification Status

-

Identity & Business verified

+

{dashboardData?.verificationSubtext || 'Complete verification to build trust'}

- Active + + {dashboardData?.verificationStatus || 'N/A'} +
-
+

Account Standing

-

Perfect track record

+

{dashboardData?.accountStandingSubtext || 'Perfect track record'}

- Good + + {dashboardData?.accountStanding || 'N/A'} +
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index ca8593b..0ad9bff 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -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() {
+ {/* How it Works Section */} +
+
+
+

Simplified Discovery. Trusted Connections.

+

+ Fix-It-Local connects homeowners and businesses with verified professionals through a transparent, AI-powered process. +

+
+ +
+ {/* Connector line for desktop */} +
+ +
+
+ +
+

1. Find a Pro

+

Browse categories or search for specific verified services near you.

+
+ +
+
+ +
+

2. Request Service

+

Submit details about your job. No signup required for initial requests.

+
+ +
+
+ +
+

3. AI Smart Match

+

Our engine matches your request with the best available verified professional.

+
+ +
+
+ +
+

4. Get it Done

+

Connect with your pro, review job history, and enjoy quality results.

+
+
+ +
+ + Learn more about our mission + + +
+
+
+ {/* Trust Features */}
diff --git a/frontend/src/pages/leads/leads-list.tsx b/frontend/src/pages/leads/leads-list.tsx index d785161..e45ce90 100644 --- a/frontend/src/pages/leads/leads-list.tsx +++ b/frontend/src/pages/leads/leads-list.tsx @@ -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 ( <> - {getPageTitle('Leads')} + {getPageTitle(pageTitle)} - + {''} @@ -169,4 +170,4 @@ LeadsTablesPage.getLayout = function getLayout(page: ReactElement) { ) } -export default LeadsTablesPage +export default LeadsTablesPage \ No newline at end of file diff --git a/frontend/src/pages/my-listing.tsx b/frontend/src/pages/my-listing.tsx index 3f5f997..b1cca98 100644 --- a/frontend/src/pages/my-listing.tsx +++ b/frontend/src/pages/my-listing.tsx @@ -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(null); const [pendingClaim, setPendingClaim] = useState(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 ; // 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 ( @@ -85,8 +160,16 @@ const MyListingPage = () => {
-
- +
+ {allPhotos.length > 0 ? ( + Business + ) : ( + + )}

{myBusiness.name}

@@ -107,19 +190,98 @@ const MyListingPage = () => {
- {/* Placeholder for stats or recent bookings */} -
- -
Total Love Letters
-
{myBusiness.reviews_business?.length || 0}
-
- -
Avg Rating
-
{myBusiness.rating || 'New'}
-
- -
Service Bookings
-
0
+ {/* Performance & Gallery Section */} +
+ {/* Stats */} +
+ +
Total Love Letters
+
{myBusiness.reviews_business?.length || 0}
+
+ +
Avg Rating
+
{myBusiness.rating ? Number(myBusiness.rating).toFixed(1) : 'New'}
+
+ +
+ +
+

Reliability Score

+
{myBusiness.reliability_score || 0}%
+
+
+ + {/* Gallery Management */} + +
+

+ + Portfolio Gallery +

+ {allPhotos.length} Pictures +
+ + {/* Upload Form */} +
+ +
+
+ + + +
+ + +
+
+ + {/* Current Photos Grid */} +
+ {allPhotos.map((photo: any) => ( +
+ Business +
+ +
+
+ ))} + {allPhotos.length === 0 && ( +
+ No photos in your gallery yet. Add some to stand out! +
+ )} +
diff --git a/frontend/src/pages/public/businesses-details.tsx b/frontend/src/pages/public/businesses-details.tsx index 17ba4cd..91a99f9 100644 --- a/frontend/src/pages/public/businesses-details.tsx +++ b/frontend/src/pages/public/businesses-details.tsx @@ -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 = () => {
{getBusinessImage() ? ( {business.name} @@ -221,7 +230,7 @@ const BusinessDetailsPublic = () => { bp.photos?.map((p: any) => (
Business @@ -285,7 +294,7 @@ const BusinessDetailsPublic = () => {

"{review.text}"

-
+
@@ -301,6 +310,22 @@ const BusinessDetailsPublic = () => {
)}
+ + {/* Business Owner Response */} + {review.response && ( +
+
+ +
+
+ Response from the business + + {dataFormatter.dateFormatter(review.response_at_ts)} + +
+

"{review.response}"

+
+ )}
))} {!business.reviews_business?.length && ( diff --git a/frontend/src/pages/public/request-service.tsx b/frontend/src/pages/public/request-service.tsx index 4c5cf78..3915090 100644 --- a/frontend/src/pages/public/request-service.tsx +++ b/frontend/src/pages/public/request-service.tsx @@ -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
; return ( -
+
Request Service | Fix-It-Localâ„¢ @@ -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 /> @@ -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 /> @@ -140,29 +149,29 @@ const RequestServicePage = () => {
- + - + - +
- +
- + - + - +
@@ -193,10 +202,10 @@ const RequestServicePage = () => { RequestServicePage.getLayout = function getLayout(page: ReactElement) { return ( - + {page} - + ); }; -export default RequestServicePage; +export default RequestServicePage; \ No newline at end of file diff --git a/frontend/src/pages/search.tsx b/frontend/src/pages/search.tsx index 120062b..8f3298d 100644 --- a/frontend/src/pages/search.tsx +++ b/frontend/src/pages/search.tsx @@ -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; diff --git a/frontend/src/pages/web_pages/about.tsx b/frontend/src/pages/web_pages/about.tsx new file mode 100644 index 0000000..420ab38 --- /dev/null +++ b/frontend/src/pages/web_pages/about.tsx @@ -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 ( +
+ + {getPageTitle('About Us')} + + + + {/* Hero Section */} +
+
+
+
+
+
+

Connecting Local Communities

+

+ {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. +

+
+
+ + {/* Our Mission */} +
+
+
+
+ + Our Mission +
+

Empowering Quality Businesses & Serving Homeowners

+

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

+

+ Our platform uses advanced verification evidence and AI-driven matching to ensure that every connection made is based on quality, reliability, and fair pricing. +

+
+
+
+ +
+
+
+
+ +
+
+

Trust First

+

Every business is verified with real evidence.

+
+
+
+
+ +
+
+

AI Efficiency

+

Find the right pro in seconds, not hours.

+
+
+
+
+ +
+
+

Quality Guaranteed

+

Focusing on high-rated local professionals.

+
+
+
+
+
+
+ + {/* How We Help */} +
+
+

How We Help

+
+ {/* For Businesses */} +
+
+ +
+

For Businesses

+
    +
  • + + Build trust with verified badges +
  • +
  • + + Receive high-quality, matched leads +
  • +
  • + + Showcase reviews and past work +
  • +
  • + + No lead-spam or irrelevant requests +
  • +
+

+ "We help local professionals focus on what they do best, while we handle the discovery and matching." +

+
+ + {/* For Customers */} +
+
+ +
+

For Customers

+
    +
  • + + Find verified, background-checked pros +
  • +
  • + + Transparent pricing and job histories +
  • +
  • + + Free and fast matching with AI +
  • +
  • + + Secure communication and history +
  • +
+

+ "Find the perfect help for your home without the stress of unverified listings or endless phone calls." +

+
+
+
+
+ + {/* The Process */} +
+

The Process

+
+ {[ + { 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) => ( +
+
+ +
+

{i+1}. {step.title}

+

{step.desc}

+
+ ))} +
+
+
+ ); +} + +AboutPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +};