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 && (
- //
- //
-
- //
- //
- //
- //
- // )}
+
+
+
Follow Us
+
- // {/* Instagram Embed */}
- // {festivalInfo.instagram_url && instagramUsername && (
- //
- //
+
+ {/* Facebook Embed */}
+ {festivalInfo.facebook_url && (
+
+
- //
- // {/* Instagram doesn't provide a direct embed like Facebook, so we show a link with preview */}
- //
- //
- //
@{instagramUsername}
- //
- // Follow us on Instagram for the latest updates and
- // behind-the-scenes content!
- //
- //
- // Follow on Instagram
- //
- //
- //
- //
- //
- // )}
- //
- //
- // );
+
+
+
+
+ )}
+
+
+ >
+ );
}
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 (
-
+