/* Structured data. * * Search engines can read the pages perfectly well; what they can't do is work out that * ThreadCount is one free product, made by one identifiable outfit, for one narrow job. That is * what this states — and it is stated only where it is true, because marking up claims a page * doesn't make is the fastest way to lose the rich result entirely. * * Notably absent: aggregateRating and review. There are no ratings, and inventing them is both a * policy violation and a lie. */ const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech"; import { PRICES } from "./plan"; export const organization = { "@type": "Organization", "@id": `${SITE}/#organization`, name: "ThreadCount", url: SITE, logo: `${SITE}/og.png`, email: "hello@threadcount.tech", description: "Uniform stock management for healthcare: hospitals, aged care, clinics and community care.", areaServed: { "@type": "Country", name: "Australia" }, contactPoint: [{ "@type": "ContactPoint", contactType: "customer support", email: "hello@threadcount.tech", availableLanguage: ["English"], }], }; export const website = { "@type": "WebSite", "@id": `${SITE}/#website`, url: SITE, name: "ThreadCount", publisher: { "@id": `${SITE}/#organization` }, inLanguage: "en-AU", }; /* The product itself, with its price as it stands. While plans are off the one offer is `price: "0"` — the honest and useful part, since a free-tier signal is exactly what someone comparing options is filtering on. Once plans are live the offers are the three editions from the pricing page, the free one first, read from lib/plan.ts so the markup cannot drift from the page. */ export const softwareApplication = (live: boolean) => ({ "@type": "SoftwareApplication", "@id": `${SITE}/#software`, name: "ThreadCount", applicationCategory: "BusinessApplication", applicationSubCategory: "Inventory management", operatingSystem: "Web, Android", url: SITE, publisher: { "@id": `${SITE}/#organization` }, description: "Uniform stock management for hospitals, aged-care homes, clinics and community care: what is on the shelf, who took it, what it cost the ward or clinic, and the supplier orders and stocktakes in between.", offers: live ? [ { "@type": "Offer", name: "Community — self-hosted", price: "0", priceCurrency: "AUD", availability: "https://schema.org/InStock" }, { "@type": "Offer", name: `Hosted Small — up to ${PRICES.freeStaff} staff records`, price: "0", priceCurrency: "AUD", availability: "https://schema.org/InStock" }, { "@type": "Offer", name: "Hosted Facility — a year", price: String(PRICES.hostedAnnual), priceCurrency: "AUD", availability: "https://schema.org/InStock", url: `${SITE}/pricing` }, { "@type": "Offer", name: `Health Service — up to ${PRICES.healthServiceFacilities} facilities, a year`, price: String(PRICES.healthServiceAnnual), priceCurrency: "AUD", availability: "https://schema.org/InStock", url: `${SITE}/pricing` }, ] : { "@type": "Offer", price: "0", priceCurrency: "AUD", availability: "https://schema.org/InStock", }, featureList: [ "Stock on hand by size", "Issue to a staff member against their entitlement", "Barcode scanning with a USB scanner or a phone camera", "Stocktakes by shelf with variance reasons", "Supplier orders, deliveries and back orders", "Cost centre reporting for finance", ], }); export function faqPage(entries: { q: string; a: string }[]) { return { "@type": "FAQPage", "@id": `${SITE}/faq#faq`, mainEntity: entries.map(({ q, a }) => ({ "@type": "Question", name: q, acceptedAnswer: { "@type": "Answer", text: a }, })), }; } /** Wraps whatever is passed in the single @graph envelope crawlers prefer. */ export function graph(...nodes: object[]) { return { "@context": "https://schema.org", "@graph": nodes }; }