feat: Refactor layout and add toast notifications

This commit introduces a major structural refactoring by creating a shared application layout and integrates a toast notification system.

Key changes:
- A new `layout.tsx` file now contains the common Header and Footer components, ensuring a consistent structure across all pages.
- The main `page.tsx` has been simplified to only contain its specific content, with layout elements removed.
- The `react-toastify` library has been added to provide user feedback through toast notifications.
- Custom CSS styles have been applied to the toast notifications for a branded look and feel, including different gradient backgrounds for success, error, warning, and info states.
- The `.gitignore` file was updated to explicitly track the `.env` file, likely for an example configuration.
This commit is contained in:
AmirReza Jamali
2025-10-15 17:12:09 +03:30
parent 36ee28ed84
commit 31d03b57d6
13 changed files with 537 additions and 199 deletions
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=https://opplens.com/api/v1/
+1
View File
@@ -39,3 +39,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
!.env
+21
View File
@@ -0,0 +1,21 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function HeaderButtons() {
const pathname = usePathname();
return (
<div className="flex justify-between items-center gap-5 mt-4 md:mt-0">
<Link
href={pathname.includes("contact-us") ? "/" : "/contact-us"}
className="text-(--primary)">
{pathname.includes("contact-us") ? "Home" : "Contact us"}
</Link>
<Link
href={"/login"}
className="bg-(--primary) text-white px-12 py-3 rounded-full">
Login
</Link>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import api from "@/service/api";
import { HTMLInputTypeAttribute } from "react";
import { RegisterOptions, useForm, UseFormRegister } from "react-hook-form";
import { toast } from "react-toastify";
type TContactUsForm = {
full_name: string;
company_name: string;
work_email: string;
phone_number: string;
};
const ContactUsForm = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<TContactUsForm>();
const onSubmit = async (data: TContactUsForm) => {
try {
const response = (await api.post("inquiries", { ...data })).data;
toast.success(response.message);
} catch (error) {
console.log(error);
toast.error("Something went wrong");
throw error;
}
};
return (
<form
className="w-full px-4 md:px-60 py-10"
onSubmit={handleSubmit(onSubmit)}>
<InputGroup
icon={UserIcon}
id="full_name"
label="Full name"
placeholder="Enter Full name"
register={register}
type="text"
/>
<InputGroup
icon={UserIcon}
id="company_name"
label="Company name"
placeholder="Enter Company Name"
register={register}
type="text"
/>
<InputGroup
icon={UserIcon}
id="work_email"
label="Work email"
placeholder="Enter Work Email"
register={register}
type="email"
/>
<InputGroup
icon={UserIcon}
id="phone_number"
label="Phone number"
placeholder="Enter Phone Number"
register={register}
type="text"
error={errors.phone_number?.message}
validation={{
pattern: {
value: /^[+\d]+$/,
message: "Please enter a valid phone number",
},
}}
/>
<button className="bg-(--primary) w-full text-white rounded-full py-4">
Submit
</button>
</form>
);
};
export default ContactUsForm;
function InputGroup({
icon,
id,
label,
placeholder,
register,
type,
validation,
error,
}: {
label: string;
id: string;
icon: () => React.JSX.Element;
placeholder: string;
register: UseFormRegister<any>;
type: HTMLInputTypeAttribute;
validation?: RegisterOptions;
error?: string;
}) {
return (
<>
<label htmlFor={id}>{label}</label>
<div className="relative w-full my-3">
<input
type={type}
className={`border ${
error ? "border-red-500" : "border-gray-200"
} p-2 px-10 rounded-lg w-full`}
id={id}
{...register(id, validation)}
placeholder={placeholder}
/>
<span className="absolute left-0 bottom-0 top-0 flex items-center pl-2">
{icon()}
</span>
</div>
{error && <p className="text-red-500 text-sm my-3">{error}</p>}
</>
);
}
function UserIcon() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M16 6H15.25C15.25 7.79493 13.7949 9.25 12 9.25V10V10.75C14.6234 10.75 16.75 8.62335 16.75 6H16ZM12 10V9.25C10.2051 9.25 8.75 7.79493 8.75 6H8H7.25C7.25 8.62335 9.37665 10.75 12 10.75V10ZM8 6H8.75C8.75 4.20507 10.2051 2.75 12 2.75V2V1.25C9.37665 1.25 7.25 3.37665 7.25 6H8ZM12 2V2.75C13.7949 2.75 15.25 4.20507 15.25 6H16H16.75C16.75 3.37665 14.6234 1.25 12 1.25V2ZM9 13V13.75H15V13V12.25H9V13ZM15 21V20.25H9V21V21.75H15V21ZM9 21V20.25C7.20507 20.25 5.75 18.7949 5.75 17H5H4.25C4.25 19.6234 6.37665 21.75 9 21.75V21ZM19 17H18.25C18.25 18.7949 16.7949 20.25 15 20.25V21V21.75C17.6234 21.75 19.75 19.6234 19.75 17H19ZM15 13V13.75C16.7949 13.75 18.25 15.2051 18.25 17H19H19.75C19.75 14.3766 17.6234 12.25 15 12.25V13ZM9 13V12.25C6.37665 12.25 4.25 14.3766 4.25 17H5H5.75C5.75 15.2051 7.20507 13.75 9 13.75V13Z"
fill="#9E9E9E"
/>
</svg>
);
}
+30
View File
@@ -0,0 +1,30 @@
import ContactUsForm from "./form";
const ContactUs = () => {
return (
<div className="flex flex-col items-center mt-20">
<div className="flex flex-col items-center mb-16">
<h6 className="text-lg w-fit font-semibold text-(--primary) bg-(--primary)/20 opacity-80 p-2 rounded-full">
Contact Us
</h6>
<h1 className="font-extrabold my-6 text-4xl">Let's get in touch</h1>
<div className=" text-center block md:flex gap-1">
<p className="font-extralight text-xl">
Or just reach out manually to
</p>
<a
href="mailto:info@opples.com"
className="text-(--primary) font-extralight text-xl">
info@opplens.com
</a>
</div>
</div>
<section className="border border-gray-300 rounded-4xl w-10/12 flex flex-col items-center">
<ContactUsForm />
</section>
</div>
);
};
export default ContactUs;
+38
View File
@@ -14,3 +14,41 @@
.gradient-text {
@apply text-transparent bg-clip-text bg-[radial-gradient(circle,rgba(1,100,255,1)_0%,rgba(111,255,255,1)_100%)] w-fit text-6xl font-extrabold;
}
.Toastify__toast {
padding: 10px !important;
border-radius: 24px !important;
font-family: inherit !important;
display: flex !important;
align-items: center !important;
box-shadow: 0px 12px 34px 0px rgba(13, 10, 44, 0.05) !important;
}
.dark .Toastify__toast {
box-shadow: 0 20px 25px -5px rgb(255 255 255 / 0.07),
0 8px 10px -6px rgb(255 255 255 / 0.05) !important;
}
.Toastify__close-button {
display: none;
}
.Toastify__close-button {
display: none;
}
.Toastify__toast--success {
background: linear-gradient(to bottom right, #82e6ac, #1a8245) !important;
}
.Toastify__toast--error {
background: linear-gradient(to bottom right, #f89090, #e10e0e) !important;
}
.Toastify__toast--warning {
background: linear-gradient(to bottom right, #f59e0b, #d97706) !important;
}
.Toastify__toast--info {
background: linear-gradient(to bottom right, #8099ec, #1c3fb7) !important;
}
+154 -5
View File
@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Image from "next/image";
import Link from "next/link";
import { ToastContainer, Zoom } from "react-toastify";
import HeaderButtons from "./_components/HeaderButtons";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
@@ -17,17 +20,163 @@ export const metadata: Metadata = {
description: "Generated by create next app",
};
export default function RootLayout({
function ContactInfo({
icon,
contact,
href,
ariaLabel,
}: {
icon: string;
contact: string;
href?: string;
ariaLabel: string;
}) {
return (
<div className="flex gap-3 my-3">
<Image
src={icon}
width={24}
height={24}
alt=""
aria-hidden="true"
/>
{href ? (
<a
href={href}
className="cursor-pointer"
aria-label={ariaLabel}>
{contact}
</a>
) : (
<p>{contact}</p>
)}
</div>
);
}
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const contactInfo = [
{
icon: "/Message.svg",
contact: "info@opplens.com",
href: "mailto:info@opplens.com",
ariaLabel: "Email us at info@opplens.com",
},
{
icon: "/Phone.svg",
contact: "(+46) - 761581526",
href: "tel:+46761581526",
ariaLabel: "Call us at +46 761581526",
},
{
icon: "/Location.svg",
contact: "Stockholm, Sweden",
ariaLabel: "Our location in Stockholm, Sweden",
},
];
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<div className="container px-4 py-4 m-auto">
<header className="flex justify-between items-center border-b md:border-none border-gray-300 pb-4">
<Link
href="/"
aria-label="Opp lens home">
<Image
src="/main-logo.svg"
alt="Opp lens logo"
width={180}
height={30}
priority
/>
</Link>
<div className="flex gap-5 items-center">
<nav
className="hidden md:flex"
aria-label="Main navigation">
<HeaderButtons />
</nav>
<label
htmlFor="language"
className="sr-only">
Select language
</label>
<select
name="language"
id="language"
aria-label="Language selector">
<option value="en">English</option>
</select>
</div>
</header>
<nav
className="block md:hidden"
aria-label="Mobile navigation">
<HeaderButtons />
</nav>
<div className="hidden absolute -z-20 left-0 md:block">
<Image
src={"/side-shallow.svg"}
alt="Shallow"
width={235}
height={235}
/>
</div>
<ToastContainer
position="top-right"
autoClose={2500}
theme={"colored"}
transition={Zoom}
hideProgressBar
newestOnTop
closeOnClick
pauseOnFocusLoss
draggable
pauseOnHover
/>
{children}
<div className="hidden absolute -z-20 right-0 rotate-y-180 md:block">
<Image
src={"/side-shallow.svg"}
alt="Shallow"
width={235}
height={235}
/>
</div>
<footer className="mt-14">
<div className="flex justify-center items-center border-t pt-5 border-gray-300">
<Image
src="/main-logo.svg"
alt="Opp lens logo"
width={180}
height={30}
/>
</div>
<address className="md:flex md:justify-evenly w-full mt-14 px-5 not-italic">
{contactInfo.map((item) => (
<ContactInfo
key={item.contact}
contact={item.contact}
href={item.href}
icon={item.icon}
ariaLabel={item.ariaLabel}
/>
))}
</address>
<div className="flex justify-center items-center">
<p className="text-center text-(--gray-primary) mt-5 leading-10">
Stockholm, Sweden © 2025 Opplens.com by PBL Partners AB. All
Rights Reserved. Democratizing Tender Access for SMEs.
</p>
</div>
</footer>
</div>
</body>
</html>
);
+57 -192
View File
@@ -1,6 +1,5 @@
import { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
export const metadata: Metadata = {
title: "Opp lens - AI-Powered Tender Management for SMEs | Win More Tenders",
@@ -45,165 +44,65 @@ rarely or never win public tenders? It's time
to change that.`,
},
];
const contactInfo = [
{
icon: "/Message.svg",
contact: "info@opplens.com",
href: "mailto:info@opplens.com",
ariaLabel: "Email us at info@opplens.com",
},
{
icon: "/Phone.svg",
contact: "(+46) - 761581526",
href: "tel:+46761581526",
ariaLabel: "Call us at +46 761581526",
},
{
icon: "/Location.svg",
contact: "Stockholm, Sweden",
ariaLabel: "Our location in Stockholm, Sweden",
},
];
return (
<div className="container px-4 py-4 m-auto">
<header className="flex justify-between items-center border-b md:border-none border-gray-300 pb-4">
<Link
href="/"
aria-label="Opp lens home">
<Image
src="/main-logo.svg"
alt="Opp lens logo"
width={180}
height={30}
priority
/>
</Link>
<div className="flex gap-5 items-center">
<nav
className="hidden md:flex"
aria-label="Main navigation">
<HeaderButtons />
</nav>
<label
htmlFor="language"
className="sr-only">
Select language
</label>
<select
name="language"
id="language"
aria-label="Language selector">
<option value="en">English</option>
</select>
</div>
</header>
<nav
className="block md:hidden"
aria-label="Mobile navigation">
<HeaderButtons />
</nav>
<main className="mt-8 flex flex-col justify-center items-center gap-14">
<section
className="flex"
aria-labelledby="hero-heading">
<div className="flex flex-col">
<h1
className="gradient-text md:!text-left md:!text-8xl md:mb-10"
id="hero-heading">
AI-Powered
</h1>
<Image
className="block md:hidden"
src={"/images/mobile.png"}
alt="Opp lens platform interface on mobile device"
width={520}
height={280}
priority
/>
<div className="w-full flex flex-col gap-6 text-xl">
<h2 className="text-(--primary) font-bold text-5xl md:!mb-10">
Tender Management
</h2>
<h3 className="font-bold">Win more tenders, automatically!</h3>
<p className="text-(--gray-primary)">
Our platform finds tenders tailored to your business, automates
document handling, and sends real-time alerts. Win more
opportunities with less effort and zero manual search.
</p>
</div>
</div>
<Image
className="hidden md:block"
src={"/images/mobile.png"}
alt="Opp lens platform interface on desktop"
width={550}
height={480}
priority
/>
</section>
<section
className="flex flex-col gap-4 text-center md:flex-row"
aria-labelledby="statistics-heading">
<h2
id="statistics-heading"
className="sr-only">
Platform Statistics
</h2>
{centerFrameItems.map((item, index) => (
<CenterFrame
key={index}
header={item.header}
text={item.text}
description={item.description}
/>
))}
</section>
</main>
<footer className="mt-14">
<div className="flex justify-center items-center border-t pt-5 border-gray-300">
<Image
src="/main-logo.svg"
alt="Opp lens logo"
width={180}
height={30}
/>
</div>
<address className="md:flex md:justify-evenly w-full mt-14 px-5 not-italic">
{contactInfo.map((item) => (
<ContactInfo
key={item.contact}
contact={item.contact}
href={item.href}
icon={item.icon}
ariaLabel={item.ariaLabel}
/>
))}
</address>
<div className="flex justify-center items-center">
<p className="text-center text-(--gray-primary) mt-5 leading-10">
Stockholm, Sweden © 2025 Opplens.com by PBL Partners AB. All Rights
Reserved. Democratizing Tender Access for SMEs.
</p>
</div>
</footer>
</div>
);
}
function HeaderButtons() {
return (
<div className="flex justify-between items-center gap-5 mt-4 md:mt-0">
<Link
href={"/contact-us"}
className="text-(--primary)">
Contact us
</Link>
<Link
href={"/login"}
className="bg-(--primary) text-white px-12 py-3 rounded-full">
Login
</Link>
</div>
<main className="mt-8 flex flex-col justify-center items-center gap-14">
<section
className="flex"
aria-labelledby="hero-heading">
<div className="flex flex-col">
<h1
className="gradient-text md:!text-left md:!text-8xl md:mb-10"
id="hero-heading">
AI-Powered
</h1>
<Image
className="block md:hidden"
src={"/images/mobile.png"}
alt="Opp lens platform interface on mobile device"
width={520}
height={280}
priority
/>
<div className="w-full flex flex-col gap-6 text-xl">
<h2 className="text-(--primary) font-bold text-5xl md:!mb-10">
Tender Management
</h2>
<h3 className="font-bold">Win more tenders, automatically!</h3>
<p className="text-(--gray-primary)">
Our platform finds tenders tailored to your business, automates
document handling, and sends real-time alerts. Win more
opportunities with less effort and zero manual search.
</p>
</div>
</div>
<Image
className="hidden md:block"
src={"/images/mobile.png"}
alt="Opp lens platform interface on desktop"
width={550}
height={480}
priority
/>
</section>
<section
className="flex flex-col gap-4 text-center md:flex-row"
aria-labelledby="statistics-heading">
<h2
id="statistics-heading"
className="sr-only">
Platform Statistics
</h2>
{centerFrameItems.map((item, index) => (
<CenterFrame
key={index}
header={item.header}
text={item.text}
description={item.description}
/>
))}
</section>
</main>
);
}
@@ -228,37 +127,3 @@ function CenterFrame({
</article>
);
}
function ContactInfo({
icon,
contact,
href,
ariaLabel,
}: {
icon: string;
contact: string;
href?: string;
ariaLabel: string;
}) {
return (
<div className="flex gap-3 my-3">
<Image
src={icon}
width={24}
height={24}
alt=""
aria-hidden="true"
/>
{href ? (
<a
href={href}
className="cursor-pointer"
aria-label={ariaLabel}>
{contact}
</a>
) : (
<p>{contact}</p>
)}
</div>
);
}
+39
View File
@@ -892,3 +892,42 @@ html body a:hover {
}
/*# sourceMappingURL=style.css.map */
.Toastify__toast {
padding: 10px !important;
border-radius: 24px !important;
font-family: inherit !important;
display: flex !important;
align-items: center !important;
box-shadow: 0px 12px 34px 0px rgba(13, 10, 44, 0.05) !important;
}
.dark .Toastify__toast {
box-shadow:
0 20px 25px -5px rgb(255 255 255 / 0.07),
0 8px 10px -6px rgb(255 255 255 / 0.05) !important;
}
.Toastify__close-button {
display: none;
}
.Toastify__close-button {
display: none;
}
.Toastify__toast--success {
background: linear-gradient(to bottom right, #82e6ac, #1a8245) !important;
}
.Toastify__toast--error {
background: linear-gradient(to bottom right, #f89090, #e10e0e) !important;
}
.Toastify__toast--warning {
background: linear-gradient(to bottom right, #f59e0b, #d97706) !important;
}
.Toastify__toast--info {
background: linear-gradient(to bottom right, #8099ec, #1c3fb7) !important;
}
+41 -1
View File
@@ -11,7 +11,9 @@
"axios": "^1.12.2",
"next": "15.5.5",
"react": "19.1.0",
"react-dom": "19.1.0"
"react-dom": "19.1.0",
"react-hook-form": "^7.65.0",
"react-toastify": "^11.0.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1051,6 +1053,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1774,6 +1785,35 @@
"react": "^19.1.0"
}
},
"node_modules/react-hook-form": {
"version": "7.65.0",
"resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.65.0.tgz",
"integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-toastify": {
"version": "11.0.5",
"resolved": "https://registry.npmmirror.com/react-toastify/-/react-toastify-11.0.5.tgz",
"integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.26.0.tgz",
+3 -1
View File
@@ -11,7 +11,9 @@
"axios": "^1.12.2",
"next": "15.5.5",
"react": "19.1.0",
"react-dom": "19.1.0"
"react-dom": "19.1.0",
"react-hook-form": "^7.65.0",
"react-toastify": "^11.0.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.8 MiB

+10
View File
@@ -0,0 +1,10 @@
import axios from "axios";
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
export default api;