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
37 changes: 37 additions & 0 deletions web/src/components/ResourceDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useEffect, useState } from "react"
import { useLanguage, useTranslation } from "../i18n/LanguageProvider.tsx"
import { fetchResource } from "../services/api.ts"
import { copyText, shareCapability, shareResource } from "../services/contactActions.ts"
import type { ResourceDto } from "../types/api.ts"
import { ErrorState } from "./ErrorState.tsx"
import { Link } from "./Link.tsx"
import { LoadingState } from "./LoadingState.tsx"
import { NotFoundState } from "./NotFoundState.tsx"
import { telHref } from "./ResourceCard.tsx"
import { useAnnounce } from "./StatusRegion.tsx"

type DetailState =
| { kind: "loading" }
Expand All @@ -17,6 +19,7 @@ type DetailState =
export function ResourceDetail({ id }: { id: number }) {
const { lang } = useLanguage()
const t = useTranslation()
const announce = useAnnounce()
const [state, setState] = useState<DetailState>({ kind: "loading" })
const [attempt, setAttempt] = useState(0)

Expand All @@ -38,6 +41,24 @@ export function ResourceDetail({ id }: { id: number }) {
if (state.kind === "missing") return <NotFoundState />

const { resource } = state
// Read once per render so the label always says what the tap will do.
const capability = shareCapability(navigator)
const canCopy = Boolean(navigator.clipboard)

const onShare = async () => {
const outcome = await shareResource(
{ name: resource.name, phone: resource.phone, url: window.location.href },
navigator
)
if (outcome === "copied") announce(t("detail.linkCopied"))
if (outcome === "failed") announce(t("detail.copyFailed"))
}

const onCopyPhone = async (phone: string) => {
const ok = await copyText(phone, navigator)
announce(t(ok ? "detail.phoneCopied" : "detail.copyFailed"))
}

return (
<article className="resource-detail stack">
<Link to="/">{t("detail.back")}</Link>
Expand All @@ -57,6 +78,15 @@ export function ResourceDetail({ id }: { id: number }) {
<dt>{t("detail.phone")}</dt>
<dd>
<a href={telHref(resource.phone)}>{resource.phone}</a>
{canCopy && (
<button
type="button"
className="copy-phone"
onClick={() => resource.phone && onCopyPhone(resource.phone)}
>
{t("detail.copyPhone")}
</button>
)}
</dd>
</>
)}
Expand Down Expand Up @@ -95,6 +125,13 @@ export function ResourceDetail({ id }: { id: number }) {
</>
)}
</dl>
{capability !== "none" && (
<div className="contact-actions">
<button type="button" onClick={onShare}>
{t(capability === "share" ? "detail.share" : "detail.copyLink")}
</button>
</div>
)}
<p className="muted">
{t("card.lastVerified")} {resource.lastVerified}
</p>
Expand Down
6 changes: 6 additions & 0 deletions web/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
"detail.website": "Website",
"detail.chat": "Chat",
"detail.address": "Address",
"detail.share": "Share",
"detail.copyLink": "Copy link",
"detail.copyPhone": "Copy number",
"detail.linkCopied": "Link copied",
"detail.phoneCopied": "Number copied",
"detail.copyFailed": "Could not copy",
"list.pagerPrev": "Previous",
"list.pagerNext": "Next",
"list.pagerStatus": "Page {page} of {pages}",
Expand Down
6 changes: 6 additions & 0 deletions web/src/i18n/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
"detail.website": "Nettside",
"detail.chat": "Chat",
"detail.address": "Adresse",
"detail.share": "Del",
"detail.copyLink": "Kopier lenke",
"detail.copyPhone": "Kopier nummer",
"detail.linkCopied": "Lenken er kopiert",
"detail.phoneCopied": "Nummeret er kopiert",
"detail.copyFailed": "Kunne ikke kopiere",
"list.pagerPrev": "Forrige",
"list.pagerNext": "Neste",
"list.pagerStatus": "Side {page} av {pages}",
Expand Down
50 changes: 50 additions & 0 deletions web/src/services/contactActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Share and copy for a resource's contact details. Pure functions over an injected
// navigator-like object so the component stays render-only and the logic tests without React.

export type ShareTarget = {
share?: (data: { title: string; text: string; url: string }) => Promise<void>
clipboard?: { writeText: (text: string) => Promise<void> }
}

export type ShareCapability = "share" | "copy" | "none"
export type ShareOutcome = "shared" | "copied" | "cancelled" | "failed"

export type ShareEntry = { name: string; phone: string | null; url: string }

export function shareText(name: string, phone: string | null): string {
return phone ? `${name} — ${phone}` : name
}

export function shareCapability(nav: ShareTarget): ShareCapability {
if (typeof nav.share === "function") return "share"
if (nav.clipboard) return "copy"
return "none"
}

export async function copyText(text: string, nav: ShareTarget): Promise<boolean> {
if (!nav.clipboard) return false
try {
await nav.clipboard.writeText(text)
return true
} catch {
return false
}
}

export async function shareResource(entry: ShareEntry, nav: ShareTarget): Promise<ShareOutcome> {
if (nav.share) {
try {
await nav.share({
title: entry.name,
text: shareText(entry.name, entry.phone),
url: entry.url,
})
return "shared"
} catch (error: unknown) {
// The user closed the sheet — nothing to announce and nothing to fall back to.
if (error instanceof DOMException && error.name === "AbortError") return "cancelled"
// Anything else (unsupported payload, sheet unavailable) degrades to copying the link.
}
}
return (await copyText(entry.url, nav)) ? "copied" : "failed"
}
12 changes: 12 additions & 0 deletions web/src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,15 @@
min-height: 44px;
min-width: 44px;
}
.contact-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm, 0.5rem);
}
.contact-actions button,
.contact .copy-phone {
min-height: 44px;
}
.contact .copy-phone {
margin-left: var(--space-sm, 0.5rem);
}
84 changes: 84 additions & 0 deletions web/tests/contactActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, test, vi } from "vitest"
import {
copyText,
shareCapability,
shareResource,
shareText,
} from "../src/services/contactActions.ts"

const entry = {
name: "Krisesenteret i Hamar",
phone: "62 00 00 00",
url: "https://varde.test/r/12",
}

describe("shareText", () => {
test("joins name and phone", () => {
expect(shareText("Krisesenteret i Hamar", "62 00 00 00")).toBe(
"Krisesenteret i Hamar — 62 00 00 00"
)
})
test("is just the name when there is no phone", () => {
expect(shareText("Krisesenteret i Hamar", null)).toBe("Krisesenteret i Hamar")
})
})

describe("shareCapability", () => {
test("prefers the share sheet when the browser has one", () => {
expect(shareCapability({ share: vi.fn(), clipboard: { writeText: vi.fn() } })).toBe("share")
})
test("falls back to copy when only the clipboard exists", () => {
expect(shareCapability({ clipboard: { writeText: vi.fn() } })).toBe("copy")
})
test("is none when the browser has neither", () => {
expect(shareCapability({})).toBe("none")
})
})

describe("shareResource", () => {
test("hands name, phone and url to the share sheet", async () => {
const share = vi.fn().mockResolvedValue(undefined)
const outcome = await shareResource(entry, { share })
expect(outcome).toBe("shared")
expect(share).toHaveBeenCalledWith({
title: "Krisesenteret i Hamar",
text: "Krisesenteret i Hamar — 62 00 00 00",
url: "https://varde.test/r/12",
})
})
test("a cancelled sheet is cancelled, not a failure and not a copy", async () => {
const share = vi.fn().mockRejectedValue(new DOMException("cancelled", "AbortError"))
const writeText = vi.fn()
expect(await shareResource(entry, { share, clipboard: { writeText } })).toBe("cancelled")
expect(writeText).not.toHaveBeenCalled()
})
test("a broken share sheet falls through to copying the link", async () => {
const share = vi.fn().mockRejectedValue(new TypeError("no can do"))
const writeText = vi.fn().mockResolvedValue(undefined)
expect(await shareResource(entry, { share, clipboard: { writeText } })).toBe("copied")
expect(writeText).toHaveBeenCalledWith("https://varde.test/r/12")
})
test("without a share sheet the link is copied", async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
expect(await shareResource(entry, { clipboard: { writeText } })).toBe("copied")
expect(writeText).toHaveBeenCalledWith("https://varde.test/r/12")
})
test("with nothing to share or copy the outcome is failed", async () => {
expect(await shareResource(entry, {})).toBe("failed")
})
})

describe("copyText", () => {
test("resolves true when the clipboard accepts the text", async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
expect(await copyText("62 00 00 00", { clipboard: { writeText } })).toBe(true)
expect(writeText).toHaveBeenCalledWith("62 00 00 00")
})
test("resolves false when the clipboard write is refused", async () => {
const writeText = vi.fn().mockRejectedValue(new DOMException("denied", "NotAllowedError"))
expect(await copyText("62 00 00 00", { clipboard: { writeText } })).toBe(false)
})
test("resolves false when there is no clipboard", async () => {
expect(await copyText("62 00 00 00", {})).toBe(false)
})
})
107 changes: 106 additions & 1 deletion web/tests/detail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { render, screen } from "@testing-library/react"
import { expect, test, vi } from "vitest"
import { ResourceDetail } from "../src/components/ResourceDetail.tsx"
import { LanguageProvider } from "../src/i18n/LanguageProvider.tsx"
import type { ResourceDto } from "../src/types/api.ts"

const detail = {
const detail: ResourceDto = {
id: 12,
name: "Krisesenteret i Hamar",
description: "Hjelp.",
Expand Down Expand Up @@ -46,3 +47,107 @@ test("a 404 renders NotFoundState with a way back", async () => {
expect(await screen.findByRole("heading", { name: "Fant ikke tjenesten" })).toBeInTheDocument()
expect(screen.getByRole("link", { name: "Tilbake til søket" })).toBeInTheDocument()
})

// --- share + copy-phone ---------------------------------------------------------------------
// jsdom ships neither navigator.share nor navigator.clipboard, so each test installs exactly
// the capabilities it is about and removes them again.

import { act } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { afterEach } from "vitest"
import { AnnouncerProvider } from "../src/components/StatusRegion.tsx"

function installNavigator(overrides: { share?: unknown; clipboard?: unknown }) {
for (const [key, value] of Object.entries(overrides)) {
Object.defineProperty(navigator, key, { value, configurable: true })
}
return () => {
for (const key of Object.keys(overrides)) {
delete (navigator as unknown as Record<string, unknown>)[key]
}
}
}

let restoreNavigator = () => {}
afterEach(() => restoreNavigator())

async function renderDetail(resource: ResourceDto = detail) {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify(resource), { status: 200 })
)
render(
<LanguageProvider initialLang="nb">
<AnnouncerProvider>
<ResourceDetail id={12} />
</AnnouncerProvider>
</LanguageProvider>
)
await screen.findByRole("heading", { name: resource.name })
}

test("with a share sheet the Del button shares name, phone and this page", async () => {
const share = vi.fn().mockResolvedValue(undefined)
restoreNavigator = installNavigator({ share })
await renderDetail()
await userEvent.click(screen.getByRole("button", { name: "Del" }))
expect(share).toHaveBeenCalledWith({
title: "Krisesenteret i Hamar",
text: "Krisesenteret i Hamar — 62 00 00 00",
url: window.location.href,
})
})

test("without a share sheet the button copies the link and says so", async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
restoreNavigator = installNavigator({ clipboard: { writeText } })
await renderDetail()
expect(screen.queryByRole("button", { name: "Del" })).not.toBeInTheDocument()
await userEvent.click(screen.getByRole("button", { name: "Kopier lenke" }))
expect(writeText).toHaveBeenCalledWith(window.location.href)
expect(await screen.findByText("Lenken er kopiert")).toBeInTheDocument()
})

test("copy-phone copies the number as shown and announces it", async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
restoreNavigator = installNavigator({ clipboard: { writeText } })
await renderDetail()
await userEvent.click(screen.getByRole("button", { name: "Kopier nummer" }))
expect(writeText).toHaveBeenCalledWith("62 00 00 00")
expect(await screen.findByText("Nummeret er kopiert")).toBeInTheDocument()
})

test("a refused clipboard write is announced, not swallowed", async () => {
const writeText = vi.fn().mockRejectedValue(new DOMException("denied", "NotAllowedError"))
restoreNavigator = installNavigator({ clipboard: { writeText } })
await renderDetail()
await userEvent.click(screen.getByRole("button", { name: "Kopier nummer" }))
expect(await screen.findByText("Kunne ikke kopiere")).toBeInTheDocument()
})

test("a cancelled share sheet announces nothing", async () => {
const share = vi.fn().mockRejectedValue(new DOMException("cancelled", "AbortError"))
restoreNavigator = installNavigator({ share })
await renderDetail()
await userEvent.click(screen.getByRole("button", { name: "Del" }))
await act(async () => {})
expect(screen.queryByText(/kopiert/)).not.toBeInTheDocument()
})

test("no phone means no copy-phone button", async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
restoreNavigator = installNavigator({ clipboard: { writeText } })
await renderDetail({ ...detail, phone: null })
expect(screen.queryByRole("button", { name: "Kopier nummer" })).not.toBeInTheDocument()
})

test("with neither share nor clipboard the page has no share or copy buttons", async () => {
await renderDetail()
expect(screen.queryByRole("button", { name: /Del|Kopier/ })).not.toBeInTheDocument()
})

test("a share sheet without a clipboard shows Del but no copy-phone button", async () => {
restoreNavigator = installNavigator({ share: vi.fn().mockResolvedValue(undefined) })
await renderDetail()
expect(screen.getByRole("button", { name: "Del" })).toBeInTheDocument()
expect(screen.queryByRole("button", { name: "Kopier nummer" })).not.toBeInTheDocument()
})
Loading