Skip to content
Merged

Dev #101

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/app/(main-layout)/(home)/blogs/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,14 @@ const BlogDetailsPage = () => {
title={`${blog.blogTitle} | Florida Yacht Trader`}
description={
blog.blogDescription
? blog.blogDescription.replace(/<[^>]*>/g, '').slice(0, 200)
? blog.blogDescription
.replace(/<\/p>/gi, '\n\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/&nbsp;/gi, ' ')
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 200)
: undefined
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,26 @@ const ItemDescriptions = ({ description }: ItemDescriptionsProps) => {
setOpenIndex((prev) => (prev === idx ? null : idx));
};

// Format description with line breaks preserved
const formatDescription = (text: string) => {
if (!text) return '';

// Replace newlines with <br> tags
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('<br /><br />');
};
Comment on lines +50 to +60

return (
<div className="px-1 md:px-4 py-5">
<h2 className="text-lg md:text-xl font-semibold text-black text-left">
Description
</h2>
<div
className="mt-3 text-sm md:text-base text-gray-500 prose prose-sm md:prose-base max-w-none"
dangerouslySetInnerHTML={{ __html: mainDescription }}
className="mt-3 text-sm md:text-base text-gray-700 leading-relaxed whitespace-pre-line"
dangerouslySetInnerHTML={{ __html: formatDescription(mainDescription) }}
/>

{sections.length > 0 && (
Expand All @@ -74,8 +86,10 @@ const ItemDescriptions = ({ description }: ItemDescriptionsProps) => {
</button>

<div
className={`${openIndex === idx ? 'block' : 'hidden'} px-2 pb-4 text-gray-600 prose prose-sm max-w-none`}
dangerouslySetInnerHTML={{ __html: item.answer }}
className={`${openIndex === idx ? 'block' : 'hidden'} px-2 pb-4 text-gray-600 whitespace-pre-line`}
dangerouslySetInnerHTML={{
__html: formatDescription(item.answer),
}}
/>
</div>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '@/types/boat-details-types';
import ItemDescriptions from './ItemDescriptions';
import ItemDetailsGallery from './ItemsDetailsGallery';
import ItemExtraDetails from './ItemExtraDetails';
import ItemSpecifications from './ItemSpecifications';
import ItemVideos from './ItemVideos';
import ShowItemsLocation from './ShowItemsLocation';
Expand All @@ -23,6 +24,7 @@ const ItemDetailsComponents = ({ boatDetails }: ItemDetailsComponentsProps) => {
<ItemDetailsGallery name={boatDetails.name} images={images} />
<ItemSpecifications specifications={specifications} />
<ItemDescriptions description={boatDetails.description} />
<ItemExtraDetails extraDetails={boatDetails.extraDetails} />
{boatDetails.videoURL && <ItemVideos videoURL={boatDetails.videoURL} />}
<ShowItemsLocation
city={boatDetails.city}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
'use client';
import { ExtraDetail } from '@/types/boat-details-types';
import { useMemo } from 'react';

interface ItemExtraDetailsProps {
extraDetails?: ExtraDetail[];
}

const ItemExtraDetails = ({ extraDetails }: ItemExtraDetailsProps) => {
const parsedSections = useMemo(() => {
if (!extraDetails || extraDetails.length === 0) return [];

const sections: {
title: string;
items: { key: string; value: string }[];
paragraphs: string[];
}[] = [];

extraDetails.forEach((detail) => {
const title = detail.key
Comment on lines +6 to +20
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())
.trim();

const content = detail.value || '';
const items: { key: string; value: string }[] = [];
const paragraphs: string[] = [];

// Split content by lines and process
const lines = content.split('\n').filter((line) => line.trim());
let i = 0;

while (i < lines.length) {
const line = lines[i].trim();

// Check if this line ends with colon (key) and next line is value
if (line.endsWith(':') && i + 1 < lines.length) {
const key = line.slice(0, -1).trim();
const value = lines[i + 1].trim();
if (value && !value.endsWith(':')) {
items.push({ key, value });
i += 2;
continue;
}
}

// Check for inline key:value pattern
const inlineMatch = line.match(/^([^:]+):\s*(.+)$/);
if (inlineMatch) {
const [, key, value] = inlineMatch;
if (key && value) {
items.push({ key: key.trim(), value: value.trim() });
i++;
continue;
}
}

// Check if line looks like a key (ends with colon, no value)
if (line.endsWith(':')) {
// Skip standalone keys without values
i++;
continue;
}

// Otherwise treat as paragraph
if (line.length > 50) {
paragraphs.push(line);
}
i++;
}

if (items.length > 0 || paragraphs.length > 0) {
sections.push({ title, items, paragraphs });
}
});

return sections;
}, [extraDetails]);

if (parsedSections.length === 0) {
return null;
}

return (
<div className="px-1 md:px-4 py-5">
<h2 className="text-lg md:text-xl font-semibold text-black text-left pb-3">
Additional Details
</h2>
<div className="bg-white rounded-2xl shadow border border-gray-100 overflow-hidden p-4 md:p-6">
{parsedSections.map((section, sectionIndex) => (
<div key={sectionIndex} className="mb-6 last:mb-0">
{section.title && (
<h3 className="text-base md:text-lg font-semibold text-gray-800 mb-3 pb-2 border-b border-gray-200">
{section.title}
</h3>
)}

{/* Grid layout for key-value pairs */}
{section.items.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-3 mb-4">
{section.items.map((item, itemIndex) => (
<div key={itemIndex} className="flex flex-col">
<span className="text-xs md:text-sm font-semibold text-gray-600 uppercase tracking-wide">
{item.key}
</span>
<span className="text-sm md:text-base text-gray-900 mt-1">
{item.value}
</span>
</div>
))}
</div>
)}

{/* Paragraphs */}
{section.paragraphs.length > 0 && (
<div className="space-y-3 text-sm md:text-base text-gray-700 leading-relaxed">
{section.paragraphs.map((para, paraIndex) => (
<p key={paraIndex}>{para}</p>
))}
</div>
)}
</div>
))}
</div>
</div>
);
};

export default ItemExtraDetails;
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,16 @@ const ItemDetailsGallery = ({ images, name }: ItemDetailsGalleryProps) => {
<button
onClick={prevImage}
className="absolute left-[4%] top-1/2 transform -translate-y-1/2 bg-gray-100 bg-opacity-50 text-black p-2 md:p-3 rounded-xl hover:bg-opacity-70 cursor-pointer"
aria-label="Previous image"
title="Previous image"
>
<FaArrowLeft className="w-5 h-5" />
</button>
<button
onClick={nextImage}
className="absolute right-[4%] top-1/2 transform -translate-y-1/2 bg-[#0064AE] bg-opacity-50 text-white p-2 md:p-3 rounded-xl hover:bg-opacity-70 cursor-pointer"
aria-label="Next image"
title="Next image"
>
<FaArrowRight className="w-5 h-5" />
</button>
Expand Down Expand Up @@ -109,6 +113,8 @@ const ItemDetailsGallery = ({ images, name }: ItemDetailsGalleryProps) => {
? 'border-[#0064AE]'
: 'border-gray-300'
}`}
aria-label={`View image ${actualIndex + 1}`}
title={`View image ${actualIndex + 1}`}
>
<Image
src={image}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { FaFacebookF, FaTwitter, FaWhatsapp } from 'react-icons/fa';
import { IoLocationOutline } from 'react-icons/io5';
import { MdContentCopy, MdEmail } from 'react-icons/md';
Expand All @@ -16,24 +16,49 @@ const ShowItemsLocation = ({
state = 'Florida',
name = 'Boat',
}: ShowItemsLocationProps) => {
const latitude = 40.594834;
const longitude = -73.510372;
const [, setCopied] = useState(false);

const shareUrl =
typeof window !== 'undefined'
? window.location.href
: 'https://floridayachttrader.com/boat-details/2011-viking-44';
const [coordinates, setCoordinates] = useState<{
lat: number;
lng: number;
} | null>(null);

const locationString = [city, state, 'USA'].filter(Boolean).join(', ');

const shareUrl = typeof window !== 'undefined' ? window.location.href : '';
const shareTitle = `${name} - Florida Yacht Trader`;
const shareText = `Check out this boat: ${name}`;

// Geocode location using Nominatim (OpenStreetMap)
useEffect(() => {
const geocodeLocation = async () => {
if (!locationString) return;
Comment on lines +24 to +33

try {
const response = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(locationString)}`,
);
const data = await response.json();
if (data && data[0]) {
setCoordinates({
lat: parseFloat(data[0].lat),
lng: parseFloat(data[0].lon),
});
}
} catch (error) {
console.error('Geocoding error:', error);
}
};

geocodeLocation();
Comment on lines +32 to +51
}, [locationString]);

const latitude = coordinates?.lat || 25.7617;
const longitude = coordinates?.lng || -80.1918;

const osmEmbedUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${longitude - 0.01},${latitude - 0.01},${longitude + 0.01},${latitude + 0.01}&layer=mapnik&marker=${latitude},${longitude}`;

const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success('Link copied to clipboard!');
} catch (err) {
console.error('Failed to copy:', err);
Expand Down
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Toaster } from 'sonner';
import ScrollToTop from '@/components/shared/ScrollToTop/ScrollToTop';

export const dynamic = 'force-dynamic';

Expand Down Expand Up @@ -32,6 +33,7 @@ export default async function RootLayout({
suppressHydrationWarning
>
<Providers token={token}>{children}</Providers>
<ScrollToTop />
<Toaster position="top-right" />
</body>
</html>
Expand Down
47 changes: 47 additions & 0 deletions src/components/shared/ScrollToTop/ScrollToTop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client';
import { useEffect, useState } from 'react';
import { FaArrowUp } from 'react-icons/fa';

const ScrollToTop = () => {
const [isVisible, setIsVisible] = useState(false);

useEffect(() => {
const toggleVisibility = () => {
if (window.scrollY > 300) {
setIsVisible(true);
} else {
setIsVisible(false);
}
};

window.addEventListener('scroll', toggleVisibility);
Comment on lines +10 to +17

return () => {
window.removeEventListener('scroll', toggleVisibility);
};
}, []);

const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};

return (
<>
{isVisible && (
<button
onClick={scrollToTop}
className="fixed left-6 bottom-6 z-50 bg-[#0064AE] hover:bg-[#004d87] text-white p-3 md:p-4 rounded-full shadow-lg transition-all duration-300 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-[#0064AE] focus:ring-offset-2"
aria-label="Scroll to top"
title="Scroll to top"
>
<FaArrowUp className="w-5 h-5 md:w-6 md:h-6" />
</button>
)}
</>
);
};

export default ScrollToTop;
11 changes: 0 additions & 11 deletions src/types/boat-details-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,17 +192,6 @@ export function transformBoatDetailsToSpecifications(
},
]
: []),
...(Array.isArray(boat.extraDetails)
? boat.extraDetails.map((detail) => ({
name: detail.key,
value: detail.value,
}))
: boat.extraDetails && typeof boat.extraDetails === 'object'
? Object.entries(boat.extraDetails).map(([key, value]) => ({
name: key,
value: String(value),
}))
: []),
];
}

Expand Down
Loading