Skip to content
2 changes: 1 addition & 1 deletion client/src/components/content-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export function ContentCard({
<div className="flex items-center justify-between gap-[clamp(0.25rem,0.8vw,0.375rem)]">
<Link
aria-label={buttonLabel}
className="inline-flex w-fit items-center gap-[clamp(0.25rem,0.8vw,0.375rem)] text-[clamp(0.75rem,1.7vw,0.875rem)] font-semibold text-muted-foreground transition-colors hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
className="inline-flex w-fit items-center gap-[clamp(0.25rem,0.8vw,0.375rem)] text-[clamp(0.75rem,1.7vw,0.875rem)] font-semibold text-muted-foreground transition-colors hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
href={href}
>
<span>{buttonLabel}</span>
Expand Down
87 changes: 87 additions & 0 deletions client/src/components/resources-horizontal-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { ArrowRight, ImageOff } from "lucide-react";
import Image from "next/image";
import Link from "next/link";

import { cn } from "@/lib/utils";

export interface HorizontalCardProps {
className?: string;
imageSrc: string;
imageAlt: string;
title: string;
description: string;
href: string;
buttonLabel?: string;
author?: string;
dateTime?: string;
}

export function HorizontalCard({
className,
imageSrc,
imageAlt,
title,
description,
href,
buttonLabel = "View More",
author,
dateTime,
}: HorizontalCardProps) {
const meta = [author, dateTime].filter(Boolean).join(" • ");

return (
<article
className={cn(
"group flex w-full flex-col overflow-hidden rounded-[var(--radius)] border border-border/60 bg-card text-card-foreground shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-lg md:min-h-[clamp(18rem,26vw,23rem)] md:flex-row",
className,
)}
>
<div className="relative aspect-[16/10] w-full shrink-0 overflow-hidden bg-muted md:aspect-auto md:w-1/2 md:self-stretch">
{imageSrc ? (
<Image
alt={imageAlt}
className="object-cover transition-transform duration-500 group-hover:scale-105"
fill
sizes="(max-width: 768px) 100vw, 50vw"
src={imageSrc}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
<ImageOff
className="size-[clamp(2rem,7vw,4rem)]"
aria-hidden="true"
/>
</div>
)}
</div>

<div className="flex flex-1 flex-col gap-[clamp(0.5rem,1.5vw,1rem)] p-[clamp(1.25rem,2.5vw,2rem)]">
{meta ? (
<p className="text-[clamp(0.8rem,1.6vw,0.9rem)] font-semibold text-primary">
{meta}
</p>
) : null}

<h3 className="overflow-hidden text-[clamp(1.5rem,3.2vw,2.25rem)] font-bold leading-tight tracking-tight text-foreground [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]">
{title}
</h3>

<p className="overflow-hidden text-[clamp(0.9rem,1.8vw,1.05rem)] leading-relaxed text-muted-foreground [-webkit-box-orient:vertical] [-webkit-line-clamp:3] [display:-webkit-box]">
{description}
</p>

<Link
aria-label={buttonLabel}
className="mt-1 inline-flex w-fit items-center gap-[clamp(0.25rem,0.8vw,0.375rem)] text-[clamp(0.85rem,1.7vw,0.95rem)] font-semibold text-accent transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
href={href}
>
<span>{buttonLabel}</span>
<ArrowRight
className="size-[clamp(1rem,1.9vw,1.15rem)]"
aria-hidden="true"
/>
</Link>
</div>
</article>
);
}
106 changes: 106 additions & 0 deletions client/src/components/savings-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import styles from "@/styles/components/savings_card.module.css";

export interface SavingCardProp {
heading: string;
body: string;
savings: string;
data: Record<string, Record<string, number>>;
}

// Your theme only defines primary/secondary/muted/accent/destructive — no green or
// purple token, so those two fall back to plain Tailwind colors. Swap in your own
// tokens here if you add them later.
const ROW_DOT_CLASSES = [
"bg-emerald-500", // no theme equivalent
"bg-destructive",
"bg-foreground",
"bg-violet-500", // no theme equivalent
];

export default function SavingHorizontalCard({
heading,
body,
savings,
data,
}: SavingCardProp) {
const columns = Object.keys(data);
// row labels come from the inner keys — assumes every column has the same set of rows
const rows = columns.length > 0 ? Object.keys(data[columns[0]]) : [];

const columnTotal = (col: string) =>
rows.reduce((sum, row) => sum + (data[col][row] ?? 0), 0);

return (
<main className={`${styles.card} mx-auto max-w-4xl font-sans`}>
{/* left panel */}
<div
className={`${styles.leftPanel} rounded-2xl border-2 border-primary bg-card p-5 text-card-foreground sm:p-6 md:p-8`}
>
<div>
<h1 className="mb-3 text-xl font-semibold sm:text-2xl md:text-3xl">
{heading}
</h1>
<p className="text-sm leading-relaxed text-muted-foreground sm:text-base">
{body}
</p>
</div>
{/* no "warning"/amber token in the theme, so this uses plain amber rather than a css var */}
<div className="mt-6 rounded-xl bg-amber-100 p-4 text-amber-900 sm:p-5">
<p className="mb-1 text-xs sm:text-sm">You could save</p>
<p className="mb-1 text-2xl font-bold sm:text-3xl md:text-4xl">
${savings}
</p>
<p className="text-xs sm:text-sm">over 15 years</p>
</div>
</div>

{/* right panel — table replaces the bar chart */}
<div
className={`${styles.rightPanel} rounded-2xl bg-card p-5 text-card-foreground sm:p-6 md:p-8`}
>
<table className={`${styles.table} min-w-[420px] sm:min-w-0`}>
<thead>
<tr>
<th className="p-2 text-center sm:p-3"></th>
{columns.map((col) => (
<th
key={col}
className="border-b border-border p-2 text-center sm:p-3"
>
<div className="text-xs font-normal text-muted-foreground sm:text-sm">
{col}
</div>
<div className="text-base font-bold text-foreground sm:text-lg md:text-xl">
${columnTotal(col).toLocaleString()}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={row}>
<td
className={`${styles.rowLabel} gap-2 whitespace-nowrap p-2 text-sm text-foreground sm:p-3 sm:text-base`}
>
<span
className={`${styles.dot} ${ROW_DOT_CLASSES[i % ROW_DOT_CLASSES.length]}`}
/>
{row}
</td>
{columns.map((col) => (
<td
key={col}
className="p-2 text-center text-sm text-foreground sm:p-3 sm:text-base"
>
${data[col][row].toLocaleString()}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</main>
);
}
57 changes: 57 additions & 0 deletions client/src/hooks/resources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useQuery } from "@tanstack/react-query";

export interface Resource {
id: string;
date_made: string;
author: string;
image: string | null;
name: string;
slug: string;
summary: string;
type: "page" | "file";
body: string;
file_url: string;
file_name: string;
}

const MOCK_RESOURCES: Resource[] = [
{
id: "1",
date_made: "2022-02-04",
author: "John Doe",
image: null,
name: "New feature available on Devias",
slug: "new-feature",
summary:
"It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
type: "page",
body: "",
file_url: "",
file_name: "",
},
{
id: "2",
date_made: "2023-08-15",
author: "Jane Smith",
image: null,
name: "How to electrify your home",
slug: "electrify-your-home",
summary: "A practical guide to going all-electric in Western Australia.",
type: "page",
body: "",
file_url: "",
file_name: "",
},
];

export function useResources() {
return useQuery({
queryKey: ["resources"],
queryFn: async (): Promise<Resource[]> => {
// Swap this block for the real fetch once the endpoint lands:
// const res = await fetch("/api/resources/");
// return res.json();
return MOCK_RESOURCES;
},
});
}
53 changes: 53 additions & 0 deletions client/src/styles/components/savings_card.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
Savinghorizontalcard.module · CSS .card {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}

.leftPanel {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}

.rightPanel {
flex: 1;
overflow-x: auto;
}

.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}

.rowLabel {
display: flex;
align-items: center;
}

.dot {
width: 8px;
height: 8px;
border-radius: 9999px;
display: inline-block;
flex-shrink: 0;
}

@media (min-width: 640px) {
.card {
flex-direction: row;
gap: 1.5rem;
}

.rightPanel {
flex: 1.2;
}

.dot {
width: 10px;
height: 10px;
}
}
Loading