Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
615 changes: 33 additions & 582 deletions package-lock.json

Large diffs are not rendered by default.

64 changes: 63 additions & 1 deletion src/components/ApiCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Skeleton from "./Skeleton";
import TagChip from "./TagChip";
import { formatPrice } from "../utils/format";
import { useCollections } from "../state/collectionsStore";
import { useFavorites } from "../hooks/useFavorites";
import type { APIItem } from "../data/mockApis";
import RatingHistogram from "./RatingHistogram";
import { useCompareStore, compareStore } from "../state/compareStore";
Expand Down Expand Up @@ -104,6 +105,60 @@ export function ApiCardSkeleton() {

// ─── Save-to-collection popover ───────────────────────────────────────────────

// ─── Favorite button ─────────────────────────────────────────────────────────

interface FavoriteButtonProps {
endpointId: string;
isFavorite: boolean;
onToggle: (id: string) => void;
}

function FavoriteButton({ endpointId, isFavorite, onToggle }: FavoriteButtonProps) {
const btnRef = useRef<HTMLButtonElement>(null);

return (
<button
ref={btnRef}
onClick={(e) => { e.stopPropagation(); onToggle(endpointId); }}
style={{
position: "absolute",
top: "8px",
left: "8px",
zIndex: 10,
background: isFavorite ? "var(--accent, rgba(78,133,255,0.9))" : "rgba(0,0,0,0.5)",
border: "none",
borderRadius: "50%",
width: "32px",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "background 160ms ease, transform 160ms ease",
flexShrink: 0,
}}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.transform = "scale(1.1)")}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.transform = "scale(1)")}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
aria-pressed={isFavorite}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill={isFavorite ? "white" : "none"}
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
);
}

// ─── Bookmark button ─────────────────────────────────────────────────────────

interface BookmarkButtonProps {
Expand Down Expand Up @@ -347,6 +402,7 @@ export default function ApiCard({
const uptimePercent = api.uptimePercent;
const isCompact = density === "compact";

const { isFavorite, toggleFavorite } = useFavorites();
const comparedApis = useCompareStore();
const isCompared = comparedApis.some(item => item.id === api.id);
const canCompare = isCompared || comparedApis.length < 3;
Expand Down Expand Up @@ -386,6 +442,12 @@ export default function ApiCard({
{/* Absolutely-positioned bookmark button in the top-right corner */}
<BookmarkButton endpointId={api.id} />

<FavoriteButton
endpointId={api.id}
isFavorite={isFavorite(api.id)}
onToggle={toggleFavorite}
/>

{/* Compare button - absolutely positioned, top-left */}
<button
onClick={handleCompareClick}
Expand All @@ -394,7 +456,7 @@ export default function ApiCard({
style={{
position: "absolute",
top: "8px",
left: "8px",
left: "48px",
zIndex: 10,
background: isCompared ? "var(--accent)" : "rgba(0,0,0,0.5)",
color: "white",
Expand Down
6 changes: 6 additions & 0 deletions src/components/FiltersBottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ interface FiltersBottomSheetProps {
popularity: string;
setPopularity: (p: string) => void;
clearFilters: () => void;
favoritesOnly: boolean;
toggleFavoritesOnly: () => void;
/** Ref to the trigger button so focus is restored on close */
triggerRef: React.RefObject<HTMLButtonElement>;
}
Expand All @@ -61,6 +63,8 @@ export default function FiltersBottomSheet({
popularity,
setPopularity,
clearFilters,
favoritesOnly,
toggleFavoritesOnly,
triggerRef,
}: FiltersBottomSheetProps) {
const [snap, setSnap] = useState<Snap>("half");
Expand Down Expand Up @@ -222,6 +226,8 @@ export default function FiltersBottomSheet({
popularity={popularity}
setPopularity={setPopularity}
clearFilters={clearFilters}
favoritesOnly={favoritesOnly}
toggleFavoritesOnly={toggleFavoritesOnly}
/>
</div>

Expand Down
22 changes: 22 additions & 0 deletions src/components/FiltersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export default function FiltersSidebar({
popularity,
setPopularity,
clearFilters,
favoritesOnly,
toggleFavoritesOnly,
}: {
selectedCategories: Set<string>;
toggleCategory: (c: string) => void;
Expand All @@ -28,6 +30,8 @@ export default function FiltersSidebar({
popularity: string;
setPopularity: (p: string) => void;
clearFilters: () => void;
favoritesOnly: boolean;
toggleFavoritesOnly: () => void;
}) {
return (
<aside className="filters-sidebar">
Expand Down Expand Up @@ -125,6 +129,24 @@ export default function FiltersSidebar({
</fieldset>
</div>

<div style={{ marginBottom: 12 }}>
<fieldset className="filter-group">
<legend className="filter-legend">Favorites</legend>
<div className="filter-option" style={{ display: "flex", gap: 8, alignItems: "center", marginTop: 8 }}>
<input
id="favorites-only-checkbox"
type="checkbox"
className="filter-checkbox"
checked={favoritesOnly}
onChange={toggleFavoritesOnly}
/>
<label htmlFor="favorites-only-checkbox" className="filter-label" style={{ color: "var(--text)" }}>
Favorites only
</label>
</div>
</fieldset>
</div>

<div style={{ marginTop: 8 }}>
<button className="ghost-button" onClick={clearFilters}>
Clear filters
Expand Down
195 changes: 195 additions & 0 deletions src/components/PricingTierTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import React, { useState, useEffect } from "react";
import { CheckIcon } from "./icons";

export interface PricingFeature {
label: string;
included: boolean;
}

export interface PricingTier {
name: string;
price: string;
description: string;
features: PricingFeature[];
ctaLabel: string;
isRecommended?: boolean;
}

interface PricingTierTableProps {
tiers: PricingTier[];
}

const XIcon = () => (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
);

export default function PricingTierTable({ tiers }: PricingTierTableProps) {
const [isMobile, setIsMobile] = useState(false);

useEffect(() => {
const mediaQuery = window.matchMedia("(max-width: 768px)");
setIsMobile(mediaQuery.matches);

const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, []);

if (isMobile) {
return (
<div className="pricing-tiers-mobile" style={{ display: "flex", flexDirection: "column", gap: 24 }}>
{tiers.map((tier, tierIdx) => (
<div
key={tier.name}
className="pricing-tier-card"
style={{
padding: 24,
borderRadius: 16,
background: tier.isRecommended ? "var(--bg-subtle)" : "var(--surface-strong)",
border: tier.isRecommended ? "2px solid var(--accent)" : "1px solid var(--border-subtle)",
position: "relative",
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
{tier.isRecommended && (
<div
style={{
position: "absolute",
top: -12,
left: "50%",
transform: "translateX(-50%)",
background: "var(--accent)",
color: "white",
padding: "4px 12px",
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
textTransform: "uppercase",
}}
>
Recommended
</div>
)}

<div style={{ textAlign: "center" }}>
<h3 style={{ margin: 0, fontSize: 20 }}>{tier.name}</h3>
<div style={{ fontSize: 28, fontWeight: 800, marginTop: 8 }}>{tier.price}</div>
<p style={{ fontSize: 14, color: "var(--muted)", marginTop: 4 }}>{tier.description}</p>
</div>

<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{tier.features.map((feature) => (
<div key={feature.label} style={{ display: "flex", alignItems: "center", gap: 12 }}>
{feature.included ? (
<CheckIcon size={20} style={{ color: "var(--accent)" }} />
) : (
<XIcon style={{ color: "var(--muted)" }} />
)}
<span style={{ fontSize: 14 }}>{feature.label}</span>
</div>
))}
</div>

<button
className="primary-button"
style={{ width: "100%", marginTop: 8 }}
>
{tier.ctaLabel}
</button>
</div>
))}
</div>
);
}

return (
<div className="pricing-tiers-desktop" style={{ display: "grid", gridTemplateColumns: `repeat(${tiers.length}, 1fr)`, gap: 24 }}>
{tiers.map((tier) => (
<div
key={tier.name}
className="pricing-tier-card"
style={{
padding: 24,
borderRadius: 16,
background: tier.isRecommended ? "var(--bg-subtle)" : "var(--surface-strong)",
border: tier.isRecommended ? "2px solid var(--accent)" : "1px solid var(--border-subtle)",
position: "relative",
display: "flex",
flexDirection: "column",
}}
>
{tier.isRecommended && (
<div
style={{
position: "absolute",
top: -12,
left: "50%",
transform: "translateX(-50%)",
background: "var(--accent)",
color: "white",
padding: "4px 12px",
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
textTransform: "uppercase",
}}
>
Recommended
</div>
)}

<div style={{ textAlign: "center", marginBottom: 24 }}>
<h3 style={{ margin: 0, fontSize: 20 }}>{tier.name}</h3>
<div style={{ fontSize: 28, fontWeight: 800, marginTop: 8 }}>{tier.price}</div>
<p style={{ fontSize: 14, color: "var(--muted)", marginTop: 4 }}>{tier.description}</p>
</div>

<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 16, marginBottom: 24 }}>
{tier.features.map((feature) => (
<div
key={feature.label}
style={{
display: "flex",
alignItems: "center",
gap: 12,
fontSize: 14,
}}
>
{feature.included ? (
<CheckIcon size={20} style={{ color: "var(--accent)" }} />
) : (
<XIcon style={{ color: "var(--muted)" }} />
)}
<span style={{ color: feature.included ? "var(--text-main)" : "var(--muted)" }}>
{feature.label}
</span>
</div>
))}
</div>

<button
className="primary-button"
style={{ width: "100%" }}
>
{tier.ctaLabel}
</button>
</div>
))}
</div>
);
}
19 changes: 19 additions & 0 deletions src/hooks/useFavorites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useLocalStorage } from "./useLocalStorage";

export function useFavorites() {
const [favorites, setFavorites] = useLocalStorage<string[]>("callora.favorites", []);

const toggleFavorite = (id: string) => {
setFavorites((prev) => {
if (prev.includes(id)) {
return prev.filter((favId) => favId !== id);
} else {
return [...prev, id];
}
});
};

const isFavorite = (id: string) => favorites.includes(id);

return { favorites, toggleFavorite, isFavorite };
}
Loading