diff --git a/package.json b/package.json index b29a668..3fec95b 100755 --- a/package.json +++ b/package.json @@ -57,7 +57,8 @@ "react-leaflet": "4.x", "react-map-gl": "^7.1.6", "react-slick": "^0.30.3", - "slick-carousel": "^1.8.1" + "slick-carousel": "^1.8.1", + "yet-another-react-lightbox": "^3.25.0" }, "devDependencies": { "autoprefixer": "^10.4.19", diff --git a/src/components/CTABanner.js b/src/components/CTABanner.js index 81116d9..2cf7bf2 100644 --- a/src/components/CTABanner.js +++ b/src/components/CTABanner.js @@ -13,19 +13,25 @@ const fadeIn = { } /** - * Reusable CTA Banner component + * Reusable CTA Banner component with audience variants * @param {Object} props - Component props - * @param {string} props.heading - The heading text for the CTA + * @param {string|React.ReactNode} props.heading - The heading text for the CTA (prefer headingJSX for styled content) + * @param {React.ReactNode} props.headingJSX - JSX content for the heading (takes precedence over heading) * @param {string} props.description - The description text for the CTA - * @param {string} props.buttonText - The text for the CTA button (defaults to "Get Your Free Cash Offer") + * @param {string} props.buttonText - The text for the CTA button + * @param {string} props.variant - The visual variant ("urgent", "business", "supportive", "default") + * @param {string} props.icon - Icon type for the banner ("clock", "chart-up", "shield-check") * @param {string} props.className - Additional CSS classes for the container * @param {string} props.formType - The type of form to display (defaults to PROPERTY_SELLER) * @param {Object} props.data - Additional data to pass to the form */ const CTABanner = ({ - heading = "Ready to sell your property?", + heading = "Ready to sell your property?", + headingJSX = null, description = "Contact us today for a no-obligation cash offer. We can complete the purchase in as little as 30 days.", buttonText = "Get Your Free Cash Offer", + variant = "default", + icon = null, className = "", formType = FORM_TYPES.PROPERTY_SELLER, data = null @@ -39,18 +45,85 @@ const CTABanner = ({ }); }; + // Variant styling configurations - using consistent brand colors + const variantStyles = { + default: { + gradient: "bg-gradient-to-b from-primary-600 to-primary-700", + textColor: "text-white", + buttonBg: "bg-white", + buttonText: "text-primary-600", + buttonHover: "hover:bg-neutral-100" + }, + urgent: { + gradient: "bg-gradient-to-br from-primary-600 to-primary-700", + textColor: "text-white", + buttonBg: "bg-white", + buttonText: "text-primary-600", + buttonHover: "hover:bg-neutral-100 font-bold" + }, + business: { + gradient: "bg-gradient-to-br from-primary-700 to-primary-600", + textColor: "text-white", + buttonBg: "bg-white", + buttonText: "text-primary-700", + buttonHover: "hover:bg-neutral-100 font-semibold" + }, + supportive: { + gradient: "bg-gradient-to-br from-primary-600/80 to-primary-600", + textColor: "text-white", + buttonBg: "bg-white", + buttonText: "text-primary-600", + buttonHover: "hover:bg-neutral-100 font-medium rounded-lg" + } + }; + + const currentStyle = variantStyles[variant] || variantStyles.default; + + // Icon components + const IconComponent = ({ type }) => { + const iconClass = "w-8 h-8 mb-4 mx-auto"; + + switch (type) { + case "clock": + return ( + + + + ); + case "chart-up": + return ( + + + + ); + case "shield-check": + return ( + + + + ); + default: + return null; + } + }; + return ( -

- {heading} + {icon && ( +
+ +
+ )} +

+ {headingJSX || heading}

-

+

{description}

{buttonText} diff --git a/src/components/HeroVariant.js b/src/components/HeroVariant.js new file mode 100644 index 0000000..7406f5e --- /dev/null +++ b/src/components/HeroVariant.js @@ -0,0 +1,277 @@ +import React from 'react' +import { motion } from 'framer-motion' +import { GatsbyImage } from 'gatsby-plugin-image' +import { useModal, FORM_TYPES } from '../context/modalContext' + +// Animation variants +const fadeIn = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.6 } + } +} + +const fadeInUp = { + hidden: { opacity: 0, y: 40 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.8, ease: [0.6, 0.05, 0.01, 0.9] } + } +} + +const staggerChildren = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.2, + delayChildren: 0.1 + } + } +} + +/** + * Enhanced Hero component with audience-specific variants + * @param {string} variant - The visual variant ("urgency", "business", "supportive", "default") + * @param {string|React.ReactNode} title - Main heading (legacy; prefer titleJSX for styled content) + * @param {React.ReactNode} titleJSX - JSX heading content (takes precedence over title) + * @param {string} subtitle - Support text below the heading + * @param {string} eyebrowText - Optional label text displayed above heading + * @param {Object} heroImage - Gatsby image data from graphql query + * @param {string} imageObjectPosition - CSS object-position for hero image (e.g., 'center 50%') + * @param {string} imageObjectFit - CSS object-fit for hero image (e.g., 'cover', 'contain') + * @param {string} ctaText - Call-to-action button text + * @param {string} formType - Form type to trigger + * @param {boolean} showStats - Whether to display statistics + * @param {Array} stats - Array of stat objects {value, label} + * @param {boolean} splitLayout - Whether to use split layout for business variant + * @param {string} className - Additional CSS classes + */ +const HeroVariant = ({ + variant = "default", + title, + titleJSX, + subtitle, + eyebrowText, + heroImage, + imageObjectPosition = "center 50%", + imageObjectFit = "cover", + ctaText, + formType = FORM_TYPES.PROPERTY_SELLER, + showStats = false, + stats = [], + splitLayout = false, + className = "" +}) => { + const { openModal } = useModal() + + // Deprecated: createMarkup was used for HTML strings in title. Prefer titleJSX. + + const handleCTAClick = () => { + openModal({ type: formType }); + }; + + // Variant configurations - using consistent brand colors with different treatments + const variantConfig = { + default: { + background: "bg-gradient-to-b from-neutral-50 to-white", + titleColor: "text-neutral-900", + subtitleColor: "text-neutral-700", + eyebrowBg: "bg-primary-600/10", + eyebrowText: "text-primary-600", + dividerColor: "bg-primary-600", + ctaButton: "bg-primary-600 hover:bg-primary-700 text-white" + }, + urgency: { + background: "bg-gradient-to-br from-primary-600 via-primary-600 to-primary-700", + titleColor: "text-white", + subtitleColor: "text-white/90", + eyebrowBg: "bg-white/20", + eyebrowText: "text-white", + dividerColor: "bg-white", + ctaButton: "bg-white text-primary-600 hover:bg-neutral-100 font-bold" + }, + business: { + background: "bg-gradient-to-br from-neutral-50 to-white border-l-4 border-primary-700", + titleColor: "text-neutral-900", + subtitleColor: "text-neutral-700", + eyebrowBg: "bg-primary-700/10", + eyebrowText: "text-primary-700", + dividerColor: "bg-primary-700", + ctaButton: "bg-primary-700 hover:bg-primary-600 text-white font-semibold" + }, + supportive: { + background: "bg-gradient-to-br from-primary-600/10 via-primary-100/50 to-white", + titleColor: "text-neutral-900", + subtitleColor: "text-neutral-700", + eyebrowBg: "bg-primary-600/20", + eyebrowText: "text-primary-700", + dividerColor: "bg-primary-600", + ctaButton: "bg-primary-600/80 hover:bg-primary-600 text-white font-medium rounded-lg" + } + }; + + const currentConfig = variantConfig[variant] || variantConfig.default; + + // Stats component for business variant + const StatsDisplay = () => ( + + {stats.map((stat, index) => ( +
+
+ {stat.value} +
+
+ {stat.label} +
+
+ ))} +
+ ); + + // Urgency indicator for urgent variant + const UrgencyIndicator = () => ( + + + + + Fast Track Available + + ); + + return ( +
+ {/* Background image */} +
+ {heroImage ? ( + + ) : ( +
+ )} +
+ +
+ {splitLayout && variant === "business" ? ( + // Split layout for business variant +
+ + {eyebrowText && ( + + + {eyebrowText} + + + )} + + + {titleJSX || title} + + + + + {subtitle && ( + + {subtitle} + + )} + + {ctaText && ( + + {ctaText} + + )} + + + {/* Stats side for business variant */} + + {showStats && stats.length > 0 && } + +
+ ) : ( + // Centered layout for other variants + + {variant === "urgency" && } + + {eyebrowText && ( + + + {eyebrowText} + + + )} + + + {titleJSX || title} + + + + + {subtitle && ( + + {subtitle} + + )} + + {showStats && stats.length > 0 && variant === "business" && } + + {ctaText && ( + + {ctaText} + + )} + + )} +
+
+ ) +} + +export default HeroVariant diff --git a/src/components/ImageCounter.js b/src/components/ImageCounter.js new file mode 100644 index 0000000..a2821c2 --- /dev/null +++ b/src/components/ImageCounter.js @@ -0,0 +1,13 @@ +import React from 'react' + +const ImageCounter = ({ currentIndex, totalImages, className = '' }) => { + if (totalImages <= 1) return null + + return ( +
+ {currentIndex + 1} / {totalImages} +
+ ) +} + +export default ImageCounter diff --git a/src/components/ImageLightbox.js b/src/components/ImageLightbox.js new file mode 100644 index 0000000..85ebcfc --- /dev/null +++ b/src/components/ImageLightbox.js @@ -0,0 +1,88 @@ +import React from 'react' +import Lightbox from 'yet-another-react-lightbox' +import Counter from 'yet-another-react-lightbox/plugins/counter' +import Thumbnails from 'yet-another-react-lightbox/plugins/thumbnails' +import Zoom from 'yet-another-react-lightbox/plugins/zoom' +import 'yet-another-react-lightbox/styles.css' +import 'yet-another-react-lightbox/plugins/counter.css' +import 'yet-another-react-lightbox/plugins/thumbnails.css' +import { getImage } from 'gatsby-plugin-image' + +const ImageLightbox = ({ + images = [], + isOpen, + onClose, + initialIndex = 0, + className = '' +}) => { + // Convert Gatsby images to lightbox format + const lightboxImages = images.map((image, index) => { + try { + const gatsbyImage = getImage(image.asset.gatsbyImageData) + + // Use the largest available image source + const imageSrc = gatsbyImage.images.sources?.[0]?.srcSet?.split(', ').pop()?.split(' ')[0] || + gatsbyImage.images.fallback.src + + return { + src: imageSrc, + alt: `Property image ${index + 1}`, + width: gatsbyImage.width || 1200, + height: gatsbyImage.height || 800 + } + } catch (error) { + console.warn(`Failed to process image ${index}:`, error) + return null + } + }).filter(Boolean) // Filter out any null images + + if (lightboxImages.length === 0) { + console.warn('No valid images for lightbox') + return null + } + + return ( + null : undefined, + buttonNext: lightboxImages.length <= 1 ? () => null : undefined, + iconNext: () => ( + + + + ), + iconPrev: () => ( + + + + ), + iconClose: () => ( + + + + + ) + }} + className={className} + /> + ) +} + +export default ImageLightbox diff --git a/src/components/PageHero.js b/src/components/PageHero.js index 7aae346..343a5d4 100644 --- a/src/components/PageHero.js +++ b/src/components/PageHero.js @@ -24,7 +24,8 @@ const staggerChildren = { /** * PageHero - A reusable hero component for page headers - * @param {string} title - Main heading (can include HTML tags for styling) + * @param {string|React.ReactNode} title - Main heading (legacy; prefer titleJSX) + * @param {React.ReactNode} titleJSX - JSX heading content (takes precedence over title) * @param {string} subtitle - Support text below the heading * @param {string} eyebrowText - Optional label text displayed above heading * @param {Object} heroImage - Gatsby image data from graphql query @@ -33,15 +34,12 @@ const staggerChildren = { */ const PageHero = ({ title, + titleJSX = null, subtitle, eyebrowText, heroImage, className = "" }) => { - // Function to safely render HTML in the title - const createMarkup = (htmlContent) => { - return { __html: htmlContent }; - }; return (
@@ -78,8 +76,9 @@ const PageHero = ({ + > + {titleJSX || title} + diff --git a/src/components/PropertyCard.js b/src/components/PropertyCard.js index 0a96ba2..14e18d6 100644 --- a/src/components/PropertyCard.js +++ b/src/components/PropertyCard.js @@ -56,7 +56,7 @@ const PropertyCard = ({ property }) => { >
{/* Image with overlay link to full details */} - + {imageData ? ( <> { alt={title} className="h-full w-full object-cover" /> -
-
+
+
- View Details + Click here to view
@@ -79,9 +79,11 @@ const PropertyCard = ({ property }) => {
)} - {/*
- £{price?.toLocaleString()} -
*/} + {/* {typeof price === 'number' && ( +
+ £{price.toLocaleString()} +
+ )} */} {getStatusBadge()}
diff --git a/src/components/PropertyFilter.js b/src/components/PropertyFilter.js index 63f2efd..c086f8a 100644 --- a/src/components/PropertyFilter.js +++ b/src/components/PropertyFilter.js @@ -179,6 +179,19 @@ const PropertyFilter = ({ filters, handleInputChange, propertyTypes, propertySta
+ +
+ +
diff --git a/src/components/PropertyImageCarousel.js b/src/components/PropertyImageCarousel.js index 8661ac5..9b09619 100644 --- a/src/components/PropertyImageCarousel.js +++ b/src/components/PropertyImageCarousel.js @@ -1,9 +1,11 @@ -import React, { useState } from 'react' +import React, { useState, useRef, useEffect } from 'react' import Slider from 'react-slick' import { GatsbyImage, getImage } from 'gatsby-plugin-image' -import { FaChevronLeft, FaChevronRight } from 'react-icons/fa' +import { FaChevronLeft, FaChevronRight, FaExpand } from 'react-icons/fa' import 'slick-carousel/slick/slick.css' import 'slick-carousel/slick/slick-theme.css' +import ImageCounter from './ImageCounter' +import ImageLightbox from './ImageLightbox' // Custom arrow components const PrevArrow = ({ className, style, onClick }) => ( @@ -41,7 +43,12 @@ const PropertyImageCarousel = ({ .filter(img => img?.asset?.gatsbyImageData) const [mainSlider, setMainSlider] = useState(null) - const [thumbnailSlider, setThumbnailSlider] = useState(null) + const [currentSlide, setCurrentSlide] = useState(0) + const [isLightboxOpen, setIsLightboxOpen] = useState(false) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(false) + + const thumbnailContainerRef = useRef(null) // Main slider settings const mainSettings = { @@ -55,28 +62,52 @@ const PropertyImageCarousel = ({ prevArrow: , nextArrow: , adaptiveHeight: false, - asNavFor: thumbnailSlider, + beforeChange: (oldIndex, newIndex) => setCurrentSlide(newIndex), } - - // Thumbnail slider settings - const thumbnailSettings = { - dots: false, - infinite: true, - speed: 500, - slidesToShow: allImages.length > 5 ? 5 : allImages.length, - slidesToScroll: 1, - arrows: false, - centerMode: allImages.length > 5, - focusOnSelect: true, - asNavFor: mainSlider, - responsive: [ - { - breakpoint: 768, - settings: { - slidesToShow: allImages.length > 3 ? 3 : allImages.length, - } - } - ] + + // Check scroll position for thumbnail buttons + const checkScrollPosition = () => { + if (thumbnailContainerRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = thumbnailContainerRef.current + setCanScrollLeft(scrollLeft > 0) + setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1) + } + } + + // Scroll thumbnail container + const scrollThumbnails = (direction) => { + if (thumbnailContainerRef.current) { + const container = thumbnailContainerRef.current + const thumbnailWidth = 88 // w-20 (80px) + gap-2 (8px) = 88px + const visibleThumbnails = Math.floor(container.clientWidth / thumbnailWidth) + const scrollAmount = thumbnailWidth * Math.max(1, Math.floor(visibleThumbnails / 2)) + + const newScrollLeft = container.scrollLeft + (direction === 'left' ? -scrollAmount : scrollAmount) + container.scrollTo({ + left: newScrollLeft, + behavior: 'smooth' + }) + } + } + + // Handle thumbnail scroll + useEffect(() => { + checkScrollPosition() + const container = thumbnailContainerRef.current + if (container) { + container.addEventListener('scroll', checkScrollPosition) + return () => container.removeEventListener('scroll', checkScrollPosition) + } + }, [allImages.length]) + + // Open lightbox + const openLightbox = () => { + setIsLightboxOpen(true) + } + + // Close lightbox + const closeLightbox = () => { + setIsLightboxOpen(false) } if (allImages.length === 0) { @@ -97,38 +128,107 @@ const PropertyImageCarousel = ({ className="h-full" > {allImages.map((image, index) => ( -
+
+ {/* Fullscreen button overlay - always visible */} +
+ +
+ {/* Clickable overlay for the entire image */} +
))} + + {/* Image Counter */} +
{/* Thumbnail Carousel */} {showThumbnails && allImages.length > 1 && ( -
- setThumbnailSlider(slider)} - > - {allImages.map((image, index) => ( -
-
- +
+ {/* Thumbnail Container with Overlay Buttons */} +
+ {/* Left Scroll Button - Overlay */} + {canScrollLeft && ( + + )} + + {/* Right Scroll Button - Overlay */} + {canScrollRight && ( + + )} + + {/* Thumbnail Container */} +
+ {allImages.map((image, index) => ( +
+
-
- ))} - + ))} +
+
)} + + {/* Image Lightbox */} +
) } diff --git a/src/components/SanityBlockRenderer.js b/src/components/SanityBlockRenderer.js new file mode 100644 index 0000000..592d798 --- /dev/null +++ b/src/components/SanityBlockRenderer.js @@ -0,0 +1,156 @@ +import React from "react"; + +export default function SanityBlockRenderer({ blocks }) { + if (!blocks || !blocks.length) return

No description available

; + + const renderMarks = (text, marks = [], markDefs = []) => { + let element = text; + marks.forEach(mark => { + if (mark === "strong") element = {element}; + else if (mark === "em") element = {element}; + else if (mark === "underline") element = {element}; + else if (mark === "strike-through") element = {element}; + else { + // Handle link annotation + const def = markDefs.find(def => def._key === mark && def._type === "link"); + if (def && def.href) { + element = ( + + {element} + + ); + } + } + }); + return element; + }; + + const output = []; + let listType = null; + let listItems = []; + let pendingExtraMarginForNextList = false; + let currentListExtraMargin = false; + let pendingZeroMarginForNextList = false; + let currentListZeroMargin = false; + + blocks.forEach((block, idx) => { + if (block._type !== "block" || !block.children) return; + + const content = block.children.map((child, i) => + + {renderMarks(child.text, child.marks, block.markDefs)} + + ); + + // Determine if this block has any non-empty text content + const isEmptyContent = block.children + .map(child => (child.text || "").trim()) + .join("") + .trim().length === 0; + + // Get full text content for heading detection + const blockText = (block.children || []) + .map(child => child.text || "") + .join("") + .trim(); + + // Handle lists (bullet or number) + if (block.listItem) { + // If starting a new list type, flush previous + if (!listType || listType !== block.listItem) { + if (listType && listItems.length) { + const marginClass = currentListZeroMargin + ? "mt-0 mb-4" + : (currentListExtraMargin ? "mt-6 mb-4" : "mt-3 mb-4"); + output.push( + listType === "number" + ?
    <>{listItems}
+ :
    <>{listItems}
+ ); + } + listType = block.listItem; + listItems = []; + currentListExtraMargin = pendingExtraMarginForNextList; + pendingExtraMarginForNextList = false; + currentListZeroMargin = pendingZeroMarginForNextList; + pendingZeroMarginForNextList = false; + } + if (!isEmptyContent) { + listItems.push(
  • {content}
  • ); + } + } else { + // If previously in a list, flush it + if (listType && listItems.length) { + const marginClass = currentListZeroMargin + ? "mt-0 mb-4" + : (currentListExtraMargin ? "mt-6 mb-4" : "mt-3 mb-4"); + output.push( + listType === "number" + ?
      <>{listItems}
    + :
      <>{listItems}
    + ); + listType = null; + listItems = []; + } + if (isEmptyContent) { + return; // Skip rendering empty paragraphs/blocks + } + switch (block.style) { + case "h1": + output.push(

    {content}

    ); + break; + case "h2": + output.push(

    {content}

    ); + break; + case "h3": + output.push(

    {content}

    ); + break; + case "blockquote": + output.push(
    {content}
    ); + break; + default: + // Nudge down the "Key Features:" heading visually and ensure bullets start immediately after + if (blockText.toLowerCase() === "key features:") { + output.push(

    {content}

    ); + pendingExtraMarginForNextList = false; + pendingZeroMarginForNextList = true; + } else { + // Detect lead-in headings like "Interior & Features:" followed by normal text in same paragraph + const firstChild = (block.children || [])[0]; + const isLeadInHeading = ( + firstChild && + Array.isArray(firstChild.marks) && + firstChild.marks.includes("strong") && + /:\\s*$/.test((firstChild.text || "")) + ) || ( + firstChild && + Array.isArray(firstChild.marks) && + firstChild.marks.includes("strong") && + (block.children || []).length > 1 + ); + if (isLeadInHeading) { + // Keep only the lead-in span bold via marks; paragraph itself not bold + output.push(

    {content}

    ); + } else { + output.push(

    {content}

    ); + } + } + } + } + }); + + // If at end and still have list items, flush them + if (listType && listItems.length) { + const marginClass = currentListZeroMargin + ? "mt-0 mb-4" + : (currentListExtraMargin ? "mt-6 mb-4" : "mt-3 mb-4"); + output.push( + listType === "number" + ?
      <>{listItems}
    + :
      <>{listItems}
    + ); + } + + // Add Tailwind prose for beautiful, consistent typography + return
    {output}
    ; +} diff --git a/src/components/SectionHeader.js b/src/components/SectionHeader.js index 3683e81..d1fa34b 100644 --- a/src/components/SectionHeader.js +++ b/src/components/SectionHeader.js @@ -13,7 +13,8 @@ const fadeIn = { /** * SectionHeader - A reusable component for section headings - * @param {string} title - The title text (can include HTML for styling) + * @param {string|React.ReactNode} title - The title text (legacy; prefer titleJSX) + * @param {React.ReactNode} titleJSX - JSX title content (takes precedence over title) * @param {string} description - Optional description text below the title * @param {string} align - Text alignment ('center', 'left', or 'right') * @param {string} className - Additional CSS classes @@ -21,6 +22,7 @@ const fadeIn = { */ const SectionHeader = ({ title, + titleJSX = null, description, align = 'center', className = '' @@ -32,10 +34,7 @@ const SectionHeader = ({ right: 'text-right' }; - // Function to safely render HTML in the title - const createMarkup = (htmlContent) => { - return { __html: htmlContent }; - }; + // Deprecated: HTML title support; prefer JSX via titleJSX // Divider alignment classes const dividerClasses = { @@ -49,8 +48,9 @@ const SectionHeader = ({ + > + {titleJSX || title} + { + const { openModal } = useModal() + + const handleServiceClick = (serviceFormType = formType) => { + openModal({ type: serviceFormType }); + }; + + // Variant configurations - using consistent brand colors + const variantConfig = { + default: { + accentColor: "text-primary-600", + borderColor: "border-primary-600", + hoverBg: "hover:bg-primary-50", + iconBg: "bg-primary-100" + }, + supportive: { + accentColor: "text-primary-600", + borderColor: "border-primary-600", + hoverBg: "hover:bg-primary-50", + iconBg: "bg-primary-100" + } + }; + + const currentConfig = variantConfig[variant] || variantConfig.default; + + return ( +
    +
    + + {/* Section Header */} +
    + + {titleJSX || title} + + + {description && ( + + {description} + + )} +
    + + {/* Services Grid */} +
    + {services.map((service, index) => ( + handleServiceClick(service.formType)} + > + {/* Service Icon */} +
    + {service.icon && ( +
    + )} +
    + + {/* Service Content */} +

    + {service.title} +

    + +

    + {service.description} +

    + + {/* Benefits List */} + {service.benefits && service.benefits.length > 0 && ( +
      + {service.benefits.map((benefit, benefitIndex) => ( +
    • + + + + {benefit} +
    • + ))} +
    + )} + + {/* Stress Relief Indicator + {service.stressReliefLevel && ( +
    +
    + Stress Relief + {service.stressReliefLevel}% +
    +
    + +
    +
    + )} */} + + {/* Service Features Tags */} + {service.tags && service.tags.length > 0 && ( +
    + {service.tags.map((tag, tagIndex) => ( + + {tag} + + ))} +
    + )} + + {/* Service CTA */} +
    + {service.ctaText && ( + + {service.ctaText} + + )} + + + +
    + + ))} +
    + + {/* Problem/Solution Highlight */} + +
    +
    +

    + Stressed About Property Management? +

    +
      +
    • • Late night tenant calls
    • +
    • • Compliance headaches
    • +
    • • Maintenance emergencies
    • +
    • • Void periods and rent arrears
    • +
    +
    +
    +

    + We Handle Everything For You +

    +
      +
    • • 24/7 professional support
    • +
    • • Full regulatory compliance
    • +
    • • Trusted contractor network
    • +
    • • Guaranteed rent collection
    • +
    +
    +
    +
    + + {/* Overall CTA */} +
    + handleServiceClick()} + className="bg-primary-600 hover:bg-primary-700 text-white px-10 py-4 rounded-lg font-medium text-lg transition-colors shadow-lg" + > + Let Us Handle Everything + + + Free consultation • No obligation • Peace of mind guaranteed + +
    +
    +
    +
    + ) +} + +export default ServiceGrid diff --git a/src/components/ServiceTable.js b/src/components/ServiceTable.js new file mode 100644 index 0000000..5b37bc5 --- /dev/null +++ b/src/components/ServiceTable.js @@ -0,0 +1,292 @@ +import React, { useState } from 'react' +import { motion } from 'framer-motion' +import { useModal, FORM_TYPES } from '../context/modalContext' + +// Animation variants +const fadeIn = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.6 } + } +} + +const staggerChildren = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1 + } + } +} + +/** + * ServiceTable - Data-rich table layout for investor services with metrics + * @param {Array} services - Array of service objects + * @param {string} title - Section title + * @param {string} description - Section description + * @param {string} formType - Form type for CTAs + * @param {boolean} showComparison - Whether to show comparison table + */ +const ServiceTable = ({ + services = [], + title = "Investment Services", + description = "", + formType = FORM_TYPES.BROKER_REFERRAL, + showComparison = true +}) => { + const { openModal } = useModal() + const [selectedService, setSelectedService] = useState(null) + + const handleServiceClick = (serviceFormType = formType) => { + openModal({ type: serviceFormType }); + }; + + const handleServiceSelect = (serviceIndex) => { + setSelectedService(selectedService === serviceIndex ? null : serviceIndex); + }; + + return ( +
    +
    + + {/* Section Header */} +
    + + + {description && ( + + {description} + + )} +
    + + {/* Services Comparison Table */} + {showComparison && services.length > 0 && ( + +
    + + + + + + + + + + + + + {services.map((service, index) => ( + handleServiceSelect(index)} + role="button" + aria-label={`Select ${service.title} service for more details`} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + handleServiceSelect(index); + } + }} + > + + + + + + + + ))} + +
    Service TypeLTV RatioTypical RateMax TermSpeedAction
    +
    +
    + {service.icon && ( +
    + )} +
    +
    +
    {service.title}
    +
    {service.subtitle}
    +
    +
    +
    + {service.ltvRatio} + + {service.typicalRate} + + {service.maxTerm} + + + {service.speed} + + + +
    +
    +
    + )} + + {/* Detailed Service Cards */} +
    + {services.map((service, index) => ( + + {/* Service Header */} +
    +
    +
    + {service.icon && ( +
    + )} +
    +
    +

    {service.title}

    +

    {service.subtitle}

    +
    +
    + {service.featured && ( + + Popular + + )} +
    + + {/* Service Description */} +

    + {service.description} +

    + + {/* Key Metrics */} +
    +
    +
    {service.ltvRatio}
    +
    Max LTV
    +
    +
    +
    {service.typicalRate}
    +
    From
    +
    +
    + + {/* Features */} + {service.features && service.features.length > 0 && ( +
      + {service.features.map((feature, featureIndex) => ( +
    • + + + + {feature} +
    • + ))} +
    + )} + + {/* ROI Calculator placeholder */} + {service.showCalculator && ( +
    +
    + Quick ROI Calculator + + + +
    +

    + Calculate potential returns with this finance option +

    +
    + )} + + {/* CTA Button */} + + + ))} +
    + + {/* Market Insights Section */} + +
    +
    +
    £2.4M
    +
    Average Weekly Lending
    +
    +
    +
    48hrs
    +
    Average Decision Time
    +
    +
    +
    95%
    +
    Client Satisfaction
    +
    +
    +
    + + {/* Overall CTA */} +
    + handleServiceClick()} + className="bg-blue-600 hover:bg-blue-700 text-white px-10 py-4 rounded-full font-bold text-lg transition-colors shadow-lg" + > + Speak to Investment Specialist + + + Free consultation • Competitive rates • Expert advice + +
    +
    +
    +
    + ) +} + +export default ServiceTable diff --git a/src/components/ServiceTimeline.js b/src/components/ServiceTimeline.js new file mode 100644 index 0000000..49c0672 --- /dev/null +++ b/src/components/ServiceTimeline.js @@ -0,0 +1,174 @@ +import React from 'react' +import { motion } from 'framer-motion' +import { useModal, FORM_TYPES } from '../context/modalContext' + +// Animation variants +const fadeIn = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.6 } + } +} + +const staggerChildren = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.2 + } + } +} + +/** + * ServiceTimeline - Timeline layout for homeowner services + * @param {Array} services - Array of service objects + * @param {string|React.ReactNode} title - Section title (legacy; prefer titleJSX) + * @param {React.ReactNode} titleJSX - JSX title content (takes precedence over title) + * @param {string} description - Section description + * @param {string} formType - Form type for CTAs + */ +const ServiceTimeline = ({ + services = [], + title = "Our Services", + titleJSX = null, + description = "", + formType = FORM_TYPES.PROPERTY_SELLER +}) => { + const { openModal } = useModal() + + const handleServiceClick = (serviceFormType = formType) => { + openModal({ type: serviceFormType }); + }; + + return ( +
    +
    + + {/* Section Header */} +
    + + {titleJSX || title} + + + {description && ( + + {description} + + )} +
    + + {/* Timeline */} +
    + {/* Timeline line */} +
    + + {services.map((service, index) => ( + + {/* Timeline dot */} +
    + + {/* Content */} +
    +
    + {/* Service icon */} +
    + {service.icon && ( +
    + )} +
    + + {/* Service content */} +

    + {service.title} +

    + +

    + {service.description} +

    + + {/* Service features */} + {service.features && service.features.length > 0 && ( +
      + {service.features.map((feature, featureIndex) => ( +
    • + + + + {feature} +
    • + ))} +
    + )} + + {/* Timeline step number */} +
    + Step {index + 1} +
    + + {/* Service CTA */} + {service.ctaText && ( + + )} +
    +
    + + {/* Spacer for even layout on desktop */} +
    + + ))} +
    + + {/* Overall CTA */} +
    + handleServiceClick()} + className="bg-primary-600 hover:bg-primary-700 text-white px-10 py-4 rounded-full font-bold text-lg transition-colors shadow-lg" + > + Get Started Today + +
    +
    +
    +
    + ) +} + +export default ServiceTimeline diff --git a/src/components/ThumbnailScrollButtons.js b/src/components/ThumbnailScrollButtons.js new file mode 100644 index 0000000..be84cf6 --- /dev/null +++ b/src/components/ThumbnailScrollButtons.js @@ -0,0 +1,44 @@ +import React from 'react' +import { FaChevronLeft, FaChevronRight } from 'react-icons/fa' + +const ThumbnailScrollButtons = ({ + onScrollLeft, + onScrollRight, + canScrollLeft = true, + canScrollRight = true, + className = '' +}) => { + return ( +
    + {/* Left Scroll Button */} + + + {/* Right Scroll Button */} + +
    + ) +} + +export default ThumbnailScrollButtons diff --git a/src/components/drawer.js b/src/components/drawer.js index d6aaec8..bafdbc7 100755 --- a/src/components/drawer.js +++ b/src/components/drawer.js @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { Link } from "gatsby"; import { useDrawer } from "../context/drawerContext"; import Logo from "../images/logos/logo.svg"; @@ -6,6 +6,11 @@ import { motion, AnimatePresence } from "framer-motion"; const Drawer = ({ menu }) => { const { toggleDrawer } = useDrawer(); + const [expandedItem, setExpandedItem] = useState(null); + + const handleItemToggle = (itemName) => { + setExpandedItem(expandedItem === itemName ? null : itemName); + }; return ( @@ -72,14 +77,64 @@ const Drawer = ({ menu }) => { animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.1 * index, duration: 0.3 }} > - - {item.name} - + {item.hasDropdown ? ( +
    + + + + {expandedItem === item.name && ( + + {item.dropdownItems.map((dropdownItem, dropdownIndex) => ( + +
    {dropdownItem.name}
    + {dropdownItem.description && ( +
    + {dropdownItem.description} +
    + )} + + ))} +
    + )} +
    +
    + ) : ( + + {item.name} + + )} ))} diff --git a/src/components/footer.js b/src/components/footer.js index e97f3b4..62249ed 100755 --- a/src/components/footer.js +++ b/src/components/footer.js @@ -2,7 +2,6 @@ import React from 'react' import { useStaticQuery, graphql } from 'gatsby' import { motion } from 'framer-motion' import { useModal, FORM_TYPES } from '../context/modalContext' -import CTABanner from './CTABanner' import navItems from '../data/navItems.json' // Animation variants @@ -50,7 +49,7 @@ const Footer = () => { const { toggleModal } = useModal() return ( <> -
    + {/*
    { formType={FORM_TYPES.PROPERTY_SELLER} />
    -
    +
    */}