Revert to version 777bc71
This commit is contained in:
parent
2304edcdf3
commit
15445f0bc1
@ -23,7 +23,7 @@ module.exports = class BusinessesDBApi {
|
||||
const limit = filter.limit || 0;
|
||||
let offset = 0;
|
||||
let where = {};
|
||||
const currentPage = +filter.page || 0;
|
||||
const currentPage = +filter.page;
|
||||
offset = currentPage * limit;
|
||||
|
||||
const currentUser = options?.currentUser;
|
||||
@ -42,67 +42,11 @@ module.exports = class BusinessesDBApi {
|
||||
where.is_active = true;
|
||||
}
|
||||
|
||||
let include = [
|
||||
{ model: db.users, as: 'owner_user' },
|
||||
{
|
||||
model: db.business_photos,
|
||||
as: 'business_photos_business',
|
||||
include: [{
|
||||
model: db.file,
|
||||
as: 'photos'
|
||||
}]
|
||||
}
|
||||
];
|
||||
let include = [{ model: db.users, as: 'owner_user' }];
|
||||
|
||||
if (filter) {
|
||||
if (filter.id) where.id = Utils.uuid(filter.id);
|
||||
|
||||
const searchConditions = [];
|
||||
|
||||
if (filter.name) {
|
||||
const terms = filter.name.split(' ').filter(t => t.length > 0);
|
||||
if (terms.length > 0) {
|
||||
const termConditions = terms.map(term => ({
|
||||
[Op.or]: [
|
||||
{ name: { [Op.iLike]: `%${term}%` } },
|
||||
{ description: { [Op.iLike]: `%${term}%` } },
|
||||
{ address: { [Op.iLike]: `%${term}%` } },
|
||||
{ city: { [Op.iLike]: `%${term}%` } },
|
||||
{ zip: { [Op.iLike]: `%${term}%` } }
|
||||
]
|
||||
}));
|
||||
searchConditions.push({ [Op.and]: termConditions });
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.city || filter.zip) {
|
||||
const location = filter.city || filter.zip;
|
||||
const terms = location.split(' ').filter(t => t.length > 0);
|
||||
if (terms.length > 0) {
|
||||
const termConditions = terms.map(term => ({
|
||||
[Op.or]: [
|
||||
{ city: { [Op.iLike]: `%${term}%` } },
|
||||
{ zip: { [Op.iLike]: `%${term}%` } },
|
||||
{ address: { [Op.iLike]: `%${term}%` } }
|
||||
]
|
||||
}));
|
||||
searchConditions.push({ [Op.and]: termConditions });
|
||||
}
|
||||
}
|
||||
|
||||
if (searchConditions.length > 0) {
|
||||
where[Op.and] = searchConditions;
|
||||
}
|
||||
|
||||
if (filter.is_active) where.is_active = filter.is_active === 'true';
|
||||
|
||||
if (filter.category) {
|
||||
include.push({
|
||||
model: db.business_categories,
|
||||
as: 'business_categories_business',
|
||||
where: { categoryId: filter.category }
|
||||
});
|
||||
}
|
||||
if (filter.name) where.name = { [Op.iLike]: `%${filter.name}%` };
|
||||
}
|
||||
|
||||
const queryOptions = {
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import React from 'react'
|
||||
import { mdiClose, mdiMagnify } from '@mdi/js'
|
||||
import { mdiLogout, mdiClose } from '@mdi/js'
|
||||
import BaseIcon from './BaseIcon'
|
||||
import AsideMenuList from './AsideMenuList'
|
||||
import { MenuAsideItem } from '../interfaces'
|
||||
import { useAppSelector } from '../stores/hooks'
|
||||
import BaseIcon from './BaseIcon'
|
||||
import Link from 'next/link';
|
||||
|
||||
|
||||
type Props = {
|
||||
menu: MenuAsideItem[]
|
||||
@ -12,50 +14,50 @@ type Props = {
|
||||
}
|
||||
|
||||
export default function AsideMenuLayer({ menu, className = '', ...props }: Props) {
|
||||
const { asideStyle, asideBrandStyle, asideScrollbarsStyle } = useAppSelector(
|
||||
(state) => state.style
|
||||
)
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const asideStyle = useAppSelector((state) => state.style.asideStyle)
|
||||
const asideBrandStyle = useAppSelector((state) => state.style.asideBrandStyle)
|
||||
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
|
||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||
|
||||
const handleAsideLgCloseClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
props.onAsideLgCloseClick()
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`${className} z-40 w-64 fixed flex flex-col h-screen transition-all duration-300 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800`}
|
||||
id='asideMenu'
|
||||
className={`${className} zzz lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
|
||||
>
|
||||
<div
|
||||
className={`${asideBrandStyle} flex flex-row w-full flex-1 h-14 items-center justify-between px-6 py-8`}
|
||||
className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`}
|
||||
>
|
||||
<div className="flex-1 flex items-center group cursor-pointer">
|
||||
<div className="w-8 h-8 bg-emerald-500 rounded-lg flex items-center justify-center mr-3 shadow-lg shadow-emerald-500/20 group-hover:scale-110 transition-transform">
|
||||
<BaseIcon path={mdiMagnify} size={20} className="text-slate-900" />
|
||||
</div>
|
||||
<b className="font-black text-slate-900 dark:text-white">Crafted Network</b>
|
||||
</div>
|
||||
<button
|
||||
className="hidden lg:inline-block xl:hidden p-3"
|
||||
onClick={handleAsideLgCloseClick}
|
||||
<div
|
||||
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
|
||||
>
|
||||
<BaseIcon path={mdiClose} size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 overflow-y-auto overflow-x-hidden ${asideScrollbarsStyle}`}
|
||||
>
|
||||
<AsideMenuList menu={menu} />
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-slate-100 dark:border-slate-800">
|
||||
<div className="bg-slate-50 dark:bg-slate-800/50 p-4 rounded-2xl border border-slate-100 dark:border-slate-700">
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">System Status</p>
|
||||
<div className="flex items-center text-emerald-500 text-xs font-black uppercase">
|
||||
<div className="w-1.5 h-1.5 bg-emerald-500 rounded-full mr-2 animate-pulse" />
|
||||
Verified Network
|
||||
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
||||
|
||||
<b className="font-black">Crafted Network</b>
|
||||
|
||||
|
||||
</div>
|
||||
<button
|
||||
className="hidden lg:inline-block xl:hidden p-3"
|
||||
onClick={handleAsideLgCloseClick}
|
||||
>
|
||||
<BaseIcon path={mdiClose} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 overflow-y-auto overflow-x-hidden ${
|
||||
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
||||
}`}
|
||||
>
|
||||
<AsideMenuList menu={menu} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,238 +1,221 @@
|
||||
import dayjs from 'dayjs';
|
||||
import _ from 'lodash';
|
||||
|
||||
export const filesFormatter = (arr) => {
|
||||
if (!arr || !arr.length) return [];
|
||||
return arr.map((item) => item);
|
||||
};
|
||||
|
||||
export const imageFormatter = (arr) => {
|
||||
if (!arr || !arr.length) return []
|
||||
return arr.map(item => ({
|
||||
publicUrl: item.publicUrl || ''
|
||||
}))
|
||||
};
|
||||
|
||||
export const oneImageFormatter = (arr) => {
|
||||
if (!arr || !arr.length) return ''
|
||||
return arr[0].publicUrl || ''
|
||||
};
|
||||
|
||||
export const dateFormatter = (date) => {
|
||||
if (!date) return ''
|
||||
return dayjs(date).format('YYYY-MM-DD')
|
||||
};
|
||||
|
||||
export const dateTimeFormatter = (date) => {
|
||||
if (!date) return ''
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm')
|
||||
};
|
||||
|
||||
export const booleanFormatter = (val) => {
|
||||
return val ? 'Yes' : 'No'
|
||||
};
|
||||
|
||||
export const dataGridEditFormatter = (obj) => {
|
||||
return _.transform(obj, (result, value, key) => {
|
||||
if (_.isArray(value)) {
|
||||
result[key] = _.map(value, 'id');
|
||||
} else if (_.isObject(value)) {
|
||||
result[key] = value.id;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const usersManyListFormatter = (val) => {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => item.firstName)
|
||||
};
|
||||
|
||||
export const usersOneListFormatter = (val) => {
|
||||
if (!val) return ''
|
||||
return val.firstName
|
||||
};
|
||||
|
||||
export const usersManyListFormatterEdit = (val) => {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => {
|
||||
return {id: item.id, label: item.firstName}
|
||||
});
|
||||
};
|
||||
|
||||
export const usersOneListFormatterEdit = (val) => {
|
||||
if (!val) return ''
|
||||
return {label: val.firstName, id: val.id}
|
||||
};
|
||||
|
||||
export const rolesManyListFormatter = (val) => {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => item.name)
|
||||
};
|
||||
|
||||
export const rolesOneListFormatter = (val) => {
|
||||
if (!val) return ''
|
||||
return val.name
|
||||
};
|
||||
|
||||
export const rolesManyListFormatterEdit = (val) => {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => {
|
||||
return {id: item.id, label: item.name}
|
||||
});
|
||||
};
|
||||
|
||||
export const rolesOneListFormatterEdit = (val) => {
|
||||
if (!val) return ''
|
||||
return {label: val.name, id: val.id}
|
||||
};
|
||||
|
||||
export const permissionsManyListFormatter = (val) => {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => item.name)
|
||||
};
|
||||
|
||||
export const permissionsOneListFormatter = (val) => {
|
||||
if (!val) return ''
|
||||
return val.name
|
||||
};
|
||||
|
||||
export const permissionsManyListFormatterEdit = (val) => {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => {
|
||||
return {id: item.id, label: item.name}
|
||||
});
|
||||
};
|
||||
|
||||
export const permissionsOneListFormatterEdit = (val) => {
|
||||
if (!val) return ''
|
||||
return {label: val.name, id: val.id}
|
||||
};
|
||||
|
||||
export const categoriesManyListFormatter = (val) => {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => item.name)
|
||||
};
|
||||
|
||||
export const categoriesOneListFormatter = (val) => {
|
||||
if (!val) return ''
|
||||
return val.name
|
||||
};
|
||||
|
||||
export const categoriesManyListFormatterEdit = (val) => {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => {
|
||||
return {id: item.id, label: item.name}
|
||||
});
|
||||
};
|
||||
|
||||
export const categoriesOneListFormatterEdit = (val) => {
|
||||
if (!val) return ''
|
||||
return {label: val.name, id: val.id}
|
||||
};
|
||||
|
||||
export const businessesManyListFormatter = (val) => {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => item.name)
|
||||
};
|
||||
|
||||
export const businessesOneListFormatter = (val) => {
|
||||
if (!val) return ''
|
||||
return val.name
|
||||
};
|
||||
|
||||
export const businessesManyListFormatterEdit = (val) => {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => {
|
||||
return {id: item.id, label: item.name}
|
||||
});
|
||||
};
|
||||
|
||||
export const businessesOneListFormatterEdit = (val) => {
|
||||
if (!val) return ''
|
||||
return {label: val.name, id: val.id}
|
||||
};
|
||||
|
||||
export const verification_submissionsManyListFormatter = (val) => {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => item.notes)
|
||||
};
|
||||
|
||||
export const verification_submissionsOneListFormatter = (val) => {
|
||||
if (!val) return ''
|
||||
return val.notes
|
||||
};
|
||||
|
||||
export const verification_submissionsManyListFormatterEdit = (val) => {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => {
|
||||
return {id: item.id, label: item.notes}
|
||||
});
|
||||
};
|
||||
|
||||
export const verification_submissionsOneListFormatterEdit = (val) => {
|
||||
if (!val) return ''
|
||||
return {label: val.notes, id: val.id}
|
||||
};
|
||||
|
||||
export const leadsManyListFormatter = (val) => {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => item.keyword)
|
||||
};
|
||||
|
||||
export const leadsOneListFormatter = (val) => {
|
||||
if (!val) return ''
|
||||
return val.keyword
|
||||
};
|
||||
|
||||
export const leadsManyListFormatterEdit = (val) => {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => {
|
||||
return {id: item.id, label: item.keyword}
|
||||
});
|
||||
};
|
||||
|
||||
export const leadsOneListFormatterEdit = (val) => {
|
||||
if (!val) return ''
|
||||
return {label: val.keyword, id: val.id}
|
||||
};
|
||||
|
||||
// Also keep the default export for compatibility
|
||||
export default {
|
||||
filesFormatter,
|
||||
imageFormatter,
|
||||
oneImageFormatter,
|
||||
dateFormatter,
|
||||
dateTimeFormatter,
|
||||
booleanFormatter,
|
||||
dataGridEditFormatter,
|
||||
usersManyListFormatter,
|
||||
usersOneListFormatter,
|
||||
usersManyListFormatterEdit,
|
||||
usersOneListFormatterEdit,
|
||||
rolesManyListFormatter,
|
||||
rolesOneListFormatter,
|
||||
rolesManyListFormatterEdit,
|
||||
rolesOneListFormatterEdit,
|
||||
permissionsManyListFormatter,
|
||||
permissionsOneListFormatter,
|
||||
permissionsManyListFormatterEdit,
|
||||
permissionsOneListFormatterEdit,
|
||||
categoriesManyListFormatter,
|
||||
categoriesOneListFormatter,
|
||||
categoriesManyListFormatterEdit,
|
||||
categoriesOneListFormatterEdit,
|
||||
businessesManyListFormatter,
|
||||
businessesOneListFormatter,
|
||||
businessesManyListFormatterEdit,
|
||||
businessesOneListFormatterEdit,
|
||||
verification_submissionsManyListFormatter,
|
||||
verification_submissionsOneListFormatter,
|
||||
verification_submissionsManyListFormatterEdit,
|
||||
verification_submissionsOneListFormatterEdit,
|
||||
leadsManyListFormatter,
|
||||
leadsOneListFormatter,
|
||||
leadsManyListFormatterEdit,
|
||||
leadsOneListFormatterEdit,
|
||||
};
|
||||
filesFormatter(arr) {
|
||||
if (!arr || !arr.length) return [];
|
||||
return arr.map((item) => item);
|
||||
},
|
||||
imageFormatter(arr) {
|
||||
if (!arr || !arr.length) return []
|
||||
return arr.map(item => ({
|
||||
publicUrl: item.publicUrl || ''
|
||||
}))
|
||||
},
|
||||
oneImageFormatter(arr) {
|
||||
if (!arr || !arr.length) return ''
|
||||
return arr[0].publicUrl || ''
|
||||
},
|
||||
dateFormatter(date) {
|
||||
if (!date) return ''
|
||||
return dayjs(date).format('YYYY-MM-DD')
|
||||
},
|
||||
dateTimeFormatter(date) {
|
||||
if (!date) return ''
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm')
|
||||
},
|
||||
booleanFormatter(val) {
|
||||
return val ? 'Yes' : 'No'
|
||||
},
|
||||
dataGridEditFormatter(obj) {
|
||||
return _.transform(obj, (result, value, key) => {
|
||||
if (_.isArray(value)) {
|
||||
result[key] = _.map(value, 'id');
|
||||
} else if (_.isObject(value)) {
|
||||
result[key] = value.id;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
usersManyListFormatter(val) {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => item.firstName)
|
||||
},
|
||||
usersOneListFormatter(val) {
|
||||
if (!val) return ''
|
||||
return val.firstName
|
||||
},
|
||||
usersManyListFormatterEdit(val) {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => {
|
||||
return {id: item.id, label: item.firstName}
|
||||
});
|
||||
},
|
||||
usersOneListFormatterEdit(val) {
|
||||
if (!val) return ''
|
||||
return {label: val.firstName, id: val.id}
|
||||
},
|
||||
|
||||
|
||||
|
||||
rolesManyListFormatter(val) {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => item.name)
|
||||
},
|
||||
rolesOneListFormatter(val) {
|
||||
if (!val) return ''
|
||||
return val.name
|
||||
},
|
||||
rolesManyListFormatterEdit(val) {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => {
|
||||
return {id: item.id, label: item.name}
|
||||
});
|
||||
},
|
||||
rolesOneListFormatterEdit(val) {
|
||||
if (!val) return ''
|
||||
return {label: val.name, id: val.id}
|
||||
},
|
||||
|
||||
|
||||
|
||||
permissionsManyListFormatter(val) {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => item.name)
|
||||
},
|
||||
permissionsOneListFormatter(val) {
|
||||
if (!val) return ''
|
||||
return val.name
|
||||
},
|
||||
permissionsManyListFormatterEdit(val) {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => {
|
||||
return {id: item.id, label: item.name}
|
||||
});
|
||||
},
|
||||
permissionsOneListFormatterEdit(val) {
|
||||
if (!val) return ''
|
||||
return {label: val.name, id: val.id}
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
categoriesManyListFormatter(val) {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => item.name)
|
||||
},
|
||||
categoriesOneListFormatter(val) {
|
||||
if (!val) return ''
|
||||
return val.name
|
||||
},
|
||||
categoriesManyListFormatterEdit(val) {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => {
|
||||
return {id: item.id, label: item.name}
|
||||
});
|
||||
},
|
||||
categoriesOneListFormatterEdit(val) {
|
||||
if (!val) return ''
|
||||
return {label: val.name, id: val.id}
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
businessesManyListFormatter(val) {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => item.name)
|
||||
},
|
||||
businessesOneListFormatter(val) {
|
||||
if (!val) return ''
|
||||
return val.name
|
||||
},
|
||||
businessesManyListFormatterEdit(val) {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => {
|
||||
return {id: item.id, label: item.name}
|
||||
});
|
||||
},
|
||||
businessesOneListFormatterEdit(val) {
|
||||
if (!val) return ''
|
||||
return {label: val.name, id: val.id}
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
verification_submissionsManyListFormatter(val) {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => item.notes)
|
||||
},
|
||||
verification_submissionsOneListFormatter(val) {
|
||||
if (!val) return ''
|
||||
return val.notes
|
||||
},
|
||||
verification_submissionsManyListFormatterEdit(val) {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => {
|
||||
return {id: item.id, label: item.notes}
|
||||
});
|
||||
},
|
||||
verification_submissionsOneListFormatterEdit(val) {
|
||||
if (!val) return ''
|
||||
return {label: val.notes, id: val.id}
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
leadsManyListFormatter(val) {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => item.keyword)
|
||||
},
|
||||
leadsOneListFormatter(val) {
|
||||
if (!val) return ''
|
||||
return val.keyword
|
||||
},
|
||||
leadsManyListFormatterEdit(val) {
|
||||
if (!val || !val.length) return []
|
||||
return val.map((item) => {
|
||||
return {id: item.id, label: item.keyword}
|
||||
});
|
||||
},
|
||||
leadsOneListFormatterEdit(val) {
|
||||
if (!val) return ''
|
||||
return {label: val.keyword, id: val.id}
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -1,176 +1,116 @@
|
||||
import React, { ReactNode, useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { mdiMenu, mdiClose, mdiLogin, mdiAccountPlus, mdiMagnify } from '@mdi/js'
|
||||
import { useAppSelector } from '../stores/hooks'
|
||||
import { useRouter } from 'next/router'
|
||||
import BaseIcon from '../components/BaseIcon'
|
||||
import React, { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { mdiShieldCheck, mdiMenu, mdiClose, mdiMagnify } from '@mdi/js';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function LayoutGuest({ children }: Props) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const { token } = useAppSelector((state) => state.auth)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 20)
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
||||
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const navLinks = [
|
||||
{ label: 'Find Services', href: '/search' },
|
||||
{ label: 'Categories', href: '/categories' },
|
||||
{ label: 'Verify Business', href: '/register' },
|
||||
{ label: 'Support', href: '/contact-form' },
|
||||
]
|
||||
{ href: '/search', label: 'Find Services' },
|
||||
{ href: '/register', label: 'List Business' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white dark:bg-slate-950 flex flex-col font-sans selection:bg-emerald-500/30">
|
||||
{/* Navigation */}
|
||||
<nav
|
||||
className={`fixed top-0 left-0 right-0 z-[100] transition-all duration-300 ${
|
||||
scrolled
|
||||
? 'bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl border-b border-slate-200 dark:border-slate-800 py-4 shadow-sm'
|
||||
: 'bg-transparent py-6'
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center group">
|
||||
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center mr-3 shadow-lg shadow-emerald-500/20 group-hover:scale-110 transition-transform">
|
||||
<BaseIcon path={mdiMagnify} size={24} className="text-slate-900" />
|
||||
<div className={`${darkMode ? 'dark' : ''} min-h-screen flex flex-col`}>
|
||||
{/* Dynamic Header */}
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-lg border-b border-slate-200/60 dark:bg-slate-900/80 dark:border-slate-800">
|
||||
<div className="container mx-auto px-6 h-20 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-3 group">
|
||||
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center shadow-lg shadow-emerald-500/20 group-hover:scale-110 transition-transform">
|
||||
<BaseIcon path={mdiShieldCheck} size={24} className="text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-black tracking-tight dark:text-white">Crafted Network<span className="text-emerald-500 italic">™</span></span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<div className="hidden md:flex items-center space-x-8">
|
||||
<nav className="hidden md:flex items-center gap-10">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm font-bold text-slate-600 dark:text-slate-400 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors"
|
||||
href={link.href}
|
||||
className={`text-sm font-bold uppercase tracking-widest hover:text-emerald-500 transition-colors ${router.pathname === link.href ? 'text-emerald-500' : 'text-slate-600 dark:text-slate-400'}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
{token ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="bg-slate-900 dark:bg-emerald-500 text-white dark:text-slate-900 px-6 py-2.5 rounded-xl text-sm font-bold hover:opacity-90 transition-all shadow-lg"
|
||||
>
|
||||
<div className="h-6 w-px bg-slate-200 dark:bg-slate-800"></div>
|
||||
{currentUser ? (
|
||||
<Link href="/dashboard" className="bg-slate-900 text-white dark:bg-white dark:text-slate-900 px-6 py-3 rounded-xl text-sm font-bold hover:shadow-xl transition-all">
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-bold text-slate-600 dark:text-slate-400 hover:text-emerald-600 transition-colors px-4 py-2"
|
||||
>
|
||||
Log In
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/login" className="text-sm font-bold text-slate-600 hover:text-emerald-500 transition-colors">
|
||||
Login
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="bg-emerald-500 text-slate-900 px-6 py-2.5 rounded-xl text-sm font-bold hover:shadow-lg hover:shadow-emerald-500/20 transition-all active:scale-95"
|
||||
>
|
||||
Join the Network
|
||||
<Link href="/register" className="bg-emerald-500 text-white px-6 py-3 rounded-xl text-sm font-bold hover:bg-emerald-600 shadow-lg shadow-emerald-500/20 transition-all">
|
||||
Join Now
|
||||
</Link>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Toggle */}
|
||||
<button
|
||||
className="md:hidden p-2 text-slate-600 dark:text-slate-400"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
>
|
||||
<button className="md:hidden p-2 text-slate-600 dark:text-slate-400" onClick={() => setIsMenuOpen(!isMenuOpen)}>
|
||||
<BaseIcon path={isMenuOpen ? mdiClose : mdiMenu} size={28} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isMenuOpen && (
|
||||
<div className="md:hidden bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 p-6 space-y-6 animate-fade-in">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="block text-lg font-bold text-slate-800 dark:text-slate-200"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Link key={link.href} href={link.href} className="block text-lg font-bold text-slate-700 dark:text-slate-300" onClick={() => setIsMenuOpen(false)}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-6 border-t border-slate-100 dark:border-slate-800 flex flex-col gap-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center justify-center gap-2 p-4 rounded-xl border border-slate-200 dark:border-slate-700 font-bold"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<BaseIcon path={mdiLogin} size={20} /> Log In
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex items-center justify-center gap-2 p-4 rounded-xl bg-emerald-500 text-slate-900 font-bold shadow-lg"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<BaseIcon path={mdiAccountPlus} size={20} /> Join Now
|
||||
</Link>
|
||||
{currentUser ? (
|
||||
<Link href="/dashboard" className="w-full bg-slate-900 text-white py-4 rounded-xl text-center font-bold" onClick={() => setIsMenuOpen(false)}>Dashboard</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login" className="w-full text-center py-4 font-bold text-slate-600" onClick={() => setIsMenuOpen(false)}>Login</Link>
|
||||
<Link href="/register" className="w-full bg-emerald-500 text-white py-4 rounded-xl text-center font-bold" onClick={() => setIsMenuOpen(false)}>Join Now</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-grow pt-16">
|
||||
<main className={`flex-grow ${bgColor} dark:bg-slate-800 dark:text-slate-100`}>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-slate-900 text-white pt-24 pb-12">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-16">
|
||||
<div className="col-span-1 md:col-span-2 space-y-6">
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-emerald-500 rounded-lg flex items-center justify-center mr-3">
|
||||
<BaseIcon path={mdiMagnify} size={20} className="text-slate-900" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold tracking-tight dark:text-white text-white">Crafted Network™</span>
|
||||
<footer className="bg-white border-t border-slate-200 py-12 dark:bg-slate-900 dark:border-slate-800">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="flex items-center mb-6 md:mb-0">
|
||||
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center mr-3">
|
||||
<BaseIcon path={mdiShieldCheck} size={24} className="text-white" />
|
||||
</div>
|
||||
<p className="text-slate-400 max-w-sm leading-relaxed">
|
||||
The most reliable platform for finding verified service professionals and businesses across all industries.
|
||||
</p>
|
||||
<span className="text-2xl font-bold tracking-tight dark:text-white text-slate-900">Crafted Network™</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold mb-6 text-white uppercase text-xs tracking-widest">Platform</h4>
|
||||
<ul className="space-y-4 text-slate-400 text-sm">
|
||||
<li><Link href="/search" className="hover:text-emerald-400 transition-colors">Find Services</Link></li>
|
||||
<li><Link href="/categories" className="hover:text-emerald-400 transition-colors">Categories</Link></li>
|
||||
<li><Link href="/register" className="hover:text-emerald-400 transition-colors">Register Business</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold mb-6 text-white uppercase text-xs tracking-widest">Legal</h4>
|
||||
<ul className="space-y-4 text-slate-400 text-sm">
|
||||
<li><Link href="/privacy-policy" className="hover:text-emerald-400 transition-colors">Privacy Policy</Link></li>
|
||||
<li><Link href="/terms-of-use" className="hover:text-emerald-400 transition-colors">Terms of Use</Link></li>
|
||||
<li><Link href="/contact-form" className="hover:text-emerald-400 transition-colors">Contact Support</Link></li>
|
||||
</ul>
|
||||
<div className="flex gap-8 text-slate-500 font-medium dark:text-slate-400">
|
||||
<Link href="/search" className="hover:text-emerald-500">Find Help</Link>
|
||||
<Link href="/register" className="hover:text-emerald-500">List Business</Link>
|
||||
<Link href="/privacy-policy" className="hover:text-emerald-500">Privacy</Link>
|
||||
<Link href="/terms-of-use" className="hover:text-emerald-500">Terms</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-12 border-t border-slate-800 text-center text-slate-500 text-sm">
|
||||
<div className="mt-12 pt-8 border-t border-slate-100 dark:border-slate-800 text-center text-slate-400 text-sm">
|
||||
© 2026 Crafted Network™. Built with Trust & Transparency.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,116 +1,201 @@
|
||||
import React, { ReactElement, ReactNode, useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import type { AppProps } from 'next/app';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import type { NextPage } from 'next';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Provider } from 'react-redux';
|
||||
import { appWithTranslation } from 'next-i18next';
|
||||
import axios from 'axios';
|
||||
import { store } from '../stores/store';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { findMe, logoutUser } from '../stores/authSlice';
|
||||
import { setDarkMode } from '../stores/styleSlice';
|
||||
import { Provider } from 'react-redux';
|
||||
import '../css/main.css';
|
||||
import '../css/_calendar.css';
|
||||
import axios from 'axios';
|
||||
import { baseURLApi } from '../config';
|
||||
import { useRouter } from 'next/router';
|
||||
import ErrorBoundary from "../components/ErrorBoundary";
|
||||
import DevModeBadge from '../components/DevModeBadge';
|
||||
import 'intro.js/introjs.css';
|
||||
import { appWithTranslation } from 'next-i18next';
|
||||
import '../i18n';
|
||||
import IntroGuide from '../components/IntroGuide';
|
||||
import { appSteps, loginSteps, usersSteps, rolesSteps } from '../stores/introSteps';
|
||||
|
||||
type NextPageWithLayout = {
|
||||
getLayout?: (page: ReactElement) => ReactNode;
|
||||
};
|
||||
// Initialize axios
|
||||
axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API
|
||||
? process.env.NEXT_PUBLIC_BACK_API
|
||||
: baseURLApi;
|
||||
|
||||
type AppPropsWithLayout = AppProps & {
|
||||
Component: NextPageWithLayout;
|
||||
};
|
||||
|
||||
// Axios Configuration
|
||||
const baseURLApi = process.env.NEXT_PUBLIC_BACK_API || '/api';
|
||||
axios.defaults.baseURL = baseURLApi;
|
||||
axios.defaults.headers.common['Content-Type'] = 'application/json';
|
||||
|
||||
const AppContent = ({ Component, pageProps }: AppPropsWithLayout) => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const { token, currentUser } = useAppSelector((state) => state.auth);
|
||||
const { darkMode } = useAppSelector((state) => state.style);
|
||||
export type NextPageWithLayout<P = Record<string, unknown>, IP = P> = NextPage<P, IP> & {
|
||||
getLayout?: (page: ReactElement) => ReactNode
|
||||
}
|
||||
|
||||
// Auth Header Interceptor
|
||||
useEffect(() => {
|
||||
const requestInterceptor = axios.interceptors.request.use(
|
||||
(config) => {
|
||||
const currentToken = localStorage.getItem('token');
|
||||
if (currentToken) {
|
||||
config.headers.Authorization = `Bearer ${currentToken}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
const responseInterceptor = axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
dispatch(logoutUser());
|
||||
if (!router.pathname.startsWith('/login') && !router.pathname.startsWith('/register') && !router.pathname.startsWith('/public')) {
|
||||
router.push('/login');
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
axios.interceptors.request.eject(requestInterceptor);
|
||||
axios.interceptors.response.eject(responseInterceptor);
|
||||
};
|
||||
}, [dispatch, router]);
|
||||
|
||||
// Initial Data Fetch
|
||||
useEffect(() => {
|
||||
const savedToken = localStorage.getItem('token');
|
||||
if (savedToken) {
|
||||
dispatch(findMe());
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
// Dark Mode Support
|
||||
useEffect(() => {
|
||||
const isDark = localStorage.getItem('darkMode') === 'true';
|
||||
dispatch(setDarkMode(isDark));
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark-scrollbars');
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [darkMode]);
|
||||
type AppPropsWithLayout = AppProps & {
|
||||
Component: NextPageWithLayout
|
||||
}
|
||||
|
||||
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
// Use the layout defined at the page level, if available
|
||||
const getLayout = Component.getLayout || ((page) => page);
|
||||
const router = useRouter();
|
||||
const [stepsEnabled, setStepsEnabled] = React.useState(false);
|
||||
const [stepName, setStepName] = React.useState('');
|
||||
const [steps, setSteps] = React.useState([]);
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
delete config.headers.Authorization;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: Remove this code in future releases
|
||||
React.useEffect(() => {
|
||||
const allowedOrigin = (() => {
|
||||
if (!document.referrer) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(document.referrer).origin;
|
||||
} catch (error) {
|
||||
console.warn('[postMessage] Failed to parse parent origin from referrer', error);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
if (event.data === 'getLocation') {
|
||||
event.source?.postMessage(
|
||||
{ iframeLocation: window.location.pathname },
|
||||
event.origin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data === 'getAuthToken') {
|
||||
if (allowedOrigin && event.origin !== allowedOrigin) {
|
||||
console.warn('[postMessage] Blocked getAuthToken from origin', event.origin);
|
||||
return;
|
||||
}
|
||||
const token = localStorage.getItem('token');
|
||||
const user = localStorage.getItem('user');
|
||||
event.source?.postMessage(
|
||||
{ iframeAuthToken: token, iframeAuthUser: user },
|
||||
event.origin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data === 'getScreenshot') {
|
||||
try {
|
||||
const html2canvas = (await import('html2canvas')).default;
|
||||
const canvas = await html2canvas(document.body, { useCORS: true });
|
||||
const url = canvas.toDataURL('image/jpeg', 0.8);
|
||||
event.source?.postMessage({ iframeScreenshot: url }, event.origin);
|
||||
} catch (e) {
|
||||
console.error('html2canvas failed', e);
|
||||
event.source?.postMessage({ iframeScreenshot: null }, event.origin);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Tour is disabled by default in generated projects.
|
||||
return;
|
||||
const isCompleted = (stepKey: string) => {
|
||||
return localStorage.getItem(`completed_${stepKey}`) === 'true';
|
||||
};
|
||||
if (router.pathname === '/login' && !isCompleted('loginSteps')) {
|
||||
setSteps(loginSteps);
|
||||
setStepName('loginSteps');
|
||||
setStepsEnabled(true);
|
||||
}else if (router.pathname === '/dashboard' && !isCompleted('appSteps')) {
|
||||
setTimeout(() => {
|
||||
setSteps(appSteps);
|
||||
setStepName('appSteps');
|
||||
setStepsEnabled(true);
|
||||
}, 1000);
|
||||
} else if (router.pathname === '/users/users-list' && !isCompleted('usersSteps')) {
|
||||
setTimeout(() => {
|
||||
setSteps(usersSteps);
|
||||
setStepName('usersSteps');
|
||||
setStepsEnabled(true);
|
||||
}, 1000);
|
||||
} else if (router.pathname === '/roles/roles-list' && !isCompleted('rolesSteps')) {
|
||||
setTimeout(() => {
|
||||
setSteps(rolesSteps);
|
||||
setStepName('rolesSteps');
|
||||
setStepsEnabled(true);
|
||||
}, 1000);
|
||||
} else {
|
||||
setSteps([]);
|
||||
setStepsEnabled(false);
|
||||
}
|
||||
}, [router.pathname]);
|
||||
|
||||
const handleExit = () => {
|
||||
setStepsEnabled(false);
|
||||
};
|
||||
|
||||
const title = 'Crafted Network'
|
||||
const description = "Trust-first service directory with verification, transparent pricing, AI-style matching, and request tracking."
|
||||
const url = "https://flatlogic.com/"
|
||||
const image = "https://project-screens.s3.amazonaws.com/screenshots/38501/app-hero-20260217-010030.png"
|
||||
const imageWidth = '1920'
|
||||
const imageHeight = '960'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
</Head>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function MyApp(props: AppPropsWithLayout) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<AppContent {...props} />
|
||||
{getLayout(
|
||||
<>
|
||||
<Head>
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<meta property="og:url" content={url} />
|
||||
<meta property="og:site_name" content="https://flatlogic.com/" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={image} />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content={imageWidth} />
|
||||
<meta property="og:image:height" content={imageHeight} />
|
||||
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:title" content={title} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
<meta property="twitter:image:src" content={image} />
|
||||
<meta property="twitter:image:width" content={imageWidth} />
|
||||
<meta property="twitter:image:height" content={imageHeight} />
|
||||
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
</Head>
|
||||
|
||||
<ErrorBoundary>
|
||||
<Component {...pageProps} />
|
||||
</ErrorBoundary>
|
||||
<IntroGuide
|
||||
steps={steps}
|
||||
stepsName={stepName}
|
||||
stepsEnabled={stepsEnabled}
|
||||
onExit={handleExit}
|
||||
/>
|
||||
{(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'dev_stage') && <DevModeBadge />}
|
||||
</>
|
||||
)}
|
||||
</Provider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default appWithTranslation(MyApp);
|
||||
|
||||
@ -1,275 +1,192 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
mdiMagnify,
|
||||
mdiMapMarker,
|
||||
mdiChevronRight,
|
||||
mdiStar,
|
||||
mdiShieldCheck,
|
||||
mdiLightningBolt,
|
||||
mdiFormatListBulleted,
|
||||
mdiAccountGroup
|
||||
mdiCurrencyUsd,
|
||||
mdiFlash,
|
||||
mdiTools,
|
||||
mdiPowerPlug,
|
||||
mdiAirConditioner,
|
||||
mdiBrush,
|
||||
mdiFormatPaint
|
||||
} from '@mdi/js';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { fetch as fetchCategories } from '../stores/categories/categoriesSlice';
|
||||
import { fetch as fetchBusinesses } from '../stores/businesses/businessesSlice';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
const LandingPage = () => {
|
||||
export default function LandingPage() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const { categories } = useAppSelector((state) => state.categories);
|
||||
const { businesses: featuredBusinesses } = useAppSelector((state) => state.businesses);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [locationQuery, setLocationQuery] = useState('');
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCategories({ query: '?limit=8' }));
|
||||
dispatch(fetchBusinesses({ query: '?limit=4&is_active=true' }));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSearch = () => {
|
||||
const featuredCategories = [
|
||||
{ name: 'Plumbing', icon: mdiTools, color: 'text-blue-500' },
|
||||
{ name: 'Electrical', icon: mdiPowerPlug, color: 'text-yellow-500' },
|
||||
{ name: 'HVAC', icon: mdiAirConditioner, color: 'text-emerald-500' },
|
||||
{ name: 'Cleaning', icon: mdiBrush, color: 'text-purple-500' },
|
||||
{ name: 'Painting', icon: mdiFormatPaint, color: 'text-orange-500' },
|
||||
{ name: 'General', icon: mdiTools, color: 'text-slate-500' },
|
||||
];
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const query = formData.get('query');
|
||||
const location = formData.get('location');
|
||||
router.push({
|
||||
pathname: '/search',
|
||||
query: { q: searchQuery, l: locationQuery },
|
||||
query: { query, location },
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{ label: 'Verified Pros', value: '12,000+', icon: mdiShieldCheck, color: 'text-emerald-500' },
|
||||
{ label: 'Services Listed', value: '850+', icon: mdiFormatListBulleted, color: 'text-blue-500' },
|
||||
{ label: 'Happy Customers', value: '95k', icon: mdiAccountGroup, color: 'text-purple-500' },
|
||||
{ label: 'Avg. Rating', value: '4.9/5', icon: mdiStar, color: 'text-amber-500' },
|
||||
];
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Verified Ownership',
|
||||
desc: 'Every business on our platform goes through a rigorous multi-step verification process to ensure authenticity.',
|
||||
icon: mdiShieldCheck,
|
||||
color: 'bg-emerald-100 text-emerald-600'
|
||||
},
|
||||
{
|
||||
title: 'Smart Matching',
|
||||
desc: 'Our AI-powered engine connects you with the right professionals based on your specific needs and location.',
|
||||
icon: mdiLightningBolt,
|
||||
color: 'bg-blue-100 text-blue-600'
|
||||
},
|
||||
{
|
||||
title: 'Price Transparency',
|
||||
desc: 'View clear pricing and service breakdowns before you book. No hidden fees, no surprises.',
|
||||
icon: mdiStar,
|
||||
color: 'bg-amber-100 text-amber-600'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-slate-50 font-sans text-slate-900">
|
||||
<Head>
|
||||
<title>Crafted Network™ | 21st Century Service Directory</title>
|
||||
<meta name="description" content="Connect with verified service professionals. Trust, transparency, and AI-powered matching." />
|
||||
</Head>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative bg-slate-900 pt-32 pb-48 overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden z-0 opacity-20">
|
||||
<div className="absolute -top-[10%] -left-[10%] w-[40%] h-[40%] bg-emerald-500 blur-[120px] rounded-full"></div>
|
||||
<div className="absolute top-[20%] -right-[5%] w-[30%] h-[30%] bg-blue-600 blur-[100px] rounded-full"></div>
|
||||
<section className="relative bg-slate-900 text-white overflow-hidden py-32 lg:py-48">
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="absolute top-0 -left-4 w-72 h-72 bg-emerald-500 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob"></div>
|
||||
<div className="absolute top-0 -right-4 w-72 h-72 bg-blue-500 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
|
||||
<div className="absolute -bottom-8 left-20 w-72 h-72 bg-purple-500 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-4000"></div>
|
||||
</div>
|
||||
|
||||
<SectionMain className="relative z-10">
|
||||
<div className="text-center max-w-4xl mx-auto space-y-8">
|
||||
<div className="inline-flex items-center px-4 py-2 rounded-full bg-slate-800 border border-slate-700 text-emerald-400 text-sm font-medium mb-4 animate-fade-in">
|
||||
<div className="container mx-auto px-6 relative z-10">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
<div className="inline-flex items-center px-4 py-2 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm font-medium mb-8">
|
||||
<BaseIcon path={mdiShieldCheck} size={18} className="mr-2" />
|
||||
The World's Most Trusted Professional Network
|
||||
Verified Professionals & AI-Powered Matching
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-7xl font-black text-white leading-tight tracking-tight">
|
||||
The <span className="text-emerald-400">Crafted</span> Network
|
||||
<h1 className="text-5xl lg:text-7xl font-bold tracking-tight mb-8">
|
||||
The <span className="text-emerald-400">Crafted</span> Service Network
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-slate-400 leading-relaxed max-w-2xl mx-auto">
|
||||
<p className="text-xl text-slate-400 mb-12 max-w-2xl mx-auto leading-relaxed">
|
||||
Find reliable, verified experts for your home or business. Real-time availability, transparent pricing, and zero spam.
|
||||
</p>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="flex flex-col md:flex-row gap-4 max-w-3xl mx-auto mt-12 bg-slate-800/50 p-3 rounded-2xl border border-slate-700/50 backdrop-blur-xl shadow-2xl">
|
||||
<div className="flex-1 relative group">
|
||||
<BaseIcon path={mdiMagnify} size={24} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-emerald-400 transition-colors" />
|
||||
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4 p-2 bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 shadow-2xl max-w-3xl mx-auto">
|
||||
<div className="flex-grow relative">
|
||||
<BaseIcon path={mdiMagnify} size={24} className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
name="query"
|
||||
placeholder="What service do you need?"
|
||||
className="w-full bg-transparent border-none focus:ring-0 py-4 pl-12 pr-4 text-white placeholder-slate-500 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-px bg-slate-700 hidden md:block"></div>
|
||||
<div className="flex-1 relative group">
|
||||
<BaseIcon path={mdiMapMarker} size={24} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-emerald-400 transition-colors" />
|
||||
<div className="md:w-px h-8 bg-white/10 my-auto"></div>
|
||||
<div className="flex-grow relative">
|
||||
<BaseIcon path={mdiMapMarker} size={24} className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={locationQuery}
|
||||
onChange={(e) => setLocationQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="City or Zip code"
|
||||
name="location"
|
||||
placeholder="Location"
|
||||
className="w-full bg-transparent border-none focus:ring-0 py-4 pl-12 pr-4 text-white placeholder-slate-500 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="bg-emerald-500 hover:bg-emerald-400 text-slate-900 font-bold px-8 py-4 rounded-xl transition-all shadow-lg shadow-emerald-500/20 active:scale-95 flex items-center justify-center"
|
||||
>
|
||||
Search
|
||||
<button type="submit" className="bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-4 px-8 rounded-xl transition-all shadow-lg shadow-emerald-500/25">
|
||||
Find Help
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className="-mt-24 relative z-20 px-6">
|
||||
<div className="max-w-6xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{stats.map((stat, idx) => (
|
||||
<div key={idx} className="bg-white p-8 rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-100 flex flex-col items-center text-center group hover:-translate-y-1 transition-all duration-300">
|
||||
<div className={`${stat.color} mb-4 p-3 bg-slate-50 rounded-2xl group-hover:scale-110 transition-transform`}>
|
||||
<BaseIcon path={stat.icon} size={24} />
|
||||
{/* Featured Categories */}
|
||||
<section className="py-24 container mx-auto px-6">
|
||||
<div className="flex items-end justify-between mb-12">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-4">Popular Services</h2>
|
||||
<p className="text-slate-500">Explore our most requested categories from verified pros.</p>
|
||||
</div>
|
||||
<Link href="/categories/categories-list" className="text-emerald-500 font-semibold hover:underline flex items-center">
|
||||
View All Categories
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||
{(categories?.length > 0 ? categories.slice(0, 6) : featuredCategories).map((cat: any, i: number) => (
|
||||
<Link
|
||||
key={i}
|
||||
href={`/search?query=${cat.name}`}
|
||||
className="group bg-white p-8 rounded-3xl border border-slate-200 hover:border-emerald-500 hover:shadow-xl hover:shadow-emerald-500/10 transition-all text-center"
|
||||
>
|
||||
<div className={`mb-4 w-16 h-16 mx-auto rounded-2xl flex items-center justify-center bg-slate-50 group-hover:bg-emerald-50 transition-colors`}>
|
||||
<BaseIcon path={cat.icon || mdiTools} size={32} className={cat.color || 'text-emerald-500'} />
|
||||
</div>
|
||||
<div className="text-3xl font-black text-slate-900 mb-1">{stat.value}</div>
|
||||
<div className="text-sm font-medium text-slate-500">{stat.label}</div>
|
||||
</div>
|
||||
<span className="font-bold text-slate-700 group-hover:text-emerald-600 transition-colors">{cat.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Categories */}
|
||||
<section className="py-32">
|
||||
<SectionMain>
|
||||
<div className="flex items-end justify-between mb-12">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-slate-900 mb-4">Popular Categories</h2>
|
||||
<p className="text-slate-500">Explore the best rated professionals in these top industries.</p>
|
||||
</div>
|
||||
<Link href="/categories" className="hidden md:flex items-center text-emerald-600 font-bold hover:gap-2 transition-all">
|
||||
View All <BaseIcon path={mdiChevronRight} size={20} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{categories?.map((cat: any) => (
|
||||
<Link key={cat.id} href={`/search?category=${cat.id}`} className="group bg-slate-50 hover:bg-white p-8 rounded-3xl border border-transparent hover:border-emerald-100 hover:shadow-xl hover:shadow-emerald-500/10 transition-all text-center">
|
||||
<div className="w-16 h-16 bg-white rounded-2xl shadow-sm mx-auto mb-6 flex items-center justify-center text-emerald-500 group-hover:bg-emerald-500 group-hover:text-white transition-all">
|
||||
<BaseIcon path={mdiStar} size={24} />
|
||||
</div>
|
||||
<span className="font-bold text-slate-700 group-hover:text-emerald-600 transition-colors">{cat.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</SectionMain>
|
||||
</section>
|
||||
|
||||
{/* Featured Businesses */}
|
||||
<section className="py-32 bg-slate-50">
|
||||
<SectionMain>
|
||||
<div className="text-center mb-16 max-w-3xl mx-auto">
|
||||
<h2 className="text-4xl font-black text-slate-900 mb-6">Verified Top Performers</h2>
|
||||
<p className="text-lg text-slate-600 italic">"Trust is earned through consistent, high-quality results."</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{featuredBusinesses?.map((biz: any) => (
|
||||
<Link key={biz.id} href={`/public/businesses-details?id=${biz.id}`} className="bg-white rounded-[2rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all group border border-slate-100">
|
||||
<div className="h-56 bg-slate-200 relative overflow-hidden">
|
||||
{biz.business_photos?.[0]?.photo_url ? (
|
||||
<img src={biz.business_photos[0].photo_url} alt={biz.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-slate-100 text-slate-300">
|
||||
<BaseIcon path={mdiStar} size={48} />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-4 right-4 bg-white/90 backdrop-blur px-3 py-1 rounded-full text-xs font-black text-emerald-600 flex items-center shadow-sm">
|
||||
<BaseIcon path={mdiStar} size={16} className="mr-1" />
|
||||
{biz.rating || '4.9'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-8">
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-2 group-hover:text-emerald-600 transition-colors line-clamp-1">{biz.name}</h3>
|
||||
<div className="flex items-center text-slate-500 text-sm mb-4">
|
||||
<BaseIcon path={mdiMapMarker} size={16} className="mr-1" />
|
||||
{biz.city || biz.locations?.[0]?.city || 'Verified Professional'}
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4 border-t border-slate-50">
|
||||
<span className="text-xs font-bold text-emerald-500 uppercase tracking-widest">Verified</span>
|
||||
<BaseIcon path={mdiChevronRight} size={20} className="text-slate-300 group-hover:text-emerald-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</SectionMain>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="py-32">
|
||||
<SectionMain>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
{features.map((feature, idx) => (
|
||||
<div key={idx} className="flex flex-col items-center text-center">
|
||||
<div className={`w-16 h-16 ${feature.color} rounded-2xl flex items-center justify-center mb-6`}>
|
||||
<BaseIcon path={feature.icon} size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-4">{feature.title}</h3>
|
||||
<p className="text-slate-500 leading-relaxed">{feature.desc}</p>
|
||||
{/* Trust Features */}
|
||||
<section className="py-24 bg-slate-900 text-white overflow-hidden relative">
|
||||
<div className="container mx-auto px-6 relative z-10">
|
||||
<div className="grid lg:grid-cols-3 gap-12 text-center lg:text-left">
|
||||
<div className="p-8 rounded-3xl bg-white/5 border border-white/10">
|
||||
<div className="w-14 h-14 rounded-2xl bg-emerald-500 flex items-center justify-center mb-6 mx-auto lg:mx-0">
|
||||
<BaseIcon path={mdiShieldCheck} size={28} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionMain>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-24 px-6">
|
||||
<div className="max-w-6xl mx-auto bg-slate-900 rounded-[3rem] p-12 md:p-24 relative overflow-hidden text-center">
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden z-0 opacity-10">
|
||||
<div className="absolute top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[80%] h-[80%] bg-emerald-500 blur-[150px] rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-2xl mx-auto">
|
||||
<h2 className="text-4xl md:text-5xl font-black text-white mb-8 leading-tight">
|
||||
Ready to grow your professional service?
|
||||
</h2>
|
||||
<p className="text-xl text-slate-400 mb-12">
|
||||
Join thousands of verified professionals and start connecting with quality leads today.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href="/auth/register" className="bg-emerald-500 hover:bg-emerald-400 text-slate-900 font-bold px-10 py-5 rounded-2xl transition-all shadow-xl shadow-emerald-500/20 active:scale-95">
|
||||
Register Your Business
|
||||
</Link>
|
||||
<Link href="/contact" className="bg-slate-800 hover:bg-slate-700 text-white font-bold px-10 py-5 rounded-2xl transition-all border border-slate-700">
|
||||
Contact Sales
|
||||
</Link>
|
||||
<h3 className="text-2xl font-bold mb-4">Verified Badges</h3>
|
||||
<p className="text-slate-400 leading-relaxed">Every business undergoes a strict evidence-based verification process. Look for the shield to ensure peace of mind.</p>
|
||||
</div>
|
||||
<div className="p-8 rounded-3xl bg-white/5 border border-white/10">
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-500 flex items-center justify-center mb-6 mx-auto lg:mx-0">
|
||||
<BaseIcon path={mdiCurrencyUsd} size={28} />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold mb-4">Price Transparency</h3>
|
||||
<p className="text-slate-400 leading-relaxed">No more hidden fees. See typical price ranges and median job costs upfront before you ever make a request.</p>
|
||||
</div>
|
||||
<div className="p-8 rounded-3xl bg-white/5 border border-white/10">
|
||||
<div className="w-14 h-14 rounded-2xl bg-amber-500 flex items-center justify-center mb-6 mx-auto lg:mx-0">
|
||||
<BaseIcon path={mdiFlash} size={28} />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold mb-4">AI Smart Matching</h3>
|
||||
<p className="text-slate-400 leading-relaxed">Our matching engine analyzes your issue and finds the best expert based on availability, score, and proximity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
{/* Call to Action */}
|
||||
<section className="py-24 container mx-auto px-6">
|
||||
<div className="bg-emerald-500 rounded-[3rem] p-12 lg:p-20 text-white relative overflow-hidden flex flex-col lg:flex-row items-center justify-between">
|
||||
<div className="relative z-10 max-w-2xl text-center lg:text-left mb-10 lg:mb-0">
|
||||
<h2 className="text-4xl lg:text-5xl font-bold mb-6">Are you a service professional?</h2>
|
||||
<p className="text-emerald-100 text-lg mb-0">Join the most trusted network of professionals and get high-quality leads that actually match your expertise.</p>
|
||||
</div>
|
||||
<div className="relative z-10 flex gap-4">
|
||||
<Link href="/register" className="bg-slate-900 hover:bg-black text-white font-bold py-5 px-10 rounded-2xl transition-all shadow-2xl">
|
||||
Register Business
|
||||
</Link>
|
||||
{!currentUser && (
|
||||
<Link href="/login" className="bg-white hover:bg-slate-100 text-emerald-600 font-bold py-5 px-10 rounded-2xl transition-all">
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
LandingPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
};
|
||||
@ -1,190 +1,276 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import { mdiAccount, mdiAsterisk, mdiShieldCheck, mdiArrowLeft } from '@mdi/js';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import BaseIcon from "../components/BaseIcon";
|
||||
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import FormField from '../components/FormField';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { loginUser, resetAction, findMe } from '../stores/authSlice';
|
||||
import FormCheckRadio from '../components/FormCheckRadio';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getPageTitle } from '../config';
|
||||
import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import Link from 'next/link';
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {};
|
||||
if (!values.email) {
|
||||
errors.email = 'Required';
|
||||
} else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(values.email)) {
|
||||
errors.email = 'Invalid email address';
|
||||
}
|
||||
if (!values.password) {
|
||||
errors.password = 'Required';
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const { isFetching, token } = useAppSelector((state) => state.auth);
|
||||
const { action, businessId } = router.query;
|
||||
const isClaiming = action === 'claim';
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||
const notify = (type, msg) => toast(msg, { type });
|
||||
const [ illustrationImage, setIllustrationImage ] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('video');
|
||||
const [contentPosition, setContentPosition] = useState('left');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||
(state) => state.auth,
|
||||
);
|
||||
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
|
||||
password: 'b2096650',
|
||||
remember: true })
|
||||
|
||||
const title = 'Crafted Network'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect( () => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage()
|
||||
const video = await getPexelsVideo()
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
// Fetch user data
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
dispatch(findMe());
|
||||
// If claiming, redirect back to business details after login
|
||||
if (isClaiming && businessId) {
|
||||
router.push(`/public/businesses-details?id=${businessId}`);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}
|
||||
}, [token, dispatch, isClaiming, businessId, router]);
|
||||
|
||||
}, [token, dispatch]);
|
||||
// Redirect to dashboard if user is logged in
|
||||
useEffect(() => {
|
||||
dispatch(resetAction());
|
||||
}, [dispatch]);
|
||||
if (currentUser?.id) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [currentUser?.id, router]);
|
||||
// Show error message if there is one
|
||||
useEffect(() => {
|
||||
if (errorMessage){
|
||||
notify('error', errorMessage)
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
const { email, password } = values;
|
||||
const rest = { email, password };
|
||||
}, [errorMessage])
|
||||
// Show notification if there is one
|
||||
useEffect(() => {
|
||||
if (notifyState?.showNotification) {
|
||||
notify('success', notifyState?.textNotification)
|
||||
dispatch(resetAction());
|
||||
}
|
||||
}, [notifyState?.showNotification])
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
const handleSubmit = async (value) => {
|
||||
const {remember, ...rest} = value
|
||||
await dispatch(loginUser(rest));
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionFullScreen bg="white">
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')} | {title}</title>
|
||||
</Head>
|
||||
const setLogin = (target: HTMLElement) => {
|
||||
setInitialValues(prev => ({
|
||||
...prev,
|
||||
email : target.innerText.trim(),
|
||||
password: target.dataset.password ?? '',
|
||||
}));
|
||||
};
|
||||
|
||||
<div className="flex flex-col lg:flex-row min-h-screen w-full overflow-hidden">
|
||||
{/* Left Side: Visual/Branding */}
|
||||
<div className="hidden lg:flex lg:w-1/2 bg-slate-900 items-center justify-center p-12 relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full opacity-20">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-emerald-500 blur-[120px] rounded-full"></div>
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-blue-600 blur-[120px] rounded-full"></div>
|
||||
const imageBlock = (image) => (
|
||||
<div className="hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3"
|
||||
style={{
|
||||
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}>
|
||||
<div className="flex justify-center w-full bg-blue-300/20">
|
||||
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
|
||||
by {image?.photographer} on Pexels</a>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-center space-y-8 max-w-md">
|
||||
<div className="inline-flex p-4 rounded-3xl bg-emerald-500 shadow-2xl shadow-emerald-500/20 rotate-3">
|
||||
<BaseIcon path={mdiShieldCheck} size={48} className="text-slate-900" />
|
||||
</div>
|
||||
<h1 className="text-5xl font-black text-white tracking-tight">
|
||||
The <span className="text-emerald-400">Crafted</span> Network
|
||||
</h1>
|
||||
<p className="text-xl text-slate-400 leading-relaxed font-medium">
|
||||
Join the world's most trusted network for verified professional services.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Login Form */}
|
||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-6 md:p-12 bg-white">
|
||||
<div className="w-full max-w-md space-y-10 animate-fade-in">
|
||||
{/* Back Button */}
|
||||
<Link href="/" className="inline-flex items-center text-sm font-bold text-slate-400 hover:text-emerald-600 transition-colors group">
|
||||
<BaseIcon path={mdiArrowLeft} size={20} className="mr-2 group-hover:-translate-x-1 transition-transform" />
|
||||
Back to Home
|
||||
</Link>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-4xl font-black text-slate-900 tracking-tight">Welcome Back</h2>
|
||||
|
||||
{isClaiming ? (
|
||||
<div className="bg-emerald-50 p-6 rounded-2xl border border-emerald-100 flex items-start space-x-4">
|
||||
<div className="p-2 bg-emerald-100 rounded-xl text-emerald-600">
|
||||
<BaseIcon path={mdiShieldCheck} size={24} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-bold text-emerald-900">Verify Ownership</h4>
|
||||
<p className="text-emerald-700 text-sm">Please login or create an account to verify ownership and take control of your business profile.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500">Log in to manage your professional presence and track your requests.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardBox className="border-none shadow-none p-0">
|
||||
<Formik
|
||||
initialValues={{ email: '', password: '' }}
|
||||
validate={validate}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{({ errors, touched }) => (
|
||||
<Form className="space-y-6">
|
||||
<FormField label="Email Address" labelColor="text-slate-900 font-bold" help={touched.email && errors.email ? (errors.email as string) : "Your registered professional email"}>
|
||||
<Field
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="alex@example.com"
|
||||
className={`w-full px-5 py-4 bg-slate-50 border-2 ${touched.email && errors.email ? 'border-red-500' : 'border-slate-100'} rounded-2xl focus:border-emerald-500 focus:ring-0 transition-all font-medium text-slate-900`}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" labelColor="text-slate-900 font-bold" help={touched.password && errors.password ? (errors.password as string) : "Security first — keep it safe"}>
|
||||
<Field
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
className={`w-full px-5 py-4 bg-slate-50 border-2 ${touched.password && errors.password ? 'border-red-500' : 'border-slate-100'} rounded-2xl focus:border-emerald-500 focus:ring-0 transition-all font-medium text-slate-900`}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center justify-between text-sm font-bold pt-2">
|
||||
<Link href="/forgot-password" size="sm" className="text-emerald-600 hover:text-emerald-500 transition-colors">
|
||||
Forgot Password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
type="submit"
|
||||
color="emerald"
|
||||
label={isFetching ? 'Verifying...' : 'Login to Dashboard'}
|
||||
className="w-full py-5 rounded-2xl font-black text-lg shadow-xl shadow-emerald-500/20 active:scale-[0.98] transition-all"
|
||||
disabled={isFetching}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
<div className="pt-10 text-center space-y-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-slate-100"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase font-bold tracking-widest text-slate-400">
|
||||
<span className="bg-white px-4">New to the Network?</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={isClaiming ? `/register?action=claim&businessId=${businessId}` : "/register"}
|
||||
className="block w-full py-4 px-6 rounded-2xl bg-slate-50 border-2 border-slate-100 text-slate-900 font-bold hover:bg-slate-100 transition-all text-center"
|
||||
>
|
||||
Create Professional Account
|
||||
</Link>
|
||||
|
||||
<p className='text-xs font-medium text-slate-400 pt-8'>
|
||||
© 2026 <span>{title}</span>. All rights reserved. Professional Directory Platform.
|
||||
</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
)
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video.user.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={contentPosition === 'background' ? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
} : {}}>
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
||||
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
||||
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
|
||||
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
||||
|
||||
<h2 className="text-4xl font-semibold my-4">{title}</h2>
|
||||
|
||||
<div className='flex flex-row text-gray-500 justify-between'>
|
||||
<div>
|
||||
|
||||
<p className='mb-2'>Use{' '}
|
||||
<code className={`cursor-pointer ${textColor} `}
|
||||
data-password="b2096650"
|
||||
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
||||
<code className={`${textColor}`}>b2096650</code>{' / '}
|
||||
to login as Admin</p>
|
||||
<p>Use <code
|
||||
className={`cursor-pointer ${textColor} `}
|
||||
data-password="7302e7d1c0fe"
|
||||
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
|
||||
<code className={`${textColor}`}>7302e7d1c0fe</code>{' / '}
|
||||
to login as User</p>
|
||||
</div>
|
||||
<div>
|
||||
<BaseIcon
|
||||
className={`${iconsColor}`}
|
||||
w='w-16'
|
||||
h='h-16'
|
||||
size={48}
|
||||
path={mdiInformation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
enableReinitialize
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<FormField
|
||||
label='Login'
|
||||
help='Please enter your login'>
|
||||
<Field name='email' />
|
||||
</FormField>
|
||||
|
||||
<div className='relative'>
|
||||
<FormField
|
||||
label='Password'
|
||||
help='Please enter your password'>
|
||||
<Field name='password' type={showPassword ? 'text' : 'password'} />
|
||||
</FormField>
|
||||
<div
|
||||
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
|
||||
onClick={togglePasswordVisibility}
|
||||
>
|
||||
<BaseIcon
|
||||
className='text-gray-500 hover:text-gray-700'
|
||||
size={20}
|
||||
path={showPassword ? mdiEyeOff : mdiEye}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-between'}>
|
||||
<FormCheckRadio type='checkbox' label='Remember'>
|
||||
<Field type='checkbox' name='remember' />
|
||||
</FormCheckRadio>
|
||||
|
||||
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
className={'w-full'}
|
||||
type='submit'
|
||||
label={isFetching ? 'Loading...' : 'Login'}
|
||||
color='info'
|
||||
disabled={isFetching}
|
||||
/>
|
||||
</BaseButtons>
|
||||
<br />
|
||||
<p className={'text-center'}>
|
||||
Don’t have an account yet?{' '}
|
||||
<Link className={`${textColor}`} href={'/register'}>
|
||||
New Account
|
||||
</Link>
|
||||
</p>
|
||||
</Form>
|
||||
</Formik>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. © All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LoginPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return page;
|
||||
};
|
||||
Login.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
@ -1,73 +1,292 @@
|
||||
import React from 'react';
|
||||
import Head from 'next/head';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
const PrivacyPolicyPage = () => {
|
||||
const title = 'Crafted Network';
|
||||
export default function PrivacyPolicy() {
|
||||
const title = 'Crafted Network'
|
||||
const [projectUrl, setProjectUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setProjectUrl(location.origin);
|
||||
}, []);
|
||||
|
||||
const Introduction = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>1. Introduction</h3>
|
||||
<p>
|
||||
{/* eslint-disable-next-line react/no-unescaped-entities */}
|
||||
We at <span>{title}</span> ("we", "us", "our") are committed to
|
||||
protecting your privacy. This Privacy Policy explains how we collect,
|
||||
use, disclose, and safeguard your information when you visit our
|
||||
website <a href={projectUrl}>{projectUrl}</a>, use our services, or
|
||||
interact with us in other ways. By using our services, you agree to
|
||||
the collection and use of information in accordance with this policy.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Information = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>2. Information We Collect</h3>
|
||||
<div className='ml-2'>
|
||||
<h4>2.1 Personal Identification Information</h4>
|
||||
<p>
|
||||
We collect various types of personal information in connection with
|
||||
the services we provide, including:
|
||||
</p>
|
||||
<ul role='list'>
|
||||
<li>
|
||||
Contact Information: Name, email address, phone number, mailing
|
||||
address.
|
||||
</li>
|
||||
<li>Account Information: Username, password, profile picture.</li>
|
||||
<li>Payment Information: Credit card details, billing address.</li>
|
||||
<li>Demographic Information: Age, gender, interests.</li>
|
||||
</ul>
|
||||
<h4>2.2 Technical Data</h4>
|
||||
<p>
|
||||
We automatically collect certain information when you visit, use, or
|
||||
navigate our services. This information may include:
|
||||
</p>
|
||||
<ul role='list'>
|
||||
<li>
|
||||
Device Information: IP address, browser type, operating system,
|
||||
device type.
|
||||
</li>
|
||||
<li>
|
||||
Usage Data: Pages visited, time spent on each page, links clicked,
|
||||
and other actions taken on our site.
|
||||
</li>
|
||||
</ul>
|
||||
<h4>2.3 Cookies and Tracking Technologies</h4>
|
||||
<p>
|
||||
We use cookies and similar tracking technologies to track the
|
||||
activity on our service and hold certain information. You can
|
||||
instruct your browser to refuse all cookies or to indicate when a
|
||||
cookie is being sent.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const HowToUser = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>3. How We Use Your Information</h3>
|
||||
<p>We use the information we collect in various ways, including to:</p>
|
||||
<ul role='list' className=''>
|
||||
<li>Provide, operate, and maintain our website and services.</li>
|
||||
<li>Improve, personalize, and expand our website and services.</li>
|
||||
<li>Understand and analyze how you use our website and services.</li>
|
||||
<li>Develop new products, services, features, and functionality.</li>
|
||||
<li>
|
||||
Communicate with you, either directly or through one of our
|
||||
partners, including for customer service, to provide you with
|
||||
updates and other information relating to the website, and for
|
||||
marketing and promotional purposes.
|
||||
</li>
|
||||
<li>
|
||||
Process your transactions and send you related information,
|
||||
including purchase confirmations and invoices.
|
||||
</li>
|
||||
<li>Find and prevent fraud.</li>
|
||||
<li>Comply with legal obligations.</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DataProtection = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>4. Data Protection and Security</h3>
|
||||
<p>
|
||||
We implement a variety of security measures to maintain the safety of
|
||||
your personal information. These measures include:
|
||||
</p>
|
||||
<ul role='list'>
|
||||
<li>
|
||||
Encryption: We use encryption to protect sensitive information
|
||||
transmitted online. Access Controls: We restrict access to your
|
||||
personal data to authorized personnel only. Regular Security Audits:
|
||||
We conduct regular audits to identify and address potential security
|
||||
vulnerabilities.
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Sharing = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>5. Sharing Your Information</h3>
|
||||
<p>
|
||||
We do not sell, trade, or otherwise transfer your Personally
|
||||
Identifiable Information to outside parties without your consent,
|
||||
except in the following cases:
|
||||
</p>
|
||||
<ul role='list'>
|
||||
<li>
|
||||
Service Providers: We may share your information with third-party
|
||||
service providers who perform services on our behalf, such as
|
||||
payment processing, data analysis, email delivery, hosting services,
|
||||
customer service, and marketing assistance.
|
||||
</li>
|
||||
<li>
|
||||
Business Transfers: In the event of a merger, acquisition, or sale
|
||||
of all or a portion of our assets, your information may be
|
||||
transferred as part of that transaction.
|
||||
</li>
|
||||
<li>
|
||||
Legal Requirements: We may disclose your information if required to
|
||||
do so by law or in response to valid requests by public authorities
|
||||
(e.g., a court or a government agency).
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ProtectionRights = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>6. Your Data Protection Rights</h3>
|
||||
<p>
|
||||
Depending on your location, you may have the following rights
|
||||
regarding your personal data:
|
||||
</p>
|
||||
<ul role='list'>
|
||||
<li>
|
||||
The Right to Access: You have the right to request copies of your
|
||||
personal data.
|
||||
</li>
|
||||
<li>
|
||||
The Right to Rectification: You have the right to request that we
|
||||
correct any information you believe is inaccurate or complete
|
||||
information you believe is incomplete.
|
||||
</li>
|
||||
<li>
|
||||
The Right to Erasure: You have the right to request that we erase
|
||||
your personal data, under certain conditions.
|
||||
</li>
|
||||
<li>
|
||||
The Right to Restrict Processing: You have the right to request that
|
||||
we restrict the processing of your personal data, under certain
|
||||
conditions.
|
||||
</li>
|
||||
<li>
|
||||
The Right to Object to Processing: You have the right to object to
|
||||
our processing of your personal data, under certain conditions.
|
||||
</li>
|
||||
<li>
|
||||
The Right to Data Portability: You have the right to request that we
|
||||
transfer the data that we have collected to another organization, or
|
||||
directly to you, under certain conditions.
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DataTransfers = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>7. International Data Transfers</h3>
|
||||
<p>
|
||||
Your information, including personal data, may be transferred to — and
|
||||
maintained on — computers located outside of your state, province,
|
||||
country, or other governmental jurisdiction where the data protection
|
||||
laws may differ from those of your jurisdiction. We will take all
|
||||
steps reasonably necessary to ensure that your data is treated
|
||||
securely and in accordance with this Privacy Policy.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const RetentionOfData = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>8. Retention of Data</h3>
|
||||
<p>
|
||||
We will retain your personal data only for as long as is necessary for
|
||||
the purposes set out in this Privacy Policy. We will retain and use
|
||||
your personal data to the extent necessary to comply with our legal
|
||||
obligations, resolve disputes, and enforce our policies.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangePrivacy = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>9. Changes to This Privacy Policy</h3>
|
||||
<p>
|
||||
We may update our Privacy Policy from time to time. We will notify you
|
||||
of any changes by posting the new Privacy Policy on this page. You are
|
||||
advised to review this Privacy Policy periodically for any changes.
|
||||
Changes to this Privacy Policy are effective when they are posted on
|
||||
this page.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ContactUs = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>10. Contact Us</h3>
|
||||
<p>
|
||||
If you have any questions about this Privacy Policy, please contact
|
||||
us:
|
||||
</p>
|
||||
<div>
|
||||
By email:{' '}
|
||||
<a href='mailto:support@flatlogic.com'> [support@flatlogic.com]</a>
|
||||
</div>
|
||||
<div>
|
||||
By visiting this page on our website:{' '}
|
||||
<a href='https://flatlogic.com/contact'>Contact Us</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='prose prose-slate mx-auto max-w-none'>
|
||||
<Head>
|
||||
<title>{getPageTitle('Privacy Policy')} | {title}</title>
|
||||
<title>{getPageTitle('Privacy Policy')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionMain className="py-32">
|
||||
<div className="max-w-3xl mx-auto space-y-12 animate-fade-in">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-5xl font-black text-slate-900 tracking-tight leading-tight">Privacy Policy</h1>
|
||||
<p className="text-slate-500 font-medium italic">Last updated: February 17, 2026</p>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-slate max-w-none text-slate-600 space-y-8 font-medium leading-relaxed">
|
||||
<p className="text-xl text-slate-900 font-bold">
|
||||
We at <span>{title}</span> ("we", "us", "our") are committed to protecting your privacy and personal data.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-black text-slate-900">1. Information We Collect</h2>
|
||||
<p>
|
||||
We collect personal information such as your name, email address, and professional details when you register on our platform. We also collect information about your interactions with the service directory to improve our matching algorithm.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-black text-slate-900">2. How We Use Your Information</h2>
|
||||
<p>
|
||||
Your information is used to provide and improve the <span>{title}</span> services, facilitate communication between professionals and clients, and enhance the overall user experience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-black text-slate-900">3. Data Sharing and Security</h2>
|
||||
<p>
|
||||
We do not sell your personal information to third parties. We use industry-standard security measures to protect your data from unauthorized access or disclosure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-black text-slate-900">4. Your Rights</h2>
|
||||
<p>
|
||||
You have the right to access, correct, or delete your personal information at any time. You can manage your privacy settings through your professional profile.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 border-t border-slate-100 pt-12">
|
||||
<p className="text-sm font-bold text-slate-400 uppercase tracking-widest">
|
||||
© 2026 Crafted Network™. Built with Trust & Transparency.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex justify-center'>
|
||||
<div className='z-10 md:w-10/12 my-4 bg-white border border-pavitra-400 rounded'>
|
||||
<div className='p-8 lg:px-12 lg:py-10'>
|
||||
<h1>Privacy Policy</h1>
|
||||
<Introduction />
|
||||
<Information />
|
||||
<HowToUser />
|
||||
<DataProtection />
|
||||
<Sharing />
|
||||
<ProtectionRights />
|
||||
<DataTransfers />
|
||||
<RetentionOfData />
|
||||
<ChangePrivacy />
|
||||
<ContactUs />
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
PrivacyPolicyPage.getLayout = function getLayout(page: ReactElement) {
|
||||
PrivacyPolicy.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
export default PrivacyPolicyPage;
|
||||
|
||||
@ -1,349 +1,372 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
mdiMapMarker,
|
||||
mdiStar,
|
||||
mdiShieldCheck,
|
||||
mdiClockOutline,
|
||||
mdiMapMarker,
|
||||
mdiPhone,
|
||||
mdiEmail,
|
||||
mdiWeb,
|
||||
mdiChevronRight,
|
||||
mdiImageMultiple,
|
||||
mdiMessageTextOutline,
|
||||
mdiShareVariant,
|
||||
mdiFlagOutline,
|
||||
mdiAccountCheck,
|
||||
mdiCalendarCheck,
|
||||
mdiMagnify
|
||||
mdiEmail,
|
||||
mdiCurrencyUsd,
|
||||
mdiCheckDecagram,
|
||||
mdiMessageDraw,
|
||||
mdiAccount
|
||||
} from '@mdi/js';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { fetch as fetchBusiness } from '../../stores/businesses/businessesSlice';
|
||||
import axios from 'axios';
|
||||
import LayoutGuest from '../../layouts/Guest';
|
||||
import SectionMain from '../../components/SectionMain';
|
||||
import BaseIcon from '../../components/BaseIcon';
|
||||
import BaseButton from '../../components/BaseButton';
|
||||
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||
import { dateFormatter } from '../../helpers/dataFormatter';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import { useAppSelector } from '../../stores/hooks';
|
||||
|
||||
export default function BusinessDetailsPage() {
|
||||
const BusinessDetailsPublic = () => {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const dispatch = useAppDispatch();
|
||||
const { item: business, isAskingResponse: loading } = useAppSelector((state) => state.businesses);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [business, setBusiness] = useState<any>(null);
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [activeTab, setActiveTab] = useState('about');
|
||||
|
||||
useEffect(() => {
|
||||
if (id && typeof id === 'string') {
|
||||
dispatch(fetchBusiness({ id }));
|
||||
if (id) {
|
||||
fetchBusiness();
|
||||
}
|
||||
}, [id, dispatch]);
|
||||
}, [id]);
|
||||
|
||||
const claimListing = () => {
|
||||
if (!currentUser) {
|
||||
router.push(`/login?action=claim&businessId=${id}`);
|
||||
} else {
|
||||
// In a real app, this would trigger the claim process thunk
|
||||
alert('Verification process started. Our team will contact you to verify ownership.');
|
||||
const fetchBusiness = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`/businesses/${id}`);
|
||||
setBusiness(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching business:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !business) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const claimListing = async () => {
|
||||
if (!currentUser) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.post(`/businesses/${id}/claim`);
|
||||
fetchBusiness(); // Refresh data
|
||||
} catch (error) {
|
||||
console.error('Error claiming business:', error);
|
||||
alert('Failed to claim business. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const isVerified = business.isVerified;
|
||||
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 null;
|
||||
};
|
||||
|
||||
if (loading) return <div className="min-h-screen flex items-center justify-center bg-slate-50"><LoadingSpinner /></div>;
|
||||
if (!business) return <div className="min-h-screen flex items-center justify-center bg-slate-50">Business not found.</div>;
|
||||
|
||||
const displayRating = business.rating ? Number(business.rating).toFixed(1) : 'New';
|
||||
|
||||
return (
|
||||
<div className="bg-white min-h-screen font-sans selection:bg-emerald-500/30">
|
||||
<div className="min-h-screen bg-slate-50 pb-20 pt-20">
|
||||
<Head>
|
||||
<title>{business.name} | Crafted Network™</title>
|
||||
</Head>
|
||||
|
||||
{/* Hero Header */}
|
||||
<div className="relative h-[450px] bg-slate-900 overflow-hidden">
|
||||
{business.business_photos?.[0]?.photo_url ? (
|
||||
<img
|
||||
src={business.business_photos[0].photo_url}
|
||||
alt={business.name}
|
||||
className="w-full h-full object-cover opacity-60 scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-slate-900 via-slate-800 to-emerald-900/20" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-transparent" />
|
||||
</div>
|
||||
<section className="bg-white border-b border-slate-200 pt-16 pb-12">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="flex flex-col lg:flex-row gap-12 items-start">
|
||||
{/* Business Photo */}
|
||||
<div className="w-32 h-32 lg:w-48 lg:h-48 bg-slate-100 rounded-[2.5rem] overflow-hidden flex items-center justify-center shadow-inner relative flex-shrink-0">
|
||||
{getBusinessImage() ? (
|
||||
<img
|
||||
src={getBusinessImage()}
|
||||
alt={business.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<BaseIcon path={mdiShieldCheck} size={64} className="text-slate-300" />
|
||||
)}
|
||||
{(business.reliability_score >= 80 || business.is_claimed) && (
|
||||
<div className="absolute -top-2 -right-2 bg-emerald-500 text-white p-2 rounded-full shadow-lg">
|
||||
<BaseIcon path={mdiCheckDecagram} size={24} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionMain className="-mt-32 relative z-10 pb-32">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
|
||||
{/* Main Info */}
|
||||
<div className="lg:col-span-2 space-y-12">
|
||||
<div className="bg-white p-10 rounded-[2.5rem] shadow-2xl shadow-slate-200/50 border border-slate-100 animate-fade-in">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6 mb-10">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{isVerified ? (
|
||||
<span className="bg-emerald-50 text-emerald-600 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider flex items-center shadow-sm border border-emerald-100">
|
||||
<BaseIcon path={mdiShieldCheck} size={16} className="mr-1" />
|
||||
Verified Business
|
||||
<div className="flex-grow w-full">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-6">
|
||||
<div>
|
||||
<h1 className="text-4xl lg:text-5xl font-bold mb-3">{business.name}</h1>
|
||||
<div className="flex flex-wrap items-center gap-4 text-slate-500 font-medium">
|
||||
<span className="flex items-center">
|
||||
<BaseIcon path={mdiMapMarker} size={18} className="mr-1 text-emerald-500" />
|
||||
{business.city}, {business.state}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<BaseIcon path={mdiStar} size={18} className="mr-1 text-amber-400" />
|
||||
{displayRating} Rating
|
||||
</span>
|
||||
{business.is_claimed ? (
|
||||
<span className="bg-emerald-50 text-emerald-600 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider">
|
||||
Verified Pro
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-slate-100 text-slate-500 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider flex items-center border border-slate-200">
|
||||
Unverified Listing
|
||||
<span className="bg-slate-100 text-slate-500 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider flex items-center">
|
||||
Unclaimed Listing
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-blue-50 text-blue-600 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider border border-blue-100">
|
||||
{business.business_categories?.[0]?.category?.name || 'Professional'}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-5xl font-black text-slate-900 tracking-tight leading-tight">{business.name}</h1>
|
||||
<div className="flex items-center text-slate-500 text-lg font-medium">
|
||||
<BaseIcon path={mdiMapMarker} size={20} className="mr-2 text-emerald-500" />
|
||||
{business.locations?.[0]?.address || 'Multiple Locations'} • {business.locations?.[0]?.city}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center p-6 bg-slate-50 rounded-3xl border border-slate-100 shadow-inner">
|
||||
<div className="flex items-center text-4xl font-black text-slate-900 mb-1">
|
||||
4.9
|
||||
<BaseIcon path={mdiStar} size={32} className="text-amber-400 ml-1" />
|
||||
</div>
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-widest">Trust Score</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => router.push(`/public/request-service?businessId=${business.id}`)}
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-xl shadow-emerald-500/20"
|
||||
>
|
||||
Request Service
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 pt-8 border-t border-slate-50">
|
||||
<BaseButton
|
||||
label="Contact Professional"
|
||||
color="emerald"
|
||||
className="px-10 py-4 rounded-2xl font-black shadow-xl shadow-emerald-500/20"
|
||||
icon={mdiMessageTextOutline}
|
||||
/>
|
||||
<BaseButton
|
||||
label="Request Quote"
|
||||
color="white"
|
||||
className="px-10 py-4 rounded-2xl font-black border-2 border-slate-100 hover:bg-slate-50"
|
||||
icon={mdiCalendarCheck}
|
||||
href={`/public/request-service?businessId=${business.id}`}
|
||||
/>
|
||||
<button className="p-4 rounded-2xl bg-slate-50 text-slate-400 hover:text-emerald-500 transition-colors">
|
||||
<BaseIcon path={mdiShareVariant} size={24} />
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 py-6 border-t border-slate-100">
|
||||
<div>
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Avg Rating</div>
|
||||
<div className="text-2xl font-bold text-slate-900">{displayRating} / 5.0</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Response Time</div>
|
||||
<div className="text-2xl font-bold text-slate-900">~{business.response_time_median_minutes || 30}m</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Status</div>
|
||||
<div className="flex items-center text-emerald-500 font-bold">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 mr-2 animate-pulse"></div>
|
||||
Available
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Total Reviews</div>
|
||||
<div className="text-2xl font-bold text-slate-900">{business.reviews_business?.length || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="border-b border-slate-100 flex items-center space-x-12 px-2 overflow-x-auto scrollbar-hide">
|
||||
{['about', 'services', 'reviews', 'photos'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`pb-4 text-sm font-black uppercase tracking-widest transition-all relative ${
|
||||
activeTab === tab ? 'text-emerald-600' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
<div className="container mx-auto px-6 py-12">
|
||||
<div className="grid lg:grid-cols-3 gap-12">
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-12">
|
||||
|
||||
{!business.is_claimed && (
|
||||
<div className="bg-amber-50 border border-amber-200 p-8 rounded-[2rem] flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div>
|
||||
<h4 className="text-xl font-bold text-amber-900 mb-2">Is this your business?</h4>
|
||||
<p className="text-amber-700">Claim your listing to respond to reviews, update your profile, and get more leads.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={claimListing}
|
||||
className="bg-amber-500 hover:bg-amber-600 text-white font-bold py-3 px-8 rounded-xl transition-all flex-shrink-0"
|
||||
>
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<div className="absolute bottom-0 left-0 w-full h-1 bg-emerald-500 rounded-full animate-fade-in" />
|
||||
)}
|
||||
Claim Listing
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="min-h-[400px]">
|
||||
{activeTab === 'about' && (
|
||||
<div className="space-y-12 animate-fade-in">
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<h3 className="text-2xl font-black text-slate-900 mb-6">About the Business</h3>
|
||||
<p className="text-lg text-slate-600 leading-relaxed font-medium italic">
|
||||
{business.description || 'This professional has not provided a detailed description yet.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="bg-slate-50 p-8 rounded-3xl border border-slate-100 space-y-4">
|
||||
<h4 className="font-bold text-slate-900 flex items-center">
|
||||
<BaseIcon path={mdiClockOutline} size={20} className="mr-2 text-emerald-500" />
|
||||
Business Hours
|
||||
</h4>
|
||||
<div className="space-y-2 text-sm font-medium text-slate-600">
|
||||
<div className="flex justify-between"><span>Mon - Fri</span><span>09:00 - 18:00</span></div>
|
||||
<div className="flex justify-between"><span>Saturday</span><span>10:00 - 15:00</span></div>
|
||||
<div className="flex justify-between font-bold text-emerald-600 italic"><span>Sunday</span><span>Closed</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 p-8 rounded-3xl border border-slate-100 space-y-4">
|
||||
<h4 className="font-bold text-slate-900 flex items-center">
|
||||
<BaseIcon path={mdiAccountCheck} size={20} className="mr-2 text-emerald-500" />
|
||||
Verification Status
|
||||
</h4>
|
||||
<p className="text-sm font-medium text-slate-500 italic">
|
||||
{isVerified
|
||||
? 'This business has verified its identity, ownership, and credentials with the Crafted Network.'
|
||||
: 'Identity verification is pending for this listing.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'reviews' && (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-2xl font-black text-slate-900">Customer Feedback</h3>
|
||||
<BaseButton label="Write a Review" color="emerald" small className="rounded-xl px-6" />
|
||||
</div>
|
||||
|
||||
{business.reviews && business.reviews.length > 0 ? (
|
||||
business.reviews.map((review: any) => (
|
||||
<div key={review.id} className="p-8 bg-slate-50 rounded-3xl border border-slate-100 space-y-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 bg-emerald-100 rounded-full flex items-center justify-center font-bold text-emerald-600 shadow-sm">
|
||||
{review.user?.firstName?.[0] || 'U'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-black text-slate-900">{review.user?.firstName} {review.user?.lastName}</div>
|
||||
<div className="text-xs text-slate-400 font-medium">{dateFormatter(review.created_at_ts)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex text-amber-400">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<BaseIcon key={i} path={mdiStar} size={16} className={i < review.rating ? 'fill-current' : 'text-slate-200'} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-slate-600 font-medium leading-relaxed italic">"{review.comment}"</p>
|
||||
{/* Photos Gallery */}
|
||||
{business.business_photos_business?.length > 0 && (
|
||||
<section className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
|
||||
<h3 className="text-2xl font-bold mb-6">Photos</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{business.business_photos_business.map((bp: any) => (
|
||||
bp.photos?.map((p: any) => (
|
||||
<div key={p.id} className="aspect-square rounded-2xl overflow-hidden bg-slate-100">
|
||||
<img
|
||||
src={`/api/file/download?privateUrl=${p.publicUrl}`}
|
||||
alt="Business"
|
||||
className="w-full h-full object-cover hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-24 bg-slate-50 rounded-[2.5rem] border-2 border-dashed border-slate-200">
|
||||
<BaseIcon path={mdiStar} size={48} className="mx-auto text-slate-200 mb-6" />
|
||||
<p className="text-slate-400 font-bold">No reviews yet. Be the first to share your experience!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'photos' && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-6 animate-fade-in">
|
||||
{business.business_photos?.map((photo: any) => (
|
||||
<div key={photo.id} className="group relative aspect-square bg-slate-100 rounded-3xl overflow-hidden cursor-pointer">
|
||||
<img
|
||||
src={photo.photo_url}
|
||||
alt="Gallery"
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-slate-900/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<BaseIcon path={mdiMagnify} size={48} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!business.business_photos || business.business_photos.length === 0) && (
|
||||
<div className="col-span-full text-center py-24 bg-slate-50 rounded-[2.5rem] border-2 border-dashed border-slate-200">
|
||||
<BaseIcon path={mdiImageMultiple} size={48} className="mx-auto text-slate-200 mb-6" />
|
||||
<p className="text-slate-400 font-bold">No photos available yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* About */}
|
||||
<section className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
|
||||
<h3 className="text-2xl font-bold mb-6">About the Business</h3>
|
||||
<div className="text-slate-600 leading-relaxed text-lg"
|
||||
dangerouslySetInnerHTML={{ __html: business.description || 'No description provided.' }} />
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
|
||||
<h3 className="text-2xl font-bold mb-6">Service Pricing Range</h3>
|
||||
<div className="grid gap-4">
|
||||
{business.service_prices_business?.map((price: any) => (
|
||||
<div key={price.id} className="flex items-center justify-between p-6 rounded-2xl bg-slate-50 hover:bg-emerald-50 transition-colors group">
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-800 text-lg group-hover:text-emerald-700">{price.service_name}</h4>
|
||||
<p className="text-slate-500 text-sm">{price.notes || 'Standard professional service.'}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-emerald-600 font-bold text-xl">${price.typical_price}</div>
|
||||
<div className="text-xs text-slate-400 font-medium">Typical Price</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!business.service_prices_business?.length && <p className="text-slate-500">No pricing information available.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Reviews */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h3 className="text-2xl font-bold">Customer Reviews</h3>
|
||||
<button
|
||||
onClick={() => router.push(`/reviews/reviews-new?businessId=${business.id}`)}
|
||||
className="flex items-center gap-2 bg-white border border-slate-200 px-6 py-3 rounded-2xl text-emerald-600 font-bold hover:bg-slate-50 transition-all shadow-sm"
|
||||
>
|
||||
<BaseIcon path={mdiMessageDraw} size={20} />
|
||||
Write a Review
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-6">
|
||||
{business.reviews_business?.map((review: any) => (
|
||||
<div key={review.id} className="bg-white p-8 rounded-3xl border border-slate-200 shadow-sm hover:shadow-md transition-all">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center gap-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<BaseIcon key={i} path={mdiStar} size={18} className={i < review.rating ? 'text-amber-400' : 'text-slate-200'} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 font-medium">{dataFormatter.dateFormatter(review.created_at_ts)}</span>
|
||||
</div>
|
||||
<p className="text-slate-700 leading-relaxed mb-4 italic text-lg">"{review.text}"</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-slate-400">
|
||||
<BaseIcon path={mdiAccount} size={18} />
|
||||
</div>
|
||||
<span className="text-sm font-bold text-slate-600">
|
||||
{review.user?.firstName || 'Anonymous'}
|
||||
</span>
|
||||
</div>
|
||||
{review.is_verified_job && (
|
||||
<div className="inline-flex items-center text-[10px] font-bold text-emerald-600 uppercase tracking-widest bg-emerald-50 px-2 py-1 rounded">
|
||||
<BaseIcon path={mdiShieldCheck} size={14} className="mr-1" />
|
||||
Verified Job
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!business.reviews_business?.length && (
|
||||
<div className="text-center py-20 bg-white rounded-[3rem] border border-dashed border-slate-300 text-slate-400">
|
||||
<BaseIcon path={mdiMessageDraw} size={48} className="mx-auto mb-4 opacity-20" />
|
||||
<p className="text-xl font-medium">No reviews yet.</p>
|
||||
<p className="mb-6">Be the first to share your experience!</p>
|
||||
<button
|
||||
onClick={() => router.push(`/reviews/reviews-new?businessId=${business.id}`)}
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-3 px-8 rounded-xl"
|
||||
>
|
||||
Write Review
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-8">
|
||||
{/* Contact Card */}
|
||||
<div className="bg-slate-900 p-8 rounded-[2.5rem] text-white space-y-8 shadow-2xl shadow-slate-300">
|
||||
<h4 className="text-xl font-black">Contact Information</h4>
|
||||
<div className="space-y-6">
|
||||
<a href={`tel:${business.phoneNumber}`} className="flex items-center group">
|
||||
<div className="w-12 h-12 bg-white/10 rounded-2xl flex items-center justify-center mr-4 group-hover:bg-emerald-500 group-hover:text-slate-900 transition-all">
|
||||
<BaseIcon path={mdiPhone} size={20} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-widest">Phone</div>
|
||||
<div className="font-bold">{business.phoneNumber}</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href={`mailto:${business.email}`} className="flex items-center group">
|
||||
<div className="w-12 h-12 bg-white/10 rounded-2xl flex items-center justify-center mr-4 group-hover:bg-emerald-500 group-hover:text-slate-900 transition-all">
|
||||
<BaseIcon path={mdiEmail} size={20} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-widest">Email</div>
|
||||
<div className="font-bold truncate max-w-[180px]">{business.email}</div>
|
||||
</div>
|
||||
</a>
|
||||
{business.website && (
|
||||
<a href={business.website} target="_blank" rel="noopener noreferrer" className="flex items-center group">
|
||||
<div className="w-12 h-12 bg-white/10 rounded-2xl flex items-center justify-center mr-4 group-hover:bg-emerald-500 group-hover:text-slate-900 transition-all">
|
||||
<BaseIcon path={mdiWeb} size={20} />
|
||||
{/* Contact Info */}
|
||||
<div className="bg-slate-900 text-white p-10 rounded-[3rem] shadow-xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-emerald-500/10 rounded-full -mr-16 -mt-16 group-hover:scale-110 transition-transform"></div>
|
||||
<h3 className="text-xl font-bold mb-8">Contact & Location</h3>
|
||||
<div className="space-y-6 relative z-10">
|
||||
<div className="flex items-start">
|
||||
<BaseIcon path={mdiPhone} size={24} className="mr-4 text-emerald-400" />
|
||||
<div>
|
||||
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Call Now</div>
|
||||
<div className="font-bold">{business.phone || 'Contact for details'}</div>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-widest">Website</div>
|
||||
<div className="font-bold truncate max-w-[180px]">{business.website.replace(/^https?:\/\/(www\.)?/, '')}</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<BaseIcon path={mdiEmail} size={24} className="mr-4 text-emerald-400" />
|
||||
<div>
|
||||
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Email</div>
|
||||
<div className="font-bold truncate max-w-[180px]">{business.email}</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<BaseIcon path={mdiWeb} size={24} className="mr-4 text-emerald-400" />
|
||||
<div>
|
||||
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Website</div>
|
||||
<div className="font-bold truncate max-w-[180px]">{business.website || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<BaseIcon path={mdiMapMarker} size={24} className="mr-4 text-emerald-400" />
|
||||
<div>
|
||||
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Address</div>
|
||||
<div className="font-bold">{business.address}, {business.city}, {business.state} {business.zip}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 border-t border-white/10 flex items-center justify-between">
|
||||
<button className="flex items-center text-sm font-bold text-slate-400 hover:text-white transition-colors">
|
||||
<BaseIcon path={mdiFlagOutline} size={16} className="mr-2" />
|
||||
Report Problem
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-white/5 flex items-center justify-center hover:bg-white/10 transition-colors cursor-pointer">
|
||||
<BaseIcon path={mdiStar} size={16} />
|
||||
</div>
|
||||
{/* Badges */}
|
||||
<div className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
|
||||
<h3 className="text-xl font-bold mb-8">Trust Signals</h3>
|
||||
<div className="space-y-6">
|
||||
{business.business_badges_business?.filter((b:any) => b.status === 'APPROVED').map((badge: any) => (
|
||||
<div key={badge.id} className="flex items-center p-4 rounded-2xl bg-slate-50">
|
||||
<div className="w-10 h-10 bg-emerald-100 rounded-xl flex items-center justify-center mr-4 text-emerald-600">
|
||||
<BaseIcon path={mdiShieldCheck} size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-slate-800 text-sm leading-tight">{badge.badge_type.replace(/_/g, ' ')}</div>
|
||||
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">Verified Badge</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{business.is_claimed && (
|
||||
<div className="flex items-center p-4 rounded-2xl bg-emerald-50">
|
||||
<div className="w-10 h-10 bg-emerald-200 rounded-xl flex items-center justify-center mr-4 text-emerald-700">
|
||||
<BaseIcon path={mdiCheckDecagram} size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-emerald-900 text-sm leading-tight">Claimed Listing</div>
|
||||
<div className="text-[10px] text-emerald-600 font-bold uppercase tracking-widest">Verified Owner</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!business.business_badges_business?.length && !business.is_claimed && <p className="text-slate-400 text-sm italic">Pending verification...</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claim Listing Section */}
|
||||
{!isVerified && (
|
||||
<div className="bg-emerald-50 p-10 rounded-[2.5rem] border border-emerald-100 text-center space-y-6 relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:scale-150 transition-transform duration-700">
|
||||
<BaseIcon path={mdiShieldCheck} size={144} className="text-emerald-900" />
|
||||
</div>
|
||||
<div className="relative z-10 space-y-6">
|
||||
<div className="w-20 h-20 bg-emerald-500 rounded-3xl flex items-center justify-center mx-auto shadow-xl shadow-emerald-500/30">
|
||||
<BaseIcon path={mdiShieldCheck} size={48} className="text-slate-900" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-2xl font-black text-emerald-900 tracking-tight">Own this business?</h4>
|
||||
<p className="text-emerald-700 font-medium leading-relaxed">
|
||||
Claim this listing to verify your identity, update information, and start receiving leads.
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
label="Claim Listing"
|
||||
color="emerald"
|
||||
className="w-full py-5 rounded-2xl font-black shadow-lg shadow-emerald-500/20 active:scale-95 transition-all"
|
||||
onClick={claimListing}
|
||||
/>
|
||||
<p className="text-xs font-bold text-emerald-600/60 uppercase tracking-widest">Verification Required</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</SectionMain>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
BusinessDetailsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
BusinessDetailsPublic.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
};
|
||||
|
||||
export default BusinessDetailsPublic;
|
||||
@ -1,234 +1,202 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import {
|
||||
mdiCalendarCheck,
|
||||
mdiAccount,
|
||||
mdiEmail,
|
||||
mdiPhone,
|
||||
mdiShieldCheck,
|
||||
mdiClockOutline,
|
||||
mdiMapMarker,
|
||||
mdiMessageTextOutline,
|
||||
mdiArrowLeft,
|
||||
mdiShieldCheck,
|
||||
mdiLightningBolt
|
||||
mdiEmail,
|
||||
mdiAccount,
|
||||
mdiPhone,
|
||||
mdiAlertDecagram
|
||||
} from '@mdi/js';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { fetch as fetchBusiness } from '../../stores/businesses/businessesSlice';
|
||||
import { create as createLead } from '../../stores/leads/leadsSlice';
|
||||
import LayoutGuest from '../../layouts/Guest';
|
||||
import SectionMain from '../../components/SectionMain';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import axios from 'axios';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
import BaseIcon from '../../components/BaseIcon';
|
||||
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||
import FormField from '../../components/FormField';
|
||||
import BaseButton from '../../components/BaseButton';
|
||||
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||
import BaseIcon from '../../components/BaseIcon';
|
||||
import Link from 'next/link';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { create as createLead } from '../../stores/leads/leadsSlice';
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {};
|
||||
if (!values.customerName) {
|
||||
errors.customerName = 'Required';
|
||||
}
|
||||
if (!values.customerEmail) {
|
||||
errors.customerEmail = 'Required';
|
||||
} else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(values.customerEmail)) {
|
||||
errors.customerEmail = 'Invalid email address';
|
||||
}
|
||||
if (!values.customerPhone) {
|
||||
errors.customerPhone = 'Required';
|
||||
}
|
||||
if (!values.serviceType) {
|
||||
errors.serviceType = 'Required';
|
||||
}
|
||||
if (!values.description) {
|
||||
errors.description = 'Required';
|
||||
} else if (values.description.length < 20) {
|
||||
errors.description = 'Please provide more details (min 20 characters)';
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
export default function RequestServicePage() {
|
||||
const RequestServicePage = () => {
|
||||
const router = useRouter();
|
||||
const { businessId } = router.query;
|
||||
const dispatch = useAppDispatch();
|
||||
const { item: business, isAskingResponse: loadingBiz } = useAppSelector((state) => state.businesses);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const { businessId } = router.query;
|
||||
const [business, setBusiness] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { currentUser } = useAppSelector(state => state.auth);
|
||||
|
||||
useEffect(() => {
|
||||
if (businessId && typeof businessId === 'string') {
|
||||
dispatch(fetchBusiness({ id: businessId }));
|
||||
if (businessId) {
|
||||
fetchBusiness();
|
||||
}
|
||||
}, [businessId, dispatch]);
|
||||
}, [businessId]);
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
const payload = {
|
||||
...values,
|
||||
businessId,
|
||||
status: 'pending'
|
||||
};
|
||||
await dispatch(createLead(payload));
|
||||
setSubmitted(true);
|
||||
const fetchBusiness = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/businesses/${businessId}`);
|
||||
setBusiness(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching business:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingBiz || !business) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const handleSubmit = async (values: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
...values,
|
||||
businessId,
|
||||
user: currentUser?.id
|
||||
};
|
||||
await dispatch(createLead(payload));
|
||||
router.push('/leads/leads-list'); // Redirect to their leads tracker
|
||||
} catch (error) {
|
||||
console.error('Lead creation error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<SectionMain className="py-32 flex items-center justify-center min-h-[80vh]">
|
||||
<div className="max-w-xl w-full text-center space-y-8 p-12 bg-white rounded-[3rem] shadow-2xl shadow-emerald-500/10 border border-emerald-50 animate-fade-in">
|
||||
<div className="w-24 h-24 bg-emerald-500 rounded-3xl flex items-center justify-center mx-auto shadow-xl shadow-emerald-500/30">
|
||||
<BaseIcon path={mdiShieldCheck} size={48} className="text-slate-900" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-4xl font-black text-slate-900 tracking-tight">Request Sent!</h2>
|
||||
<p className="text-lg text-slate-500 leading-relaxed font-medium">
|
||||
Your request has been successfully delivered to <span className="text-emerald-600 font-bold">{business.name}</span>. They will review your details and contact you shortly.
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-8">
|
||||
<Link href="/" className="bg-slate-900 text-white font-black px-12 py-5 rounded-2xl hover:bg-slate-800 transition-all shadow-xl shadow-slate-900/20 active:scale-95 inline-block">
|
||||
Back to Marketplace
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
);
|
||||
}
|
||||
if (!business && businessId) return <div className="min-h-screen flex items-center justify-center bg-slate-50"><LoadingSpinner /></div>;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 min-h-screen font-sans selection:bg-emerald-500/30">
|
||||
<div className="min-h-screen bg-slate-50 pb-20 pt-20">
|
||||
<Head>
|
||||
<title>Request Service | Crafted Network™</title>
|
||||
</Head>
|
||||
|
||||
<SectionMain className="py-24">
|
||||
<div className="max-w-6xl mx-auto flex flex-col lg:flex-row gap-12">
|
||||
|
||||
{/* Form Side */}
|
||||
<div className="flex-1 space-y-12 animate-fade-in">
|
||||
<div className="space-y-6">
|
||||
<Link href={`/public/businesses-details?id=${business.id}`} className="inline-flex items-center text-sm font-bold text-slate-400 hover:text-emerald-600 transition-colors group">
|
||||
<BaseIcon path={mdiArrowLeft} size={20} className="mr-2 group-hover:-translate-x-1 transition-transform" />
|
||||
Back to Profile
|
||||
</Link>
|
||||
<h1 className="text-5xl font-black text-slate-900 tracking-tight leading-tight">Request <span className="text-emerald-500">Service</span></h1>
|
||||
<p className="text-lg text-slate-500 font-medium leading-relaxed max-w-lg">
|
||||
You are requesting a service from <span className="text-emerald-400 font-bold">{business?.name || 'a professional'}</span>. Your details are protected by our verified network.
|
||||
</p>
|
||||
<div className="container mx-auto px-6 max-w-4xl">
|
||||
<div className="bg-white rounded-[3rem] shadow-xl border border-slate-200 overflow-hidden">
|
||||
<div className="bg-slate-900 p-12 text-white relative">
|
||||
<div className="absolute top-0 right-0 p-12 opacity-10">
|
||||
<BaseIcon path={mdiShieldCheck} size={120} />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-4">Request Service</h1>
|
||||
<p className="text-slate-400 text-lg max-w-xl">
|
||||
You are requesting a service from <span className="text-emerald-400 font-bold">{business?.name || 'a professional'}</span>.
|
||||
Our smart matching system ensures your request is handled with priority.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-10 md:p-12 rounded-[2.5rem] shadow-xl shadow-slate-200/50 border border-white">
|
||||
<Formik
|
||||
initialValues={{
|
||||
customerName: '',
|
||||
customerEmail: '',
|
||||
customerPhone: '',
|
||||
serviceType: '',
|
||||
description: ''
|
||||
}}
|
||||
validate={validate}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{({ errors, touched }) => (
|
||||
<Form className="space-y-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<FormField label="Full Name" labelColor="text-slate-900 font-bold" help={touched.customerName && errors.customerName ? (errors.customerName as string) : ""}>
|
||||
<Field name="customerName" placeholder="John Doe" className={`w-full px-5 py-4 bg-slate-50 border-2 ${touched.customerName && errors.customerName ? 'border-red-500' : 'border-slate-50'} rounded-2xl focus:border-emerald-500 focus:ring-0 transition-all font-medium text-slate-900`} />
|
||||
</FormField>
|
||||
<FormField label="Email Address" labelColor="text-slate-900 font-bold" help={touched.customerEmail && errors.customerEmail ? (errors.customerEmail as string) : ""}>
|
||||
<Field name="customerEmail" type="email" placeholder="john@example.com" className={`w-full px-5 py-4 bg-slate-50 border-2 ${touched.customerEmail && errors.customerEmail ? 'border-red-500' : 'border-slate-50'} rounded-2xl focus:border-emerald-500 focus:ring-0 transition-all font-medium text-slate-900`} />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<FormField label="Phone Number" labelColor="text-slate-900 font-bold" help={touched.customerPhone && errors.customerPhone ? (errors.customerPhone as string) : ""}>
|
||||
<Field name="customerPhone" placeholder="+1 (555) 000-0000" className={`w-full px-5 py-4 bg-slate-50 border-2 ${touched.customerPhone && errors.customerPhone ? 'border-red-500' : 'border-slate-50'} rounded-2xl focus:border-emerald-500 focus:ring-0 transition-all font-medium text-slate-900`} />
|
||||
</FormField>
|
||||
<FormField label="Service Type" labelColor="text-slate-900 font-bold" help={touched.serviceType && errors.serviceType ? (errors.serviceType as string) : ""}>
|
||||
<Field name="serviceType" placeholder="Consultation, Repair, etc." className={`w-full px-5 py-4 bg-slate-50 border-2 ${touched.serviceType && errors.serviceType ? 'border-red-500' : 'border-slate-50'} rounded-2xl focus:border-emerald-500 focus:ring-0 transition-all font-medium text-slate-900`} />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Request Details" labelColor="text-slate-900 font-bold" help={touched.description && errors.description ? (errors.description as string) : "Explain what you need in detail for a better quote."}>
|
||||
<Field
|
||||
name="description"
|
||||
as="textarea"
|
||||
rows={6}
|
||||
placeholder="Please describe the service you require..."
|
||||
className={`w-full px-5 py-4 bg-slate-50 border-2 ${touched.description && errors.description ? 'border-red-500' : 'border-slate-50'} rounded-2xl focus:border-emerald-500 focus:ring-0 transition-all font-medium text-slate-900 resize-none`}
|
||||
/>
|
||||
<div className="p-12">
|
||||
<Formik
|
||||
initialValues={{
|
||||
keyword: '',
|
||||
description: '',
|
||||
urgency: 'TODAY',
|
||||
contact_name: currentUser ? `${currentUser.firstName} ${currentUser.lastName}` : '',
|
||||
contact_email: currentUser?.email || '',
|
||||
contact_phone: currentUser?.phoneNumber || '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zip: ''
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<Form className="space-y-8">
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<FormField label="What do you need help with?" labelFor="keyword">
|
||||
<Field
|
||||
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"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="pt-4">
|
||||
<BaseButton
|
||||
type="submit"
|
||||
color="emerald"
|
||||
label="Send Request"
|
||||
className="w-full py-5 rounded-2xl font-black text-xl shadow-xl shadow-emerald-500/20 active:scale-[0.98] transition-all"
|
||||
icon={mdiLightningBolt}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</div>
|
||||
<FormField label="Urgency" labelFor="urgency">
|
||||
<Field
|
||||
name="urgency"
|
||||
as="select"
|
||||
className="w-full bg-slate-50 border-slate-200 rounded-2xl py-4 px-6 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
>
|
||||
<option value="EMERGENCY">🚨 Emergency (Immediate)</option>
|
||||
<option value="TODAY">📅 Today</option>
|
||||
<option value="THIS_WEEK">🗓️ This Week</option>
|
||||
<option value="FLEXIBLE">🍃 Flexible</option>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* Info Side */}
|
||||
<div className="w-full lg:w-96 space-y-8 lg:mt-32">
|
||||
<div className="bg-slate-900 p-8 rounded-[2.5rem] text-white space-y-8 shadow-2xl shadow-slate-300">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xl font-black">Trust Guarantee</h4>
|
||||
<p className="text-slate-400 text-sm font-medium leading-relaxed">
|
||||
Your information is only shared with the selected professional. We never sell your data or send spam.
|
||||
</p>
|
||||
</div>
|
||||
<FormField label="Details of the issue" labelFor="description">
|
||||
<Field
|
||||
name="description"
|
||||
as="textarea"
|
||||
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"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="space-y-6">
|
||||
{[
|
||||
{ icon: mdiShieldCheck, label: 'Verified Professional', desc: 'Identity and credentials checked.' },
|
||||
{ icon: mdiLightningBolt, label: 'Fast Response', desc: 'Avg. response time under 2 hours.' },
|
||||
{ icon: mdiMessageTextOutline, label: 'Direct Chat', desc: 'Communicate securely on platform.' }
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-start space-x-4">
|
||||
<div className="p-2 bg-emerald-500 rounded-xl text-slate-900">
|
||||
<BaseIcon path={item.icon} size={20} />
|
||||
<div className="bg-slate-50 p-8 rounded-3xl space-y-8">
|
||||
<h3 className="text-xl font-bold flex items-center">
|
||||
<BaseIcon path={mdiAccount} size={24} className="mr-3 text-emerald-500" />
|
||||
Contact Information
|
||||
</h3>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<FormField label="Your Name" labelFor="contact_name">
|
||||
<Field name="contact_name" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" />
|
||||
</FormField>
|
||||
<FormField label="Email" labelFor="contact_email">
|
||||
<Field name="contact_email" type="email" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" />
|
||||
</FormField>
|
||||
<FormField label="Phone" labelFor="contact_phone">
|
||||
<Field name="contact_phone" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" />
|
||||
</FormField>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-sm">{item.label}</div>
|
||||
<div className="text-xs text-slate-500">{item.desc}</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<FormField label="Service Address" labelFor="address">
|
||||
<Field name="address" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" />
|
||||
</FormField>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField label="City" labelFor="city">
|
||||
<Field name="city" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" />
|
||||
</FormField>
|
||||
<FormField label="State" labelFor="state">
|
||||
<Field name="state" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" />
|
||||
</FormField>
|
||||
<FormField label="ZIP" labelFor="zip">
|
||||
<Field name="zip" className="w-full bg-white border-slate-200 rounded-xl py-3 px-4" />
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[2.5rem] border border-slate-200 flex items-center space-x-4">
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-2xl overflow-hidden shadow-inner">
|
||||
{business.business_photos?.[0]?.photo_url && (
|
||||
<img src={business.business_photos[0].photo_url} alt={business.name} className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-black text-slate-400 uppercase tracking-widest mb-1">Requesting from</div>
|
||||
<div className="font-black text-slate-900 truncate">{business.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-8 border-t border-slate-100">
|
||||
<div className="text-slate-500 text-sm flex items-center max-w-sm">
|
||||
<BaseIcon path={mdiShieldCheck} size={20} className="mr-2 text-emerald-500" />
|
||||
Your data is protected and will only be shared with the professional you request.
|
||||
</div>
|
||||
<BaseButton
|
||||
type="submit"
|
||||
color="emerald"
|
||||
label={loading ? 'Submitting...' : 'Send Request'}
|
||||
className="py-5 px-12 rounded-2xl text-lg font-bold shadow-2xl shadow-emerald-500/30"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
RequestServicePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
return (
|
||||
<LayoutAuthenticated permission={'CREATE_LEADS'}>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestServicePage;
|
||||
|
||||
@ -1,205 +1,92 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { ToastContainer, toast } from 'react-toastify';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import { mdiAccount, mdiEmail, mdiAsterisk, mdiShieldCheck, mdiArrowLeft } from '@mdi/js';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import FormField from '../components/FormField';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { registerUser, resetAction } from '../stores/authSlice';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {};
|
||||
if (!values.firstName) {
|
||||
errors.firstName = 'Required';
|
||||
}
|
||||
if (!values.lastName) {
|
||||
errors.lastName = 'Required';
|
||||
}
|
||||
if (!values.email) {
|
||||
errors.email = 'Required';
|
||||
} else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(values.email)) {
|
||||
errors.email = 'Invalid email address';
|
||||
}
|
||||
if (!values.password) {
|
||||
errors.password = 'Required';
|
||||
} else if (values.password.length < 8) {
|
||||
errors.password = 'Must be at least 8 characters';
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
import axios from "axios";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const { isFetching, token } = useAppSelector((state) => state.auth);
|
||||
const { action, businessId } = router.query;
|
||||
const isClaiming = action === 'claim';
|
||||
export default function Register() {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const router = useRouter();
|
||||
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
||||
|
||||
const title = 'Crafted Network'
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
if (isClaiming && businessId) {
|
||||
router.push(`/login?action=claim&businessId=${businessId}`);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}
|
||||
}, [token, isClaiming, businessId, router]);
|
||||
const handleSubmit = async (value) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
|
||||
const { data: response } = await axios.post('/auth/signup',value);
|
||||
await router.push('/login')
|
||||
setLoading(false)
|
||||
notify('success', 'Please check your email for verification link')
|
||||
} catch (error) {
|
||||
setLoading(false)
|
||||
console.log('error: ', error)
|
||||
notify('error', 'Something was wrong. Try again')
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(resetAction());
|
||||
}, [dispatch]);
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')}</title>
|
||||
</Head>
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
await dispatch(registerUser(values));
|
||||
};
|
||||
<SectionFullScreen bg='violet'>
|
||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: '',
|
||||
password: '',
|
||||
confirm: ''
|
||||
}}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
|
||||
<FormField label='Email' help='Please enter your email'>
|
||||
<Field type='email' name='email' />
|
||||
</FormField>
|
||||
<FormField label='Password' help='Please enter your password'>
|
||||
<Field type='password' name='password' />
|
||||
</FormField>
|
||||
<FormField label='Confirm Password' help='Please confirm your password'>
|
||||
<Field type='password' name='confirm' />
|
||||
</FormField>
|
||||
|
||||
return (
|
||||
<SectionFullScreen bg="white">
|
||||
<Head>
|
||||
<title>{getPageTitle('Register')} | {title}</title>
|
||||
</Head>
|
||||
<BaseDivider />
|
||||
|
||||
<div className="flex flex-col lg:flex-row min-h-screen w-full overflow-hidden">
|
||||
{/* Left Side: Visual/Branding */}
|
||||
<div className="hidden lg:flex lg:w-1/2 bg-slate-900 items-center justify-center p-12 relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full opacity-20">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-emerald-500 blur-[120px] rounded-full"></div>
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-blue-600 blur-[120px] rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-center space-y-8 max-w-md">
|
||||
<div className="inline-flex p-4 rounded-3xl bg-emerald-500 shadow-2xl shadow-emerald-500/20 -rotate-3">
|
||||
<BaseIcon path={mdiShieldCheck} size={48} className="text-slate-900" />
|
||||
</div>
|
||||
<h1 className="text-5xl font-black text-white tracking-tight">
|
||||
Join the <span className="text-emerald-400">Crafted</span> Network
|
||||
</h1>
|
||||
<p className="text-xl text-slate-400 leading-relaxed font-medium">
|
||||
Join thousands of service professionals who trust our platform for verified leads and transparent tools.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Register Form */}
|
||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-6 md:p-12 bg-white">
|
||||
<div className="w-full max-w-md space-y-10 animate-fade-in">
|
||||
{/* Back Button */}
|
||||
<Link href="/" className="inline-flex items-center text-sm font-bold text-slate-400 hover:text-emerald-600 transition-colors group">
|
||||
<BaseIcon path={mdiArrowLeft} size={20} className="mr-2 group-hover:-translate-x-1 transition-transform" />
|
||||
Back to Home
|
||||
</Link>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-4xl font-black text-slate-900 tracking-tight">Get Started</h2>
|
||||
|
||||
{isClaiming ? (
|
||||
<div className="bg-emerald-50 p-6 rounded-2xl border border-emerald-100 flex items-start space-x-4">
|
||||
<div className="p-2 bg-emerald-100 rounded-xl text-emerald-600">
|
||||
<BaseIcon path={mdiShieldCheck} size={24} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-bold text-emerald-900">Step 1: Create Account</h4>
|
||||
<p className="text-emerald-700 text-sm">Create your professional profile to verify and take control of your business listing.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500">Create your professional profile in the global service directory.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardBox className="border-none shadow-none p-0">
|
||||
<Formik
|
||||
initialValues={{ firstName: '', lastName: '', email: '', password: '' }}
|
||||
validate={validate}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{({ errors, touched }) => (
|
||||
<Form className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="First Name" labelColor="text-slate-900 font-bold" help={touched.firstName && errors.firstName ? (errors.firstName as string) : ""}>
|
||||
<Field
|
||||
name="firstName"
|
||||
placeholder="John"
|
||||
className={`w-full px-5 py-4 bg-slate-50 border-2 ${touched.firstName && errors.firstName ? 'border-red-500' : 'border-slate-100'} rounded-2xl focus:border-emerald-500 focus:ring-0 transition-all font-medium text-slate-900`}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Last Name" labelColor="text-slate-900 font-bold" help={touched.lastName && errors.lastName ? (errors.lastName as string) : ""}>
|
||||
<Field
|
||||
name="lastName"
|
||||
placeholder="Doe"
|
||||
className={`w-full px-5 py-4 bg-slate-50 border-2 ${touched.lastName && errors.lastName ? 'border-red-500' : 'border-slate-100'} rounded-2xl focus:border-emerald-500 focus:ring-0 transition-all font-medium text-slate-900`}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Email Address" labelColor="text-slate-900 font-bold" help={touched.email && errors.email ? (errors.email as string) : ""}>
|
||||
<Field
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
className={`w-full px-5 py-4 bg-slate-50 border-2 ${touched.email && errors.email ? 'border-red-500' : 'border-slate-100'} rounded-2xl focus:border-emerald-500 focus:ring-0 transition-all font-medium text-slate-900`}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" labelColor="text-slate-900 font-bold" help={touched.password && errors.password ? (errors.password as string) : "Minimum 8 characters"}>
|
||||
<Field
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
className={`w-full px-5 py-4 bg-slate-50 border-2 ${touched.password && errors.password ? 'border-red-500' : 'border-slate-100'} rounded-2xl focus:border-emerald-500 focus:ring-0 transition-all font-medium text-slate-900`}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<BaseButton
|
||||
type="submit"
|
||||
color="emerald"
|
||||
label={isFetching ? 'Creating Account...' : 'Join the Network'}
|
||||
className="w-full py-5 rounded-2xl font-black text-lg shadow-xl shadow-emerald-500/20 active:scale-[0.98] transition-all"
|
||||
disabled={isFetching}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
<div className="pt-10 text-center space-y-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-slate-100"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase font-bold tracking-widest text-slate-400">
|
||||
<span className="bg-white px-4">Already a Member?</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={isClaiming ? `/login?action=claim&businessId=${businessId}` : "/login"}
|
||||
className="block w-full py-4 px-6 rounded-2xl bg-slate-50 border-2 border-slate-100 text-slate-900 font-bold hover:bg-slate-100 transition-all text-center"
|
||||
>
|
||||
Log in to your Account
|
||||
</Link>
|
||||
|
||||
<p className='text-xs font-medium text-slate-400 pt-8'>
|
||||
© 2026 <span>{title}</span>. All rights reserved. Professional Directory Platform.
|
||||
</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
);
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
type='submit'
|
||||
label={loading ? 'Loading...' : 'Register' }
|
||||
color='info'
|
||||
/>
|
||||
<BaseButton
|
||||
href={'/login'}
|
||||
label={'Login'}
|
||||
color='info'
|
||||
/>
|
||||
</BaseButtons>
|
||||
</Form>
|
||||
</Formik>
|
||||
</CardBox>
|
||||
</SectionFullScreen>
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
RegisterPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return page;
|
||||
};
|
||||
Register.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
@ -1,259 +1,243 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
mdiMagnify,
|
||||
mdiMapMarker,
|
||||
mdiFilterVariant,
|
||||
mdiStar,
|
||||
mdiShieldCheck,
|
||||
mdiChevronRight,
|
||||
mdiSortVariant
|
||||
mdiShieldCheck,
|
||||
mdiClockOutline,
|
||||
mdiCurrencyUsd,
|
||||
mdiFilterVariant
|
||||
} from '@mdi/js';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { fetch as fetchBusinesses } from '../stores/businesses/businessesSlice';
|
||||
import { fetch as fetchCategories } from '../stores/categories/categoriesSlice';
|
||||
import axios from 'axios';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import Link from 'next/link';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
const SearchPage = () => {
|
||||
const SearchView = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const { businesses, loading } = useAppSelector((state) => state.businesses);
|
||||
const { categories } = useAppSelector((state) => state.categories);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [locationQuery, setLocationQuery] = useState('');
|
||||
|
||||
const executeSearch = useCallback((q: string, l: string, category?: string) => {
|
||||
let queryStr = '?';
|
||||
if (q) queryStr += `name=${encodeURIComponent(q)}&`;
|
||||
if (l) queryStr += `city=${encodeURIComponent(l)}&`;
|
||||
if (category) queryStr += `category=${encodeURIComponent(category)}&`;
|
||||
|
||||
dispatch(fetchBusinesses({ query: queryStr }));
|
||||
}, [dispatch]);
|
||||
const { query: searchQueryParam, location: locationParam } = router.query;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [searchQuery, setSearchQuery] = useState(searchQueryParam || '');
|
||||
const [location, setLocation] = useState(locationParam || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (router.isReady) {
|
||||
const q = router.query.q as string || '';
|
||||
const l = router.query.l as string || '';
|
||||
const category = router.query.category as string || '';
|
||||
|
||||
setSearchQuery(q);
|
||||
setLocationQuery(l);
|
||||
|
||||
executeSearch(q, l, category);
|
||||
dispatch(fetchCategories({ query: '' }));
|
||||
if (searchQueryParam) {
|
||||
setSearchQuery(searchQueryParam as string);
|
||||
fetchData(searchQueryParam as string);
|
||||
}
|
||||
}, [dispatch, router.isReady, router.query, executeSearch]);
|
||||
}, [searchQueryParam]);
|
||||
|
||||
const handleSearchClick = () => {
|
||||
const fetchData = async (query: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.post('/search', { searchQuery: query });
|
||||
setSearchResults(response.data);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
router.push({
|
||||
pathname: '/search',
|
||||
query: { q: searchQuery, l: locationQuery },
|
||||
query: { query: searchQuery, location },
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearchClick();
|
||||
const businesses = searchResults.filter((item: any) => item.tableName === 'businesses');
|
||||
|
||||
const getBusinessImage = (biz: any) => {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white min-h-screen font-sans selection:bg-emerald-500/30">
|
||||
<div className="min-h-screen bg-slate-50 pb-20">
|
||||
<Head>
|
||||
<title>Find Services | Crafted Network™</title>
|
||||
</Head>
|
||||
|
||||
{/* Hero Search Area */}
|
||||
<section className="bg-slate-900 pt-32 pb-24 relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full opacity-10">
|
||||
<div className="absolute -top-1/2 -left-1/4 w-full h-full bg-emerald-500 blur-[150px] rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<SectionMain className="relative z-10">
|
||||
<div className="max-w-5xl mx-auto space-y-12">
|
||||
<h1 className="text-4xl md:text-5xl font-black text-white text-center tracking-tight leading-tight">
|
||||
What <span className="text-emerald-400">service</span> are you looking for today?
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 bg-slate-800/50 p-2 rounded-2xl border border-slate-700/50 backdrop-blur-xl shadow-2xl">
|
||||
<div className="flex-1 relative group">
|
||||
<BaseIcon path={mdiMagnify} size={24} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-emerald-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Service, professional or business name"
|
||||
className="w-full bg-transparent border-none focus:ring-0 py-3 pl-12 pr-4 text-white placeholder-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-px bg-slate-700 hidden md:block"></div>
|
||||
<div className="flex-1 relative group">
|
||||
<BaseIcon path={mdiMapMarker} size={24} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-emerald-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={locationQuery}
|
||||
onChange={(e) => setLocationQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="City or Zip code"
|
||||
className="w-full bg-transparent border-none focus:ring-0 py-3 pl-12 pr-4 text-white placeholder-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearchClick}
|
||||
className="bg-emerald-500 hover:bg-emerald-400 text-slate-900 font-black px-10 py-4 rounded-xl transition-all shadow-lg shadow-emerald-500/20 active:scale-95"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
{/* Search Header */}
|
||||
<div className="bg-slate-900 pt-32 pb-12 shadow-inner">
|
||||
<div className="container mx-auto px-6">
|
||||
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4 p-2 bg-white/10 backdrop-blur-md rounded-2xl border border-white/10 shadow-xl max-w-4xl mx-auto">
|
||||
<div className="flex-grow relative">
|
||||
<BaseIcon path={mdiMagnify} size={24} className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Service (e.g. Plumbing)"
|
||||
className="w-full bg-transparent border-none focus:ring-0 py-3 pl-12 pr-4 text-white placeholder-slate-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</section>
|
||||
<div className="md:w-px h-8 bg-white/10 my-auto"></div>
|
||||
<div className="flex-grow relative">
|
||||
<BaseIcon path={mdiMapMarker} size={24} className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Location"
|
||||
className="w-full bg-transparent border-none focus:ring-0 py-3 pl-12 pr-4 text-white placeholder-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-3 px-8 rounded-xl transition-all">
|
||||
Update Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<SectionMain className="py-16">
|
||||
<div className="flex flex-col lg:flex-row gap-12">
|
||||
<div className="container mx-auto px-6 mt-12">
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
|
||||
{/* Filters Sidebar */}
|
||||
<aside className="w-full lg:w-64 space-y-8">
|
||||
<div className="flex items-center justify-between lg:mb-8">
|
||||
<h3 className="text-xl font-black text-slate-900 flex items-center">
|
||||
<BaseIcon path={mdiFilterVariant} size={20} className="mr-2" />
|
||||
Filters
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setLocationQuery('');
|
||||
router.push('/search');
|
||||
}}
|
||||
className="text-sm font-bold text-emerald-600 hover:text-emerald-500"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-widest">Categories</h4>
|
||||
<div className="space-y-2">
|
||||
{categories && categories.slice(0, 8).map((cat: any) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={`/search?category=${cat.id}`}
|
||||
className="flex items-center group cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={router.query.category === cat.id}
|
||||
readOnly
|
||||
className="w-5 h-5 rounded-md border-slate-200 text-emerald-500 focus:ring-emerald-500 transition-all cursor-pointer"
|
||||
/>
|
||||
<span className="ml-3 text-sm font-medium text-slate-600 group-hover:text-emerald-600 transition-colors">{cat.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-3xl border border-slate-200 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="font-bold text-lg">Filters</h3>
|
||||
<BaseIcon path={mdiFilterVariant} size={20} className="text-slate-400" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-6 border-t border-slate-100">
|
||||
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-widest">Trust Score</h4>
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-400">
|
||||
<span>Any</span>
|
||||
<span>80+</span>
|
||||
<span>95+</span>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-3">Availability</label>
|
||||
<div className="space-y-2">
|
||||
{['Available Today', 'This Week', 'Next Week'].map(label => (
|
||||
<label key={label} className="flex items-center text-sm text-slate-600 cursor-pointer hover:text-emerald-600">
|
||||
<input type="checkbox" className="rounded text-emerald-500 mr-3 border-slate-300 focus:ring-emerald-500" />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-3">Reliability Score</label>
|
||||
<input type="range" min="0" max="100" className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-emerald-500" />
|
||||
<div className="flex justify-between text-xs text-slate-400 mt-2">
|
||||
<span>Any</span>
|
||||
<span>80+</span>
|
||||
</div>
|
||||
</div>
|
||||
<input type="range" min="0" max="100" className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Results Area */}
|
||||
<div className="flex-1 space-y-12">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-6">
|
||||
<div className="text-slate-500 font-medium">
|
||||
{loading ? (
|
||||
<span>Searching professionals...</span>
|
||||
) : (
|
||||
<>Found <span className="text-slate-900 font-black">{businesses ? businesses.length : 0} verified</span> professionals</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center text-sm font-bold text-slate-400">
|
||||
<BaseIcon path={mdiSortVariant} size={16} className="mr-2" />
|
||||
Sort by: <span className="text-slate-900 cursor-pointer hover:text-emerald-500 transition-colors ml-1">Reliability Score</span>
|
||||
<main className="flex-grow">
|
||||
<div className="flex items-baseline justify-between mb-8">
|
||||
<h2 className="text-2xl font-bold">
|
||||
{loading ? 'Searching...' : `${businesses.length} Results for "${searchQueryParam || 'Businesses'}"`}
|
||||
</h2>
|
||||
<div className="text-sm text-slate-500 font-medium">
|
||||
Sort by: <span className="text-slate-900 cursor-pointer hover:text-emerald-500">Reliability Score</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 opacity-50">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-80 bg-slate-100 rounded-[2.5rem] animate-pulse"></div>
|
||||
))}
|
||||
<div className="flex justify-center py-20">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{businesses && businesses.length > 0 ? (
|
||||
businesses.map((biz: any) => (
|
||||
<Link key={biz.id} href={`/public/businesses-details?id=${biz.id}`} className="group bg-white rounded-[2.5rem] border border-slate-100 hover:border-emerald-100 hover:shadow-2xl hover:shadow-emerald-500/10 transition-all overflow-hidden flex flex-col">
|
||||
<div className="h-48 bg-slate-100 relative overflow-hidden">
|
||||
{biz.business_photos?.[0]?.photo_url ? (
|
||||
<img src={biz.business_photos[0].photo_url} alt={biz.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
||||
<div className="grid gap-6">
|
||||
{businesses.map((biz: any) => (
|
||||
<Link key={biz.id} href={`/public/businesses-details?id=${biz.id}`}>
|
||||
<div className="group bg-white rounded-3xl border border-slate-200 hover:border-emerald-500 hover:shadow-xl transition-all overflow-hidden flex flex-col md:flex-row">
|
||||
{/* Image */}
|
||||
<div className="md:w-64 h-48 md:h-auto bg-slate-100 relative">
|
||||
{getBusinessImage(biz) ? (
|
||||
<img
|
||||
src={getBusinessImage(biz)}
|
||||
alt={biz.name}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-300">
|
||||
<BaseIcon path={mdiShieldCheck} size={48} />
|
||||
<div className="absolute inset-0 flex items-center justify-center text-slate-300">
|
||||
<BaseIcon path={mdiShieldCheck} size={64} />
|
||||
</div>
|
||||
)}
|
||||
{biz.reliability_score >= 80 && (
|
||||
<div className="absolute top-4 left-4 bg-emerald-500 text-white text-[10px] font-black uppercase tracking-widest px-2 py-1 rounded-md shadow-lg">
|
||||
Top Rated
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-4 right-4 bg-white/90 backdrop-blur px-3 py-1 rounded-full text-xs font-black text-emerald-600 flex items-center shadow-sm">
|
||||
<BaseIcon path={mdiStar} size={16} className="mr-1" />
|
||||
{biz.rating || '4.9'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-8 flex-1 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xl font-black text-slate-900 group-hover:text-emerald-600 transition-colors line-clamp-1">{biz.name}</h3>
|
||||
{biz.is_active && (
|
||||
<BaseIcon path={mdiShieldCheck} size={20} className="text-emerald-500" />
|
||||
)}
|
||||
|
||||
<div className="p-8 flex-grow">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold group-hover:text-emerald-600 transition-colors mb-1">{biz.name}</h3>
|
||||
<div className="flex items-center text-slate-500 text-sm">
|
||||
<BaseIcon path={mdiMapMarker} size={16} className="mr-1" />
|
||||
{biz.city}, {biz.state} {biz.address}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-slate-400 text-sm font-bold mb-4">
|
||||
<BaseIcon path={mdiMapMarker} size={16} className="mr-1" />
|
||||
{biz.city || biz.locations?.[0]?.city || 'Verified Professional'}
|
||||
<div className="text-right">
|
||||
<div className="flex items-center justify-end text-emerald-500 font-bold text-xl mb-1">
|
||||
<BaseIcon path={mdiStar} size={24} className="mr-1 text-amber-400" />
|
||||
{biz.rating || ((biz.reliability_score || 0) / 20).toFixed(1)}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-tighter">Reliability Score</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-6 border-t border-slate-50 flex items-center justify-between">
|
||||
<span className="text-xs font-black text-emerald-500 uppercase tracking-widest">Top Professional</span>
|
||||
<div className="w-10 h-10 rounded-xl bg-slate-50 flex items-center justify-center text-slate-300 group-hover:bg-emerald-500 group-hover:text-white transition-all">
|
||||
<BaseIcon path={mdiChevronRight} size={20} />
|
||||
|
||||
<p className="text-slate-600 line-clamp-2 mb-6 leading-relaxed">
|
||||
{biz.description || 'Verified service professional providing high-quality solutions for your needs.'}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-4 pt-6 border-t border-slate-100">
|
||||
<div className="flex items-center text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||
<BaseIcon path={mdiClockOutline} size={16} className="mr-2 text-emerald-500" />
|
||||
~{biz.response_time_median_minutes || 30}m Response
|
||||
</div>
|
||||
<div className="flex items-center text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||
<BaseIcon path={mdiCurrencyUsd} size={16} className="mr-2 text-emerald-500" />
|
||||
Fair Pricing
|
||||
</div>
|
||||
<div className="flex items-center text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||
<BaseIcon path={mdiShieldCheck} size={16} className="mr-2 text-emerald-500" />
|
||||
Verified
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full py-24 text-center">
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{businesses.length === 0 && !loading && (
|
||||
<div className="bg-white rounded-3xl p-20 text-center border-2 border-dashed border-slate-200">
|
||||
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-6 text-slate-300">
|
||||
<BaseIcon path={mdiMagnify} size={40} />
|
||||
</div>
|
||||
<h3 className="text-2xl font-black text-slate-900 mb-2">No results found</h3>
|
||||
<p className="text-slate-500">Try adjusting your filters or searching for something else.</p>
|
||||
<h3 className="text-xl font-bold mb-2">No businesses found</h3>
|
||||
<p className="text-slate-500">Try adjusting your search terms or location.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SearchPage.getLayout = function getLayout(page: ReactElement) {
|
||||
SearchView.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
export default SearchPage;
|
||||
export default SearchView;
|
||||
@ -1,73 +1,206 @@
|
||||
import React from 'react';
|
||||
import Head from 'next/head';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
const TermsOfUsePage = () => {
|
||||
export default function PrivacyPolicy() {
|
||||
const title = 'Crafted Network';
|
||||
const [projectUrl, setProjectUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setProjectUrl(location.origin);
|
||||
}, []);
|
||||
|
||||
const Information = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>1. Acceptance of Terms</h3>
|
||||
<div className=''>
|
||||
<p>
|
||||
By accessing and using our application, you agree to comply with and
|
||||
be bound by these Terms of Use. If you do not agree to these terms,
|
||||
please do not use the application.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangesTerms = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>2. Changes to Terms</h3>
|
||||
<p>
|
||||
We reserve the right to modify these Terms of Use at any time. Any
|
||||
changes will be effective immediately upon posting. Your continued use
|
||||
of the application after any such changes constitutes your acceptance
|
||||
of the new terms.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const UseApplication = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>3. Use of the Application</h3>
|
||||
<p>
|
||||
You agree to use the application only for lawful purposes and in a way
|
||||
that does not infringe the rights of, restrict, or inhibit anyone
|
||||
else’s use and enjoyment of the application. Prohibited behavior
|
||||
includes harassing or causing distress or inconvenience to any other
|
||||
user, transmitting obscene or offensive content, or disrupting the
|
||||
normal flow of dialogue within the application.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const IntellectualProperty = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>4. Intellectual Property</h3>
|
||||
<p>
|
||||
All content included on the application, such as text, graphics,
|
||||
logos, images, and software, is the property of {title} or
|
||||
its content suppliers and protected by international copyright laws.
|
||||
Unauthorized use of the content may violate copyright, trademark, and
|
||||
other laws.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const UserContent = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>5. User Content</h3>
|
||||
<p>
|
||||
You are responsible for any content you upload, post, or otherwise
|
||||
make available through the application. You grant {title}
|
||||
a worldwide, irrevocable, non-exclusive, royalty-free license to use,
|
||||
reproduce, modify, publish, and distribute such content for any
|
||||
purpose.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Privacy = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>6. Privacy</h3>
|
||||
<p>
|
||||
Your privacy is important to us. Please review our Privacy Policy to
|
||||
understand our practices regarding the collection, use, and disclosure
|
||||
of your personal information.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Liability = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>7. Limitation of Liability</h3>
|
||||
<p>
|
||||
The application is provided “as is” and “as available” without any
|
||||
warranties of any kind, either express or implied. {title}
|
||||
does not warrant that the application will be uninterrupted or
|
||||
error-free. In no event shall {title} be liable for any
|
||||
damages arising out of your use of the application.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Indemnification = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>8. Indemnification</h3>
|
||||
<p>
|
||||
You agree to indemnify, defend, and hold harmless {title},
|
||||
its officers, directors, employees, and agents from and against any
|
||||
claims, liabilities, damages, losses, and expenses, including without
|
||||
limitation reasonable legal and accounting fees, arising out of or in
|
||||
any way connected with your access to or use of the application or
|
||||
your violation of these Terms of Use.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Termination = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>9. Termination</h3>
|
||||
<p>
|
||||
We reserve the right to terminate or suspend your access to the
|
||||
application at our sole discretion, without notice and without
|
||||
liability, for any reason, including if we believe you have violated
|
||||
these Terms of Use.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const GoverningLaw = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>10. Governing Law</h3>
|
||||
<p>
|
||||
These Terms of Use are governed by and interpreted in accordance with
|
||||
applicable laws, without regard to any conflict of law principles. You
|
||||
agree to submit to the exclusive jurisdiction of the courts that have
|
||||
authority to resolve any dispute arising from the use of the
|
||||
application.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ContactUs = () => {
|
||||
return (
|
||||
<>
|
||||
<h3>11. Contact Information</h3>
|
||||
<p>
|
||||
If you have any questions about these Terms of Use, please contact us
|
||||
at: <a href='mailto:support@flatlogic.com'> [support@flatlogic.com]</a>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='prose prose-slate mx-auto max-w-none'>
|
||||
<Head>
|
||||
<title>{getPageTitle('Terms of Use')} | {title}</title>
|
||||
<title>{getPageTitle('Terms of Use')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionMain className="py-32">
|
||||
<div className="max-w-3xl mx-auto space-y-12 animate-fade-in">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-5xl font-black text-slate-900 tracking-tight leading-tight">Terms of Use</h1>
|
||||
<p className="text-slate-500 font-medium italic">Last updated: February 17, 2026</p>
|
||||
</div>
|
||||
<div className='flex justify-center'>
|
||||
<div className='z-10 md:w-10/12 my-4 bg-white border border-pavitra-400 rounded'>
|
||||
<div className='p-8 lg:px-12 lg:py-10'>
|
||||
<h1>Terms of Use</h1>
|
||||
|
||||
<div className="prose prose-slate max-w-none text-slate-600 space-y-8 font-medium leading-relaxed">
|
||||
<p className="text-xl text-slate-900 font-bold">
|
||||
Welcome to the Crafted Network. By accessing our platform, you agree to the following terms and conditions.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-black text-slate-900">1. Acceptance of Terms</h2>
|
||||
<p>
|
||||
By using the <span>{title}</span> platform, you signify your agreement to these Terms of Use and our Privacy Policy. If you do not agree to any of these terms, please do not use the platform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-black text-slate-900">2. Service Description</h2>
|
||||
<p>
|
||||
The <span>{title}</span> provides a directory service connecting service professionals with potential clients. We do not provide the services listed on the platform and are not responsible for the performance or quality of those services.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-black text-slate-900">3. User Responsibilities</h2>
|
||||
<p>
|
||||
Users are responsible for verifying the credentials and reputation of any service professional they choose to engage. Professionals are responsible for providing accurate and honest information about their services and verification status.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-black text-slate-900">4. Intellectual Property</h2>
|
||||
<p>
|
||||
All content on the platform, including logos, designs, and text, is the property of the Crafted Network and is protected by intellectual property laws.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 border-t border-slate-100 pt-12">
|
||||
<p className="text-sm font-bold text-slate-400 uppercase tracking-widest">
|
||||
© 2026 Crafted Network™. Built with Trust & Transparency.
|
||||
</p>
|
||||
</div>
|
||||
<Information />
|
||||
<ChangesTerms />
|
||||
<UseApplication />
|
||||
<IntellectualProperty />
|
||||
<UserContent />
|
||||
<Privacy />
|
||||
<Liability />
|
||||
<Indemnification />
|
||||
<Termination />
|
||||
<GoverningLaw />
|
||||
<ContactUs />
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
TermsOfUsePage.getLayout = function getLayout(page: ReactElement) {
|
||||
PrivacyPolicy.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
export default TermsOfUsePage;
|
||||
@ -40,21 +40,6 @@ export const loginUser = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
export const registerUser = createAsyncThunk(
|
||||
'auth/registerUser',
|
||||
async (creds: Record<string, string>, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await axios.post('auth/signup', creds);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (!error.response) {
|
||||
throw error;
|
||||
}
|
||||
return rejectWithValue(error.response.data);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const passwordReset = createAsyncThunk(
|
||||
'auth/passwordReset',
|
||||
async (value: Record<string, string>, { rejectWithValue }) => {
|
||||
@ -112,19 +97,6 @@ export const authSlice = createSlice({
|
||||
state.errorMessage = String(action.payload) || 'Something went wrong. Try again';
|
||||
state.isFetching = false;
|
||||
});
|
||||
|
||||
builder.addCase(registerUser.pending, (state) => {
|
||||
state.isFetching = true;
|
||||
});
|
||||
builder.addCase(registerUser.fulfilled, (state) => {
|
||||
state.isFetching = false;
|
||||
state.errorMessage = '';
|
||||
});
|
||||
builder.addCase(registerUser.rejected, (state, action) => {
|
||||
state.errorMessage = String(action.payload) || 'Something went wrong. Try again';
|
||||
state.isFetching = false;
|
||||
});
|
||||
|
||||
builder.addCase(findMe.pending, () => {
|
||||
console.log('Pending findMe');
|
||||
});
|
||||
@ -149,4 +121,4 @@ export const authSlice = createSlice({
|
||||
// Action creators are generated for each case reducer function
|
||||
export const { logoutUser } = authSlice.actions;
|
||||
|
||||
export default authSlice.reducer;
|
||||
export default authSlice.reducer;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user