Skip to content
Merged
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
61 changes: 61 additions & 0 deletions src/components/PageBreadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';

interface PageBreadcrumbProps {
/**
* The current page title, will be shown as the last breadcrumb item
*/
currentPage: string;
/**
* Optional additional breadcrumb items between home and current page
*/
items?: Array<{
label: string;
href: string;
}>;
}

/**
* A reusable breadcrumb component for pages
* Always includes Home as the first item and current page as the last
*/
export const PageBreadcrumb = ({ currentPage, items = [] }: PageBreadcrumbProps) => {
const { t } = useTranslation();

return (
<div className="max-w-7xl mx-auto px-4 pt-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/">{t('navigation.home')}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
{items.map((item) => (
<React.Fragment key={item.href}>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={item.href}>{item.label}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
</React.Fragment>
))}
<BreadcrumbItem>
<BreadcrumbPage>{currentPage}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
);
};
24 changes: 6 additions & 18 deletions src/components/blog/CommentsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Loader2 } from 'lucide-react';
import { Link } from 'react-router-dom';
import { getNormalizedLocale } from '@/lib/i18n';
import { createDateFormatter } from '@/lib/i18n';

interface CommentsSectionProps {
postId: string;
Expand Down Expand Up @@ -47,26 +47,14 @@ const CommentsSection = ({ postId }: CommentsSectionProps) => {
const queryClient = useQueryClient();
const [newComment, setNewComment] = useState('');

const normalizedLocale = useMemo(
() => getNormalizedLocale(i18n.language),
const dateFormatter = useMemo(
() => createDateFormatter(i18n.language, {
dateStyle: 'medium',
timeStyle: 'short',
}),
[i18n.language]
);

const dateFormatter = useMemo(() => {
try {
return new Intl.DateTimeFormat(normalizedLocale, {
dateStyle: 'medium',
timeStyle: 'short',
});
} catch (error) {
console.error('Unsupported locale for comment date formatting', error);
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
});
}
}, [normalizedLocale]);

const {
data: comments,
isLoading,
Expand Down
49 changes: 49 additions & 0 deletions src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,52 @@ export const getNormalizedLocale = (language: string | undefined | null) => {
return language;
}
};

/**
* Creates a date formatter with fallback handling
* @param locale - The locale to use for formatting
* @param options - Intl.DateTimeFormat options
* @returns A DateTimeFormat instance with fallback to en-US on error
*/
export const createDateFormatter = (
locale: string | undefined | null,
options: Intl.DateTimeFormatOptions = { dateStyle: 'medium' }
): Intl.DateTimeFormat => {
const normalizedLocale = getNormalizedLocale(locale);

try {
return new Intl.DateTimeFormat(normalizedLocale, options);
} catch (error) {
console.error(`Unsupported locale for date formatting: ${normalizedLocale}`, error);
return new Intl.DateTimeFormat('en-US', options);
}
};

/**
* Formats a date string with fallback handling
* @param dateString - The date string to format
* @param formatter - The DateTimeFormat instance to use
* @param fallbackFormatter - Optional fallback formatter
* @returns Formatted date string or em-dash if invalid
*/
export const formatDate = (
dateString: string | null | undefined,
formatter: Intl.DateTimeFormat,
fallbackFormatter?: Intl.DateTimeFormat
): string => {
if (!dateString) return '—';

try {
return formatter.format(new Date(dateString));
} catch (error) {
console.error('Error formatting date', error);
if (fallbackFormatter) {
try {
return fallbackFormatter.format(new Date(dateString));
} catch {
return '—';
}
}
return '—';
}
};
107 changes: 107 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,110 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

/**
* Type guard to check if value is a non-empty string
*/
const isNonEmptyString = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;

/**
* Normalizes a value to an array of strings
* Handles JSON strings, arrays, and objects with an 'options' property
* @param value - The value to normalize
* @returns An array of unique non-empty strings
*/
export const normalizeToStringArray = (value: unknown): string[] => {
if (value === null || value === undefined) {
return [];
}

let parsedValue = value;

// Try to parse JSON string
if (typeof parsedValue === 'string') {
const trimmedValue = parsedValue.trim();
if (!trimmedValue) {
return [];
}

try {
parsedValue = JSON.parse(trimmedValue);
} catch {
return [trimmedValue];
}
}

// Handle arrays
if (Array.isArray(parsedValue)) {
return Array.from(
new Set(
parsedValue
.filter(isNonEmptyString)
.map((item) => item.trim())
)
);
}

// Handle objects with 'options' or 'stats' property
if (typeof parsedValue === 'object' && parsedValue !== null) {
const record = parsedValue as Record<string, unknown>;
if (Array.isArray(record.options)) {
return Array.from(
new Set(
record.options
.filter(isNonEmptyString)
.map((item) => item.trim())
)
);
}
}

return [];
};

/**
* Normalizes a value to an array of typed objects
* @param value - The value to normalize
* @param validator - Function to validate and transform each item
* @returns An array of validated objects
*/
export const normalizeToTypedArray = <T>(
value: unknown,
validator: (item: unknown) => T | null
): T[] => {
if (!value) {
return [];
}

let parsedValue: unknown = value;

// Try to parse JSON string
if (typeof parsedValue === 'string') {
try {
parsedValue = JSON.parse(parsedValue);
} catch {
return [];
}
}

// Handle arrays
if (Array.isArray(parsedValue)) {
return parsedValue
.map(validator)
.filter((item): item is T => item !== null);
}

// Handle objects with nested arrays
if (typeof parsedValue === 'object' && parsedValue !== null) {
const record = parsedValue as Record<string, unknown>;
if (Array.isArray(record.stats)) {
return normalizeToTypedArray(record.stats, validator);
}
if (Array.isArray(record.options)) {
return normalizeToTypedArray(record.options, validator);
}
}

return [];
};
62 changes: 16 additions & 46 deletions src/pages/About.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { supabase } from '@/integrations/supabase';
import { useTranslation } from 'react-i18next';
import { normalizeToTypedArray } from '@/lib/utils';

interface TeamMemberCard {
id: string;
Expand All @@ -22,51 +23,6 @@ interface AboutStat {
label: string;
}

const normalizeAboutStats = (value: unknown): AboutStat[] => {
if (!value) {
return [];
}

let parsedValue: unknown = value;

if (typeof parsedValue === 'string') {
try {
parsedValue = JSON.parse(parsedValue);
} catch {
return [];
}
}

if (Array.isArray(parsedValue)) {
return parsedValue
.map((item) => {
if (item && typeof item === 'object') {
const record = item as Record<string, unknown>;
const number = record.number;
const label = record.label;

if ((typeof number === 'string' || typeof number === 'number') && typeof label === 'string') {
return {
number: number.toString(),
label,
} satisfies AboutStat;
}
}
return null;
})
.filter((stat): stat is AboutStat => stat !== null);
}

if (typeof parsedValue === 'object' && parsedValue !== null) {
const record = parsedValue as Record<string, unknown>;
if (Array.isArray(record.stats)) {
return normalizeAboutStats(record.stats);
}
}

return [];
};

const About = () => {
const { t } = useTranslation();
const values = useMemo(
Expand Down Expand Up @@ -133,7 +89,21 @@ const About = () => {

if (error) throw error;

return normalizeAboutStats(data?.value);
return normalizeToTypedArray<AboutStat>(data?.value, (item) => {
if (item && typeof item === 'object') {
const record = item as Record<string, unknown>;
const number = record.number;
const label = record.label;

if ((typeof number === 'string' || typeof number === 'number') && typeof label === 'string') {
return {
number: number.toString(),
label,
};
}
}
return null;
});
},
});

Expand Down
Loading
Loading