diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..8b30f3b --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173 + } + ] +} diff --git a/package-lock.json b/package-lock.json index 005d2ce..27887a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@emailjs/browser": "^4.3.3", "@headlessui/react": "^1.7.18", "@heroicons/react": "^2.1.3", + "framer-motion": "^13.2.0", "i18next": "^23.11.5", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.5.2", @@ -2595,6 +2596,29 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.2.0.tgz", + "integrity": "sha512-9E33ebgMaO33w1nN/jEdW8z3/GO483fMi4rqbMG9rt83XgW9QLKRe4NcmJ8s+fQ3O34++UHrIQwlIWGIWTITjA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^13.2.0", + "motion-utils": "^13.0.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3158,6 +3182,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.2.0.tgz", + "integrity": "sha512-N6gdSoWRDk0Rh/fVtlqUtLs+fEN3ELFZI3cn3IQE9Mnf3E+Mh8wjO6MstzCOPFh4Yf0L1as5m2eUyYWj8ylVSQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^13.0.0" + } + }, + "node_modules/motion-utils": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz", + "integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -4238,6 +4277,12 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 0662684..6b8b5df 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@emailjs/browser": "^4.3.3", "@headlessui/react": "^1.7.18", "@heroicons/react": "^2.1.3", + "framer-motion": "^13.2.0", "i18next": "^23.11.5", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.5.2", diff --git a/src/Components/Card.tsx b/src/Components/Card.tsx index 6d7efaf..180fefe 100644 --- a/src/Components/Card.tsx +++ b/src/Components/Card.tsx @@ -1,28 +1,49 @@ +import { motion, useReducedMotion } from "framer-motion"; + interface IProps { title: string; description: string; image: string; + index?: number; } -const Card = ({ title, description, image }: IProps) => { - const defaultImgClasses = `w-full h-20 mt-5`; - const imgClasses = image ? defaultImgClasses : `${defaultImgClasses} hidden`; +const Card = ({ title, description, image, index = 0 }: IProps) => { + const prefersReducedMotion = useReducedMotion(); return ( -
-
- -

{title}

-

+ + + + {image && } + +
+

+ {title} +

+

{description.split("\n").map((line, i) => ( {line}
))} -

+

-
+ ); }; diff --git a/src/Components/Footer/Footer.tsx b/src/Components/Footer/Footer.tsx index 78dc7cc..f35d635 100644 --- a/src/Components/Footer/Footer.tsx +++ b/src/Components/Footer/Footer.tsx @@ -1,55 +1,69 @@ import { useTranslation } from "react-i18next"; import { FaFacebook, FaLinkedin } from "react-icons/fa"; +import { motion } from "framer-motion"; const Footer = () => { const { t, i18n } = useTranslation(); return ( - @@ -57,4 +71,3 @@ const Footer = () => { }; export default Footer; - diff --git a/src/Components/Loading.tsx b/src/Components/Loading.tsx index 3743d13..2f8e887 100644 --- a/src/Components/Loading.tsx +++ b/src/Components/Loading.tsx @@ -1,7 +1,19 @@ +import { motion, useReducedMotion } from "framer-motion"; + const Loading = () => { + const prefersReducedMotion = useReducedMotion(); + return ( -
-
+
+ {prefersReducedMotion ? ( +
+ ) : ( + + )}
); }; diff --git a/src/Components/Navbar/MenuContent.tsx b/src/Components/Navbar/MenuContent.tsx new file mode 100644 index 0000000..b47bdad --- /dev/null +++ b/src/Components/Navbar/MenuContent.tsx @@ -0,0 +1,49 @@ +import { motion } from "framer-motion"; + +interface MenuContentProps { + navigation: { title: string; path: string }[]; + activeSection: string; + handleNavigation: (path: string) => void; +} + +const MenuContent = ({ + navigation, + activeSection, + handleNavigation, +}: MenuContentProps) => { + return ( + + ); +}; + +export default MenuContent; diff --git a/src/Components/Navbar/MobileMenu.tsx b/src/Components/Navbar/MobileMenu.tsx new file mode 100644 index 0000000..b448344 --- /dev/null +++ b/src/Components/Navbar/MobileMenu.tsx @@ -0,0 +1,85 @@ +import { motion } from "framer-motion"; +import { + FaLayerGroup, + FaGaugeHigh, + FaAward, + FaEnvelope, +} from "react-icons/fa6"; +import { langFlagUrl } from "./langFlag"; + +const ICONS: Record = { + Services: FaLayerGroup, + Optimization: FaGaugeHigh, + Advantages: FaAward, + Contact: FaEnvelope, +}; + +interface MobileMenuProps { + navigation: { title: string; path: string }[]; + handleNavigation: (path: string) => void; + setState: (v: boolean) => void; + currentLang: "en" | "pt"; + changeLanguage: (lng: string) => void; +} + +const MobileMenu = ({ + navigation, + handleNavigation, + setState, + currentLang, + changeLanguage, +}: MobileMenuProps) => { + return ( +
+
    + {navigation.map((item, idx) => { + const Icon = ICONS[item.path]; + return ( +
  • + { + e.preventDefault(); + setState(false); + handleNavigation(item.path); + }} + > + {Icon && } + {item.title} + +
  • + ); + })} +
+ +
+ + +
+ ); +}; + +export default MobileMenu; diff --git a/src/Components/Navbar/Navbar.tsx b/src/Components/Navbar/Navbar.tsx index 940f704..55e4d15 100644 --- a/src/Components/Navbar/Navbar.tsx +++ b/src/Components/Navbar/Navbar.tsx @@ -1,35 +1,27 @@ import { useState, useEffect } from "react"; import { FaTimes, FaBars } from "react-icons/fa"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import Logo from "../../assets/logo.svg"; import { useTranslation } from "react-i18next"; +import MenuContent from "./MenuContent"; +import MobileMenu from "./MobileMenu"; +import { langFlagUrl } from "./langFlag"; -export default () => { +const NAVBAR_HEIGHT = 110; + +const Navbar = () => { const [state, setState] = useState(false); const { t, i18n } = useTranslation(); + const prefersReducedMotion = useReducedMotion(); const [colorChange, setColorChange] = useState(false); - useEffect(() => { - const changeColor = () => { - if (window.scrollY >= 100) { - setColorChange(true); - } else { - setColorChange(false); - } - }; + const [activeSection, setActiveSection] = useState("Services"); - window.addEventListener("scroll", changeColor); - - const scrollPath = sessionStorage.getItem("scrollPath"); - if (scrollPath) { - smoothScroll(scrollPath); - sessionStorage.removeItem("scrollPath"); - } - - return () => { - window.removeEventListener("scroll", changeColor); - }; - }, []); + // Browsers report region variants (e.g. "en-US", "pt-BR"), not just the + // bare "en"/"pt" i18next is configured with - matching on that exactly + // would leave the toggle mislabeled for most real visitors. + const currentLang = i18n.language?.startsWith("pt") ? "pt" : "en"; const navigation = [ { title: t("navbar.services"), path: "Services" }, @@ -38,43 +30,97 @@ export default () => { { title: t("navbar.contact"), path: "Contact" }, ]; + useEffect(() => { + const handleScroll = () => { + setColorChange(window.scrollY >= 100); + + // The active section is whichever nav target's start we've scrolled + // past most recently - not just "is it in the viewport", since a + // short section could otherwise never register as active at all. + let current = navigation[0].path; + for (const item of navigation) { + const el = document.getElementById(item.path); + if (!el) continue; + const top = el.getBoundingClientRect().top + window.scrollY; + if (window.scrollY + NAVBAR_HEIGHT + 40 >= top) { + current = item.path; + } + } + setActiveSection(current); + }; + + handleScroll(); + window.addEventListener("scroll", handleScroll, { passive: true }); + + const scrollPath = sessionStorage.getItem("scrollPath"); + if (scrollPath) { + smoothScroll(scrollPath); + sessionStorage.removeItem("scrollPath"); + } + + return () => { + window.removeEventListener("scroll", handleScroll); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const handleNavigation = (path: string) => { - if ((window.location.pathname === "/tos" || window.location.pathname === "/privacy") && navigation.some(navItem => navItem.path === path)) { + setState(false); + if ( + (window.location.pathname === "/tos" || + window.location.pathname === "/privacy") && + navigation.some((navItem) => navItem.path === path) + ) { sessionStorage.setItem("scrollPath", path); window.location.href = "/"; - }else{ - smoothScroll(path) + } else { + smoothScroll(path); } }; - const smoothScroll = (path :string) => { + const smoothScroll = (path: string) => { const element = document.getElementById(path); - const navbarHeight = 110; // Replace with your navbar's height if (element) { const top = - element.getBoundingClientRect().top + window.scrollY - navbarHeight; + element.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT; window.scrollTo({ top: top, behavior: "smooth" }); } - } + }; const changeLanguage = (lng: string) => { i18n.changeLanguage(lng); }; + useEffect(() => { + document.body.style.overflow = state ? "hidden" : ""; + return () => { + document.body.style.overflow = ""; + }; + }, [state]); + + const springTap = prefersReducedMotion ? {} : { whileTap: { scale: 0.94 } }; + return ( - + + + {state && ( + <> + setState(false)} + /> + + + + + )} + + ); }; + +export default Navbar; diff --git a/src/Components/Navbar/langFlag.ts b/src/Components/Navbar/langFlag.ts new file mode 100644 index 0000000..795a0b7 --- /dev/null +++ b/src/Components/Navbar/langFlag.ts @@ -0,0 +1,11 @@ +// Flag images (not emoji) for the same reason the phone country-code +// picker uses them: emoji flag glyphs render with wildly inconsistent +// metrics across platforms (and don't render as flags at all on some, +// e.g. Windows), while a fixed-size image is pixel-identical everywhere. +const LANG_ISO: Record = { + en: "gb", + pt: "pt", +}; + +export const langFlagUrl = (lng: string) => + `https://flagcdn.com/${LANG_ISO[lng] ?? lng}.svg`; diff --git a/src/Components/Pages/Contact/ContactForm.tsx b/src/Components/Pages/Contact/ContactForm.tsx index 08033d3..bebda2d 100644 --- a/src/Components/Pages/Contact/ContactForm.tsx +++ b/src/Components/Pages/Contact/ContactForm.tsx @@ -1,11 +1,60 @@ -import React from "react"; +import React, { useState } from "react"; import emailjs from "@emailjs/browser"; import { ToastContainer, toast } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; import { useTranslation } from "react-i18next"; +import { motion, useReducedMotion } from "framer-motion"; +import PhoneCodeSelect from "./PhoneCodeSelect"; + +const PHONE_PATTERNS: Record = { + "+351": "### ### ###", + "+34": "### ### ###", + "+33": "# ## ## ## ##", + "+44": "#### ######", + "+1": "(###) ###-####", + "+55": "(##) #####-####", +}; + +const digitsOnly = (value: string) => value.replace(/\D/g, ""); + +const maxDigitsFor = (pattern: string) => + pattern.split("").filter((c) => c === "#").length; + +const formatPhoneNumber = (digits: string, pattern: string) => { + let result = ""; + let digitIndex = 0; + for (let i = 0; i < pattern.length && digitIndex < digits.length; i++) { + const ch = pattern[i]; + if (ch === "#") { + result += digits[digitIndex]; + digitIndex++; + } else { + result += ch; + } + } + return result; +}; const ContactForm = () => { const { t } = useTranslation(); + const prefersReducedMotion = useReducedMotion(); + const [countryCode, setCountryCode] = useState("+351"); + const [phoneDigits, setPhoneDigits] = useState(""); + + const activePattern = PHONE_PATTERNS[countryCode]; + const formattedPhone = formatPhoneNumber(phoneDigits, activePattern); + + const handleCountryChange = (code: string) => { + setCountryCode(code); + setPhoneDigits((prev) => + prev.slice(0, maxDigitsFor(PHONE_PATTERNS[code])) + ); + }; + + const handlePhoneChange = (raw: string) => { + const digits = digitsOnly(raw).slice(0, maxDigitsFor(activePattern)); + setPhoneDigits(digits); + }; const notify = () => { toast.success(t("contact.toasts.success")); @@ -22,11 +71,12 @@ const ContactForm = () => { const sendEmail = (e: React.FormEvent) => { e.preventDefault(); - let isEmpty = Array.from(e.currentTarget.elements).some( - (input) => - (input as HTMLInputElement).type !== "submit" && - (input as HTMLInputElement).value.trim() === "" - ); + const isEmpty = Array.from(e.currentTarget.elements).some((el) => { + if (el.tagName !== "INPUT" && el.tagName !== "TEXTAREA") return false; + const field = el as HTMLInputElement | HTMLTextAreaElement; + if (field.type === "hidden") return false; + return field.value.trim() === ""; + }); if (isEmpty) { emptyForm(); @@ -49,20 +99,31 @@ const ContactForm = () => { ); e.currentTarget.reset(); + setPhoneDigits(""); + setCountryCode("+351"); }; return ( -
-
+
+
{
- +
+ +
{ >
-
- +
{ pauseOnHover theme="dark" /> - +
); }; diff --git a/src/Components/Pages/Contact/ContactPage.tsx b/src/Components/Pages/Contact/ContactPage.tsx index 3cafca1..86e0c2d 100644 --- a/src/Components/Pages/Contact/ContactPage.tsx +++ b/src/Components/Pages/Contact/ContactPage.tsx @@ -1,22 +1,48 @@ import { useTranslation } from "react-i18next"; +import { motion, useReducedMotion } from "framer-motion"; import ContactForm from "./ContactForm"; const ContactPage = () => { const { t } = useTranslation(); + const prefersReducedMotion = useReducedMotion(); return (
-

+ {t("contact.heading.part1")}
{t("contact.heading.part2")}{" "} {t("contact.heading.part3")}{" "} {t("contact.heading.part4")} -

-

+ + {t("contact.subheading.part1")}
{t("contact.subheading.part2")} -

+
diff --git a/src/Components/Pages/Contact/PhoneCodeSelect.tsx b/src/Components/Pages/Contact/PhoneCodeSelect.tsx new file mode 100644 index 0000000..c88f55b --- /dev/null +++ b/src/Components/Pages/Contact/PhoneCodeSelect.tsx @@ -0,0 +1,106 @@ +import { useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; + +interface Country { + code: string; + iso: string; + label: string; +} + +const COUNTRIES: Country[] = [ + { code: "+351", iso: "pt", label: "Portugal" }, + { code: "+34", iso: "es", label: "Spain" }, + { code: "+33", iso: "fr", label: "France" }, + { code: "+44", iso: "gb", label: "United Kingdom" }, + { code: "+1", iso: "us", label: "United States" }, + { code: "+55", iso: "br", label: "Brazil" }, +]; + +const flagUrl = (iso: string) => `https://flagcdn.com/${iso}.svg`; + +interface Props { + value: string; + onChange: (code: string) => void; +} + +const PhoneCodeSelect = ({ value, onChange }: Props) => { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const active = COUNTRIES.find((c) => c.code === value) ?? COUNTRIES[0]; + + useEffect(() => { + if (!open) return; + const handlePointerDown = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, [open]); + + return ( +
+ + setOpen((o) => !o)} + whileTap={{ scale: 0.95 }} + transition={{ type: "spring", stiffness: 500, damping: 30 }} + aria-label="Country code" + > + {active.label} + + {active.code} + + + + + {open && ( + + {COUNTRIES.map((country) => ( + + ))} + + )} + +
+ ); +}; + +export default PhoneCodeSelect; diff --git a/src/Components/Pages/Hero/Hero.tsx b/src/Components/Pages/Hero/Hero.tsx index 1b91b77..bf2ccda 100644 --- a/src/Components/Pages/Hero/Hero.tsx +++ b/src/Components/Pages/Hero/Hero.tsx @@ -1,13 +1,15 @@ import { useTranslation } from "react-i18next"; -import HeroImg from "../../../assets/HeroImg.svg"; +import { motion, useReducedMotion } from "framer-motion"; import Stars from "../../../assets/Stars.svg"; +import HeroVisual from "./HeroVisual"; const Hero = () => { const { t, i18n } = useTranslation(); + const prefersReducedMotion = useReducedMotion(); const handleNavigation = (path: string) => { const element = document.getElementById(path); - const navbarHeight = 110; // Replace with your navbar's height + const navbarHeight = 110; if (element) { const top = element.getBoundingClientRect().top + window.scrollY - navbarHeight; @@ -15,38 +17,56 @@ const Hero = () => { } }; + const rise = (delay = 0) => + prefersReducedMotion + ? { initial: { opacity: 0 }, animate: { opacity: 1 } } + : { + initial: { opacity: 0, y: 24 }, + animate: { opacity: 1, y: 0 }, + transition: { + type: "spring" as const, + damping: 22, + stiffness: 140, + delay, + }, + }; + return (
-
- hero -
+ + +
- hero + {...rise(0)} + />
-

{t("hero.heading.part1")} -

-

+ - {t("hero.heading.part2")} -

-
- heroSm + {t("hero.heading.part2")} + +
+
-
+

{ > {t("hero.subheading.part2")}

-
- + +
diff --git a/src/Components/Pages/Hero/HeroVisual.tsx b/src/Components/Pages/Hero/HeroVisual.tsx new file mode 100644 index 0000000..7d1bdbd --- /dev/null +++ b/src/Components/Pages/Hero/HeroVisual.tsx @@ -0,0 +1,79 @@ +import { motion, useReducedMotion } from "framer-motion"; +import Logo from "../../../assets/logo.svg"; + +interface OrbitConfig { + rotateZ: number; + rotateX: number; + duration: number; + reverse: boolean; + inset: string; +} + +const orbits: OrbitConfig[] = [ + { rotateZ: 55, rotateX: 66, duration: 9, reverse: false, inset: "6%" }, + { rotateZ: -55, rotateX: 66, duration: 7, reverse: true, inset: "16%" }, +]; + +const HeroVisual = () => { + const prefersReducedMotion = useReducedMotion(); + + return ( +
+
+ + {orbits.map((orbit, i) => ( + + + + + + ))} + + + LK Results + +
+ ); +}; + +export default HeroVisual; diff --git a/src/Components/Pages/Optimization/CardContainer.tsx b/src/Components/Pages/Optimization/CardContainer.tsx index 8fc6bb0..5258fef 100644 --- a/src/Components/Pages/Optimization/CardContainer.tsx +++ b/src/Components/Pages/Optimization/CardContainer.tsx @@ -9,21 +9,24 @@ const CardContainer = () => { const { t } = useTranslation(); return ( -
+
); diff --git a/src/Components/Pages/Optimization/Optimization.tsx b/src/Components/Pages/Optimization/Optimization.tsx index e0854fb..18d922a 100644 --- a/src/Components/Pages/Optimization/Optimization.tsx +++ b/src/Components/Pages/Optimization/Optimization.tsx @@ -1,18 +1,29 @@ -import CardContainer from './CardContainer' -import { useTranslation } from 'react-i18next' +import CardContainer from "./CardContainer"; +import { useTranslation } from "react-i18next"; +import { motion, useReducedMotion } from "framer-motion"; const Optimization = () => { - const { t } = useTranslation() + const { t } = useTranslation(); + const prefersReducedMotion = useReducedMotion(); + return (
-

- " - {t("optimization.heading.part1")}
{t("optimization.heading.part2")} - " -

+ + “ + {t("optimization.heading.part1")}
{" "} + {t("optimization.heading.part2")} + ” +
- ) -} + ); +}; -export default Optimization \ No newline at end of file +export default Optimization; diff --git a/src/Components/Pages/Results/CardContainer.tsx b/src/Components/Pages/Results/CardContainer.tsx index dac2c09..27b501c 100644 --- a/src/Components/Pages/Results/CardContainer.tsx +++ b/src/Components/Pages/Results/CardContainer.tsx @@ -5,26 +5,30 @@ const CardContainer = () => { const { t } = useTranslation(); return ( -
+
); diff --git a/src/Components/Pages/Results/ResultsPage.tsx b/src/Components/Pages/Results/ResultsPage.tsx index 4118d4a..e0c3e46 100644 --- a/src/Components/Pages/Results/ResultsPage.tsx +++ b/src/Components/Pages/Results/ResultsPage.tsx @@ -1,16 +1,25 @@ import { useTranslation } from "react-i18next"; +import { motion, useReducedMotion } from "framer-motion"; import CardContainer from "./CardContainer"; const ResultsPage = () => { const { t } = useTranslation(); + const prefersReducedMotion = useReducedMotion(); return (
-

- " + + “ {t("results.heading.part1")}
{t("results.heading.part2")} - " -

+ ” +
); diff --git a/src/Components/Pages/Services/Buttons.tsx b/src/Components/Pages/Services/Buttons.tsx index a976d68..d9fe2b8 100644 --- a/src/Components/Pages/Services/Buttons.tsx +++ b/src/Components/Pages/Services/Buttons.tsx @@ -16,7 +16,7 @@ const Buttons = ({ changeActiveGroup }: props) => { }; return ( -
+
handleClick("markCards")} @@ -28,12 +28,12 @@ const Buttons = ({ changeActiveGroup }: props) => { isSelected={selectedButton === "webCards"} /> handleClick("smCards")} isSelected={selectedButton === "smCards"} /> handleClick("camCards")} isSelected={selectedButton === "camCards"} /> diff --git a/src/Components/Pages/Services/CameraCards.tsx b/src/Components/Pages/Services/CameraCards.tsx index 8581569..e439779 100644 --- a/src/Components/Pages/Services/CameraCards.tsx +++ b/src/Components/Pages/Services/CameraCards.tsx @@ -9,30 +9,34 @@ const CameraCards = () => { const { t } = useTranslation(); return ( -
+
); diff --git a/src/Components/Pages/Services/CardBtn.tsx b/src/Components/Pages/Services/CardBtn.tsx index f944963..72a0b02 100644 --- a/src/Components/Pages/Services/CardBtn.tsx +++ b/src/Components/Pages/Services/CardBtn.tsx @@ -1,3 +1,5 @@ +import { motion } from "framer-motion"; + interface props { title: string; onClick: () => void; @@ -5,14 +7,24 @@ interface props { } const CardBtn = ({ title, onClick, isSelected }: props) => { - const buttonClass = isSelected - ? "w-full font-bold md:px-4 py-1 text-xs lg:text-sm xl:text-2xl cursor-pointer" - : "w-full font-medium md:px-4 py-1 text-xs lg:text-sm xl:text-xl text-gray-400 shadow-inner hover:text-text-gold cursor-pointer"; - return ( - - {title} - + + {isSelected && ( + + )} + {title} + ); }; diff --git a/src/Components/Pages/Services/CardGroup.tsx b/src/Components/Pages/Services/CardGroup.tsx index 77f1c82..3c4955a 100644 --- a/src/Components/Pages/Services/CardGroup.tsx +++ b/src/Components/Pages/Services/CardGroup.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import WesiteCards from "./WesiteCards"; import Buttons from "./Buttons"; import MarketingCards from "./MarketingCards"; @@ -7,6 +8,7 @@ import SocialMediaCards from "./SocialMedia"; const CardGroup = () => { const [activeGroup, setActiveGroup] = useState("webCards"); + const prefersReducedMotion = useReducedMotion(); const changeActiveGroup = (group: string) => { setActiveGroup(group); @@ -31,9 +33,29 @@ const CardGroup = () => { } return ( -
- {ActiveCards && } +
+
+ + {ActiveCards && ( + + + + )} + +
); }; diff --git a/src/Components/Pages/Services/MarketingCards.tsx b/src/Components/Pages/Services/MarketingCards.tsx index f0b63c6..2c69acb 100644 --- a/src/Components/Pages/Services/MarketingCards.tsx +++ b/src/Components/Pages/Services/MarketingCards.tsx @@ -9,30 +9,34 @@ const MarketingCards = () => { const { t } = useTranslation(); return ( -
+
); diff --git a/src/Components/Pages/Services/ServiceCard.tsx b/src/Components/Pages/Services/ServiceCard.tsx index 64a79be..a6850af 100644 --- a/src/Components/Pages/Services/ServiceCard.tsx +++ b/src/Components/Pages/Services/ServiceCard.tsx @@ -1,36 +1,49 @@ +import { motion, useReducedMotion } from "framer-motion"; + interface IProps { + step: number; title: string; description: string; imgSrc: string; - delay: string; + delay: number; } -const Card = ({ title, description, imgSrc, delay }: IProps) => { +const Card = ({ step, title, description, imgSrc, delay }: IProps) => { + const prefersReducedMotion = useReducedMotion(); + return ( -
-
-

+
+ +
+ + {String(step).padStart(2, "0")} + +
+
+

{title} -

-

+

+

{description}

-
+ ); }; diff --git a/src/Components/Pages/Services/ServiceCardContainer.tsx b/src/Components/Pages/Services/ServiceCardContainer.tsx index 2bbf091..1f8b620 100644 --- a/src/Components/Pages/Services/ServiceCardContainer.tsx +++ b/src/Components/Pages/Services/ServiceCardContainer.tsx @@ -2,7 +2,7 @@ import CardGroup from "./CardGroup"; const ServiceCardContainer = () => { return ( -
+
); diff --git a/src/Components/Pages/Services/Services.tsx b/src/Components/Pages/Services/Services.tsx index 7a4cb1f..c419182 100644 --- a/src/Components/Pages/Services/Services.tsx +++ b/src/Components/Pages/Services/Services.tsx @@ -1,13 +1,22 @@ import { useTranslation } from "react-i18next"; +import { motion, useReducedMotion } from "framer-motion"; import Underline from "../../../assets/Underline.svg"; import ServiceCardContainer from "./ServiceCardContainer"; const Services = () => { const { t } = useTranslation(); + const prefersReducedMotion = useReducedMotion(); return (
-

+ {t("services.heading.part1")} {t("services.heading.part2")}{" "} @@ -17,10 +26,17 @@ const Services = () => { className="absolute bottom-0 left-0" /> {" "} -

-

+ + {t("services.heading.part3")} -

+
); diff --git a/src/Components/Pages/Services/SocialMedia.tsx b/src/Components/Pages/Services/SocialMedia.tsx index 0e66f2e..8956836 100644 --- a/src/Components/Pages/Services/SocialMedia.tsx +++ b/src/Components/Pages/Services/SocialMedia.tsx @@ -9,30 +9,34 @@ const SocialMediaCards = () => { const { t } = useTranslation(); return ( -
+
); diff --git a/src/Components/Pages/Services/WesiteCards.tsx b/src/Components/Pages/Services/WesiteCards.tsx index 71f4a3f..264b396 100644 --- a/src/Components/Pages/Services/WesiteCards.tsx +++ b/src/Components/Pages/Services/WesiteCards.tsx @@ -9,30 +9,34 @@ const WesiteCards = () => { const { t } = useTranslation(); return ( -
+
); diff --git a/src/index.css b/src/index.css index c6fa923..a01314b 100644 --- a/src/index.css +++ b/src/index.css @@ -1,71 +1,177 @@ -@import url("https://fonts.googleapis.com/css2?family=Bai+Jamjuree:wght@400;700&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Bai+Jamjuree:wght@400;500;600;700&display=swap"); @tailwind base; @tailwind components; @tailwind utilities; -body { - @apply bg-gradient-to-br from-custom-bglight to-custom-bgdark; +::selection { + background: rgba(250, 204, 151, 0.25); + color: #fff; } +html { + scroll-behavior: smooth; + color-scheme: dark; +} + +body { + @apply bg-custom-bgdark text-white; + background: radial-gradient(120% 90% at 50% -10%, #26262c 0%, #131316 42%, #06060b 100%); + min-height: 100vh; +} + +::-webkit-scrollbar { + width: 10px; +} +::-webkit-scrollbar-track { + background: #06060b; +} +::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #facc97, #54330f); + border-radius: 999px; + border: 2px solid #06060b; +} + +:focus-visible { + outline: 2px solid #facc97; + outline-offset: 2px; + border-radius: 4px; +} + +/* Display / hero type โ€” negative tracking + tight leading, sized down for room to breathe */ .hero-text { - @apply font-medium text-5xl lg:text-7xl xl:text-8xl; + @apply font-semibold text-5xl lg:text-7xl xl:text-8xl; + line-height: 1.02; + letter-spacing: -0.02em; } .hero-text-pt { - @apply font-medium text-4xl lg:text-6xl xl:text-7xl; + @apply font-semibold text-4xl lg:text-6xl xl:text-7xl; + line-height: 1.04; + letter-spacing: -0.015em; } .hero-sub-text { - @apply italic text-lg md:text-2xl 2xl:text-3xl; + @apply text-lg md:text-2xl 2xl:text-3xl text-white/60; + line-height: 1.4; + letter-spacing: -0.005em; + font-style: normal; } .hero-sub-text-pt { - @apply italic text-lg md:text-xl 2xl:text-2xl; + @apply text-lg md:text-xl 2xl:text-2xl text-white/60; + line-height: 1.4; + letter-spacing: 0; + font-style: normal; } .header-text { - @apply mt-20 lg:mt-48 text-3xl md:text-5xl xl:text-7xl text-center; + @apply mt-20 lg:mt-48 text-3xl md:text-5xl xl:text-7xl text-center font-semibold; + line-height: 1.1; + letter-spacing: -0.015em; } .sub-header-text { - @apply italic font-thin block text-sm mx-10 md:text-2xl mt-5 text-gray-400; + @apply block text-sm mx-10 md:text-2xl mt-5 text-white/50; + font-weight: 300; + line-height: 1.45; } .form-input { - @apply rounded-xl px-4 py-2 mt-2 bg-gray-bg border-solid border border-gray-300 text-sm md:text-lg; + @apply rounded-xl px-4 py-3 mt-2 bg-white/[0.03] border border-white/10 text-sm md:text-base text-white placeholder-white/35 transition-colors duration-200; +} +.form-input:focus { + @apply border-gold-light/50 bg-white/[0.05]; + outline: none; +} + +.phone-input-group { + @apply rounded-xl mt-2 px-4 py-3 bg-white/[0.03] border border-white/10 transition-colors duration-200; +} +.phone-input-group:focus-within { + @apply border-gold-light/50 bg-white/[0.05]; +} +.phone-number-input { + @apply flex-1 min-w-0 bg-transparent text-white placeholder-white/35 text-sm md:text-base pl-2 outline-none leading-tight; } .gold-text { - @apply bg-clip-text text-transparent bg-gradient-to-br from-gold-light to-gold-dark; + @apply bg-clip-text text-transparent bg-gradient-to-br from-gold-light to-gold-mid; } .fade-effect { - animation: fadeEffect 1s ease-in-out; -} - -.results-card-heading { - @apply mt-2 lg:mt-6 text-3xl xl:text-5xl; -} -.results-card-heading-pt { - @apply mt-2 lg:mt-6 text-2xl xl:text-4xl; -} - -.results-card-content { - @apply mb-2 mt-3 italic text-lg xl:text-2xl font-medium text-gray-300; + animation: fadeEffect 0.6s ease-out both; } .footer-text { - @apply text-gray-400 font-medium text-xs md:text-base + @apply text-white/45 font-medium text-xs md:text-sm tracking-wide; } .footer-text-pt { - @apply text-gray-400 font-medium text-xs md:text-base + @apply text-white/45 font-medium text-xs md:text-sm tracking-wide; +} + +/* Glass material โ€” the floating functional layer (ยง12) */ +.glass-panel { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.015)); + border: 1px solid rgba(250, 204, 151, 0.14); + backdrop-filter: blur(20px) saturate(160%); + -webkit-backdrop-filter: blur(20px) saturate(160%); +} + +.glass-nav { + background: rgba(10, 10, 13, 0.55); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border-bottom: 1px solid rgba(250, 204, 151, 0.1); +} + +/* Heavier material for full takeover surfaces (mobile menu sheet) โ€” a + bigger surface needs to read as thicker, per the materials guidance. */ +.glass-sheet { + background: rgba(14, 14, 17, 0.92); + backdrop-filter: blur(28px) saturate(150%); + -webkit-backdrop-filter: blur(28px) saturate(150%); + border: 1px solid rgba(250, 204, 151, 0.14); +} + +@media (prefers-reduced-transparency: reduce) { + .glass-sheet { + background: #0c0c10; + backdrop-filter: none; + -webkit-backdrop-filter: none; + } } @keyframes fadeEffect { 0% { opacity: 0; + transform: translateY(6px); } 100% { opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +@media (prefers-contrast: more) { + .glass-panel, + .glass-nav { + background: #0c0c10; + border-color: rgba(250, 204, 151, 0.4); + backdrop-filter: none; + -webkit-backdrop-filter: none; } } diff --git a/src/routes/PP.tsx b/src/routes/PP.tsx index 0b460cf..70acbe3 100644 --- a/src/routes/PP.tsx +++ b/src/routes/PP.tsx @@ -3,69 +3,145 @@ import Navbar from "../Components/Navbar/Navbar"; import Footer from "../Components/Footer/Footer"; const PP = () => { - const { t } = useTranslation("PP") - + const { t } = useTranslation("PP"); + return (
-
-

{t("pp.pageTitle")}

-
-

{t("pp.lastUpdated")}

-

{t("pp.introText")}

-

{t("pp.pp1.title")}

- {t("pp.pp1.desc").split("\n\n").map((paragraph, index) => ( -

{paragraph.split("\n").map((line, index) => ( - {line}
- ))}

- ))} -

{t("pp.pp2.title")}

- {t("pp.pp2.desc").split("\n\n").map((paragraph, index) => ( -

{paragraph.split("\n").map((line, index) => ( - {line}
- ))}

- ))} -

{t("pp.pp3.title")}

- {t("pp.pp3.desc").split("\n\n").map((paragraph, index) => ( -

{paragraph.split("\n").map((line, index) => ( - {line}
- ))}

- ))} -

{t("pp.pp4.title")}

- {t("pp.pp4.desc").split("\n\n").map((paragraph, index) => ( -

{paragraph.split("\n").map((line, index) => ( - {line}
- ))}

- ))} -

{t("pp.pp5.title")}

- {t("pp.pp5.desc").split("\n\n").map((paragraph, index) => ( -

{paragraph.split("\n").map((line, index) => ( - {line}
- ))}

- ))} -

{t("pp.pp6.title")}

- {t("pp.pp6.desc").split("\n\n").map((paragraph, index) => ( -

{paragraph.split("\n").map((line, index) => ( - {line}
- ))}

- ))} -

{t("pp.pp7.title")}

- {t("pp.pp7.desc").split("\n\n").map((paragraph, index) => ( -

{paragraph.split("\n").map((line, index) => ( - {line}
- ))}

- ))} -

{t("pp.pp8.title")}

- {t("pp.pp8.desc").split("\n\n").map((paragraph, index) => ( -

{paragraph.split("\n").map((line, index) => ( - {line}
- ))}

- ))} +
+

+ {t("pp.pageTitle")} +

+
+

{t("pp.lastUpdated")}

+

+ {t("pp.introText")} +

+

+ {t("pp.pp1.title")} +

+ {t("pp.pp1.desc") + .split("\n\n") + .map((paragraph, index) => ( +

+ {paragraph.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +

+ ))} +

+ {t("pp.pp2.title")} +

+ {t("pp.pp2.desc") + .split("\n\n") + .map((paragraph, index) => ( +

+ {paragraph.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +

+ ))} +

+ {t("pp.pp3.title")} +

+ {t("pp.pp3.desc") + .split("\n\n") + .map((paragraph, index) => ( +

+ {paragraph.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +

+ ))} +

+ {t("pp.pp4.title")} +

+ {t("pp.pp4.desc") + .split("\n\n") + .map((paragraph, index) => ( +

+ {paragraph.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +

+ ))} +

+ {t("pp.pp5.title")} +

+ {t("pp.pp5.desc") + .split("\n\n") + .map((paragraph, index) => ( +

+ {paragraph.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +

+ ))} +

+ {t("pp.pp6.title")} +

+ {t("pp.pp6.desc") + .split("\n\n") + .map((paragraph, index) => ( +

+ {paragraph.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +

+ ))} +

+ {t("pp.pp7.title")} +

+ {t("pp.pp7.desc") + .split("\n\n") + .map((paragraph, index) => ( +

+ {paragraph.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +

+ ))} +

+ {t("pp.pp8.title")} +

+ {t("pp.pp8.desc") + .split("\n\n") + .map((paragraph, index) => ( +

+ {paragraph.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +

+ ))}
-
+
); }; -export default PP; \ No newline at end of file +export default PP; diff --git a/src/routes/TOS.tsx b/src/routes/TOS.tsx index 0452933..d1dabe6 100644 --- a/src/routes/TOS.tsx +++ b/src/routes/TOS.tsx @@ -3,57 +3,115 @@ import Navbar from "../Components/Navbar/Navbar"; import Footer from "../Components/Footer/Footer"; const TOS = () => { - const { t } = useTranslation("TOS") - + const { t } = useTranslation("TOS"); + return (
-
-

{t("tos.pageTitle")}

-
-

{t("tos.lastUpdated")}

-

{t("tos.introText")}

-

{t("tos.tos1.title")}

- {t("tos.tos1.desc").split("\n\n").map((paragraph, index) => ( -

{paragraph.split("\n").map((line, index) => ( - {line}
- ))}

- ))} -

{t("tos.tos2.title")}

- {t("tos.tos2.desc").split("\n\n").map((paragraph, index) => ( -

{paragraph.split("\n").map((line, index) => ( - {line}
- ))}

- ))} -

{t("tos.tos3.title")}

- {t("tos.tos3.desc").split("\n\n").map((paragraph, index) => ( -

{paragraph.split("\n").map((line, index) => ( - {line}
- ))}

- ))} -

{t("tos.tos4.title")}

- {t("tos.tos4.desc").split("\n\n").map((paragraph, index) => ( -

{paragraph.split("\n").map((line, index) => ( - {line}
- ))}

- ))} -

{t("tos.tos5.title")}

- {t("tos.tos5.desc").split("\n\n").map((paragraph, index) => ( -

{paragraph.split("\n").map((line, index) => ( - {line}
- ))}

- ))} -

{t("tos.tos6.title")}

- {t("tos.tos6.desc").split("\n\n").map((paragraph, index) => ( -

{paragraph.split("\n").map((line, index) => ( - {line}
- ))}

- ))} +
+

+ {t("tos.pageTitle")} +

+
+

{t("tos.lastUpdated")}

+

+ {t("tos.introText")} +

+

+ {t("tos.tos1.title")} +

+ {t("tos.tos1.desc") + .split("\n\n") + .map((paragraph, index) => ( +

+ {paragraph.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +

+ ))} +

+ {t("tos.tos2.title")} +

+ {t("tos.tos2.desc") + .split("\n\n") + .map((paragraph, index) => ( +

+ {paragraph.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +

+ ))} +

+ {t("tos.tos3.title")} +

+ {t("tos.tos3.desc") + .split("\n\n") + .map((paragraph, index) => ( +

+ {paragraph.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +

+ ))} +

+ {t("tos.tos4.title")} +

+ {t("tos.tos4.desc") + .split("\n\n") + .map((paragraph, index) => ( +

+ {paragraph.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +

+ ))} +

+ {t("tos.tos5.title")} +

+ {t("tos.tos5.desc") + .split("\n\n") + .map((paragraph, index) => ( +

+ {paragraph.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +

+ ))} +

+ {t("tos.tos6.title")} +

+ {t("tos.tos6.desc") + .split("\n\n") + .map((paragraph, index) => ( +

+ {paragraph.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +

+ ))}
-
); }; -export default TOS; \ No newline at end of file +export default TOS; diff --git a/tailwind.config.js b/tailwind.config.js index c6262ec..10e1eb8 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -12,13 +12,21 @@ export default { "text-gold": "#FACC97", "gold-light": "#FACC97", "gold-dark": "#54330F", + "gold-mid": "#C9924F", "gray-bg": "#1C1C1C", + surface: "rgba(255, 255, 255, 0.04)", + "surface-border": "rgba(250, 204, 151, 0.14)", }, fontFamily: { baijam: ['"Bai Jamjuree"', "sans-serif"], }, - backgroundImage: { - "hero-pattern": "url('./src/assets/HeroImg.svg')", + boxShadow: { + glass: "0 1px 1px rgba(250, 204, 151, 0.08), 0 20px 60px -20px rgba(0, 0, 0, 0.6)", + "glass-lg": "0 1px 1px rgba(250, 204, 151, 0.1), 0 30px 90px -20px rgba(0, 0, 0, 0.7)", + "gold-glow": "0 8px 30px -8px rgba(250, 204, 151, 0.35)", + }, + backdropBlur: { + xs: "2px", }, }, },