From 6e43d0d170f4c8ac77b4cfd577059e3fa9c73e51 Mon Sep 17 00:00:00 2001 From: Tshepang Date: Mon, 25 May 2026 18:44:56 +0200 Subject: [PATCH 1/6] =?UTF-8?q?test(marketing-shell):=20add=20navigation?= =?UTF-8?q?=20view=20model=20tests=20=E2=80=94=20RED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...createMarketingNavigationViewModel.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.test.ts diff --git a/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.test.ts b/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.test.ts new file mode 100644 index 0000000..95668e3 --- /dev/null +++ b/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { + MARKETING_FOOTER_LINKS, + MARKETING_NAVIGATION_ITEM_IDS, + MARKETING_NAVIGATION_ITEMS, +} from "../config"; +import { createMarketingNavigationViewModel } from "./createMarketingNavigationViewModel"; + +describe("createMarketingNavigationViewModel", () => { + it("marks the active navigation item", () => { + const viewModel = createMarketingNavigationViewModel({ + activeNavigationItemId: MARKETING_NAVIGATION_ITEM_IDS.CONCEPTS, + footerLinks: MARKETING_FOOTER_LINKS, + navigationItems: MARKETING_NAVIGATION_ITEMS, + }); + + expect( + viewModel.navigationItems.find( + (item) => item.id === MARKETING_NAVIGATION_ITEM_IDS.CONCEPTS, + )?.isActive, + ).toBe(true); + expect( + viewModel.navigationItems.find( + (item) => item.id === MARKETING_NAVIGATION_ITEM_IDS.GUIDE, + )?.isActive, + ).toBe(false); + }); + + it("leaves all navigation items inactive when active item is null", () => { + const viewModel = createMarketingNavigationViewModel({ + activeNavigationItemId: null, + footerLinks: MARKETING_FOOTER_LINKS, + navigationItems: MARKETING_NAVIGATION_ITEMS, + }); + + expect(viewModel.navigationItems.every((item) => !item.isActive)).toBe( + true, + ); + }); +}); From 822f46dec447ea4cf3ed1b8c6d5ed3ba3b4f8fe2 Mon Sep 17 00:00:00 2001 From: Tshepang Date: Mon, 25 May 2026 18:45:11 +0200 Subject: [PATCH 2/6] feat(marketing-shell): add navigation config and model --- src/widgets/marketing-shell/config/index.ts | 8 +++ .../config/marketingShellConfig.ts | 59 +++++++++++++++++++ .../lib/createMarketingNavigationViewModel.ts | 47 +++++++++++++++ src/widgets/marketing-shell/lib/index.ts | 7 +++ src/widgets/marketing-shell/model/index.ts | 1 + .../model/useMarketingNavigation.ts | 16 +++++ 6 files changed, 138 insertions(+) create mode 100644 src/widgets/marketing-shell/config/index.ts create mode 100644 src/widgets/marketing-shell/config/marketingShellConfig.ts create mode 100644 src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.ts create mode 100644 src/widgets/marketing-shell/lib/index.ts create mode 100644 src/widgets/marketing-shell/model/index.ts create mode 100644 src/widgets/marketing-shell/model/useMarketingNavigation.ts diff --git a/src/widgets/marketing-shell/config/index.ts b/src/widgets/marketing-shell/config/index.ts new file mode 100644 index 0000000..c248557 --- /dev/null +++ b/src/widgets/marketing-shell/config/index.ts @@ -0,0 +1,8 @@ +export { + MARKETING_FOOTER_LINKS, + MARKETING_NAVIGATION_ITEM_IDS, + MARKETING_NAVIGATION_ITEMS, + MARKETING_ROUTES, + MARKETING_SHELL_COPY, + type MarketingNavigationItemId, +} from "./marketingShellConfig"; diff --git a/src/widgets/marketing-shell/config/marketingShellConfig.ts b/src/widgets/marketing-shell/config/marketingShellConfig.ts new file mode 100644 index 0000000..b9c09cf --- /dev/null +++ b/src/widgets/marketing-shell/config/marketingShellConfig.ts @@ -0,0 +1,59 @@ +export const MARKETING_NAVIGATION_ITEM_IDS = { + GUIDE: "guide", + CONCEPTS: "concepts", +} as const; + +export type MarketingNavigationItemId = + (typeof MARKETING_NAVIGATION_ITEM_IDS)[keyof typeof MARKETING_NAVIGATION_ITEM_IDS]; + +export const MARKETING_ROUTES = { + HOME: "/", + GAME: "/game", + GUIDE: "/tutorial", + CONCEPTS: "/concepts", + GITHUB_REPOSITORY: "https://github.com/Teeldinho/runestone", +} as const; + +export const MARKETING_SHELL_COPY = { + BRAND_NAME: "RUNESTONE", + BRAND_RUNE_SEGMENT: "RUNE", + BRAND_STONE_SEGMENT: "STONE", + COMPACT_BRAND_RUNE_SEGMENT: "R", + COMPACT_BRAND_STONE_SEGMENT: "S", + ENTER_DUNGEON_LABEL: "Enter Dungeon", + DRAWER_TITLE: "Runestone", + DRAWER_DESCRIPTION: "Navigate the playable architecture guide.", + FOOTER_COPYRIGHT: "© 2026 Runestone", + FOOTER_TAGLINE: "Playable architecture", +} as const; + +export const MARKETING_NAVIGATION_ITEMS = [ + { + id: MARKETING_NAVIGATION_ITEM_IDS.GUIDE, + label: "Guide", + to: MARKETING_ROUTES.GUIDE, + }, + { + id: MARKETING_NAVIGATION_ITEM_IDS.CONCEPTS, + label: "Concepts", + to: MARKETING_ROUTES.CONCEPTS, + }, +] as const; + +export const MARKETING_FOOTER_LINKS = [ + { + label: "Guide", + to: MARKETING_ROUTES.GUIDE, + type: "internal", + }, + { + label: "Concepts", + to: MARKETING_ROUTES.CONCEPTS, + type: "internal", + }, + { + label: "GitHub", + to: MARKETING_ROUTES.GITHUB_REPOSITORY, + type: "external", + }, +] as const; diff --git a/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.ts b/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.ts new file mode 100644 index 0000000..fe594c2 --- /dev/null +++ b/src/widgets/marketing-shell/lib/createMarketingNavigationViewModel.ts @@ -0,0 +1,47 @@ +import type { + MARKETING_FOOTER_LINKS, + MARKETING_NAVIGATION_ITEMS, + MarketingNavigationItemId, +} from "../config"; + +type MarketingNavigationItem = (typeof MARKETING_NAVIGATION_ITEMS)[number]; + +type MarketingFooterLink = (typeof MARKETING_FOOTER_LINKS)[number]; + +type CreateMarketingNavigationViewModelInput = { + activeNavigationItemId: MarketingNavigationItemId | null; + footerLinks: readonly MarketingFooterLink[]; + navigationItems: readonly MarketingNavigationItem[]; +}; + +type MarketingNavigationLinkViewModel = MarketingNavigationItem & { + isActive: boolean; +}; + +type MarketingFooterLinkViewModel = MarketingFooterLink; + +type MarketingNavigationViewModel = { + footerLinks: readonly MarketingFooterLinkViewModel[]; + navigationItems: readonly MarketingNavigationLinkViewModel[]; +}; + +export const createMarketingNavigationViewModel = ({ + activeNavigationItemId, + footerLinks, + navigationItems, +}: CreateMarketingNavigationViewModelInput): MarketingNavigationViewModel => { + return { + footerLinks, + navigationItems: navigationItems.map((item) => ({ + ...item, + isActive: item.id === activeNavigationItemId, + })), + }; +}; + +export type { + CreateMarketingNavigationViewModelInput, + MarketingFooterLinkViewModel, + MarketingNavigationLinkViewModel, + MarketingNavigationViewModel, +}; diff --git a/src/widgets/marketing-shell/lib/index.ts b/src/widgets/marketing-shell/lib/index.ts new file mode 100644 index 0000000..f9cb151 --- /dev/null +++ b/src/widgets/marketing-shell/lib/index.ts @@ -0,0 +1,7 @@ +export { + type CreateMarketingNavigationViewModelInput, + createMarketingNavigationViewModel, + type MarketingFooterLinkViewModel, + type MarketingNavigationLinkViewModel, + type MarketingNavigationViewModel, +} from "./createMarketingNavigationViewModel"; diff --git a/src/widgets/marketing-shell/model/index.ts b/src/widgets/marketing-shell/model/index.ts new file mode 100644 index 0000000..dee1dfa --- /dev/null +++ b/src/widgets/marketing-shell/model/index.ts @@ -0,0 +1 @@ +export { useMarketingNavigation } from "./useMarketingNavigation"; diff --git a/src/widgets/marketing-shell/model/useMarketingNavigation.ts b/src/widgets/marketing-shell/model/useMarketingNavigation.ts new file mode 100644 index 0000000..95af245 --- /dev/null +++ b/src/widgets/marketing-shell/model/useMarketingNavigation.ts @@ -0,0 +1,16 @@ +import { + MARKETING_FOOTER_LINKS, + MARKETING_NAVIGATION_ITEMS, + type MarketingNavigationItemId, +} from "../config"; +import { createMarketingNavigationViewModel } from "../lib"; + +export const useMarketingNavigation = ( + activeNavigationItemId: MarketingNavigationItemId | null, +) => { + return createMarketingNavigationViewModel({ + activeNavigationItemId, + footerLinks: MARKETING_FOOTER_LINKS, + navigationItems: MARKETING_NAVIGATION_ITEMS, + }); +}; From 88c1676f61ac5685c49e6aa39da0c8b2193044c9 Mon Sep 17 00:00:00 2001 From: Tshepang Date: Mon, 25 May 2026 18:45:22 +0200 Subject: [PATCH 3/6] feat(marketing-shell): add shared shell ui --- src/widgets/marketing-shell/index.ts | 6 ++ .../marketing-shell/ui/MarketingFooter.tsx | 46 ++++++++ .../marketing-shell/ui/MarketingHeader.tsx | 94 ++++++++++++++++ .../ui/MarketingNavigationDrawer.tsx | 102 ++++++++++++++++++ .../marketing-shell/ui/MarketingShell.tsx | 41 +++++++ .../marketing-shell/ui/RunestoneLogo.tsx | 59 ++++++++++ src/widgets/marketing-shell/ui/index.ts | 2 + 7 files changed, 350 insertions(+) create mode 100644 src/widgets/marketing-shell/index.ts create mode 100644 src/widgets/marketing-shell/ui/MarketingFooter.tsx create mode 100644 src/widgets/marketing-shell/ui/MarketingHeader.tsx create mode 100644 src/widgets/marketing-shell/ui/MarketingNavigationDrawer.tsx create mode 100644 src/widgets/marketing-shell/ui/MarketingShell.tsx create mode 100644 src/widgets/marketing-shell/ui/RunestoneLogo.tsx create mode 100644 src/widgets/marketing-shell/ui/index.ts diff --git a/src/widgets/marketing-shell/index.ts b/src/widgets/marketing-shell/index.ts new file mode 100644 index 0000000..614d3ee --- /dev/null +++ b/src/widgets/marketing-shell/index.ts @@ -0,0 +1,6 @@ +export { + MARKETING_NAVIGATION_ITEM_IDS, + MARKETING_ROUTES, + type MarketingNavigationItemId, +} from "./config"; +export { MarketingShell, RunestoneLogo } from "./ui"; diff --git a/src/widgets/marketing-shell/ui/MarketingFooter.tsx b/src/widgets/marketing-shell/ui/MarketingFooter.tsx new file mode 100644 index 0000000..1769942 --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingFooter.tsx @@ -0,0 +1,46 @@ +import { Link } from "@tanstack/react-router"; + +import { MARKETING_SHELL_COPY } from "../config"; +import type { MarketingNavigationViewModel } from "../lib"; + +type MarketingFooterProps = { + viewModel: MarketingNavigationViewModel; +}; + +export function MarketingFooter({ viewModel }: MarketingFooterProps) { + return ( +
+
+

{MARKETING_SHELL_COPY.FOOTER_COPYRIGHT}

+ + + +

+ {MARKETING_SHELL_COPY.FOOTER_TAGLINE} +

+
+
+ ); +} diff --git a/src/widgets/marketing-shell/ui/MarketingHeader.tsx b/src/widgets/marketing-shell/ui/MarketingHeader.tsx new file mode 100644 index 0000000..f870a4a --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingHeader.tsx @@ -0,0 +1,94 @@ +import { Link } from "@tanstack/react-router"; +import { DoorOpen } from "lucide-react"; + +import { cn } from "@/shared/lib"; +import { Button } from "@/shared/ui"; + +import { MARKETING_ROUTES, MARKETING_SHELL_COPY } from "../config"; +import type { MarketingNavigationViewModel } from "../lib"; +import { MarketingNavigationDrawer } from "./MarketingNavigationDrawer"; +import { RunestoneLogo } from "./RunestoneLogo"; + +type MarketingHeaderProps = { + isAuthenticated: boolean; + viewModel: MarketingNavigationViewModel; +}; + +export function MarketingHeader({ + isAuthenticated, + viewModel, +}: MarketingHeaderProps) { + return ( +
+
+
+ +
+
+ +
+ + + +
+ {isAuthenticated ? ( + + ) : ( + + )} + + {isAuthenticated ? ( + + ) : ( + + )} + + +
+
+
+ ); +} diff --git a/src/widgets/marketing-shell/ui/MarketingNavigationDrawer.tsx b/src/widgets/marketing-shell/ui/MarketingNavigationDrawer.tsx new file mode 100644 index 0000000..9b8552b --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingNavigationDrawer.tsx @@ -0,0 +1,102 @@ +import { Link } from "@tanstack/react-router"; +import { DoorOpen, Github, Menu } from "lucide-react"; + +import { cn } from "@/shared/lib"; +import { + Button, + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, + Separator, +} from "@/shared/ui"; + +import { MARKETING_ROUTES, MARKETING_SHELL_COPY } from "../config"; +import type { MarketingNavigationViewModel } from "../lib"; + +type MarketingNavigationDrawerProps = { + isAuthenticated: boolean; + viewModel: MarketingNavigationViewModel; +}; + +export function MarketingNavigationDrawer({ + isAuthenticated, + viewModel, +}: MarketingNavigationDrawerProps) { + return ( + + + + + + + {MARKETING_SHELL_COPY.DRAWER_TITLE} + + {MARKETING_SHELL_COPY.DRAWER_DESCRIPTION} + + + + + + + + + {isAuthenticated ? ( + + + + ) : ( + + )} + + + + ); +} diff --git a/src/widgets/marketing-shell/ui/MarketingShell.tsx b/src/widgets/marketing-shell/ui/MarketingShell.tsx new file mode 100644 index 0000000..3025412 --- /dev/null +++ b/src/widgets/marketing-shell/ui/MarketingShell.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; + +import type { MarketingNavigationItemId } from "../config"; +import { useMarketingNavigation } from "../model"; +import { MarketingFooter } from "./MarketingFooter"; +import { MarketingHeader } from "./MarketingHeader"; + +type MarketingShellProps = { + activeNavigationItemId: MarketingNavigationItemId | null; + children: ReactNode; + isAuthenticated: boolean; +}; + +export function MarketingShell({ + activeNavigationItemId, + children, + isAuthenticated, +}: MarketingShellProps) { + const viewModel = useMarketingNavigation(activeNavigationItemId); + + return ( +
+