diff --git a/apps/web/app/api/member/[id]/documents/[page]/route.ts b/apps/web/app/api/member/[id]/documents/[page]/route.ts
new file mode 100644
index 000000000..b64c12c58
--- /dev/null
+++ b/apps/web/app/api/member/[id]/documents/[page]/route.ts
@@ -0,0 +1,22 @@
+import getMemberDocuments from "@lib/api/documents/get-member-documents";
+
+interface Payload {
+ params: {
+ id: string;
+ page: string;
+ };
+}
+
+export async function GET(
+ _request: Request,
+ { params: { id, page } }: Payload,
+) {
+ const pageInt = parseInt(page);
+
+ if (Number.isNaN(pageInt)) {
+ return new Response("Invalid page", { status: 400 });
+ }
+
+ const memberDocuments = await getMemberDocuments({ id, page: pageInt });
+ return Response.json(memberDocuments);
+}
diff --git a/apps/web/app/cookie-policy/page.tsx b/apps/web/app/cookie-policy/page.tsx
index 57bb426a2..7ab09e23c 100644
--- a/apps/web/app/cookie-policy/page.tsx
+++ b/apps/web/app/cookie-policy/page.tsx
@@ -1,4 +1,4 @@
-import { Card } from "@components/card";
+import { Card } from "@components/common/card";
import Container from "@components/common/container";
import PageTitle from "@components/common/page-title";
import { CodeBracketIcon } from "@heroicons/react/24/solid";
diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx
index 253d8f1f5..2e3007c70 100644
--- a/apps/web/app/error.tsx
+++ b/apps/web/app/error.tsx
@@ -1,7 +1,7 @@
"use client";
-import { PrimaryButton } from "@components/button";
-import { Card } from "@components/card";
+import { PrimaryButton } from "@components/common/button";
+import { Card } from "@components/common/card";
import Container from "@components/common/container";
import PageTitle from "@components/common/page-title";
import { ExclamationCircleIcon } from "@heroicons/react/24/solid";
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 36b09ca0b..0bbddff8a 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -5,6 +5,7 @@ import Head from "./head";
import Footer from "./footer";
import Header from "@components/header/header";
import { ThemeProvider } from "@components/providers/theme-provider";
+import { twMerge } from "tailwind-merge";
const roboto = Roboto({
weight: ["300", "400", "500", "700"],
@@ -18,7 +19,10 @@ export default function RootLayout({ children }: PropsWithChildren) {
diff --git a/apps/web/app/ledamot/[id]/biography-entry.tsx b/apps/web/app/ledamot/[id]/biography-entry.tsx
new file mode 100644
index 000000000..4de60b74e
--- /dev/null
+++ b/apps/web/app/ledamot/[id]/biography-entry.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { ChevronDownIcon } from "@heroicons/react/24/solid";
+import type { Information } from "@lib/api/member/types";
+import { useState } from "react";
+
+interface Props {
+ information: Information;
+}
+
+export default function BiographyEntry({ information }: Props) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ function toggleEntry() {
+ setIsOpen((prevState) => !prevState);
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/app/ledamot/[id]/biography.tsx b/apps/web/app/ledamot/[id]/biography.tsx
new file mode 100644
index 000000000..8af125f53
--- /dev/null
+++ b/apps/web/app/ledamot/[id]/biography.tsx
@@ -0,0 +1,27 @@
+import { Card } from "@components/common/card";
+import type { Information } from "@lib/api/member/types";
+import BiographyEntry from "./biography-entry";
+
+interface Props {
+ memberInformation: Information[];
+}
+
+export default function Biography({ memberInformation }: Props) {
+ if (memberInformation.length === 0) {
+ return null;
+ }
+
+ return (
+
+ Biografi
+
+ {memberInformation.map((info) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/app/ledamot/[id]/documents.tsx b/apps/web/app/ledamot/[id]/documents.tsx
new file mode 100644
index 000000000..6b6c4393a
--- /dev/null
+++ b/apps/web/app/ledamot/[id]/documents.tsx
@@ -0,0 +1,73 @@
+"use client";
+import { Card, CommitteeHeader } from "@components/common/card";
+import Pagination from "@components/common/pagination";
+import type { MemberDocument, MemberDocuments } from "@lib/api/member/types";
+import { routes } from "@lib/navigation";
+import Link from "next/link";
+import { useRef, useState } from "react";
+
+interface DocumentProps {
+ document: MemberDocument;
+}
+
+function Document({ document }: DocumentProps) {
+ return (
+
+
+
+
+
+ {document.title}
+
+
{document.altTitle}
+
+ {document.subtitle}
+
+
+
+
+ );
+}
+
+interface Props {
+ memberId: string;
+ initialDocuments: MemberDocuments;
+}
+
+export default function Documents({ initialDocuments, memberId }: Props) {
+ const containerRef = useRef(null);
+ const [page, setPage] = useState(1);
+ const [documents, setDocuments] = useState(initialDocuments.documents);
+
+ async function changePage(page: number) {
+ const response = await fetch(
+ `${window.location.origin}${routes.api.memberDocument(memberId, page)}`,
+ { next: { revalidate: 60 * 60 * 24 } },
+ );
+ const newDocuments: MemberDocuments = await response.json();
+ setPage(page);
+ setDocuments(newDocuments.documents);
+ containerRef.current?.scrollIntoView();
+ }
+
+ return (
+
+
+ {documents.map((document) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/app/ledamot/[id]/page.tsx b/apps/web/app/ledamot/[id]/page.tsx
index 7c76327d5..51988bff3 100644
--- a/apps/web/app/ledamot/[id]/page.tsx
+++ b/apps/web/app/ledamot/[id]/page.tsx
@@ -2,6 +2,16 @@ import getMember from "@lib/api/member/get-member";
import getMembers from "@lib/api/member/get-members";
import { ERROR_404_TITLE } from "@lib/constants";
import { notFound } from "next/navigation";
+import Profile from "./profile";
+import BreadcrumbsSocialMediaShare from "@components/common/breadcrumbs-social-media-share";
+import { routes } from "@lib/navigation";
+import Container from "@components/common/container";
+import Statistics from "./statistics";
+import getMemberWithAbsence from "@lib/api/member/get-member-with-absence";
+import getMemberDocuments from "@lib/api/documents/get-member-documents";
+import Biography from "./biography";
+import Tabs from "./tabs";
+import getMemberTwitterFeed from "@lib/api/wikidata/get-member-twitter-feed";
interface PageProps {
params: {
@@ -29,15 +39,44 @@ export async function generateMetadata({ params: { id } }: PageProps) {
}
export default async function MemberPage({ params: { id } }: PageProps) {
- const member = await getMember(id);
+ const memberPromise = getMemberWithAbsence(id);
+ const memberDocumentsPromise = getMemberDocuments({ id, page: 1 });
+ const memberTwitterPromise = getMemberTwitterFeed(id);
+ const [member, memberDocuments, memberTwitter] = await Promise.all([
+ memberPromise,
+ memberDocumentsPromise,
+ memberTwitterPromise,
+ ]);
+
if (!member) {
return notFound();
}
- // TODO: Implement
+
return (
-
- {member.firstName} {member.lastName}
-
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/web/app/ledamot/[id]/profile.tsx b/apps/web/app/ledamot/[id]/profile.tsx
new file mode 100644
index 000000000..1004945ac
--- /dev/null
+++ b/apps/web/app/ledamot/[id]/profile.tsx
@@ -0,0 +1,27 @@
+import MemberImage from "@components/parliament/member-image";
+import type { MemberResponse } from "@lib/api/member/types";
+
+interface ProfileProps {
+ member: MemberResponse;
+}
+
+export default function Profile({ member }: ProfileProps) {
+ return (
+
+
+
+
+ {member.status}
+ {member.isLeader && " och partiledare"}
+
+
+ {member.firstName} {member.lastName}
+
+
{member.age} år
+
+ );
+}
diff --git a/apps/web/app/ledamot/[id]/statistics.tsx b/apps/web/app/ledamot/[id]/statistics.tsx
new file mode 100644
index 000000000..80cc51eee
--- /dev/null
+++ b/apps/web/app/ledamot/[id]/statistics.tsx
@@ -0,0 +1,51 @@
+import { Card } from "@components/common/card";
+import type { MemberDetailedResponse } from "@lib/api/member/types";
+import { twMerge } from "tailwind-merge";
+
+interface StatisticsCardProps {
+ value: string;
+ description: string;
+ className?: string;
+}
+function StatisticsCard({
+ value,
+ description,
+ className,
+}: StatisticsCardProps) {
+ return (
+
+ {value}
+ {description}
+
+ );
+}
+
+interface Props {
+ absence: MemberDetailedResponse["absence"];
+ documentCount: number;
+}
+
+export default function Statistics({ absence, documentCount }: Props) {
+ return (
+
+ {absence.mandatePeriod.value !== null && (
+
+ )}
+ {absence.parliamentYear.value !== null && (
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/app/ledamot/[id]/tabs.tsx b/apps/web/app/ledamot/[id]/tabs.tsx
new file mode 100644
index 000000000..d0d17ddde
--- /dev/null
+++ b/apps/web/app/ledamot/[id]/tabs.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { Card } from "@components/common/card";
+import type { MemberDocuments } from "@lib/api/member/types";
+import { useState } from "react";
+import Documents from "./documents";
+import type { TwitterResult } from "@lib/api/wikidata/types";
+import TwitterFeed from "./twitter-feed";
+
+enum Tab {
+ Document = "document-tab",
+ Twitter = "twitter-tab",
+}
+
+interface Props {
+ memberId: string;
+ initialDocuments: MemberDocuments;
+ twitterFeed?: TwitterResult;
+}
+
+export default function Tabs({
+ memberId,
+ initialDocuments,
+ twitterFeed,
+}: Props) {
+ const [activeTab, setActiveTab] = useState(Tab.Document);
+
+ function setTab(event: React.MouseEvent) {
+ if ("id" in event.target) {
+ setActiveTab(event.target.id as Tab);
+ }
+ }
+
+ return (
+
+
+
+
+
+ {activeTab === Tab.Document && (
+
+ )}
+ {activeTab === Tab.Twitter && }
+
+ );
+}
diff --git a/apps/web/app/ledamot/[id]/twitter-feed.tsx b/apps/web/app/ledamot/[id]/twitter-feed.tsx
new file mode 100644
index 000000000..4da1fa5cb
--- /dev/null
+++ b/apps/web/app/ledamot/[id]/twitter-feed.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import Loading from "@components/common/loading";
+import type { TwitterResult } from "@lib/api/wikidata/types";
+import { useTheme } from "next-themes";
+import { useEffect, useState } from "react";
+
+declare global {
+ interface Window {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ twttr: any;
+ }
+}
+
+interface Props {
+ twitterFeed?: TwitterResult;
+}
+
+export default function TwitterFeed({ twitterFeed }: Props) {
+ const theme = useTheme();
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const twitterHandle = twitterFeed?.twitterHandle.value;
+ if (twitterHandle) {
+ setLoading(true);
+ const container = document.getElementById("twitterContainer");
+ if (container) {
+ container.innerHTML = "";
+ }
+ window.twttr.widgets
+ .createTimeline(
+ {
+ sourceType: "profile",
+ screenName: twitterHandle,
+ },
+ container,
+ {
+ theme: theme.theme,
+ chrome: "nofooter noborders",
+ },
+ )
+ .then(() => {
+ setLoading(false);
+ });
+ } else {
+ setLoading(false);
+ }
+ }, [theme.theme, twitterFeed?.twitterHandle.value]);
+
+ const twitterHandle = twitterFeed?.twitterHandle.value;
+
+ if (!twitterHandle) {
+ return Inget Twitterkonto hittades.
;
+ }
+
+ return (
+ <>
+
+ {loading && }
+ >
+ );
+}
diff --git a/apps/web/app/loading.tsx b/apps/web/app/loading.tsx
index 83f5a4ab5..ad605dbb3 100644
--- a/apps/web/app/loading.tsx
+++ b/apps/web/app/loading.tsx
@@ -1,23 +1,5 @@
-export default function Loading() {
- return (
-
- );
+import Loading from "@components/common/loading";
+
+export default function LoadingPage() {
+ return ;
}
diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx
index 8c1ba8c0d..3f7de4a95 100644
--- a/apps/web/app/not-found.tsx
+++ b/apps/web/app/not-found.tsx
@@ -1,4 +1,4 @@
-import { Card } from "@components/card";
+import { Card } from "@components/common/card";
import Container from "@components/common/container";
import PageTitle from "@components/common/page-title";
import { ExclamationCircleIcon } from "@heroicons/react/24/solid";
diff --git a/apps/web/app/om-oss/page.tsx b/apps/web/app/om-oss/page.tsx
index 5ab591caf..1cfaf76a5 100644
--- a/apps/web/app/om-oss/page.tsx
+++ b/apps/web/app/om-oss/page.tsx
@@ -1,6 +1,6 @@
import { githubFrontend, githubProfile, linkedIn } from "@lib/socials";
import PageTitle from "@components/common/page-title";
-import { Card } from "@components/card";
+import { Card } from "@components/common/card";
import React from "react";
import { InformationCircleIcon } from "@heroicons/react/24/solid";
import ExternalLink from "@components/common/external-link";
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 09fadb736..3bd1220a5 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -1,9 +1,10 @@
-import { Card } from "@components/card";
+import { Card } from "@components/common/card";
import Typed from "@components/common/typed";
import Link from "next/link";
import { routes } from "@lib/navigation";
import PageTitle from "@components/common/page-title";
import Container from "@components/common/container";
+import { twMerge } from "tailwind-merge";
export const metadata = {
title: "Partiguiden | Rösta rätt",
@@ -53,7 +54,10 @@ export default function IndexPage() {
{subject.name}
diff --git a/apps/web/app/parti/[party]/page.tsx b/apps/web/app/parti/[party]/page.tsx
index 84f8dc198..64093c622 100644
--- a/apps/web/app/parti/[party]/page.tsx
+++ b/apps/web/app/parti/[party]/page.tsx
@@ -1,4 +1,4 @@
-import { Card } from "@components/card";
+import { Card } from "@components/common/card";
import Container from "@components/common/container";
import { Divider } from "@components/common/divider";
import ExternalLink from "@components/common/external-link";
diff --git a/apps/web/app/standpunkter/[id]/party-standpoints.tsx b/apps/web/app/standpunkter/[id]/party-standpoints.tsx
index a21ab9b80..8a3977907 100644
--- a/apps/web/app/standpunkter/[id]/party-standpoints.tsx
+++ b/apps/web/app/standpunkter/[id]/party-standpoints.tsx
@@ -1,13 +1,13 @@
"use client";
-import { Card } from "@components/card";
+import { Card } from "@components/common/card";
import { ChevronUpIcon } from "@heroicons/react/24/solid";
import { dateString } from "@lib/dates";
import {
- backgroundHover,
- borderBottom,
- marker,
- textColor,
+ partyBackgroundHover,
+ partyBorderBottom,
+ partyMarker,
+ partyTextColor,
} from "@lib/styles/party";
import type { Standpoint } from "@partiguiden/party-data/types";
import type { Party } from "@partiguiden/party-data/types";
@@ -34,13 +34,14 @@ export default function PartyStandpoints({
<>
{visible && (
@@ -50,7 +51,7 @@ export default function PartyStandpoints({
{standpoint.title}
{standpoint.opinions.length > 0 ? (
{standpoint.opinions.map((opinion) => (
- {opinion}
@@ -63,8 +64,8 @@ export default function PartyStandpoints({
->;
-
-export function Card({ children, className = "", ...props }: BaseCardProps) {
- return (
-
- {children}
-
- );
-}
diff --git a/apps/web/components/common/breadcrumbs.tsx b/apps/web/components/common/breadcrumbs.tsx
index 56e387578..f550f23dd 100644
--- a/apps/web/components/common/breadcrumbs.tsx
+++ b/apps/web/components/common/breadcrumbs.tsx
@@ -17,7 +17,7 @@ export interface BreadcrumbsProps {
export default function Breadcrumbs({ links, current }: BreadcrumbsProps) {
return (
-