diff --git a/src/components/PageTitle/PageTitle.tsx b/src/components/PageTitle/PageTitle.tsx index 61a2bd8..7de2461 100644 --- a/src/components/PageTitle/PageTitle.tsx +++ b/src/components/PageTitle/PageTitle.tsx @@ -3,23 +3,6 @@ import { Helmet } from "react-helmet-async"; const DEFAULT_TITLE = "UpLine"; const TITLE_SEPARATOR = " - "; -/** - * Utility function to build title from parts - */ -export function buildTitle(title?: string, prefix?: string): string { - const parts = [DEFAULT_TITLE]; - - if (title) { - parts.unshift(title); - } - - if (prefix) { - parts.unshift(prefix); - } - - return parts.join(TITLE_SEPARATOR); -} - /** * Component to set the page title and meta tags using Helmet */ @@ -31,7 +14,6 @@ interface PageTitleProps { export function PageTitle({ title, prefix, description }: PageTitleProps) { const fullTitle = buildTitle(title, prefix); - return ( {fullTitle} @@ -43,3 +25,44 @@ export function PageTitle({ title, prefix, description }: PageTitleProps) { ); } + +/** + * Get environment prefix based on current hostname + */ +function getEnvironmentPrefix(): string | undefined { + if (typeof window === "undefined") return undefined; + + const hostname = window.location.hostname; + + if (hostname === "localhost" || hostname === "127.0.0.1") { + return "DEV"; + } + + if (!hostname.includes("getupline.com")) { + return "STAG"; + } + + return undefined; +} + +/** + * Utility function to build title from parts + */ +function buildTitle(title?: string, prefix?: string): string { + const parts = [DEFAULT_TITLE]; + + if (title) { + parts.unshift(title); + } + + if (prefix) { + parts.unshift(prefix); + } + + const envPrefix = getEnvironmentPrefix(); + if (envPrefix) { + parts.unshift(envPrefix); + } + + return parts.join(TITLE_SEPARATOR); +} diff --git a/src/lib/__tests__/slug.test.ts b/src/lib/__tests__/slug.test.ts new file mode 100644 index 0000000..23ba59f --- /dev/null +++ b/src/lib/__tests__/slug.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { generateSlug, isValidSlug, sanitizeSlug } from "../slug"; + +describe("generateSlug", () => { + it("converts basic text to lowercase slug", () => { + expect(generateSlug("Hello World")).toBe("hello-world"); + expect(generateSlug("Test String")).toBe("test-string"); + }); + + it("handles special characters", () => { + expect(generateSlug("Hello & World!")).toBe("hello-world"); + expect(generateSlug("Test@#$%String")).toBe("test-string"); + expect(generateSlug("Special (chars) [here]")).toBe("special-chars-here"); + }); + + it("handles multiple spaces and whitespace", () => { + expect(generateSlug(" Multiple Spaces ")).toBe("multiple-spaces"); + expect(generateSlug("\t\nTabs and\r\nNewlines\t")).toBe( + "tabs-and-newlines", + ); + }); + + it("collapses multiple hyphens", () => { + expect(generateSlug("Test---Multiple--Hyphens")).toBe( + "test-multiple-hyphens", + ); + expect(generateSlug("A----B----C")).toBe("a-b-c"); + }); + + it("removes leading and trailing hyphens", () => { + expect(generateSlug("-Leading Hyphen")).toBe("leading-hyphen"); + expect(generateSlug("Trailing Hyphen-")).toBe("trailing-hyphen"); + expect(generateSlug("--Both Sides--")).toBe("both-sides"); + }); + + it("handles numbers correctly", () => { + expect(generateSlug("Test 123 Numbers")).toBe("test-123-numbers"); + expect(generateSlug("2024 Festival Edition")).toBe("2024-festival-edition"); + }); + + it("handles edge cases", () => { + expect(generateSlug("")).toBe(""); + expect(generateSlug(" ")).toBe(""); + expect(generateSlug("---")).toBe(""); + expect(generateSlug("@#$%")).toBe(""); + }); + + it("handles unicode characters", () => { + expect(generateSlug("Café Münchën")).toBe("caf-m-nch-n"); + expect(generateSlug("测试 Test")).toBe("test"); + }); + + it("preserves existing hyphens appropriately", () => { + expect(generateSlug("Pre-existing-hyphens")).toBe("pre-existing-hyphens"); + expect(generateSlug("Mix of-hyphens and spaces")).toBe( + "mix-of-hyphens-and-spaces", + ); + }); +}); + +describe("isValidSlug", () => { + it("validates correct slugs", () => { + expect(isValidSlug("hello-world")).toBe(true); + expect(isValidSlug("test-123")).toBe(true); + expect(isValidSlug("simple")).toBe(true); + expect(isValidSlug("2024-festival")).toBe(true); + expect(isValidSlug("a-b-c-d")).toBe(true); + }); + + it("rejects invalid slugs", () => { + expect(isValidSlug("")).toBe(false); + expect(isValidSlug("-leading-hyphen")).toBe(false); + expect(isValidSlug("trailing-hyphen-")).toBe(false); + expect(isValidSlug("UPPERCASE")).toBe(false); + expect(isValidSlug("with spaces")).toBe(false); + expect(isValidSlug("special!chars")).toBe(false); + expect(isValidSlug("multiple--hyphens")).toBe(false); + }); + + it("handles edge cases", () => { + expect(isValidSlug("a")).toBe(true); + expect(isValidSlug("1")).toBe(true); + expect(isValidSlug("-")).toBe(false); + expect(isValidSlug("--")).toBe(false); + }); +}); + +describe("sanitizeSlug", () => { + it("is an alias for generateSlug", () => { + const testCases = [ + "Hello World", + "Special!@#Characters", + " Multiple Spaces ", + "2024-Festival-Edition", + ]; + + testCases.forEach((testCase) => { + expect(sanitizeSlug(testCase)).toBe(generateSlug(testCase)); + }); + }); + + it("produces valid slugs", () => { + const inputs = [ + "Random Input!", + "123 Test Case", + "Special @#$ Characters", + " Whitespace ", + ]; + + inputs.forEach((input) => { + const result = sanitizeSlug(input); + if (result !== "") { + expect(isValidSlug(result)).toBe(true); + } + }); + }); +}); diff --git a/src/pages/EditionView/EditionView.tsx b/src/pages/EditionView/EditionView.tsx index 1b9e859..fc356ed 100644 --- a/src/pages/EditionView/EditionView.tsx +++ b/src/pages/EditionView/EditionView.tsx @@ -1,5 +1,5 @@ import { AppHeader } from "@/components/layout/AppHeader"; -import { MainTabNavigation } from "./MainTabNavigation"; +import { MainTabNavigation } from "./TabNavigation/TabNavigation"; import ErrorBoundary from "@/components/ErrorBoundary"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { Outlet } from "react-router-dom"; diff --git a/src/pages/EditionView/MainTabNavigation.tsx b/src/pages/EditionView/MainTabNavigation.tsx deleted file mode 100644 index 3940cb4..0000000 --- a/src/pages/EditionView/MainTabNavigation.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { cn } from "@/lib/utils"; -import { Calendar, List, Info } from "lucide-react"; -import { NavLink } from "react-router-dom"; -import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; - -export type MainTab = "sets" | "schedule" | "map" | "info" | "social"; - -const TAB_CONFIG = { - sets: { - icon: List, - label: "Vote", - shortLabel: "Vote", - disabled: false, - }, - schedule: { - icon: Calendar, - label: "Schedule", - shortLabel: "Schedule", - disabled: false, - }, - // map: { - // icon: Map, - // label: "Map", - // shortLabel: "Map", - // disabled: false, - // }, - info: { - icon: Info, - label: "Info", - shortLabel: "Info", - disabled: false, - }, - // social: { - // icon: MessageSquare, - // label: "Social", - // shortLabel: "Social", - // disabled: true, - // }, -} as const; - -export function MainTabNavigation() { - const { basePath } = useFestivalEdition(); - - return ( - <> - {/* Desktop: Horizontal tabs at top */} -
-
-
- {Object.entries(TAB_CONFIG).map(([tabKey, config]) => { - const tab = tabKey as MainTab; - - return ( - - cn( - ` - flex items-center justify-center gap-2 - px-6 py-3 rounded-lg - transition-all duration-200 active:scale-95`, - isActive - ? "bg-purple-600 text-white shadow-lg" - : "text-purple-200 hover:text-white hover:bg-white/10", - config.disabled ? "cursor-not-allowed opacity-50" : "", - ) - } - > - - {config.label} - - ); - })} -
-
-
- - {/* Mobile: Fixed bottom navigation */} -
-
- {Object.entries(TAB_CONFIG).map(([tabKey, config]) => { - const tab = tabKey as MainTab; - - return ( - ` - flex-1 flex flex-col items-center justify-center - py-2 px-1 transition-colors duration-200 min-h-16 - ${ - isActive - ? "text-purple-400" - : "text-gray-400 active:text-purple-300" - } - ${config.disabled ? "cursor-not-allowed opacity-50" : ""} - `} - > - {({ isActive }) => ( - <> - - - {config.shortLabel} - - - )} - - ); - })} -
-
- - ); -} diff --git a/src/pages/EditionView/TabNavigation/DesktopTabButton.tsx b/src/pages/EditionView/TabNavigation/DesktopTabButton.tsx new file mode 100644 index 0000000..7f9b27c --- /dev/null +++ b/src/pages/EditionView/TabNavigation/DesktopTabButton.tsx @@ -0,0 +1,26 @@ +import { cn } from "@/lib/utils"; +import { NavLink } from "react-router-dom"; +import { TabButtonProps } from "./types"; + +export function DesktopTabButton({ config, basePath }: TabButtonProps) { + return ( + + cn( + ` + flex items-center justify-center gap-2 + px-6 py-3 rounded-lg + transition-all duration-200 active:scale-95`, + isActive + ? "bg-purple-600 text-white shadow-lg" + : "text-purple-200 hover:text-white hover:bg-white/10", + ) + } + > + + {config.label} + + ); +} diff --git a/src/pages/EditionView/TabNavigation/MobileTabButton.tsx b/src/pages/EditionView/TabNavigation/MobileTabButton.tsx new file mode 100644 index 0000000..d77f95e --- /dev/null +++ b/src/pages/EditionView/TabNavigation/MobileTabButton.tsx @@ -0,0 +1,29 @@ +import { NavLink } from "react-router-dom"; +import { TabButtonProps } from "./types"; + +export function MobileTabButton({ config, basePath }: TabButtonProps) { + return ( + ` + flex-1 flex flex-col items-center justify-center + py-2 px-1 transition-colors duration-200 min-h-16 + ${isActive ? "text-purple-400" : "text-gray-400 active:text-purple-300"} + `} + > + {({ isActive }) => ( + <> + + + {config.shortLabel} + + + )} + + ); +} diff --git a/src/pages/EditionView/TabNavigation/TabNavigation.tsx b/src/pages/EditionView/TabNavigation/TabNavigation.tsx new file mode 100644 index 0000000..df6667a --- /dev/null +++ b/src/pages/EditionView/TabNavigation/TabNavigation.tsx @@ -0,0 +1,49 @@ +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +import { useFestivalInfoQuery } from "@/hooks/queries/festival-info/useFestivalInfo"; +import { DesktopTabButton } from "./DesktopTabButton"; +import { MobileTabButton } from "./MobileTabButton"; +import { config } from "./config"; + +export function MainTabNavigation() { + const { basePath, festival } = useFestivalEdition(); + const { data: festivalInfo } = useFestivalInfoQuery(festival?.id); + + const visibleTabs = config.filter((config) => { + if (typeof config.enabled === "boolean") { + return config.enabled; + } + return config.enabled(festivalInfo); + }); + + return ( + <> + {/* Desktop: Horizontal tabs at top */} +
+
+
+ {visibleTabs.map((config) => ( + + ))} +
+
+
+ + {/* Mobile: Fixed bottom navigation */} +
+
+ {visibleTabs.map((config) => ( + + ))} +
+
+ + ); +} diff --git a/src/pages/EditionView/TabNavigation/config.ts b/src/pages/EditionView/TabNavigation/config.ts new file mode 100644 index 0000000..05139cb --- /dev/null +++ b/src/pages/EditionView/TabNavigation/config.ts @@ -0,0 +1,47 @@ +import { + CalendarIcon, + InfoIcon, + ListIcon, + MapIcon, + MessageSquareIcon, +} from "lucide-react"; +import { TabConfig } from "./types"; + +export const config: TabConfig[] = [ + { + key: "sets", + icon: ListIcon, + label: "Vote", + shortLabel: "Vote", + enabled: true, + }, + { + key: "schedule", + icon: CalendarIcon, + label: "Schedule", + shortLabel: "Schedule", + enabled: true, + }, + { + key: "map", + icon: MapIcon, + label: "Map", + shortLabel: "Map", + enabled: (festivalInfo) => !!festivalInfo?.map_image_url, + }, + { + key: "info", + icon: InfoIcon, + label: "Info", + shortLabel: "Info", + enabled: (festivalInfo) => !!festivalInfo?.info_text, + }, + { + key: "social", + icon: MessageSquareIcon, + label: "Social", + shortLabel: "Social", + enabled: (festivalInfo) => + !!(festivalInfo?.facebook_url || festivalInfo?.instagram_url), + }, +]; diff --git a/src/pages/EditionView/TabNavigation/types.ts b/src/pages/EditionView/TabNavigation/types.ts new file mode 100644 index 0000000..4deed3d --- /dev/null +++ b/src/pages/EditionView/TabNavigation/types.ts @@ -0,0 +1,17 @@ +import { FestivalInfo } from "@/hooks/queries/festival-info/useFestivalInfo"; +import { LucideIcon } from "lucide-react"; + +export type MainTab = "sets" | "schedule" | "map" | "info" | "social"; + +export type TabConfig = { + key: MainTab; + icon: LucideIcon; + label: string; + shortLabel: string; + enabled: boolean | ((festivalInfo?: FestivalInfo) => boolean); +}; + +export interface TabButtonProps { + config: TabConfig; + basePath: string; +} diff --git a/src/pages/EditionView/tabs/SocialTab.tsx b/src/pages/EditionView/tabs/SocialTab.tsx index ab6af60..dbe2879 100644 --- a/src/pages/EditionView/tabs/SocialTab.tsx +++ b/src/pages/EditionView/tabs/SocialTab.tsx @@ -1,26 +1,7 @@ import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { useFestivalInfoQuery } from "@/hooks/queries/festival-info/useFestivalInfo"; import { PageTitle } from "@/components/PageTitle/PageTitle"; - -function extractFacebookPageId(url: string): string | null { - // Extract Facebook page ID/username from various Facebook URL formats - const patterns = [/facebook\.com\/([^/?]+)/, /fb\.com\/([^/?]+)/]; - - for (const pattern of patterns) { - const match = url.match(pattern); - if (match) { - return match[1]; - } - } - return null; -} - -function extractInstagramUsername(url: string): string | null { - // Extract Instagram username from URL - const pattern = /instagram\.com\/([^/?]+)/; - const match = url.match(pattern); - return match ? match[1] : null; -} +import { ExternalLinkIcon } from "lucide-react"; export function SocialTab() { const { festival } = useFestivalEdition(); @@ -37,14 +18,8 @@ export function SocialTab() { ); } - const facebookPageId = festivalInfo.facebook_url - ? extractFacebookPageId(festivalInfo.facebook_url) - : null; - const instagramUsername = festivalInfo.instagram_url - ? extractInstagramUsername(festivalInfo.instagram_url) - : null; - - if (!facebookPageId && !instagramUsername) { + const { facebook_url, instagram_url } = festivalInfo; + if (!facebook_url && !instagram_url) { return ( <> @@ -58,96 +33,45 @@ export function SocialTab() { return ( <> - <>Unavailable - - ); - - // return ( - //
- //
- //

Follow Us

- //
- - //
- // {/* Facebook Embed */} - // {facebookPageId && festivalInfo.facebook_url && ( - //
- //
- //

Facebook

- // - // View on Facebook - // - // - //
- - //
- // +
+
+ )} +
+
+ + ); } diff --git a/src/pages/FestivalSelection.tsx b/src/pages/FestivalSelection.tsx index a3cf0b6..c859508 100644 --- a/src/pages/FestivalSelection.tsx +++ b/src/pages/FestivalSelection.tsx @@ -16,6 +16,7 @@ import { } from "@/lib/subdomain"; import { Link } from "react-router-dom"; import { useCustomLinksQuery } from "@/hooks/queries/custom-links/useCustomLinks"; +import { PageTitle } from "@/components/PageTitle/PageTitle"; export default function FestivalSelection() { const { data: availableFestivals = [], isLoading: festivalsLoading } = @@ -50,11 +51,8 @@ export default function FestivalSelection() { return (
- + +
@@ -105,7 +103,10 @@ function FestivalCard({ festival }: { festival: Festival }) { const websiteUrl = customLinksQuery.data?.[0]?.url; return ( - +