diff --git a/CHANGELOG.md b/CHANGELOG.md index f27fb910..8f196699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -234,6 +234,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Introduced SDD structure for Services Page - Updated SDD docs to include new Service page issues +- Added "What We Offer" section to the Services page ### Fixed diff --git a/components/icons/CloseIcon/index.js b/components/icons/CloseIcon/index.js new file mode 100644 index 00000000..e83521de --- /dev/null +++ b/components/icons/CloseIcon/index.js @@ -0,0 +1,18 @@ +export default function CloseIcon(props) { + const { className, fill = 'var(--color-black)' } = props; + + return ( + + + + ); +} diff --git a/components/overlays/Dialog/Dialog.module.scss b/components/overlays/Dialog/Dialog.module.scss new file mode 100644 index 00000000..15bb251a --- /dev/null +++ b/components/overlays/Dialog/Dialog.module.scss @@ -0,0 +1,28 @@ +@use '@/styles/index' as *; + +.backdrop { + position: fixed; + inset: 0; + background-color: var(--overlay-backdrop); + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + z-index: 100; +} + +.panel { + border-radius: 1rem; + max-width: 40rem; + width: 100%; + min-width: 0; + max-height: 90vh; + overflow-y: auto; + padding: 2rem; +} + +.containedPanel { + border-radius: 1rem; + min-width: 0; + padding: 2rem; +} diff --git a/components/overlays/Dialog/index.js b/components/overlays/Dialog/index.js new file mode 100644 index 00000000..bd29196a --- /dev/null +++ b/components/overlays/Dialog/index.js @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import styles from './Dialog.module.scss'; + +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export default function Dialog({ + open, + onClose, + titleId, + className, + contained = false, + children, +}) { + const [mounted, setMounted] = useState(false); + const panelRef = useRef(null); + const previouslyFocusedRef = useRef(null); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!open) return undefined; + + previouslyFocusedRef.current = document.activeElement; + const panel = panelRef.current; + const focusable = panel + ? Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)) + : []; + (focusable[0] || panel)?.focus(); + + const handleKeyDown = event => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + + if (event.key !== 'Tab' || focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + const handleOutsideClick = event => { + if (panel && !panel.contains(event.target)) { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + if (contained) { + document.addEventListener('mousedown', handleOutsideClick); + } + + return () => { + document.removeEventListener('keydown', handleKeyDown); + if (contained) { + document.removeEventListener('mousedown', handleOutsideClick); + } + previouslyFocusedRef.current?.focus(); + }; + }, [open, onClose, contained]); + + if (!open) return null; + + if (contained) { + return ( +
+ {children} +
+ ); + } + + if (!mounted) return null; + + return createPortal( +
+
event.stopPropagation()} + > + {children} +
+
, + document.body, + ); +} diff --git a/components/services/Pill/Pill.module.scss b/components/services/Pill/Pill.module.scss new file mode 100644 index 00000000..7c295e97 --- /dev/null +++ b/components/services/Pill/Pill.module.scss @@ -0,0 +1,29 @@ +@use '@/styles/index' as *; + +.pill { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 2px solid var(--color-primary-accent); + background-color: var(--color-transparent); + color: var(--color-black); + font-family: $font-family-primary; + font-weight: $font-weight-regular; + white-space: nowrap; + cursor: pointer; + padding: 0.5rem 1rem; + font-size: $font-size-2xs; + line-height: 1.5rem; + @include transition(all 0.3s ease); + + @include desktop { + padding: 0.75rem 2rem; + font-size: $font-size-sm; + line-height: 2rem; + } + + &.secondary { + border-color: var(--color-secondary-accent); + } +} diff --git a/components/services/Pill/index.js b/components/services/Pill/index.js new file mode 100644 index 00000000..d4a94e6d --- /dev/null +++ b/components/services/Pill/index.js @@ -0,0 +1,22 @@ +import styles from './Pill.module.scss'; +import { combineClasses } from '@/utils/classnames'; + +export default function Pill({ + label, + variant = 'primary', + onClick, + tabIndex, +}) { + const pillClass = combineClasses(styles.pill, variant, styles); + + return ( + + ); +} diff --git a/components/services/ServiceDialog/ServiceDialog.module.scss b/components/services/ServiceDialog/ServiceDialog.module.scss new file mode 100644 index 00000000..3d3d59f4 --- /dev/null +++ b/components/services/ServiceDialog/ServiceDialog.module.scss @@ -0,0 +1,105 @@ +@use '@/styles/index' as *; + +.panel { + position: absolute; + min-height: 100%; + z-index: 1; + display: flex; + flex-direction: column; + gap: 1.5rem; + + @include mobile { + top: -0.5rem; + right: -2rem; + bottom: -4rem; + left: -2rem; + } + + @include tablet { + top: -1rem; + right: -2rem; + left: -2rem; + } + + @include desktop { + top: -1rem; + right: -2rem; + bottom: -2rem; + left: -2rem; + } + + &.primary { + background-color: var(--color-primary-accent); + } + + &.secondary { + background-color: var(--color-secondary-surface); + } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-width: 0; +} + +.echoPill { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background-color: var(--color-black); + color: var(--color-white); + font-family: $font-family-primary; + font-weight: $font-weight-regular; + padding: 0.25rem 0.75rem; + font-size: 0.75rem; + line-height: 1rem; + white-space: nowrap; + flex-shrink: 0; + + @include desktop { + padding: 0.5rem 1.25rem; + font-size: 1rem; + line-height: 1.5rem; + } +} + +.closeButton { + display: flex; + align-items: center; + justify-content: center; + width: 3.5rem; + height: 3.5rem; + border: none; + border-radius: 0.25rem; + background-color: var(--color-transparent); + cursor: pointer; + flex-shrink: 0; + @include transition(all 0.3s ease); + + &:hover { + background-color: var(--color-box-shadow); + } +} + +.closeIcon { + width: 2rem; + height: 2rem; +} + +.description { + font-family: $font-family-primary; + font-size: $font-size-2xs; + line-height: 1.5rem; + color: var(--color-primary-content); + word-break: break-word; + margin: 0; + + @include desktop { + font-size: $font-size-sm; + line-height: 2rem; + } +} diff --git a/components/services/ServiceDialog/index.js b/components/services/ServiceDialog/index.js new file mode 100644 index 00000000..cae3d60c --- /dev/null +++ b/components/services/ServiceDialog/index.js @@ -0,0 +1,40 @@ +import Dialog from '@/components/overlays/Dialog'; +import CloseIcon from '@/components/icons/CloseIcon'; +import { combineClasses } from '@/utils/classnames'; +import styles from './ServiceDialog.module.scss'; + +export default function ServiceDialog({ + open, + onClose, + titleId, + label, + description, + variant = 'primary', +}) { + const panelClass = combineClasses(styles.panel, variant, styles); + + return ( + +
+ + {label} + + +
+

{description}

+
+ ); +} diff --git a/components/services/WhatWeOffer/WhatWeOffer.module.scss b/components/services/WhatWeOffer/WhatWeOffer.module.scss new file mode 100644 index 00000000..3d32c6fb --- /dev/null +++ b/components/services/WhatWeOffer/WhatWeOffer.module.scss @@ -0,0 +1,140 @@ +@use '@/styles/index' as *; + +.whatWeOffer { + position: relative; + width: 100%; + overflow-x: hidden; + overflow-y: visible; + background-color: var(--color-white); + padding: 4rem 0; + + @include desktop { + padding: 6rem 0; + } +} + +.content { + opacity: 0; + transform: translateY(-16rem); + @include transition(transform 1s, opacity 1s); + + &.revealed { + opacity: 1; + transform: translateY(0); + } +} + +.leftDecoration { + display: none; + pointer-events: none; + + @include desktop { + display: block; + position: absolute; + top: 50%; + left: -17rem; + height: 14rem; + width: auto; + opacity: 0; + transform: translateY(-50%) translateX(-13rem); + @include transition(transform 1s, opacity 1s); + + &.revealed { + opacity: 1; + transform: translateY(-50%) translateX(0); + } + } +} + +.rightDecoration { + display: none; + pointer-events: none; + + @include desktop { + display: flex; + align-items: center; + gap: 0.5rem; + position: absolute; + top: 50%; + right: -24rem; + opacity: 0; + transform: translateY(-50%) translateX(13rem); + @include transition(transform 1s, opacity 1s); + + &.revealed { + opacity: 1; + transform: translateY(-50%) translateX(0); + } + } +} + +.slash { + height: 19rem; + width: auto; +} + +.angleBracket { + height: 14rem; + width: auto; +} + +.heading { + font-family: $font-family-secondary; + font-weight: $font-weight-bold; + font-size: $font-size-md; + line-height: 2.75rem; + text-align: center; + margin: 0 0 1.5rem; + + @include desktop { + font-size: 4rem; + line-height: 4.5rem; + } +} + +.body { + font-family: $font-family-primary; + font-size: $font-size-2xs; + line-height: 1.5rem; + text-align: center; + max-width: 60rem; + margin: 0 auto 2.5rem; + + @include desktop { + font-size: $font-size-sm; + line-height: 2rem; + } +} + +.pillRowsWrapper { + position: relative; +} + +.pillRows { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.pillRowViewport { + width: 100%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.pillTrack { + display: flex; + gap: 1rem; + width: max-content; +} + +.pillGroup { + display: flex; + gap: 1rem; +} diff --git a/components/services/WhatWeOffer/index.js b/components/services/WhatWeOffer/index.js new file mode 100644 index 00000000..45835c9b --- /dev/null +++ b/components/services/WhatWeOffer/index.js @@ -0,0 +1,149 @@ +import { useEffect, useId, useState } from 'react'; +import Pill from '@/components/services/Pill'; +import Container from '@/components/containers/Container'; +import ServiceDialog from '@/components/services/ServiceDialog'; +import { services } from '@/utils/services'; +import { useAutoScroll } from '@/hooks/useAutoScroll'; +import { useIntersect } from '@/hooks/useIntersect'; +import { combineClasses } from '@/utils/classnames'; +import styles from './WhatWeOffer.module.scss'; + +const ROW_COUNT = 3; +const rowIndexes = Array.from({ length: ROW_COUNT }, (_, index) => index); +const REVEAL_TRANSITION_MS = 1000; + +function getRowDirection(rowIndex) { + return rowIndex % 2 === 0 ? 'left' : 'right'; +} + +function PillRow({ pills, direction, enabled, onSelect }) { + const { viewportRef, groupRef, pause, resume } = useAutoScroll({ + direction, + enabled, + }); + + return ( +
+
+
+ {pills.map(pill => ( + onSelect(pill)} + /> + ))} +
+ +
+
+ ); +} + +export default function WhatWeOffer() { + const [activeService, setActiveService] = useState(null); + const titleId = useId(); + const [setSectionNode, entry] = useIntersect({}); + const [revealed, setRevealed] = useState(false); + const [marqueeEnabled, setMarqueeEnabled] = useState(false); + + useEffect(() => { + if (entry.isIntersecting && !revealed) { + setRevealed(true); + } + }, [entry.isIntersecting, revealed]); + + useEffect(() => { + if (!revealed) return undefined; + + const timeoutId = setTimeout( + () => setMarqueeEnabled(true), + REVEAL_TRANSITION_MS, + ); + return () => clearTimeout(timeoutId); + }, [revealed]); + + const contentClass = combineClasses( + styles.content, + revealed ? 'revealed' : null, + styles, + ); + const leftDecorationClass = combineClasses( + styles.leftDecoration, + revealed ? 'revealed' : null, + styles, + ); + const rightDecorationClass = combineClasses( + styles.rightDecoration, + revealed ? 'revealed' : null, + styles, + ); + + return ( +
+ +
+

What We Offer

+

+ We deliver high-end digital solutions — from{' '} + design and development to{' '} + project management and strategy — tailored to your + goals and budget, with no compromise on quality. +

+
+
+ + +
+ {rowIndexes.map(rowIndex => ( + + ))} +
+ setActiveService(null)} + titleId={titleId} + label={activeService?.label} + description={activeService?.description} + variant={activeService?.variant} + /> +
+
+
+ ); +} diff --git a/hooks/useAutoScroll.js b/hooks/useAutoScroll.js new file mode 100644 index 00000000..3ba5b548 --- /dev/null +++ b/hooks/useAutoScroll.js @@ -0,0 +1,56 @@ +import { useEffect, useRef } from 'react'; + +export function useAutoScroll({ + direction = 'left', + speed = 60, + enabled = true, +} = {}) { + const viewportRef = useRef(null); + const groupRef = useRef(null); + const pausedRef = useRef(false); + + useEffect(() => { + const viewport = viewportRef.current; + const group = groupRef.current; + if (!viewport || !group || !enabled) return undefined; + + const prefersReducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches; + if (prefersReducedMotion) return undefined; + + const dir = direction === 'right' ? -1 : 1; + const groupWidth = group.offsetWidth; + viewport.scrollLeft = dir === -1 ? groupWidth : 0; + + let frameId; + let lastTimestamp; + + const step = timestamp => { + if (lastTimestamp !== undefined && !pausedRef.current) { + const delta = ((timestamp - lastTimestamp) / 1000) * speed * dir; + viewport.scrollLeft += delta; + + if (viewport.scrollLeft >= groupWidth) { + viewport.scrollLeft -= groupWidth; + } else if (viewport.scrollLeft <= 0) { + viewport.scrollLeft += groupWidth; + } + } + lastTimestamp = timestamp; + frameId = requestAnimationFrame(step); + }; + + frameId = requestAnimationFrame(step); + return () => cancelAnimationFrame(frameId); + }, [direction, speed, enabled]); + + const pause = () => { + pausedRef.current = true; + }; + const resume = () => { + pausedRef.current = false; + }; + + return { viewportRef, groupRef, pause, resume }; +} diff --git a/pages/services.js b/pages/services.js new file mode 100644 index 00000000..56c1509c --- /dev/null +++ b/pages/services.js @@ -0,0 +1,5 @@ +import WhatWeOffer from '@/components/services/WhatWeOffer'; + +export default function Services() { + return ; +} diff --git a/styles/globals.scss b/styles/globals.scss index b8be84c9..34745cba 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -12,8 +12,11 @@ --color-light-bg: #8cd5e8; --color-primary-accent: #ffcc4c; --color-primary-content: #292929; + --color-secondary-accent: #19aad1; + --color-secondary-surface: #75cce3; --color-transparent: transparent; --color-box-shadow: rgba(0, 0, 0, 0.08); + --overlay-backdrop: rgba(0, 0, 0, 0.5); --dark-bg: #023047; --error: #be1313; --shadow-default: 0px 8px 24px rgba(0, 0, 0, 0.15); diff --git a/utils/services.js b/utils/services.js new file mode 100644 index 00000000..3dd38929 --- /dev/null +++ b/utils/services.js @@ -0,0 +1,54 @@ +export const services = [ + { + id: 'web-development', + label: 'Web Development', + variant: 'primary', + subServices: ['Custom Websites', 'CMS Development (WordPress)'], + description: + 'We create modern, responsive websites that are fast, secure, and tailored to your business—designed to engage users, drive results, and grow your online presence.', + }, + { + id: 'product-project-management', + label: 'Product & Project Management', + variant: 'secondary', + subServices: [ + 'Agile Project Management', + 'Product Discovery', + 'Technical Roadmapping', + ], + description: + 'Our service ensures every project is strategically planned, efficiently executed, and successfully delivered—on time, within budget, and aligned with your business goals.', + }, + { + id: 'design', + label: 'Design', + variant: 'primary', + subServices: ['UI/UX Design', 'Product Design', 'UX Audits'], + description: + 'We craft intuitive, user-centered designs — from UI/UX and product design to UX audits — that make every digital experience clear, engaging, and easy to use.', + }, + { + id: 'startup-nonprofit-tech-support', + label: 'Startup & Nonprofit Tech Support', + variant: 'secondary', + subServices: [ + 'MVP Development', + 'Technical Advisory', + 'Digital Transformation for Nonprofits', + ], + description: + 'We support startups and nonprofits with MVP development, technical advisory, and digital transformation — helping mission-driven teams build smart and move fast, without compromising on quality.', + }, + { + id: 'optimization-growth', + label: 'Optimization & Growth', + variant: 'primary', + subServices: [ + 'SEO Optimization', + 'Website Performance Optimization', + 'Accessibility Improvements', + ], + description: + 'We help your website perform at its best through SEO optimization, performance tuning, and accessibility improvements — keeping you visible, fast, and inclusive for every user.', + }, +];