diff --git a/.eslintrc.json b/.eslintrc.json index 5408704..475c69c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,4 +1,5 @@ { + "root": true, "extends": ["next/core-web-vitals", "next/typescript"], "ignorePatterns": ["out/", ".next/", "node_modules/", "coverage/"] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f54053..817e2df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,11 @@ -name: CI +name: CI/CD Pipeline env: GITHUB_PAGES: "true" - NEXT_PUBLIC_BASE_PATH: "/OpenDQM" + NEXT_PUBLIC_BASE_PATH: "/${{ github.event.repository.name }}" + NEXT_PUBLIC_INTERVIEW_ENDPOINT: ${{ vars.NEXT_PUBLIC_INTERVIEW_ENDPOINT }} + NEXT_PUBLIC_INTERVIEW_EMAIL_ENDPOINT: ${{ vars.NEXT_PUBLIC_INTERVIEW_EMAIL_ENDPOINT }} + NEXT_PUBLIC_INTERVIEW_APPS_SCRIPT_URL: ${{ vars.NEXT_PUBLIC_INTERVIEW_APPS_SCRIPT_URL }} on: push: @@ -17,7 +20,7 @@ permissions: jobs: validate: - name: Lint, Test, and Build + name: Test, Build & Upload runs-on: ubuntu-latest steps: @@ -31,7 +34,7 @@ jobs: cache: npm - name: Install dependencies - run: npm install + run: npm ci - name: Lint run: npm run lint @@ -39,12 +42,15 @@ jobs: - name: Typecheck run: npm run typecheck + - name: Prettier + run: npm run format:check + - name: Test run: npm run test - name: Security audit run: npm audit --omit=dev --audit-level=moderate - + - name: Build run: npm run build diff --git a/app/about/page.tsx b/app/about/page.tsx index eac21f8..a7d2f68 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -25,19 +25,17 @@ export default function AboutPage() {
- +

- A community-driven effort that connects people exploring how quality can work across organizations, tools, and locations/ + A community-driven effort that connects people exploring how quality can work across + organizations, tools, and locations/

How can information move without requiring every participant to use the same system? - - How can open participation be supported while preserving accountability, learning, and trust? + How can open participation be supported while preserving accountability, learning, and + trust?

diff --git a/app/contact/page.tsx b/app/contact/page.tsx index 0c67de5..0d9f3b6 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next"; import { PageHeader } from "@/components/PageHeader"; +import { ContactChoices } from "@/components/ContactChoices"; +import "@/app/interview.css"; export const metadata: Metadata = { title: "Contact", @@ -12,22 +14,11 @@ export default function ContactPage() { return ( <> -
-
-
- Contact -

Contact information coming soon.

-

- OpenDQM is formalizing contact details, community calls, and workshop logistics. More - ways to connect will be shared here as they become available. -

-
-
-
+ ); } diff --git a/app/events/page.tsx b/app/events/page.tsx index a3ce6a0..03f898c 100644 --- a/app/events/page.tsx +++ b/app/events/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { EventCollections } from "@/components/EventCollections"; import { PageHeader } from "@/components/PageHeader"; -import { SurveyCta } from "@/components/SurveyCta"; +import { InterviewCta } from "@/components/InterviewCta"; export const metadata: Metadata = { title: "Events", @@ -22,7 +22,7 @@ export default function EventsPage() {
- + ); } diff --git a/app/globals.css b/app/globals.css index de958d9..84abfe1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3247,7 +3247,6 @@ footer .brand span:last-child { } } - .horizontal-story-list { display: grid; grid-template-columns: repeat(4, 1fr); @@ -3275,4 +3274,4 @@ footer .brand span:last-child { .footer-funding span { font-size: 0.9rem; line-height: 1.4; -} \ No newline at end of file +} diff --git a/app/interview.css b/app/interview.css new file mode 100644 index 0000000..6521cdc --- /dev/null +++ b/app/interview.css @@ -0,0 +1,215 @@ +#schedule-interview { + scroll-margin-top: 120px; +} + +.interview-panel { + max-width: 940px; + padding: clamp(24px, 5vw, 56px); + background: #faf9ff; + border: 1px solid #c8c3e2; + border-radius: var(--radius); + box-shadow: var(--shadow-soft); +} + +.interview-form { + display: grid; + gap: 24px; + margin-top: 32px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; +} + +.interview-form label { + display: grid; + gap: 8px; + color: var(--night); + font-size: 0.95rem; + min-width: 0; +} + +.interview-form label > span { + color: var(--muted); +} + +.interview-form input, +.interview-form textarea { + width: 100%; + min-width: 0; + padding: 12px 14px; + border: 1px solid #827a9e; + border-radius: 10px; + background: #fff; + color: var(--ink); + font: inherit; +} + +.interview-form textarea { + resize: vertical; +} + +.form-help { + font-size: 0.85rem; + color: var(--muted); +} + +.interview-form .consent { + display: flex; + align-items: flex-start; + gap: 12px; + font-size: 0.85rem; +} + +.interview-form .consent input { + width: 20px; + height: 20px; + flex-shrink: 0; + margin-top: 4px; + accent-color: var(--grape); +} + +.form-trap { + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; +} + +.form-errors { + color: #842236; + background: #fff0f2; + padding: 20px 20px 20px 40px; + border-radius: 12px; +} + +.form-success { + padding: 24px; + background: #eceafa; + border-radius: var(--radius-sm); +} + +.interview-form button[type="submit"] { + justify-self: start; +} + +button:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.contact-choices { + background: linear-gradient(180deg, #f7f6fc, #e5e8f2); +} + +.contact-choices h2 { + margin: 0 0 12px; + font-size: clamp(1.8rem, 3vw, 2.6rem); + color: var(--night); +} + +.connect-intro { + color: var(--muted); + margin-bottom: 32px; +} + +.brand-icon { + flex-shrink: 0; + vertical-align: middle; +} + +.contact-flow { + background: linear-gradient(180deg, #f7f6fc, #e5e8f2); +} + +.contact-flow > section { + background: transparent; +} + +.contact-flow .interview-section { + background: transparent; +} + +.contact-paths { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: clamp(32px, 6vw, 80px); + margin-top: 40px; +} + +.contact-path { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; +} + +.contact-path-icon { + display: grid; + place-items: center; + width: 48px; + height: 48px; + border-radius: 50%; + background: #e7e2f6; + color: var(--grape); +} + +.contact-path h3 { + margin: 0; + font-size: clamp(1.4rem, 2.3vw, 1.8rem); + color: var(--night); +} + +.contact-path p { + margin: 0; + color: var(--muted); +} + +.contact-path .contact-email { + color: var(--grape); + text-decoration: underline; + text-underline-offset: 4px; +} + +.contact-path .button { + margin-top: auto; +} + +.interview-section { + padding-top: 32px; + background: #f7f6fc; +} + +.availability-fields { + padding: 24px; + background: #eeebfa; + border-radius: var(--radius-sm); +} + +.timezone-field { + max-width: 370px; + margin-top: 24px; +} + +.interview-form input:focus-visible, +.interview-form textarea:focus-visible, +.contact-flow button:focus-visible, +.contact-flow [tabindex="-1"]:focus-visible { + outline: 3px solid #49369a; + outline-offset: 4px; +} +.contact-flow [hidden] { + display: none !important; +} +@media (max-width: 767px) { + .form-grid, + .contact-paths { + grid-template-columns: 1fr; + } + .availability-fields { + padding: 16px; + } +} diff --git a/app/resources/page.tsx b/app/resources/page.tsx index c4db3e5..513ed00 100644 --- a/app/resources/page.tsx +++ b/app/resources/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { PageHeader } from "@/components/PageHeader"; // import { resources } from "@/data/resources"; -import { SurveyCta } from "@/components/SurveyCta"; +import { InterviewCta } from "@/components/InterviewCta"; export const metadata: Metadata = { title: "Resources", @@ -12,12 +12,8 @@ export const metadata: Metadata = { export default function ResourcesPage() { return ( <> - - ; + + ; {/*
diff --git a/components/BrandIcon.tsx b/components/BrandIcon.tsx new file mode 100644 index 0000000..c7fc75d --- /dev/null +++ b/components/BrandIcon.tsx @@ -0,0 +1,37 @@ +type BrandIconProps = { kind: "mail" | "calendar" | "arrow" }; + +export function BrandIcon({ kind }: BrandIconProps) { + return ( + + ); +} diff --git a/components/ContactChoices.tsx b/components/ContactChoices.tsx new file mode 100644 index 0000000..b472038 --- /dev/null +++ b/components/ContactChoices.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { InterviewForm } from "@/components/InterviewForm"; +import { BrandIcon } from "@/components/BrandIcon"; + +export function ContactChoices() { + const [expanded, setExpanded] = useState(false); + useEffect(() => { + const openFromHash = () => { + if (window.location.hash === "#schedule-interview") setExpanded(true); + }; + openFromHash(); + window.addEventListener("hashchange", openFromHash); + return () => window.removeEventListener("hashchange", openFromHash); + }, []); + useEffect(() => { + if (expanded) { + // Let the expanded layout and the router's fragment handling settle first. + const timer = window.setTimeout( + () => document.getElementById("schedule-interview")?.scrollIntoView(), + 100 + ); + return () => window.clearTimeout(timer); + } + }, [expanded]); + return ( +
+
+
+

Connect with the team

+

Choose how you’d like to get involved.

+
+ +
+ + + +

Schedule an Interview

+

Share your experience and help inform the research.

+ +
+
+
+
+ +
+ ); +} diff --git a/components/EcosystemSection.tsx b/components/EcosystemSection.tsx index df14ea9..4178adf 100644 --- a/components/EcosystemSection.tsx +++ b/components/EcosystemSection.tsx @@ -22,10 +22,7 @@ export function EcosystemSection() {
{/*

Part of a broader quality ecosystem

*/} - +
@@ -38,7 +35,10 @@ export function EcosystemSection() {
{/*

What we are building

*/}

- OpenDQM is a research project sponsored by the National Science Foundation Pathways for Open Source Ecosystems Phase 1 grant. The Global Open Source Quality Assurance System (GOSQAS), a project of Public Invention and central core of the OpenDQM ecosystem, gratefully partners with other open source community initiatives. + OpenDQM is a research project sponsored by the National Science Foundation Pathways for + Open Source Ecosystems Phase 1 grant. The Global Open Source Quality Assurance System + (GOSQAS), a project of Public Invention and central core of the OpenDQM ecosystem, + gratefully partners with other open source community initiatives.

diff --git a/components/EventsSection.tsx b/components/EventsSection.tsx index 9561b6c..0a87482 100644 --- a/components/EventsSection.tsx +++ b/components/EventsSection.tsx @@ -23,7 +23,8 @@ export function EventsSection() {

Events

- OpenDQM workshops connect experts in quality assurance, manufacturing, hardware, testing, standards, and distributed production. + OpenDQM workshops connect experts in quality assurance, manufacturing, hardware, + testing, standards, and distributed production.

diff --git a/components/Footer.tsx b/components/Footer.tsx index 8721183..3554f02 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -27,10 +27,7 @@ export function Footer() {
-

- Open, shared infrastructure for trusted distributed quality - management. -

+

Open, shared infrastructure for trusted distributed quality management.

); -} \ No newline at end of file +} diff --git a/components/Hero.tsx b/components/Hero.tsx index 1ef1360..c3d94b5 100644 --- a/components/Hero.tsx +++ b/components/Hero.tsx @@ -2,7 +2,6 @@ import Link from "next/link"; import { useEffect, useRef } from "react"; -import { surveyUrl } from "@/data/resources"; export function Hero() { const heroRef = useRef(null); @@ -48,13 +47,13 @@ export function Hero() {

Distributed Quality Management

- Quality management has not yet adapted to an emerging democratized, distributed reality. + Quality management has not yet adapted to an emerging democratized, distributed reality. OpenDQM is researching a shared foundation for this new context.

- - Take the OpenDQM Survey - + + Schedule an Interview + About us diff --git a/components/InstitutionalSupport.tsx b/components/InstitutionalSupport.tsx index b1f5fd0..9c5600c 100644 --- a/components/InstitutionalSupport.tsx +++ b/components/InstitutionalSupport.tsx @@ -7,12 +7,7 @@ export function InstitutionalSupport() {
- +

diff --git a/components/SurveyCta.tsx b/components/InterviewCta.tsx similarity index 62% rename from components/SurveyCta.tsx rename to components/InterviewCta.tsx index 21dcd42..593b818 100644 --- a/components/SurveyCta.tsx +++ b/components/InterviewCta.tsx @@ -1,23 +1,24 @@ import { Reveal } from "@/components/Reveal"; import { GradientHeading } from "@/components/GradientHeading"; -import { surveyUrl } from "@/data/resources"; +import Link from "next/link"; -type SurveyCtaProps = { compact?: boolean }; +type InterviewCtaProps = { compact?: boolean }; -export function SurveyCta({ compact = false }: SurveyCtaProps) { +export function InterviewCta({ compact = false }: InterviewCtaProps) { return (
- +

- Your experience can help OpenDQM understand the challenges, solutions, and new ideas for distributed quality management. + Your experience can help OpenDQM understand the challenges, solutions, and new ideas + for distributed quality management.

- + Schedule an Interview - +
diff --git a/components/InterviewForm.tsx b/components/InterviewForm.tsx new file mode 100644 index 0000000..7b2df82 --- /dev/null +++ b/components/InterviewForm.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { validateInterview, type InterviewRequest } from "@/lib/interview"; +import { submitAppsScript } from "@/lib/appsScript"; + +const scriptEndpoint = process.env.NEXT_PUBLIC_INTERVIEW_APPS_SCRIPT_URL; +const emailEndpoint = !scriptEndpoint + ? process.env.NEXT_PUBLIC_INTERVIEW_EMAIL_ENDPOINT + : undefined; +const endpoint = scriptEndpoint || emailEndpoint || process.env.NEXT_PUBLIC_INTERVIEW_ENDPOINT; + +export function InterviewForm() { + const [timezone, setTimezone] = useState(""); + const [errors, setErrors] = useState([]); + const [status, setStatus] = useState<"idle" | "sending" | "success">("idle"); + const feedback = useRef(null); + const submitting = useRef(false); + useEffect(() => setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"), []); + useEffect(() => { + if (errors.length || status === "success") feedback.current?.focus(); + }, [errors, status]); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (submitting.current) return; + const form = new FormData(event.currentTarget); + const text = (name: string) => String(form.get(name) || "").trim(); + const request: InterviewRequest = { + first_name: text("first_name"), + last_name: text("last_name"), + professional_background: text("professional_background"), + linkedin: text("linkedin"), + email: text("email"), + preferred_interview_times: text("preferred_interview_times"), + timezone, + interest_reason: text("interest_reason"), + consent: form.get("consent") === "on", + website: text("website") + }; + const issues = validateInterview(request); + if (issues.length) { + setErrors(issues); + return; + } + if (!endpoint) { + setErrors(["Interview scheduling is not available yet. Please email info@opendqm.org."]); + return; + } + submitting.current = true; + setStatus("sending"); + setErrors([]); + try { + if (scriptEndpoint) { + await submitAppsScript(scriptEndpoint, request); + } else { + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify( + emailEndpoint + ? { + ...request, + _subject: "OpenDQM interview request", + _gotcha: request.website, + source: "opendqm-interview-form" + } + : request + ), + signal: AbortSignal.timeout(25000) + }); + const result = await response.json(); + if (!response.ok || (emailEndpoint ? Boolean(result.errors) : result.ok !== true)) + throw new Error(); + } + setStatus("success"); + } catch { + setStatus("idle"); + setErrors([ + "We couldn’t confirm your request was saved. Your entries are still here. Please email info@opendqm.org before retrying to avoid a duplicate request." + ]); + } finally { + submitting.current = false; + } + } + return ( +
+
+

Schedule an Interview

+

+ Share your experience and help shape OpenDQM research. Tell us when you’re available; the + team will contact you to arrange an interview. +

+
+ {errors.length > 0 && ( +
    + {errors.map((error) => ( +
  • {error}
  • + ))} +
+ )} + {status === "success" && ( +
+

Thank you for supporting OpenDQM research.

+

+ Your interview request has been received. The team will contact you by email to + confirm a time. +

+
+ )} +
+ {status !== "success" && ( +
+
+ + + + +
+