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;
|
const limit = filter.limit || 0;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let where = {};
|
let where = {};
|
||||||
const currentPage = +filter.page || 0;
|
const currentPage = +filter.page;
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const currentUser = options?.currentUser;
|
const currentUser = options?.currentUser;
|
||||||
@ -42,67 +42,11 @@ module.exports = class BusinessesDBApi {
|
|||||||
where.is_active = true;
|
where.is_active = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let include = [
|
let include = [{ model: db.users, as: 'owner_user' }];
|
||||||
{ model: db.users, as: 'owner_user' },
|
|
||||||
{
|
|
||||||
model: db.business_photos,
|
|
||||||
as: 'business_photos_business',
|
|
||||||
include: [{
|
|
||||||
model: db.file,
|
|
||||||
as: 'photos'
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
if (filter.id) where.id = Utils.uuid(filter.id);
|
if (filter.id) where.id = Utils.uuid(filter.id);
|
||||||
|
if (filter.name) where.name = { [Op.iLike]: `%${filter.name}%` };
|
||||||
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 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryOptions = {
|
const queryOptions = {
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import React from 'react'
|
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 AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppSelector } from '../stores/hooks'
|
||||||
import BaseIcon from './BaseIcon'
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
menu: MenuAsideItem[]
|
menu: MenuAsideItem[]
|
||||||
@ -12,49 +14,49 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AsideMenuLayer({ menu, className = '', ...props }: Props) {
|
export default function AsideMenuLayer({ menu, className = '', ...props }: Props) {
|
||||||
const { asideStyle, asideBrandStyle, asideScrollbarsStyle } = useAppSelector(
|
const corners = useAppSelector((state) => state.style.corners);
|
||||||
(state) => state.style
|
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) => {
|
const handleAsideLgCloseClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
props.onAsideLgCloseClick()
|
props.onAsideLgCloseClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<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
|
<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
|
||||||
<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">
|
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
|
||||||
<BaseIcon path={mdiMagnify} size={20} className="text-slate-900" />
|
>
|
||||||
</div>
|
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
||||||
<b className="font-black text-slate-900 dark:text-white">Crafted Network</b>
|
|
||||||
|
<b className="font-black">Crafted Network</b>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="hidden lg:inline-block xl:hidden p-3"
|
className="hidden lg:inline-block xl:hidden p-3"
|
||||||
onClick={handleAsideLgCloseClick}
|
onClick={handleAsideLgCloseClick}
|
||||||
>
|
>
|
||||||
<BaseIcon path={mdiClose} size={24} />
|
<BaseIcon path={mdiClose} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`flex-1 overflow-y-auto overflow-x-hidden ${asideScrollbarsStyle}`}
|
className={`flex-1 overflow-y-auto overflow-x-hidden ${
|
||||||
|
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<AsideMenuList menu={menu} />
|
<AsideMenuList menu={menu} />
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,38 +1,33 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
export const filesFormatter = (arr) => {
|
export default {
|
||||||
|
filesFormatter(arr) {
|
||||||
if (!arr || !arr.length) return [];
|
if (!arr || !arr.length) return [];
|
||||||
return arr.map((item) => item);
|
return arr.map((item) => item);
|
||||||
};
|
},
|
||||||
|
imageFormatter(arr) {
|
||||||
export const imageFormatter = (arr) => {
|
|
||||||
if (!arr || !arr.length) return []
|
if (!arr || !arr.length) return []
|
||||||
return arr.map(item => ({
|
return arr.map(item => ({
|
||||||
publicUrl: item.publicUrl || ''
|
publicUrl: item.publicUrl || ''
|
||||||
}))
|
}))
|
||||||
};
|
},
|
||||||
|
oneImageFormatter(arr) {
|
||||||
export const oneImageFormatter = (arr) => {
|
|
||||||
if (!arr || !arr.length) return ''
|
if (!arr || !arr.length) return ''
|
||||||
return arr[0].publicUrl || ''
|
return arr[0].publicUrl || ''
|
||||||
};
|
},
|
||||||
|
dateFormatter(date) {
|
||||||
export const dateFormatter = (date) => {
|
|
||||||
if (!date) return ''
|
if (!date) return ''
|
||||||
return dayjs(date).format('YYYY-MM-DD')
|
return dayjs(date).format('YYYY-MM-DD')
|
||||||
};
|
},
|
||||||
|
dateTimeFormatter(date) {
|
||||||
export const dateTimeFormatter = (date) => {
|
|
||||||
if (!date) return ''
|
if (!date) return ''
|
||||||
return dayjs(date).format('YYYY-MM-DD HH:mm')
|
return dayjs(date).format('YYYY-MM-DD HH:mm')
|
||||||
};
|
},
|
||||||
|
booleanFormatter(val) {
|
||||||
export const booleanFormatter = (val) => {
|
|
||||||
return val ? 'Yes' : 'No'
|
return val ? 'Yes' : 'No'
|
||||||
};
|
},
|
||||||
|
dataGridEditFormatter(obj) {
|
||||||
export const dataGridEditFormatter = (obj) => {
|
|
||||||
return _.transform(obj, (result, value, key) => {
|
return _.transform(obj, (result, value, key) => {
|
||||||
if (_.isArray(value)) {
|
if (_.isArray(value)) {
|
||||||
result[key] = _.map(value, 'id');
|
result[key] = _.map(value, 'id');
|
||||||
@ -42,197 +37,185 @@ export const dataGridEditFormatter = (obj) => {
|
|||||||
result[key] = value;
|
result[key] = value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
|
||||||
export const usersManyListFormatter = (val) => {
|
|
||||||
|
usersManyListFormatter(val) {
|
||||||
if (!val || !val.length) return []
|
if (!val || !val.length) return []
|
||||||
return val.map((item) => item.firstName)
|
return val.map((item) => item.firstName)
|
||||||
};
|
},
|
||||||
|
usersOneListFormatter(val) {
|
||||||
export const usersOneListFormatter = (val) => {
|
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
return val.firstName
|
return val.firstName
|
||||||
};
|
},
|
||||||
|
usersManyListFormatterEdit(val) {
|
||||||
export const usersManyListFormatterEdit = (val) => {
|
|
||||||
if (!val || !val.length) return []
|
if (!val || !val.length) return []
|
||||||
return val.map((item) => {
|
return val.map((item) => {
|
||||||
return {id: item.id, label: item.firstName}
|
return {id: item.id, label: item.firstName}
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
usersOneListFormatterEdit(val) {
|
||||||
export const usersOneListFormatterEdit = (val) => {
|
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
return {label: val.firstName, id: val.id}
|
return {label: val.firstName, id: val.id}
|
||||||
};
|
},
|
||||||
|
|
||||||
export const rolesManyListFormatter = (val) => {
|
|
||||||
|
|
||||||
|
rolesManyListFormatter(val) {
|
||||||
if (!val || !val.length) return []
|
if (!val || !val.length) return []
|
||||||
return val.map((item) => item.name)
|
return val.map((item) => item.name)
|
||||||
};
|
},
|
||||||
|
rolesOneListFormatter(val) {
|
||||||
export const rolesOneListFormatter = (val) => {
|
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
return val.name
|
return val.name
|
||||||
};
|
},
|
||||||
|
rolesManyListFormatterEdit(val) {
|
||||||
export const rolesManyListFormatterEdit = (val) => {
|
|
||||||
if (!val || !val.length) return []
|
if (!val || !val.length) return []
|
||||||
return val.map((item) => {
|
return val.map((item) => {
|
||||||
return {id: item.id, label: item.name}
|
return {id: item.id, label: item.name}
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
rolesOneListFormatterEdit(val) {
|
||||||
export const rolesOneListFormatterEdit = (val) => {
|
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
return {label: val.name, id: val.id}
|
return {label: val.name, id: val.id}
|
||||||
};
|
},
|
||||||
|
|
||||||
export const permissionsManyListFormatter = (val) => {
|
|
||||||
|
|
||||||
|
permissionsManyListFormatter(val) {
|
||||||
if (!val || !val.length) return []
|
if (!val || !val.length) return []
|
||||||
return val.map((item) => item.name)
|
return val.map((item) => item.name)
|
||||||
};
|
},
|
||||||
|
permissionsOneListFormatter(val) {
|
||||||
export const permissionsOneListFormatter = (val) => {
|
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
return val.name
|
return val.name
|
||||||
};
|
},
|
||||||
|
permissionsManyListFormatterEdit(val) {
|
||||||
export const permissionsManyListFormatterEdit = (val) => {
|
|
||||||
if (!val || !val.length) return []
|
if (!val || !val.length) return []
|
||||||
return val.map((item) => {
|
return val.map((item) => {
|
||||||
return {id: item.id, label: item.name}
|
return {id: item.id, label: item.name}
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
permissionsOneListFormatterEdit(val) {
|
||||||
export const permissionsOneListFormatterEdit = (val) => {
|
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
return {label: val.name, id: val.id}
|
return {label: val.name, id: val.id}
|
||||||
};
|
},
|
||||||
|
|
||||||
export const categoriesManyListFormatter = (val) => {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
categoriesManyListFormatter(val) {
|
||||||
if (!val || !val.length) return []
|
if (!val || !val.length) return []
|
||||||
return val.map((item) => item.name)
|
return val.map((item) => item.name)
|
||||||
};
|
},
|
||||||
|
categoriesOneListFormatter(val) {
|
||||||
export const categoriesOneListFormatter = (val) => {
|
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
return val.name
|
return val.name
|
||||||
};
|
},
|
||||||
|
categoriesManyListFormatterEdit(val) {
|
||||||
export const categoriesManyListFormatterEdit = (val) => {
|
|
||||||
if (!val || !val.length) return []
|
if (!val || !val.length) return []
|
||||||
return val.map((item) => {
|
return val.map((item) => {
|
||||||
return {id: item.id, label: item.name}
|
return {id: item.id, label: item.name}
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
categoriesOneListFormatterEdit(val) {
|
||||||
export const categoriesOneListFormatterEdit = (val) => {
|
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
return {label: val.name, id: val.id}
|
return {label: val.name, id: val.id}
|
||||||
};
|
},
|
||||||
|
|
||||||
export const businessesManyListFormatter = (val) => {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
businessesManyListFormatter(val) {
|
||||||
if (!val || !val.length) return []
|
if (!val || !val.length) return []
|
||||||
return val.map((item) => item.name)
|
return val.map((item) => item.name)
|
||||||
};
|
},
|
||||||
|
businessesOneListFormatter(val) {
|
||||||
export const businessesOneListFormatter = (val) => {
|
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
return val.name
|
return val.name
|
||||||
};
|
},
|
||||||
|
businessesManyListFormatterEdit(val) {
|
||||||
export const businessesManyListFormatterEdit = (val) => {
|
|
||||||
if (!val || !val.length) return []
|
if (!val || !val.length) return []
|
||||||
return val.map((item) => {
|
return val.map((item) => {
|
||||||
return {id: item.id, label: item.name}
|
return {id: item.id, label: item.name}
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
businessesOneListFormatterEdit(val) {
|
||||||
export const businessesOneListFormatterEdit = (val) => {
|
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
return {label: val.name, id: val.id}
|
return {label: val.name, id: val.id}
|
||||||
};
|
},
|
||||||
|
|
||||||
export const verification_submissionsManyListFormatter = (val) => {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
verification_submissionsManyListFormatter(val) {
|
||||||
if (!val || !val.length) return []
|
if (!val || !val.length) return []
|
||||||
return val.map((item) => item.notes)
|
return val.map((item) => item.notes)
|
||||||
};
|
},
|
||||||
|
verification_submissionsOneListFormatter(val) {
|
||||||
export const verification_submissionsOneListFormatter = (val) => {
|
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
return val.notes
|
return val.notes
|
||||||
};
|
},
|
||||||
|
verification_submissionsManyListFormatterEdit(val) {
|
||||||
export const verification_submissionsManyListFormatterEdit = (val) => {
|
|
||||||
if (!val || !val.length) return []
|
if (!val || !val.length) return []
|
||||||
return val.map((item) => {
|
return val.map((item) => {
|
||||||
return {id: item.id, label: item.notes}
|
return {id: item.id, label: item.notes}
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
verification_submissionsOneListFormatterEdit(val) {
|
||||||
export const verification_submissionsOneListFormatterEdit = (val) => {
|
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
return {label: val.notes, id: val.id}
|
return {label: val.notes, id: val.id}
|
||||||
};
|
},
|
||||||
|
|
||||||
export const leadsManyListFormatter = (val) => {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
leadsManyListFormatter(val) {
|
||||||
if (!val || !val.length) return []
|
if (!val || !val.length) return []
|
||||||
return val.map((item) => item.keyword)
|
return val.map((item) => item.keyword)
|
||||||
};
|
},
|
||||||
|
leadsOneListFormatter(val) {
|
||||||
export const leadsOneListFormatter = (val) => {
|
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
return val.keyword
|
return val.keyword
|
||||||
};
|
},
|
||||||
|
leadsManyListFormatterEdit(val) {
|
||||||
export const leadsManyListFormatterEdit = (val) => {
|
|
||||||
if (!val || !val.length) return []
|
if (!val || !val.length) return []
|
||||||
return val.map((item) => {
|
return val.map((item) => {
|
||||||
return {id: item.id, label: item.keyword}
|
return {id: item.id, label: item.keyword}
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
leadsOneListFormatterEdit(val) {
|
||||||
export const leadsOneListFormatterEdit = (val) => {
|
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
return {label: val.keyword, id: val.id}
|
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,
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,172 +1,112 @@
|
|||||||
import React, { ReactNode, useState, useEffect } from 'react'
|
import React, { ReactNode } from 'react';
|
||||||
import Link from 'next/link'
|
import Link from 'next/link';
|
||||||
import { mdiMenu, mdiClose, mdiLogin, mdiAccountPlus, mdiMagnify } from '@mdi/js'
|
import { useRouter } from 'next/router';
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { mdiShieldCheck, mdiMenu, mdiClose, mdiMagnify } from '@mdi/js';
|
||||||
import { useRouter } from 'next/router'
|
import { useAppSelector } from '../stores/hooks';
|
||||||
import BaseIcon from '../components/BaseIcon'
|
import BaseIcon from '../components/BaseIcon';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LayoutGuest({ children }: Props) {
|
export default function LayoutGuest({ children }: Props) {
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
const darkMode = useAppSelector((state) => state.style.darkMode);
|
||||||
const [scrolled, setScrolled] = useState(false)
|
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
||||||
const { token } = useAppSelector((state) => state.auth)
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
const router = useRouter()
|
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
||||||
|
const router = useRouter();
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
setScrolled(window.scrollY > 20)
|
|
||||||
}
|
|
||||||
window.addEventListener('scroll', handleScroll)
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ label: 'Find Services', href: '/search' },
|
{ href: '/search', label: 'Find Services' },
|
||||||
{ label: 'Categories', href: '/categories' },
|
{ href: '/register', label: 'List Business' },
|
||||||
{ label: 'Verify Business', href: '/register' },
|
];
|
||||||
{ label: 'Support', href: '/contact-form' },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white dark:bg-slate-950 flex flex-col font-sans selection:bg-emerald-500/30">
|
<div className={`${darkMode ? 'dark' : ''} min-h-screen flex flex-col`}>
|
||||||
{/* Navigation */}
|
{/* Dynamic Header */}
|
||||||
<nav
|
<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">
|
||||||
className={`fixed top-0 left-0 right-0 z-[100] transition-all duration-300 ${
|
<div className="container mx-auto px-6 h-20 flex items-center justify-between">
|
||||||
scrolled
|
<Link href="/" className="flex items-center gap-3 group">
|
||||||
? 'bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl border-b border-slate-200 dark:border-slate-800 py-4 shadow-sm'
|
<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">
|
||||||
: 'bg-transparent py-6'
|
<BaseIcon path={mdiShieldCheck} size={24} className="text-white" />
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
<span className="text-xl font-black tracking-tight dark:text-white">Crafted Network<span className="text-emerald-500 italic">™</span></span>
|
<span className="text-xl font-black tracking-tight dark:text-white">Crafted Network<span className="text-emerald-500 italic">™</span></span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Nav */}
|
<nav className="hidden md:flex items-center gap-10">
|
||||||
<div className="hidden md:flex items-center space-x-8">
|
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
<Link
|
<Link
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={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"
|
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.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
<div className="h-6 w-px bg-slate-200 dark:bg-slate-800"></div>
|
||||||
|
{currentUser ? (
|
||||||
<div className="hidden md:flex items-center space-x-4">
|
<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">
|
||||||
{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"
|
|
||||||
>
|
|
||||||
Go to Dashboard
|
Go to Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex items-center gap-4">
|
||||||
<Link
|
<Link href="/login" className="text-sm font-bold text-slate-600 hover:text-emerald-500 transition-colors">
|
||||||
href="/login"
|
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
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<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">
|
||||||
href="/register"
|
Join Now
|
||||||
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>
|
</Link>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Toggle */}
|
|
||||||
<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.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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Main Content */}
|
<button className="md:hidden p-2 text-slate-600 dark:text-slate-400" onClick={() => setIsMenuOpen(!isMenuOpen)}>
|
||||||
<main className="flex-grow pt-16">
|
<BaseIcon path={isMenuOpen ? mdiClose : mdiMenu} size={28} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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-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">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={`flex-grow ${bgColor} dark:bg-slate-800 dark:text-slate-100`}>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
<footer className="bg-white border-t border-slate-200 py-12 dark:bg-slate-900 dark:border-slate-800">
|
||||||
<footer className="bg-slate-900 text-white pt-24 pb-12">
|
<div className="container mx-auto px-6">
|
||||||
<div className="max-w-7xl mx-auto px-6">
|
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-16">
|
<div className="flex items-center mb-6 md:mb-0">
|
||||||
<div className="col-span-1 md:col-span-2 space-y-6">
|
<div className="w-10 h-10 bg-emerald-500 rounded-xl flex items-center justify-center mr-3">
|
||||||
<div className="flex items-center">
|
<BaseIcon path={mdiShieldCheck} size={24} className="text-white" />
|
||||||
<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>
|
</div>
|
||||||
<span className="text-2xl font-bold tracking-tight dark:text-white text-white">Crafted Network™</span>
|
<span className="text-2xl font-bold tracking-tight dark:text-white text-slate-900">Crafted Network™</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-400 max-w-sm leading-relaxed">
|
<div className="flex gap-8 text-slate-500 font-medium dark:text-slate-400">
|
||||||
The most reliable platform for finding verified service professionals and businesses across all industries.
|
<Link href="/search" className="hover:text-emerald-500">Find Help</Link>
|
||||||
</p>
|
<Link href="/register" className="hover:text-emerald-500">List Business</Link>
|
||||||
</div>
|
<Link href="/privacy-policy" className="hover:text-emerald-500">Privacy</Link>
|
||||||
<div>
|
<Link href="/terms-of-use" className="hover:text-emerald-500">Terms</Link>
|
||||||
<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>
|
</div>
|
||||||
</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.
|
© 2026 Crafted Network™. Built with Trust & Transparency.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 { AppProps } from 'next/app';
|
||||||
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
|
import type { NextPage } from 'next';
|
||||||
import Head from 'next/head';
|
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 { store } from '../stores/store';
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { Provider } from 'react-redux';
|
||||||
import { findMe, logoutUser } from '../stores/authSlice';
|
|
||||||
import { setDarkMode } from '../stores/styleSlice';
|
|
||||||
import '../css/main.css';
|
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 '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 = {
|
// Initialize axios
|
||||||
getLayout?: (page: ReactElement) => ReactNode;
|
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';
|
axios.defaults.headers.common['Content-Type'] = 'application/json';
|
||||||
|
|
||||||
const AppContent = ({ Component, pageProps }: AppPropsWithLayout) => {
|
export type NextPageWithLayout<P = Record<string, unknown>, IP = P> = NextPage<P, IP> & {
|
||||||
const router = useRouter();
|
getLayout?: (page: ReactElement) => ReactNode
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const { token, currentUser } = useAppSelector((state) => state.auth);
|
|
||||||
const { darkMode } = useAppSelector((state) => state.style);
|
|
||||||
|
|
||||||
// Auth Header Interceptor
|
|
||||||
useEffect(() => {
|
|
||||||
const requestInterceptor = axios.interceptors.request.use(
|
|
||||||
(config) => {
|
|
||||||
const currentToken = localStorage.getItem('token');
|
|
||||||
if (currentToken) {
|
|
||||||
config.headers.Authorization = `Bearer ${currentToken}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
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 Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
// TODO: Remove this code in future releases
|
||||||
axios.interceptors.request.eject(requestInterceptor);
|
React.useEffect(() => {
|
||||||
axios.interceptors.response.eject(responseInterceptor);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [dispatch, router]);
|
|
||||||
|
|
||||||
// Initial Data Fetch
|
window.addEventListener('message', handleMessage);
|
||||||
useEffect(() => {
|
return () => window.removeEventListener('message', handleMessage);
|
||||||
const savedToken = localStorage.getItem('token');
|
}, []);
|
||||||
if (savedToken) {
|
|
||||||
dispatch(findMe());
|
|
||||||
}
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
// Dark Mode Support
|
React.useEffect(() => {
|
||||||
useEffect(() => {
|
// Tour is disabled by default in generated projects.
|
||||||
const isDark = localStorage.getItem('darkMode') === 'true';
|
return;
|
||||||
dispatch(setDarkMode(isDark));
|
const isCompleted = (stepKey: string) => {
|
||||||
if (isDark) {
|
return localStorage.getItem(`completed_${stepKey}`) === 'true';
|
||||||
document.documentElement.classList.add('dark-scrollbars');
|
};
|
||||||
}
|
if (router.pathname === '/login' && !isCompleted('loginSteps')) {
|
||||||
}, [dispatch]);
|
setSteps(loginSteps);
|
||||||
|
setStepName('loginSteps');
|
||||||
useEffect(() => {
|
setStepsEnabled(true);
|
||||||
if (darkMode) {
|
}else if (router.pathname === '/dashboard' && !isCompleted('appSteps')) {
|
||||||
document.documentElement.classList.add('dark');
|
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 {
|
} else {
|
||||||
document.documentElement.classList.remove('dark');
|
setSteps([]);
|
||||||
|
setStepsEnabled(false);
|
||||||
}
|
}
|
||||||
}, [darkMode]);
|
}, [router.pathname]);
|
||||||
|
|
||||||
|
const handleExit = () => {
|
||||||
|
setStepsEnabled(false);
|
||||||
|
};
|
||||||
|
|
||||||
const getLayout = Component.getLayout || ((page) => page);
|
|
||||||
const title = 'Crafted Network'
|
const title = 'Crafted Network'
|
||||||
const description = "Trust-first service directory with verification, transparent pricing, AI-style matching, and request tracking."
|
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 (
|
return (
|
||||||
<Provider store={store}>
|
<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>
|
</Provider>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default appWithTranslation(MyApp);
|
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 Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import {
|
import {
|
||||||
mdiMagnify,
|
mdiMagnify,
|
||||||
mdiMapMarker,
|
mdiMapMarker,
|
||||||
mdiChevronRight,
|
|
||||||
mdiStar,
|
|
||||||
mdiShieldCheck,
|
mdiShieldCheck,
|
||||||
mdiLightningBolt,
|
mdiCurrencyUsd,
|
||||||
mdiFormatListBulleted,
|
mdiFlash,
|
||||||
mdiAccountGroup
|
mdiTools,
|
||||||
|
mdiPowerPlug,
|
||||||
|
mdiAirConditioner,
|
||||||
|
mdiBrush,
|
||||||
|
mdiFormatPaint
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import BaseIcon from '../components/BaseIcon';
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import SectionMain from '../components/SectionMain';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
import { fetch as fetchCategories } from '../stores/categories/categoriesSlice';
|
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 router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { categories } = useAppSelector((state) => state.categories);
|
const { categories } = useAppSelector((state) => state.categories);
|
||||||
const { businesses: featuredBusinesses } = useAppSelector((state) => state.businesses);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [locationQuery, setLocationQuery] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchCategories({ query: '?limit=8' }));
|
dispatch(fetchCategories({ query: '?limit=8' }));
|
||||||
dispatch(fetchBusinesses({ query: '?limit=4&is_active=true' }));
|
|
||||||
}, [dispatch]);
|
}, [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({
|
router.push({
|
||||||
pathname: '/search',
|
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 (
|
return (
|
||||||
<>
|
<div className="min-h-screen bg-slate-50 font-sans text-slate-900">
|
||||||
<Head>
|
<Head>
|
||||||
<title>Crafted Network™ | 21st Century Service Directory</title>
|
<title>Crafted Network™ | 21st Century Service Directory</title>
|
||||||
<meta name="description" content="Connect with verified service professionals. Trust, transparency, and AI-powered matching." />
|
<meta name="description" content="Connect with verified service professionals. Trust, transparency, and AI-powered matching." />
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative bg-slate-900 pt-32 pb-48 overflow-hidden">
|
<section className="relative bg-slate-900 text-white overflow-hidden py-32 lg:py-48">
|
||||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden z-0 opacity-20">
|
<div className="absolute inset-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-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-[20%] -right-[5%] w-[30%] h-[30%] bg-blue-600 blur-[100px] rounded-full"></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>
|
</div>
|
||||||
|
|
||||||
<SectionMain className="relative z-10">
|
<div className="container mx-auto px-6 relative z-10">
|
||||||
<div className="text-center max-w-4xl mx-auto space-y-8">
|
<div className="text-center max-w-4xl mx-auto">
|
||||||
<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="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" />
|
<BaseIcon path={mdiShieldCheck} size={18} className="mr-2" />
|
||||||
The World's Most Trusted Professional Network
|
Verified Professionals & AI-Powered Matching
|
||||||
</div>
|
</div>
|
||||||
|
<h1 className="text-5xl lg:text-7xl font-bold tracking-tight mb-8">
|
||||||
<h1 className="text-5xl md:text-7xl font-black text-white leading-tight tracking-tight">
|
The <span className="text-emerald-400">Crafted</span> Service Network
|
||||||
The <span className="text-emerald-400">Crafted</span> Network
|
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="text-xl text-slate-400 mb-12 max-w-2xl mx-auto leading-relaxed">
|
||||||
<p className="text-xl text-slate-400 leading-relaxed max-w-2xl mx-auto">
|
|
||||||
Find reliable, verified experts for your home or business. Real-time availability, transparent pricing, and zero spam.
|
Find reliable, verified experts for your home or business. Real-time availability, transparent pricing, and zero spam.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* 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">
|
<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-1 relative group">
|
<div className="flex-grow relative">
|
||||||
<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" />
|
<BaseIcon path={mdiMagnify} size={24} className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
name="query"
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="What service do you need?"
|
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"
|
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>
|
||||||
<div className="w-px bg-slate-700 hidden md:block"></div>
|
<div className="md:w-px h-8 bg-white/10 my-auto"></div>
|
||||||
<div className="flex-1 relative group">
|
<div className="flex-grow relative">
|
||||||
<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" />
|
<BaseIcon path={mdiMapMarker} size={24} className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={locationQuery}
|
name="location"
|
||||||
onChange={(e) => setLocationQuery(e.target.value)}
|
placeholder="Location"
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="City or Zip code"
|
|
||||||
className="w-full bg-transparent border-none focus:ring-0 py-4 pl-12 pr-4 text-white placeholder-slate-500 rounded-xl"
|
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>
|
||||||
<button
|
<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">
|
||||||
onClick={handleSearch}
|
Find Help
|
||||||
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>
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionMain>
|
|
||||||
</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} />
|
|
||||||
</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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Featured Categories */}
|
||||||
<section className="py-32">
|
<section className="py-24 container mx-auto px-6">
|
||||||
<SectionMain>
|
|
||||||
<div className="flex items-end justify-between mb-12">
|
<div className="flex items-end justify-between mb-12">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-black text-slate-900 mb-4">Popular Categories</h2>
|
<h2 className="text-3xl font-bold mb-4">Popular Services</h2>
|
||||||
<p className="text-slate-500">Explore the best rated professionals in these top industries.</p>
|
<p className="text-slate-500">Explore our most requested categories from verified pros.</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/categories" className="hidden md:flex items-center text-emerald-600 font-bold hover:gap-2 transition-all">
|
<Link href="/categories/categories-list" className="text-emerald-500 font-semibold hover:underline flex items-center">
|
||||||
View All <BaseIcon path={mdiChevronRight} size={20} />
|
View All Categories
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||||
{categories?.map((cat: any) => (
|
{(categories?.length > 0 ? categories.slice(0, 6) : featuredCategories).map((cat: any, i: number) => (
|
||||||
<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">
|
<Link
|
||||||
<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">
|
key={i}
|
||||||
<BaseIcon path={mdiStar} size={24} />
|
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>
|
||||||
<span className="font-bold text-slate-700 group-hover:text-emerald-600 transition-colors">{cat.name}</span>
|
<span className="font-bold text-slate-700 group-hover:text-emerald-600 transition-colors">{cat.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SectionMain>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Featured Businesses */}
|
{/* Trust Features */}
|
||||||
<section className="py-32 bg-slate-50">
|
<section className="py-24 bg-slate-900 text-white overflow-hidden relative">
|
||||||
<SectionMain>
|
<div className="container mx-auto px-6 relative z-10">
|
||||||
<div className="text-center mb-16 max-w-3xl mx-auto">
|
<div className="grid lg:grid-cols-3 gap-12 text-center lg:text-left">
|
||||||
<h2 className="text-4xl font-black text-slate-900 mb-6">Verified Top Performers</h2>
|
<div className="p-8 rounded-3xl bg-white/5 border border-white/10">
|
||||||
<p className="text-lg text-slate-600 italic">"Trust is earned through consistent, high-quality results."</p>
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
{/* Call to Action */}
|
||||||
{featuredBusinesses?.map((biz: any) => (
|
<section className="py-24 container mx-auto px-6">
|
||||||
<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="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="h-56 bg-slate-200 relative overflow-hidden">
|
<div className="relative z-10 max-w-2xl text-center lg:text-left mb-10 lg:mb-0">
|
||||||
{biz.business_photos?.[0]?.photo_url ? (
|
<h2 className="text-4xl lg:text-5xl font-bold mb-6">Are you a service professional?</h2>
|
||||||
<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" />
|
<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 className="w-full h-full flex items-center justify-center bg-slate-100 text-slate-300">
|
|
||||||
<BaseIcon path={mdiStar} size={48} />
|
|
||||||
</div>
|
</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 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>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
LandingPage.getLayout = function getLayout(page: ReactElement) {
|
LandingPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
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 type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
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 BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
|
import BaseIcon from "../components/BaseIcon";
|
||||||
|
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
import SectionFullScreen from '../components/SectionFullScreen';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
import FormField from '../components/FormField';
|
import FormField from '../components/FormField';
|
||||||
import BaseIcon from '../components/BaseIcon';
|
import FormCheckRadio from '../components/FormCheckRadio';
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import BaseDivider from '../components/BaseDivider';
|
||||||
import { loginUser, resetAction, findMe } from '../stores/authSlice';
|
import BaseButtons from '../components/BaseButtons';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import { getPageTitle } from '../config';
|
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) => {
|
export default function Login() {
|
||||||
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() {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { isFetching, token } = useAppSelector((state) => state.auth);
|
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||||
const { action, businessId } = router.query;
|
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||||
const isClaiming = action === 'claim';
|
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'
|
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(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
dispatch(findMe());
|
dispatch(findMe());
|
||||||
// If claiming, redirect back to business details after login
|
}
|
||||||
if (isClaiming && businessId) {
|
}, [token, dispatch]);
|
||||||
router.push(`/public/businesses-details?id=${businessId}`);
|
// Redirect to dashboard if user is logged in
|
||||||
} else {
|
useEffect(() => {
|
||||||
|
if (currentUser?.id) {
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
}
|
}
|
||||||
}
|
}, [currentUser?.id, router]);
|
||||||
}, [token, dispatch, isClaiming, businessId, router]);
|
// Show error message if there is one
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(resetAction());
|
if (errorMessage){
|
||||||
}, [dispatch]);
|
notify('error', errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (values: any) => {
|
}, [errorMessage])
|
||||||
const { email, password } = values;
|
// Show notification if there is one
|
||||||
const rest = { email, password };
|
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));
|
await dispatch(loginUser(rest));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setLogin = (target: HTMLElement) => {
|
||||||
|
setInitialValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
email : target.innerText.trim(),
|
||||||
|
password: target.dataset.password ?? '',
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
|
||||||
|
const videoBlock = (video) => {
|
||||||
|
if (video?.video_files?.length > 0) {
|
||||||
return (
|
return (
|
||||||
<SectionFullScreen bg="white">
|
<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>
|
<Head>
|
||||||
<title>{getPageTitle('Login')} | {title}</title>
|
<title>{getPageTitle('Login')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row min-h-screen w-full overflow-hidden">
|
<SectionFullScreen bg='violet'>
|
||||||
{/* Left Side: Visual/Branding */}
|
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
||||||
<div className="hidden lg:flex lg:w-1/2 bg-slate-900 items-center justify-center p-12 relative overflow-hidden">
|
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
||||||
<div className="absolute top-0 left-0 w-full h-full opacity-20">
|
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
|
||||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-emerald-500 blur-[120px] rounded-full"></div>
|
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||||
<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">
|
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
||||||
<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 */}
|
<h2 className="text-4xl font-semibold my-4">{title}</h2>
|
||||||
<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">
|
<div className='flex flex-row text-gray-500 justify-between'>
|
||||||
<h2 className="text-4xl font-black text-slate-900 tracking-tight">Welcome Back</h2>
|
<div>
|
||||||
|
|
||||||
{isClaiming ? (
|
<p className='mb-2'>Use{' '}
|
||||||
<div className="bg-emerald-50 p-6 rounded-2xl border border-emerald-100 flex items-start space-x-4">
|
<code className={`cursor-pointer ${textColor} `}
|
||||||
<div className="p-2 bg-emerald-100 rounded-xl text-emerald-600">
|
data-password="b2096650"
|
||||||
<BaseIcon path={mdiShieldCheck} size={24} />
|
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>
|
||||||
<div className="space-y-1">
|
<div>
|
||||||
<h4 className="font-bold text-emerald-900">Verify Ownership</h4>
|
<BaseIcon
|
||||||
<p className="text-emerald-700 text-sm">Please login or create an account to verify ownership and take control of your business profile.</p>
|
className={`${iconsColor}`}
|
||||||
|
w='w-16'
|
||||||
|
h='h-16'
|
||||||
|
size={48}
|
||||||
|
path={mdiInformation}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</CardBox>
|
||||||
<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">
|
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{ email: '', password: '' }}
|
initialValues={initialValues}
|
||||||
validate={validate}
|
enableReinitialize
|
||||||
onSubmit={handleSubmit}
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
>
|
>
|
||||||
{({ errors, touched }) => (
|
<Form>
|
||||||
<Form className="space-y-6">
|
<FormField
|
||||||
<FormField label="Email Address" labelColor="text-slate-900 font-bold" help={touched.email && errors.email ? (errors.email as string) : "Your registered professional email"}>
|
label='Login'
|
||||||
<Field
|
help='Please enter your login'>
|
||||||
name="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>
|
||||||
|
|
||||||
<FormField label="Password" labelColor="text-slate-900 font-bold" help={touched.password && errors.password ? (errors.password as string) : "Security first — keep it safe"}>
|
<div className='relative'>
|
||||||
<Field
|
<FormField
|
||||||
name="password"
|
label='Password'
|
||||||
type="password"
|
help='Please enter your password'>
|
||||||
placeholder="••••••••"
|
<Field name='password' type={showPassword ? 'text' : 'password'} />
|
||||||
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>
|
</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 items-center justify-between text-sm font-bold pt-2">
|
<div className={'flex justify-between'}>
|
||||||
<Link href="/forgot-password" size="sm" className="text-emerald-600 hover:text-emerald-500 transition-colors">
|
<FormCheckRadio type='checkbox' label='Remember'>
|
||||||
Forgot Password?
|
<Field type='checkbox' name='remember' />
|
||||||
|
</FormCheckRadio>
|
||||||
|
|
||||||
|
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
|
||||||
|
Forgot password?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BaseDivider />
|
||||||
|
|
||||||
|
<BaseButtons>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
type="submit"
|
className={'w-full'}
|
||||||
color="emerald"
|
type='submit'
|
||||||
label={isFetching ? 'Verifying...' : 'Login to Dashboard'}
|
label={isFetching ? 'Loading...' : 'Login'}
|
||||||
className="w-full py-5 rounded-2xl font-black text-lg shadow-xl shadow-emerald-500/20 active:scale-[0.98] transition-all"
|
color='info'
|
||||||
disabled={isFetching}
|
disabled={isFetching}
|
||||||
/>
|
/>
|
||||||
</Form>
|
</BaseButtons>
|
||||||
)}
|
<br />
|
||||||
</Formik>
|
<p className={'text-center'}>
|
||||||
|
Don’t have an account yet?{' '}
|
||||||
<div className="pt-10 text-center space-y-6">
|
<Link className={`${textColor}`} href={'/register'}>
|
||||||
<div className="relative">
|
New Account
|
||||||
<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>
|
</Link>
|
||||||
|
|
||||||
<p className='text-xs font-medium text-slate-400 pt-8'>
|
|
||||||
© 2026 <span>{title}</span>. All rights reserved. Professional Directory Platform.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</Form>
|
||||||
|
</Formik>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</SectionFullScreen>
|
</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) {
|
Login.getLayout = function getLayout(page: ReactElement) {
|
||||||
return page;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
@ -1,73 +1,292 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import Head from 'next/head';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
|
import Head from 'next/head';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import SectionMain from '../components/SectionMain';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
|
|
||||||
const PrivacyPolicyPage = () => {
|
export default function PrivacyPolicy() {
|
||||||
const title = 'Crafted Network';
|
const title = 'Crafted Network'
|
||||||
|
const [projectUrl, setProjectUrl] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProjectUrl(location.origin);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const Introduction = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<h3>1. Introduction</h3>
|
||||||
<title>{getPageTitle('Privacy Policy')} | {title}</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>
|
<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.
|
{/* 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>
|
</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>
|
|
||||||
</div>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PrivacyPolicyPage.getLayout = function getLayout(page: ReactElement) {
|
const Information = () => {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PrivacyPolicyPage;
|
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>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PrivacyPolicy.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
|
};
|
||||||
|
|||||||
@ -1,349 +1,372 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Link from 'next/link';
|
|
||||||
import {
|
import {
|
||||||
mdiMapMarker,
|
|
||||||
mdiStar,
|
mdiStar,
|
||||||
mdiShieldCheck,
|
mdiShieldCheck,
|
||||||
mdiClockOutline,
|
mdiClockOutline,
|
||||||
|
mdiMapMarker,
|
||||||
mdiPhone,
|
mdiPhone,
|
||||||
mdiEmail,
|
|
||||||
mdiWeb,
|
mdiWeb,
|
||||||
mdiChevronRight,
|
mdiEmail,
|
||||||
mdiImageMultiple,
|
mdiCurrencyUsd,
|
||||||
mdiMessageTextOutline,
|
mdiCheckDecagram,
|
||||||
mdiShareVariant,
|
mdiMessageDraw,
|
||||||
mdiFlagOutline,
|
mdiAccount
|
||||||
mdiAccountCheck,
|
|
||||||
mdiCalendarCheck,
|
|
||||||
mdiMagnify
|
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import axios from 'axios';
|
||||||
import { fetch as fetchBusiness } from '../../stores/businesses/businessesSlice';
|
|
||||||
import LayoutGuest from '../../layouts/Guest';
|
import LayoutGuest from '../../layouts/Guest';
|
||||||
import SectionMain from '../../components/SectionMain';
|
|
||||||
import BaseIcon from '../../components/BaseIcon';
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
import BaseButton from '../../components/BaseButton';
|
|
||||||
import LoadingSpinner from '../../components/LoadingSpinner';
|
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 router = useRouter();
|
||||||
const { id } = router.query;
|
const { id } = router.query;
|
||||||
const dispatch = useAppDispatch();
|
const [loading, setLoading] = useState(true);
|
||||||
const { item: business, isAskingResponse: loading } = useAppSelector((state) => state.businesses);
|
const [business, setBusiness] = useState<any>(null);
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
const [activeTab, setActiveTab] = useState('about');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id && typeof id === 'string') {
|
if (id) {
|
||||||
dispatch(fetchBusiness({ id }));
|
fetchBusiness();
|
||||||
}
|
}
|
||||||
}, [id, dispatch]);
|
}, [id]);
|
||||||
|
|
||||||
const claimListing = () => {
|
const fetchBusiness = async () => {
|
||||||
if (!currentUser) {
|
setLoading(true);
|
||||||
router.push(`/login?action=claim&businessId=${id}`);
|
try {
|
||||||
} else {
|
const response = await axios.get(`/businesses/${id}`);
|
||||||
// In a real app, this would trigger the claim process thunk
|
setBusiness(response.data);
|
||||||
alert('Verification process started. Our team will contact you to verify ownership.');
|
} catch (error) {
|
||||||
|
console.error('Error fetching business:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading || !business) {
|
const claimListing = async () => {
|
||||||
return (
|
if (!currentUser) {
|
||||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
router.push('/login');
|
||||||
<LoadingSpinner />
|
return;
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
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 (
|
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>
|
<Head>
|
||||||
<title>{business.name} | Crafted Network™</title>
|
<title>{business.name} | Crafted Network™</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
{/* Hero Header */}
|
{/* Hero Header */}
|
||||||
<div className="relative h-[450px] bg-slate-900 overflow-hidden">
|
<section className="bg-white border-b border-slate-200 pt-16 pb-12">
|
||||||
{business.business_photos?.[0]?.photo_url ? (
|
<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
|
<img
|
||||||
src={business.business_photos[0].photo_url}
|
src={getBusinessImage()}
|
||||||
alt={business.name}
|
alt={business.name}
|
||||||
className="w-full h-full object-cover opacity-60 scale-105"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full bg-gradient-to-br from-slate-900 via-slate-800 to-emerald-900/20" />
|
<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 className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-transparent" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SectionMain className="-mt-32 relative z-10 pb-32">
|
<div className="flex-grow w-full">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-6">
|
||||||
|
<div>
|
||||||
{/* Main Info */}
|
<h1 className="text-4xl lg:text-5xl font-bold mb-3">{business.name}</h1>
|
||||||
<div className="lg:col-span-2 space-y-12">
|
<div className="flex flex-wrap items-center gap-4 text-slate-500 font-medium">
|
||||||
<div className="bg-white p-10 rounded-[2.5rem] shadow-2xl shadow-slate-200/50 border border-slate-100 animate-fade-in">
|
<span className="flex items-center">
|
||||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6 mb-10">
|
<BaseIcon path={mdiMapMarker} size={18} className="mr-1 text-emerald-500" />
|
||||||
<div className="space-y-4">
|
{business.city}, {business.state}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
</span>
|
||||||
{isVerified ? (
|
<span className="flex items-center">
|
||||||
<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={mdiStar} size={18} className="mr-1 text-amber-400" />
|
||||||
<BaseIcon path={mdiShieldCheck} size={16} className="mr-1" />
|
{displayRating} Rating
|
||||||
Verified Business
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<span className="bg-slate-100 text-slate-500 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider flex items-center">
|
||||||
Unverified Listing
|
Unclaimed Listing
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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
|
<button
|
||||||
key={tab}
|
onClick={() => router.push(`/public/request-service?businessId=${business.id}`)}
|
||||||
onClick={() => setActiveTab(tab)}
|
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"
|
||||||
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'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{tab}
|
Request Service
|
||||||
{activeTab === tab && (
|
|
||||||
<div className="absolute bottom-0 left-0 w-full h-1 bg-emerald-500 rounded-full animate-fade-in" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div className="bg-slate-50 p-8 rounded-3xl border border-slate-100 space-y-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 py-6 border-t border-slate-100">
|
||||||
<h4 className="font-bold text-slate-900 flex items-center">
|
<div>
|
||||||
<BaseIcon path={mdiAccountCheck} size={20} className="mr-2 text-emerald-500" />
|
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Avg Rating</div>
|
||||||
Verification Status
|
<div className="text-2xl font-bold text-slate-900">{displayRating} / 5.0</div>
|
||||||
</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>
|
<div>
|
||||||
<div className="font-black text-slate-900">{review.user?.firstName} {review.user?.lastName}</div>
|
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Response Time</div>
|
||||||
<div className="text-xs text-slate-400 font-medium">{dateFormatter(review.created_at_ts)}</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>
|
||||||
<div className="flex text-amber-400">
|
<div>
|
||||||
{[...Array(5)].map((_, i) => (
|
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Total Reviews</div>
|
||||||
<BaseIcon key={i} path={mdiStar} size={16} className={i < review.rating ? 'fill-current' : 'text-slate-200'} />
|
<div className="text-2xl font-bold text-slate-900">{business.reviews_business?.length || 0}</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-600 font-medium leading-relaxed italic">"{review.comment}"</p>
|
|
||||||
</div>
|
</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>
|
||||||
)}
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Claim Listing
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'photos' && (
|
{/* Photos Gallery */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-6 animate-fade-in">
|
{business.business_photos_business?.length > 0 && (
|
||||||
{business.business_photos?.map((photo: any) => (
|
<section className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
|
||||||
<div key={photo.id} className="group relative aspect-square bg-slate-100 rounded-3xl overflow-hidden cursor-pointer">
|
<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
|
<img
|
||||||
src={photo.photo_url}
|
src={`/api/file/download?privateUrl=${p.publicUrl}`}
|
||||||
alt="Gallery"
|
alt="Business"
|
||||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
className="w-full h-full object-cover 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">
|
</div>
|
||||||
<BaseIcon path={mdiMagnify} size={48} className="text-white" />
|
))
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(!business.business_photos || business.business_photos.length === 0) && (
|
{!business.service_prices_business?.length && <p className="text-slate-500">No pricing information available.</p>}
|
||||||
<div className="col-span-full text-center py-24 bg-slate-50 rounded-[2.5rem] border-2 border-dashed border-slate-200">
|
</div>
|
||||||
<BaseIcon path={mdiImageMultiple} size={48} className="mx-auto text-slate-200 mb-6" />
|
</section>
|
||||||
<p className="text-slate-400 font-bold">No photos available yet.</p>
|
|
||||||
|
{/* 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>
|
</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>
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Contact Card */}
|
{/* Contact Info */}
|
||||||
<div className="bg-slate-900 p-8 rounded-[2.5rem] text-white space-y-8 shadow-2xl shadow-slate-300">
|
<div className="bg-slate-900 text-white p-10 rounded-[3rem] shadow-xl relative overflow-hidden group">
|
||||||
<h4 className="text-xl font-black">Contact Information</h4>
|
<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>
|
||||||
<div className="space-y-6">
|
<h3 className="text-xl font-bold mb-8">Contact & Location</h3>
|
||||||
<a href={`tel:${business.phoneNumber}`} className="flex items-center group">
|
<div className="space-y-6 relative z-10">
|
||||||
<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">
|
<div className="flex items-start">
|
||||||
<BaseIcon path={mdiPhone} size={20} />
|
<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>
|
||||||
<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>
|
</div>
|
||||||
</a>
|
<div className="flex items-start">
|
||||||
<a href={`mailto:${business.email}`} className="flex items-center group">
|
<BaseIcon path={mdiEmail} size={24} className="mr-4 text-emerald-400" />
|
||||||
<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">
|
<div>
|
||||||
<BaseIcon path={mdiEmail} size={20} />
|
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Email</div>
|
||||||
</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 className="font-bold truncate max-w-[180px]">{business.email}</div>
|
||||||
</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} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="flex items-start">
|
||||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-widest">Website</div>
|
<BaseIcon path={mdiWeb} size={24} className="mr-4 text-emerald-400" />
|
||||||
<div className="font-bold truncate max-w-[180px]">{business.website.replace(/^https?:\/\/(www\.)?/, '')}</div>
|
<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>
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-start">
|
||||||
<div className="pt-8 border-t border-white/10 flex items-center justify-between">
|
<BaseIcon path={mdiMapMarker} size={24} className="mr-4 text-emerald-400" />
|
||||||
<button className="flex items-center text-sm font-bold text-slate-400 hover:text-white transition-colors">
|
<div>
|
||||||
<BaseIcon path={mdiFlagOutline} size={16} className="mr-2" />
|
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Address</div>
|
||||||
Report Problem
|
<div className="font-bold">{business.address}, {business.city}, {business.state} {business.zip}</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Claim Listing Section */}
|
{/* Badges */}
|
||||||
{!isVerified && (
|
<div className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
|
||||||
<div className="bg-emerald-50 p-10 rounded-[2.5rem] border border-emerald-100 text-center space-y-6 relative overflow-hidden group">
|
<h3 className="text-xl font-bold mb-8">Trust Signals</h3>
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:scale-150 transition-transform duration-700">
|
<div className="space-y-6">
|
||||||
<BaseIcon path={mdiShieldCheck} size={144} className="text-emerald-900" />
|
{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="relative z-10 space-y-6">
|
<div>
|
||||||
<div className="w-20 h-20 bg-emerald-500 rounded-3xl flex items-center justify-center mx-auto shadow-xl shadow-emerald-500/30">
|
<div className="font-bold text-slate-800 text-sm leading-tight">{badge.badge_type.replace(/_/g, ' ')}</div>
|
||||||
<BaseIcon path={mdiShieldCheck} size={48} className="text-slate-900" />
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">Verified Badge</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<BaseButton
|
))}
|
||||||
label="Claim Listing"
|
{business.is_claimed && (
|
||||||
color="emerald"
|
<div className="flex items-center p-4 rounded-2xl bg-emerald-50">
|
||||||
className="w-full py-5 rounded-2xl font-black shadow-lg shadow-emerald-500/20 active:scale-95 transition-all"
|
<div className="w-10 h-10 bg-emerald-200 rounded-xl flex items-center justify-center mr-4 text-emerald-700">
|
||||||
onClick={claimListing}
|
<BaseIcon path={mdiCheckDecagram} size={24} />
|
||||||
/>
|
</div>
|
||||||
<p className="text-xs font-bold text-emerald-600/60 uppercase tracking-widest">Verification Required</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!business.business_badges_business?.length && !business.is_claimed && <p className="text-slate-400 text-sm italic">Pending verification...</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionMain>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
BusinessDetailsPage.getLayout = function getLayout(page: ReactElement) {
|
BusinessDetailsPublic.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default BusinessDetailsPublic;
|
||||||
@ -1,179 +1,184 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { Formik, Form, Field } from 'formik';
|
|
||||||
import {
|
import {
|
||||||
mdiCalendarCheck,
|
|
||||||
mdiAccount,
|
|
||||||
mdiEmail,
|
|
||||||
mdiPhone,
|
|
||||||
mdiMapMarker,
|
|
||||||
mdiMessageTextOutline,
|
|
||||||
mdiArrowLeft,
|
|
||||||
mdiShieldCheck,
|
mdiShieldCheck,
|
||||||
mdiLightningBolt
|
mdiClockOutline,
|
||||||
|
mdiMapMarker,
|
||||||
|
mdiEmail,
|
||||||
|
mdiAccount,
|
||||||
|
mdiPhone,
|
||||||
|
mdiAlertDecagram
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { Formik, Form, Field } from 'formik';
|
||||||
import { fetch as fetchBusiness } from '../../stores/businesses/businessesSlice';
|
import axios from 'axios';
|
||||||
import { create as createLead } from '../../stores/leads/leadsSlice';
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
import LayoutGuest from '../../layouts/Guest';
|
import BaseIcon from '../../components/BaseIcon';
|
||||||
import SectionMain from '../../components/SectionMain';
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
import FormField from '../../components/FormField';
|
import FormField from '../../components/FormField';
|
||||||
import BaseButton from '../../components/BaseButton';
|
import BaseButton from '../../components/BaseButton';
|
||||||
import LoadingSpinner from '../../components/LoadingSpinner';
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
import BaseIcon from '../../components/BaseIcon';
|
import { create as createLead } from '../../stores/leads/leadsSlice';
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const validate = (values: any) => {
|
const RequestServicePage = () => {
|
||||||
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 router = useRouter();
|
const router = useRouter();
|
||||||
const { businessId } = router.query;
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { item: business, isAskingResponse: loadingBiz } = useAppSelector((state) => state.businesses);
|
const { businessId } = router.query;
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [business, setBusiness] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { currentUser } = useAppSelector(state => state.auth);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (businessId && typeof businessId === 'string') {
|
if (businessId) {
|
||||||
dispatch(fetchBusiness({ id: businessId }));
|
fetchBusiness();
|
||||||
}
|
}
|
||||||
}, [businessId, dispatch]);
|
}, [businessId]);
|
||||||
|
|
||||||
|
const fetchBusiness = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/businesses/${businessId}`);
|
||||||
|
setBusiness(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching business:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (values: any) => {
|
const handleSubmit = async (values: any) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
businessId,
|
businessId,
|
||||||
status: 'pending'
|
user: currentUser?.id
|
||||||
};
|
};
|
||||||
await dispatch(createLead(payload));
|
await dispatch(createLead(payload));
|
||||||
setSubmitted(true);
|
router.push('/leads/leads-list'); // Redirect to their leads tracker
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lead creation error:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loadingBiz || !business) {
|
if (!business && businessId) return <div className="min-h-screen flex items-center justify-center bg-slate-50"><LoadingSpinner /></div>;
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
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>
|
<Head>
|
||||||
<title>Request Service | Crafted Network™</title>
|
<title>Request Service | Crafted Network™</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionMain className="py-24">
|
<div className="container mx-auto px-6 max-w-4xl">
|
||||||
<div className="max-w-6xl mx-auto flex flex-col lg:flex-row gap-12">
|
<div className="bg-white rounded-[3rem] shadow-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="bg-slate-900 p-12 text-white relative">
|
||||||
{/* Form Side */}
|
<div className="absolute top-0 right-0 p-12 opacity-10">
|
||||||
<div className="flex-1 space-y-12 animate-fade-in">
|
<BaseIcon path={mdiShieldCheck} size={120} />
|
||||||
<div className="space-y-6">
|
</div>
|
||||||
<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">
|
<h1 className="text-4xl font-bold mb-4">Request Service</h1>
|
||||||
<BaseIcon path={mdiArrowLeft} size={20} className="mr-2 group-hover:-translate-x-1 transition-transform" />
|
<p className="text-slate-400 text-lg max-w-xl">
|
||||||
Back to Profile
|
You are requesting a service from <span className="text-emerald-400 font-bold">{business?.name || 'a professional'}</span>.
|
||||||
</Link>
|
Our smart matching system ensures your request is handled with priority.
|
||||||
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-10 md:p-12 rounded-[2.5rem] shadow-xl shadow-slate-200/50 border border-white">
|
<div className="p-12">
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
customerName: '',
|
keyword: '',
|
||||||
customerEmail: '',
|
description: '',
|
||||||
customerPhone: '',
|
urgency: 'TODAY',
|
||||||
serviceType: '',
|
contact_name: currentUser ? `${currentUser.firstName} ${currentUser.lastName}` : '',
|
||||||
description: ''
|
contact_email: currentUser?.email || '',
|
||||||
|
contact_phone: currentUser?.phoneNumber || '',
|
||||||
|
address: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
zip: ''
|
||||||
}}
|
}}
|
||||||
validate={validate}
|
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
{({ errors, touched }) => (
|
{({ values }) => (
|
||||||
<Form className="space-y-8">
|
<Form className="space-y-8">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="grid 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) : ""}>
|
<FormField label="What do you need help with?" labelFor="keyword">
|
||||||
<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
|
<Field
|
||||||
name="description"
|
name="keyword"
|
||||||
as="textarea"
|
placeholder="e.g. Leaking faucet in kitchen"
|
||||||
rows={6}
|
className="w-full bg-slate-50 border-slate-200 rounded-2xl py-4 px-6 focus:ring-emerald-500 focus:border-emerald-500"
|
||||||
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`}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className="pt-4">
|
<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>
|
||||||
|
|
||||||
|
<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="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 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 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
|
<BaseButton
|
||||||
type="submit"
|
type="submit"
|
||||||
color="emerald"
|
color="emerald"
|
||||||
label="Send Request"
|
label={loading ? 'Submitting...' : '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"
|
className="py-5 px-12 rounded-2xl text-lg font-bold shadow-2xl shadow-emerald-500/30"
|
||||||
icon={mdiLightningBolt}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
@ -181,54 +186,17 @@ export default function RequestServicePage() {
|
|||||||
</Formik>
|
</Formik>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<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>
|
|
||||||
<div>
|
|
||||||
<div className="font-bold text-sm">{item.label}</div>
|
|
||||||
<div className="text-xs text-slate-500">{item.desc}</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>
|
|
||||||
</div>
|
|
||||||
</SectionMain>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
RequestServicePage.getLayout = function getLayout(page: ReactElement) {
|
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 type { ReactElement } from 'react';
|
||||||
|
import { ToastContainer, toast } from 'react-toastify';
|
||||||
import Head from 'next/head';
|
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 BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
import SectionFullScreen from '../components/SectionFullScreen';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
|
import { Field, Form, Formik } from 'formik';
|
||||||
import FormField from '../components/FormField';
|
import FormField from '../components/FormField';
|
||||||
import BaseIcon from '../components/BaseIcon';
|
import BaseDivider from '../components/BaseDivider';
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import BaseButtons from '../components/BaseButtons';
|
||||||
import { registerUser, resetAction } from '../stores/authSlice';
|
import { useRouter } from 'next/router';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
|
|
||||||
const validate = (values: any) => {
|
import axios from "axios";
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function Register() {
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
||||||
const { isFetching, token } = useAppSelector((state) => state.auth);
|
|
||||||
const { action, businessId } = router.query;
|
|
||||||
const isClaiming = action === 'claim';
|
|
||||||
|
|
||||||
const title = 'Crafted Network'
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSubmit = async (value) => {
|
||||||
if (token) {
|
setLoading(true)
|
||||||
if (isClaiming && businessId) {
|
try {
|
||||||
router.push(`/login?action=claim&businessId=${businessId}`);
|
|
||||||
} else {
|
const { data: response } = await axios.post('/auth/signup',value);
|
||||||
router.push('/dashboard');
|
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')
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, [token, isClaiming, businessId, router]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(resetAction());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const handleSubmit = async (values: any) => {
|
|
||||||
await dispatch(registerUser(values));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionFullScreen bg="white">
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Register')} | {title}</title>
|
<title>{getPageTitle('Login')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row min-h-screen w-full overflow-hidden">
|
<SectionFullScreen bg='violet'>
|
||||||
{/* Left Side: Visual/Branding */}
|
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
||||||
<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
|
<Formik
|
||||||
initialValues={{ firstName: '', lastName: '', email: '', password: '' }}
|
initialValues={{
|
||||||
validate={validate}
|
email: '',
|
||||||
onSubmit={handleSubmit}
|
password: '',
|
||||||
|
confirm: ''
|
||||||
|
}}
|
||||||
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
>
|
>
|
||||||
{({ errors, touched }) => (
|
<Form>
|
||||||
<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) : ""}>
|
<FormField label='Email' help='Please enter your email'>
|
||||||
<Field
|
<Field type='email' name='email' />
|
||||||
name="email"
|
</FormField>
|
||||||
type="email"
|
<FormField label='Password' help='Please enter your password'>
|
||||||
placeholder="john@example.com"
|
<Field type='password' name='password' />
|
||||||
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='Confirm Password' help='Please confirm your password'>
|
||||||
|
<Field type='password' name='confirm' />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="Password" labelColor="text-slate-900 font-bold" help={touched.password && errors.password ? (errors.password as string) : "Minimum 8 characters"}>
|
<BaseDivider />
|
||||||
<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>
|
|
||||||
|
|
||||||
|
<BaseButtons>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
type="submit"
|
type='submit'
|
||||||
color="emerald"
|
label={loading ? 'Loading...' : 'Register' }
|
||||||
label={isFetching ? 'Creating Account...' : 'Join the Network'}
|
color='info'
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
|
<BaseButton
|
||||||
|
href={'/login'}
|
||||||
|
label={'Login'}
|
||||||
|
color='info'
|
||||||
|
/>
|
||||||
|
</BaseButtons>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
|
||||||
</Formik>
|
</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>
|
</CardBox>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SectionFullScreen>
|
</SectionFullScreen>
|
||||||
|
<ToastContainer />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
RegisterPage.getLayout = function getLayout(page: ReactElement) {
|
Register.getLayout = function getLayout(page: ReactElement) {
|
||||||
return page;
|
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 Head from 'next/head';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import {
|
import {
|
||||||
mdiMagnify,
|
mdiMagnify,
|
||||||
mdiMapMarker,
|
mdiMapMarker,
|
||||||
mdiFilterVariant,
|
|
||||||
mdiStar,
|
mdiStar,
|
||||||
mdiShieldCheck,
|
mdiShieldCheck,
|
||||||
mdiChevronRight,
|
mdiClockOutline,
|
||||||
mdiSortVariant
|
mdiCurrencyUsd,
|
||||||
|
mdiFilterVariant
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import BaseIcon from '../components/BaseIcon';
|
import axios from 'axios';
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
|
||||||
import { fetch as fetchBusinesses } from '../stores/businesses/businessesSlice';
|
|
||||||
import { fetch as fetchCategories } from '../stores/categories/categoriesSlice';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
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 Link from 'next/link';
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
|
|
||||||
const SearchPage = () => {
|
const SearchView = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const { query: searchQueryParam, location: locationParam } = router.query;
|
||||||
const { businesses, loading } = useAppSelector((state) => state.businesses);
|
const [loading, setLoading] = useState(false);
|
||||||
const { categories } = useAppSelector((state) => state.categories);
|
const [searchResults, setSearchResults] = useState([]);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState(searchQueryParam || '');
|
||||||
const [locationQuery, setLocationQuery] = useState('');
|
const [location, setLocation] = useState(locationParam || '');
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (router.isReady) {
|
if (searchQueryParam) {
|
||||||
const q = router.query.q as string || '';
|
setSearchQuery(searchQueryParam as string);
|
||||||
const l = router.query.l as string || '';
|
fetchData(searchQueryParam as string);
|
||||||
const category = router.query.category as string || '';
|
|
||||||
|
|
||||||
setSearchQuery(q);
|
|
||||||
setLocationQuery(l);
|
|
||||||
|
|
||||||
executeSearch(q, l, category);
|
|
||||||
dispatch(fetchCategories({ query: '' }));
|
|
||||||
}
|
}
|
||||||
}, [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({
|
router.push({
|
||||||
pathname: '/search',
|
pathname: '/search',
|
||||||
query: { q: searchQuery, l: locationQuery },
|
query: { query: searchQuery, location },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const businesses = searchResults.filter((item: any) => item.tableName === 'businesses');
|
||||||
if (e.key === 'Enter') {
|
|
||||||
handleSearchClick();
|
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 (
|
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>
|
<Head>
|
||||||
<title>Find Services | Crafted Network™</title>
|
<title>Find Services | Crafted Network™</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
{/* Hero Search Area */}
|
{/* Search Header */}
|
||||||
<section className="bg-slate-900 pt-32 pb-24 relative overflow-hidden">
|
<div className="bg-slate-900 pt-32 pb-12 shadow-inner">
|
||||||
<div className="absolute top-0 left-0 w-full h-full opacity-10">
|
<div className="container mx-auto px-6">
|
||||||
<div className="absolute -top-1/2 -left-1/4 w-full h-full bg-emerald-500 blur-[150px] rounded-full"></div>
|
<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>
|
<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" />
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
placeholder="Service (e.g. Plumbing)"
|
||||||
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"
|
className="w-full bg-transparent border-none focus:ring-0 py-3 pl-12 pr-4 text-white placeholder-slate-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px bg-slate-700 hidden md:block"></div>
|
<div className="md:w-px h-8 bg-white/10 my-auto"></div>
|
||||||
<div className="flex-1 relative group">
|
<div className="flex-grow relative">
|
||||||
<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" />
|
<BaseIcon path={mdiMapMarker} size={24} className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={locationQuery}
|
value={location}
|
||||||
onChange={(e) => setLocationQuery(e.target.value)}
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
placeholder="Location"
|
||||||
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"
|
className="w-full bg-transparent border-none focus:ring-0 py-3 pl-12 pr-4 text-white placeholder-slate-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button type="submit" className="bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-3 px-8 rounded-xl transition-all">
|
||||||
onClick={handleSearchClick}
|
Update Search
|
||||||
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>
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionMain>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
<div className="container mx-auto px-6 mt-12">
|
||||||
<SectionMain className="py-16">
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
<div className="flex flex-col lg:flex-row gap-12">
|
|
||||||
{/* Filters Sidebar */}
|
{/* Filters Sidebar */}
|
||||||
<aside className="w-full lg:w-64 space-y-8">
|
<aside className="w-full lg:w-64 space-y-8">
|
||||||
<div className="flex items-center justify-between lg:mb-8">
|
<div className="bg-white p-6 rounded-3xl border border-slate-200 shadow-sm">
|
||||||
<h3 className="text-xl font-black text-slate-900 flex items-center">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<BaseIcon path={mdiFilterVariant} size={20} className="mr-2" />
|
<h3 className="font-bold text-lg">Filters</h3>
|
||||||
Filters
|
<BaseIcon path={mdiFilterVariant} size={20} className="text-slate-400" />
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSearchQuery('');
|
|
||||||
setLocationQuery('');
|
|
||||||
router.push('/search');
|
|
||||||
}}
|
|
||||||
className="text-sm font-bold text-emerald-600 hover:text-emerald-500"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-4">
|
<div>
|
||||||
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-widest">Categories</h4>
|
<label className="block text-sm font-semibold text-slate-700 mb-3">Availability</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{categories && categories.slice(0, 8).map((cat: any) => (
|
{['Available Today', 'This Week', 'Next Week'].map(label => (
|
||||||
<Link
|
<label key={label} className="flex items-center text-sm text-slate-600 cursor-pointer hover:text-emerald-600">
|
||||||
key={cat.id}
|
<input type="checkbox" className="rounded text-emerald-500 mr-3 border-slate-300 focus:ring-emerald-500" />
|
||||||
href={`/search?category=${cat.id}`}
|
{label}
|
||||||
className="flex items-center group cursor-pointer"
|
</label>
|
||||||
>
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 pt-6 border-t border-slate-100">
|
<div>
|
||||||
<h4 className="text-xs font-bold text-slate-400 uppercase tracking-widest">Trust Score</h4>
|
<label className="block text-sm font-semibold text-slate-700 mb-3">Reliability Score</label>
|
||||||
<div className="flex items-center justify-between text-xs font-bold text-slate-400">
|
<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>Any</span>
|
||||||
<span>80+</span>
|
<span>80+</span>
|
||||||
<span>95+</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Results Area */}
|
{/* Results Area */}
|
||||||
<div className="flex-1 space-y-12">
|
<main className="flex-grow">
|
||||||
<div className="flex items-center justify-between border-b border-slate-100 pb-6">
|
<div className="flex items-baseline justify-between mb-8">
|
||||||
<div className="text-slate-500 font-medium">
|
<h2 className="text-2xl font-bold">
|
||||||
{loading ? (
|
{loading ? 'Searching...' : `${businesses.length} Results for "${searchQueryParam || 'Businesses'}"`}
|
||||||
<span>Searching professionals...</span>
|
</h2>
|
||||||
) : (
|
<div className="text-sm text-slate-500 font-medium">
|
||||||
<>Found <span className="text-slate-900 font-black">{businesses ? businesses.length : 0} verified</span> professionals</>
|
Sort by: <span className="text-slate-900 cursor-pointer hover:text-emerald-500">Reliability Score</span>
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 opacity-50">
|
<div className="flex justify-center py-20">
|
||||||
{[1, 2, 3, 4].map((i) => (
|
<LoadingSpinner />
|
||||||
<div key={i} className="h-80 bg-slate-100 rounded-[2.5rem] animate-pulse"></div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="grid gap-6">
|
||||||
{businesses && businesses.length > 0 ? (
|
{businesses.map((biz: any) => (
|
||||||
businesses.map((biz: any) => (
|
<Link key={biz.id} href={`/public/businesses-details?id=${biz.id}`}>
|
||||||
<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="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">
|
||||||
<div className="h-48 bg-slate-100 relative overflow-hidden">
|
{/* Image */}
|
||||||
{biz.business_photos?.[0]?.photo_url ? (
|
<div className="md:w-64 h-48 md:h-auto bg-slate-100 relative">
|
||||||
<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" />
|
{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">
|
<div className="absolute inset-0 flex items-center justify-center text-slate-300">
|
||||||
<BaseIcon path={mdiShieldCheck} size={48} />
|
<BaseIcon path={mdiShieldCheck} size={64} />
|
||||||
</div>
|
</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">
|
{biz.reliability_score >= 80 && (
|
||||||
<BaseIcon path={mdiStar} size={16} className="mr-1" />
|
<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">
|
||||||
{biz.rating || '4.9'}
|
Top Rated
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-8 flex-1 flex flex-col justify-between">
|
|
||||||
|
<div className="p-8 flex-grow">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<h3 className="text-2xl font-bold group-hover:text-emerald-600 transition-colors mb-1">{biz.name}</h3>
|
||||||
<h3 className="text-xl font-black text-slate-900 group-hover:text-emerald-600 transition-colors line-clamp-1">{biz.name}</h3>
|
<div className="flex items-center text-slate-500 text-sm">
|
||||||
{biz.is_active && (
|
|
||||||
<BaseIcon path={mdiShieldCheck} size={20} className="text-emerald-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center text-slate-400 text-sm font-bold mb-4">
|
|
||||||
<BaseIcon path={mdiMapMarker} size={16} className="mr-1" />
|
<BaseIcon path={mdiMapMarker} size={16} className="mr-1" />
|
||||||
{biz.city || biz.locations?.[0]?.city || 'Verified Professional'}
|
{biz.city}, {biz.state} {biz.address}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-6 border-t border-slate-50 flex items-center justify-between">
|
<div className="text-right">
|
||||||
<span className="text-xs font-black text-emerald-500 uppercase tracking-widest">Top Professional</span>
|
<div className="flex items-center justify-end text-emerald-500 font-bold text-xl mb-1">
|
||||||
<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={mdiStar} size={24} className="mr-1 text-amber-400" />
|
||||||
<BaseIcon path={mdiChevronRight} size={20} />
|
{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>
|
||||||
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))
|
))}
|
||||||
) : (
|
|
||||||
<div className="col-span-full py-24 text-center">
|
{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">
|
<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} />
|
<BaseIcon path={mdiMagnify} size={40} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-black text-slate-900 mb-2">No results found</h3>
|
<h3 className="text-xl font-bold mb-2">No businesses found</h3>
|
||||||
<p className="text-slate-500">Try adjusting your filters or searching for something else.</p>
|
<p className="text-slate-500">Try adjusting your search terms or location.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionMain>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
SearchPage.getLayout = function getLayout(page: ReactElement) {
|
SearchView.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SearchPage;
|
export default SearchView;
|
||||||
@ -1,73 +1,206 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import Head from 'next/head';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
|
import Head from 'next/head';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import SectionMain from '../components/SectionMain';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
|
|
||||||
const TermsOfUsePage = () => {
|
export default function PrivacyPolicy() {
|
||||||
const title = 'Crafted Network';
|
const title = 'Crafted Network';
|
||||||
|
const [projectUrl, setProjectUrl] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProjectUrl(location.origin);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const Information = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<h3>1. Acceptance of Terms</h3>
|
||||||
<title>{getPageTitle('Terms of Use')} | {title}</title>
|
<div className=''>
|
||||||
</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="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>
|
<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.
|
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>
|
</p>
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
TermsOfUsePage.getLayout = function getLayout(page: ReactElement) {
|
const ChangesTerms = () => {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TermsOfUsePage;
|
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>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Information />
|
||||||
|
<ChangesTerms />
|
||||||
|
<UseApplication />
|
||||||
|
<IntellectualProperty />
|
||||||
|
<UserContent />
|
||||||
|
<Privacy />
|
||||||
|
<Liability />
|
||||||
|
<Indemnification />
|
||||||
|
<Termination />
|
||||||
|
<GoverningLaw />
|
||||||
|
<ContactUs />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PrivacyPolicy.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
|
};
|
||||||
|
|||||||
@ -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(
|
export const passwordReset = createAsyncThunk(
|
||||||
'auth/passwordReset',
|
'auth/passwordReset',
|
||||||
async (value: Record<string, string>, { rejectWithValue }) => {
|
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.errorMessage = String(action.payload) || 'Something went wrong. Try again';
|
||||||
state.isFetching = false;
|
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, () => {
|
builder.addCase(findMe.pending, () => {
|
||||||
console.log('Pending findMe');
|
console.log('Pending findMe');
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user