diff --git a/src/app/(marketing)/careers/page.tsx b/src/app/(marketing)/careers/page.tsx index d46fafa..1156237 100644 --- a/src/app/(marketing)/careers/page.tsx +++ b/src/app/(marketing)/careers/page.tsx @@ -1,64 +1,82 @@ import { HeartHandshake, Sparkles, TrendingUp } from "lucide-react"; import Link from "next/link"; +import { notFound } from "next/navigation"; import { Container } from "@/components/shared/container"; import { Reveal } from "@/components/shared/reveal"; import { SectionHeading } from "@/components/shared/section-heading"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { buildAuthRedirect, buildServerDomainConfig } from "@/lib/domains"; +import { env } from "@/lib/env"; +import { requirePublicTenantContext } from "@/lib/tenancy/context"; +import { getPublicTenantPresentation } from "@/modules/branding/service"; +import { resolveTenantSiteContent } from "@/modules/cms/site-content"; +import { getPublishedSiteContent } from "@/modules/cms/site-content-service"; -const VALUES = [ - { - icon: TrendingUp, - title: "Real ownership", - body: "Small, senior team where your work ships to real buyers and sellers — not a backlog that never sees daylight.", - }, - { - icon: HeartHandshake, - title: "Trust by default", - body: "We win by making property transactions feel safe and transparent. That standard shapes how we work together too.", - }, - { - icon: Sparkles, - title: "Craft that shows", - body: "We care about the details buyers feel — clarity, speed, and follow-through — across every part of the experience.", - }, -]; +export const dynamic = "force-dynamic"; + +/** Fixed icon set for the three value cards; copy comes from the tenant CMS. */ +const VALUE_ICONS = [TrendingUp, HeartHandshake, Sparkles] as const; + +export default async function CareersPage() { + const tenant = await requirePublicTenantContext(); + const [presentation, storedContent] = await Promise.all([ + getPublicTenantPresentation(tenant), + getPublishedSiteContent(tenant), + ]); + const runtimeConfig = buildServerDomainConfig(env); + const siteContent = resolveTenantSiteContent({ + companyName: presentation.companyName, + startPurchaseHref: buildAuthRedirect(runtimeConfig, { + returnTo: "/portal", + tenantSlug: tenant.companySlug, + tenantHost: tenant.host, + entry: "buyer", + }), + stored: storedContent, + }); + + // Companies that aren't hiring can hide the page entirely from the CMS. + if (!siteContent.careers.visible) { + notFound(); + } + + const careers = siteContent.careers; -export default function CareersPage() { return ( - {VALUES.map((value) => ( - - - - -

{value.title}

-

{value.body}

-
- ))} + {careers.values.map((value, index) => { + const Icon = VALUE_ICONS[index] ?? Sparkles; + return ( + + + + +

{value.title}

+

{value.description}

+
+ ); + })}
-

No open roles right now

-

- We're not actively hiring at the moment, but we always like meeting great people. Tell us - how you'd like to contribute and we'll keep you in mind. -

+

{careers.ctaHeading}

+

{careers.ctaBody}

- +
diff --git a/src/app/(marketing)/contact/page.tsx b/src/app/(marketing)/contact/page.tsx index d6286f3..9ce6d3c 100644 --- a/src/app/(marketing)/contact/page.tsx +++ b/src/app/(marketing)/contact/page.tsx @@ -1,18 +1,40 @@ -import { Mail, MapPin, Phone, type LucideIcon } from "lucide-react"; +import { Clock, Mail, MapPin, MessageCircle, Phone, type LucideIcon } from "lucide-react"; import { InquiryForm } from "@/components/marketing/inquiry-form"; import { Container } from "@/components/shared/container"; import { Reveal } from "@/components/shared/reveal"; import { SectionHeading } from "@/components/shared/section-heading"; import { Card } from "@/components/ui/card"; +import { buildAuthRedirect, buildServerDomainConfig } from "@/lib/domains"; +import { env } from "@/lib/env"; import { requirePublicTenantContext } from "@/lib/tenancy/context"; -import { getPublicTenantContact } from "@/modules/cms/site-content-service"; +import { resolveTenantSiteContent } from "@/modules/cms/site-content"; +import { + getPublicTenantContact, + getPublishedSiteContent, +} from "@/modules/cms/site-content-service"; +import { getPublicTenantPresentation } from "@/modules/branding/service"; export const dynamic = "force-dynamic"; export default async function ContactPage() { const tenant = await requirePublicTenantContext(); - const contact = await getPublicTenantContact(tenant); + const [contact, presentation, storedContent] = await Promise.all([ + getPublicTenantContact(tenant), + getPublicTenantPresentation(tenant), + getPublishedSiteContent(tenant), + ]); + const runtimeConfig = buildServerDomainConfig(env); + const siteContent = resolveTenantSiteContent({ + companyName: presentation.companyName, + startPurchaseHref: buildAuthRedirect(runtimeConfig, { + returnTo: "/portal", + tenantSlug: tenant.companySlug, + tenantHost: tenant.host, + entry: "buyer", + }), + stored: storedContent, + }); const methods: { icon: LucideIcon; label: string; value: string; href?: string }[] = []; if (contact.address) { @@ -29,14 +51,23 @@ export default async function ContactPage() { href: `tel:${contact.phone.replace(/[^+\d]/g, "")}`, }); } + if (siteContent.social.whatsapp) { + methods.push({ + icon: MessageCircle, + label: "WhatsApp", + value: siteContent.social.whatsapp, + href: `https://wa.me/${siteContent.social.whatsapp.replace(/[^\d]/g, "")}`, + }); + } + methods.push({ icon: Clock, label: "Office hours", value: siteContent.contact.hours }); return ( {methods.length > 0 ? ( diff --git a/src/app/(marketing)/layout.tsx b/src/app/(marketing)/layout.tsx index 01aafab..ed9900d 100644 --- a/src/app/(marketing)/layout.tsx +++ b/src/app/(marketing)/layout.tsx @@ -3,6 +3,8 @@ import { notFound } from "next/navigation"; import { TenantPublicShell } from "@/components/marketing/tenant-public-shell"; import { getPublishedTenantBranding, getTenantPresentation } from "@/modules/branding/service"; +import { resolveTenantSiteContent } from "@/modules/cms/site-content"; +import { getPublishedSiteContent } from "@/modules/cms/site-content-service"; import { requirePublicTenantContext } from "@/lib/tenancy/context"; export const dynamic = "force-dynamic"; @@ -10,15 +12,29 @@ export const dynamic = "force-dynamic"; export async function generateMetadata(): Promise { try { const tenant = await requirePublicTenantContext(); - const [branding, presentation] = await Promise.all([ + const [branding, presentation, storedContent] = await Promise.all([ getPublishedTenantBranding(tenant), getTenantPresentation(tenant), + getPublishedSiteContent(tenant), ]); const name = presentation.companyName; + // Tenant-authored SEO title/description with company-derived fallbacks. + const siteContent = resolveTenantSiteContent({ + companyName: name, + startPurchaseHref: "/portal", + stored: storedContent, + }); return { - title: { default: name, template: `%s \u00b7 ${name}` }, - description: `${name} — property listings, viewings, and secure transactions.`, + title: { default: siteContent.seo.title, template:`%s \u00b7 ${name}` }, + description: siteContent.seo.description, + openGraph: { + title: siteContent.seo.title, + description: siteContent.seo.description, + siteName: name, + type: "website", + ...(branding.heroImageUrl ? { images: [{ url: branding.heroImageUrl }] } : {}), + }, icons: branding.faviconUrl ? { icon: branding.faviconUrl } : undefined, }; } catch { diff --git a/src/components/admin/site-content-management.tsx b/src/components/admin/site-content-management.tsx index d4294a4..125796f 100644 --- a/src/components/admin/site-content-management.tsx +++ b/src/components/admin/site-content-management.tsx @@ -10,7 +10,17 @@ import { formatStableDate } from "@/lib/utils"; import type { TenantSiteContent } from "@/modules/cms/site-content"; import type { TenantSiteContentState } from "@/modules/cms/site-content-service"; +/** + * Full-site CMS editor: every piece of public marketing copy — SEO, hero + * (+ stat cards + side panel), the buyer journey, section headings, about, + * contact extras, social links, footer — with draft → publish control. + * Blank fields fall back to smart company-derived defaults (shown as + * placeholders), so tenants only override what they want to change. + */ + type DraftState = { + seoTitle: string; + seoDescription: string; heroEyebrow: string; heroHeadline: string; heroSubhead: string; @@ -18,15 +28,68 @@ type DraftState = { heroPrimaryHref: string; heroSecondaryLabel: string; heroSecondaryHref: string; - footerTagline: string; + statInventoryLabel: string; + statInventoryNote: string; + statMarketersLabel: string; + statMarketersNote: string; + statTrustLabel: string; + statTrustNote: string; + panelBadge: string; + panelTitle: string; + panelBody: string; + journeyHeading: string; + journeyStep1Title: string; + journeyStep1Description: string; + journeyStep2Title: string; + journeyStep2Description: string; + journeyStep3Title: string; + journeyStep3Description: string; + featuredEyebrow: string; + featuredTitle: string; + featuredDescription: string; + marketersTitle: string; + marketersDescription: string; + testimonialsEyebrow: string; + testimonialsTitle: string; + testimonialsDescription: string; aboutEyebrow: string; aboutTitle: string; aboutIntro: string; + careersVisible: boolean; + careersEyebrow: string; + careersTitle: string; + careersIntro: string; + careersValue1Title: string; + careersValue1Description: string; + careersValue2Title: string; + careersValue2Description: string; + careersValue3Title: string; + careersValue3Description: string; + careersCtaHeading: string; + careersCtaBody: string; + careersCtaLabel: string; + contactHours: string; + contactNote: string; + socialFacebook: string; + socialInstagram: string; + socialTwitter: string; + socialLinkedin: string; + socialTiktok: string; + socialWhatsapp: string; + footerTagline: string; }; +/** Keys of DraftState whose values are strings (everything except toggles). */ +type StringDraftKey = { + [K in keyof DraftState]: DraftState[K] extends string ? K : never; +}[keyof DraftState]; + function buildInitialDraft(state: TenantSiteContentState): DraftState { - const { hero, footer, about } = state.draft; + const { seo, hero, heroStats, heroPanel, journey, sections, about, careers, contact, social, footer } = + state.draft; return { + seoTitle: seo?.title ?? "", + seoDescription: seo?.description ?? "", heroEyebrow: hero?.eyebrow ?? "", heroHeadline: hero?.headline ?? "", heroSubhead: hero?.subhead ?? "", @@ -34,15 +97,61 @@ function buildInitialDraft(state: TenantSiteContentState): DraftState { heroPrimaryHref: hero?.primaryCta?.href ?? "", heroSecondaryLabel: hero?.secondaryCta?.label ?? "", heroSecondaryHref: hero?.secondaryCta?.href ?? "", - footerTagline: footer?.tagline ?? "", + statInventoryLabel: heroStats?.inventoryLabel ?? "", + statInventoryNote: heroStats?.inventoryNote ?? "", + statMarketersLabel: heroStats?.marketersLabel ?? "", + statMarketersNote: heroStats?.marketersNote ?? "", + statTrustLabel: heroStats?.trustLabel ?? "", + statTrustNote: heroStats?.trustNote ?? "", + panelBadge: heroPanel?.badge ?? "", + panelTitle: heroPanel?.title ?? "", + panelBody: heroPanel?.body ?? "", + journeyHeading: journey?.heading ?? "", + journeyStep1Title: journey?.steps?.[0]?.title ?? "", + journeyStep1Description: journey?.steps?.[0]?.description ?? "", + journeyStep2Title: journey?.steps?.[1]?.title ?? "", + journeyStep2Description: journey?.steps?.[1]?.description ?? "", + journeyStep3Title: journey?.steps?.[2]?.title ?? "", + journeyStep3Description: journey?.steps?.[2]?.description ?? "", + featuredEyebrow: sections?.featured?.eyebrow ?? "", + featuredTitle: sections?.featured?.title ?? "", + featuredDescription: sections?.featured?.description ?? "", + marketersTitle: sections?.marketers?.title ?? "", + marketersDescription: sections?.marketers?.description ?? "", + testimonialsEyebrow: sections?.testimonials?.eyebrow ?? "", + testimonialsTitle: sections?.testimonials?.title ?? "", + testimonialsDescription: sections?.testimonials?.description ?? "", aboutEyebrow: about?.eyebrow ?? "", aboutTitle: about?.title ?? "", aboutIntro: about?.intro ?? "", + careersVisible: careers?.visible !== false, + careersEyebrow: careers?.eyebrow ?? "", + careersTitle: careers?.title ?? "", + careersIntro: careers?.intro ?? "", + careersValue1Title: careers?.values?.[0]?.title ?? "", + careersValue1Description: careers?.values?.[0]?.description ?? "", + careersValue2Title: careers?.values?.[1]?.title ?? "", + careersValue2Description: careers?.values?.[1]?.description ?? "", + careersValue3Title: careers?.values?.[2]?.title ?? "", + careersValue3Description: careers?.values?.[2]?.description ?? "", + careersCtaHeading: careers?.ctaHeading ?? "", + careersCtaBody: careers?.ctaBody ?? "", + careersCtaLabel: careers?.ctaLabel ?? "", + contactHours: contact?.hours ?? "", + contactNote: contact?.note ?? "", + socialFacebook: social?.facebook ?? "", + socialInstagram: social?.instagram ?? "", + socialTwitter: social?.twitter ?? "", + socialLinkedin: social?.linkedin ?? "", + socialTiktok: social?.tiktok ?? "", + socialWhatsapp: social?.whatsapp ?? "", + footerTagline: footer?.tagline ?? "", }; } function buildPayload(draft: DraftState) { return { + seo: { title: draft.seoTitle, description: draft.seoDescription }, hero: { eyebrow: draft.heroEyebrow, headline: draft.heroHeadline, @@ -50,12 +159,72 @@ function buildPayload(draft: DraftState) { primaryCta: { label: draft.heroPrimaryLabel, href: draft.heroPrimaryHref }, secondaryCta: { label: draft.heroSecondaryLabel, href: draft.heroSecondaryHref }, }, - footer: { tagline: draft.footerTagline }, + heroStats: { + inventoryLabel: draft.statInventoryLabel, + inventoryNote: draft.statInventoryNote, + marketersLabel: draft.statMarketersLabel, + marketersNote: draft.statMarketersNote, + trustLabel: draft.statTrustLabel, + trustNote: draft.statTrustNote, + }, + heroPanel: { + badge: draft.panelBadge, + title: draft.panelTitle, + body: draft.panelBody, + }, + journey: { + heading: draft.journeyHeading, + steps: [ + { title: draft.journeyStep1Title, description: draft.journeyStep1Description }, + { title: draft.journeyStep2Title, description: draft.journeyStep2Description }, + { title: draft.journeyStep3Title, description: draft.journeyStep3Description }, + ], + }, + sections: { + featured: { + eyebrow: draft.featuredEyebrow, + title: draft.featuredTitle, + description: draft.featuredDescription, + }, + marketers: { + title: draft.marketersTitle, + description: draft.marketersDescription, + }, + testimonials: { + eyebrow: draft.testimonialsEyebrow, + title: draft.testimonialsTitle, + description: draft.testimonialsDescription, + }, + }, about: { eyebrow: draft.aboutEyebrow, title: draft.aboutTitle, intro: draft.aboutIntro, }, + careers: { + visible: draft.careersVisible, + eyebrow: draft.careersEyebrow, + title: draft.careersTitle, + intro: draft.careersIntro, + values: [ + { title: draft.careersValue1Title, description: draft.careersValue1Description }, + { title: draft.careersValue2Title, description: draft.careersValue2Description }, + { title: draft.careersValue3Title, description: draft.careersValue3Description }, + ], + ctaHeading: draft.careersCtaHeading, + ctaBody: draft.careersCtaBody, + ctaLabel: draft.careersCtaLabel, + }, + contact: { hours: draft.contactHours, note: draft.contactNote }, + social: { + facebook: draft.socialFacebook, + instagram: draft.socialInstagram, + twitter: draft.socialTwitter, + linkedin: draft.socialLinkedin, + tiktok: draft.socialTiktok, + whatsapp: draft.socialWhatsapp, + }, + footer: { tagline: draft.footerTagline }, }; } @@ -71,7 +240,7 @@ export function SiteContentManagement({ const initial = useMemo(() => buildInitialDraft(state), [state]); const [draft, setDraft] = useState(initial); - const update = (key: keyof DraftState) => (value: string) => + const update = (key: StringDraftKey) => (value: string) => setDraft((current) => ({ ...current, [key]: value })); async function saveDraft() { @@ -132,11 +301,12 @@ export function SiteContentManagement({ Site content

- Edit your public site copy with draft control. + Make the public site sound like your company.

- Changes stay in draft until you publish. Any field left blank uses a smart default - derived from your company — shown as the placeholder. + Every section of your public website is editable here. Changes stay in draft until + you publish. Any field left blank uses a smart default derived from your company — + shown as the placeholder.

Last published: {state.publishedAt ? formatStableDate(state.publishedAt) : "Never"} @@ -166,6 +336,21 @@ export function SiteContentManagement({
+
+
+ + + + +