diff --git a/src/features/form/components/AdminFormDetailPage.tsx b/src/features/form/components/AdminFormDetailPage.tsx index 5ce3b25..2fac259 100644 --- a/src/features/form/components/AdminFormDetailPage.tsx +++ b/src/features/form/components/AdminFormDetailPage.tsx @@ -5,7 +5,7 @@ import { SEO_CONFIG } from "@/seo/seo.config"; import { useSeo } from "@/seo/useSeo"; import { Button, ErrorMessage, LoadingSpinner, useToast } from "@/shared/components"; import { useIsMutating } from "@tanstack/react-query"; -import { Check, LoaderCircle } from "lucide-react"; +import { Check, Link, LoaderCircle } from "lucide-react"; import { useState } from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import styles from "./AdminFormDetailPage.module.css"; @@ -58,6 +58,17 @@ export const AdminFormDetailPage = () => { window.open(`/forms/${formid}`, "_blank", "noopener,noreferrer"); }; + const handleCopyFormLink = async () => { + if (!formid) return; + const formUrl = `${window.location.origin}/forms/${formid}`; + try { + await navigator.clipboard.writeText(formUrl); + pushToast({ title: "已複製填寫連結", variant: "success" }); + } catch { + pushToast({ title: "複製失敗", variant: "error" }); + } + }; + if (formQuery.isLoading) { return ( @@ -94,12 +105,20 @@ export const AdminFormDetailPage = () => { - - + {formQuery.data.status === "DRAFT" ? ( + + ) : ( + <> + + + + )} diff --git a/src/features/form/components/AdminFormPreviewPage.tsx b/src/features/form/components/AdminFormPreviewPage.tsx index e180499..5bf1547 100644 --- a/src/features/form/components/AdminFormPreviewPage.tsx +++ b/src/features/form/components/AdminFormPreviewPage.tsx @@ -7,7 +7,10 @@ import type { FormsSection } from "@nycu-sdc/core-system-sdk"; import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { useParams } from "react-router-dom"; import styles from "./AdminFormPreviewPage.module.css"; -import formStyles from "./FormDetailPage.module.css"; +import { FormHeader } from "./FormDetail/components/FormHeader/FormHeader"; +import { FormPreviewSection } from "./FormDetail/components/FormPreviewSection/FormPreviewSection"; +import { FormStructure } from "./FormDetail/components/FormStructure/FormStructure"; +import formStyles from "./FormFilloutPage.module.css"; import { FormQuestionRenderer } from "./FormQuestionRenderer"; const ensureEmfontStylesheet = (fontId: string) => { @@ -60,6 +63,7 @@ export const AdminFormPreviewPage = () => { }, [sections]); const safeCurrentStep = sections.length > 0 ? Math.min(currentStep, sections.length - 1) : currentStep; + const currentSection = sections[safeCurrentStep]; const isFirstStep = safeCurrentStep === 0; const isLastStep = sections.length === 0 || safeCurrentStep === sections.length - 1; const isOnPreviewStep = isLastStep && sections[safeCurrentStep]?.id === "preview"; @@ -128,73 +132,16 @@ export const AdminFormPreviewPage = () => {
{coverImageUrl && 表單封面 (e.currentTarget.style.display = "none")} />}
-
-

{form.title}

- {safeCurrentStep === 0 ?

{form.description}

:

{sections[safeCurrentStep]?.title}

} -
+ -
-
-

表單結構

-
-
- {sections.map((section, index) => ( - - ))} -
-
+
{sections[safeCurrentStep] && (
{isOnPreviewStep ? ( -
- {sections - .filter(s => s.id !== "preview") - .map(section => ( -
-
-

{section.title}

- -
-
    - {section.questions?.map((q, qi) => { - const raw = answers[q.id] ?? ""; - const displayValue = raw - ? q.choices - ? raw - .split(",") - .map(id => q.choices?.find(c => c.id === id)?.name ?? id) - .join("、") - : raw - : ""; - return ( -
  • - - {q.title} - {q.required && *} - - - {displayValue || "未填寫"} -
  • - ); - })} -
-
- ))} -
+ ) : ( <> {sections[safeCurrentStep].questions?.map(question => ( diff --git a/src/features/form/components/FormDetail/components/FormHeader/FormHeader.module.css b/src/features/form/components/FormDetail/components/FormHeader/FormHeader.module.css new file mode 100644 index 0000000..9ea0d89 --- /dev/null +++ b/src/features/form/components/FormDetail/components/FormHeader/FormHeader.module.css @@ -0,0 +1,58 @@ +.header { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.topBar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.saveStatus { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: var(--comment); + font-size: 0.9rem; +} + +.spinningIcon { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.title { + margin-bottom: 1.5rem; + text-align: center; + font-family: var(--form-header-font, inherit); +} + +.description { + color: var(--color-caption); + font-size: 1rem; + margin-bottom: 1rem; +} + +.sectionHeader { + font-size: 1.6rem; + margin: 0; + color: var(--color-heading); + text-align: left; + font-family: var(--form-header-font, inherit); +} + +.sectionDescription { + color: var(--color-caption); + font-size: 1rem; +} diff --git a/src/features/form/components/FormDetail/components/FormHeader/FormHeader.tsx b/src/features/form/components/FormDetail/components/FormHeader/FormHeader.tsx new file mode 100644 index 0000000..c67329a --- /dev/null +++ b/src/features/form/components/FormDetail/components/FormHeader/FormHeader.tsx @@ -0,0 +1,53 @@ +import { Button } from "@/shared/components"; +import { AlertCircle, Check, ChevronLeft, LoaderCircle } from "lucide-react"; +import styles from "./FormHeader.module.css"; + +interface FormHeaderProps { + title: string; + formDescription?: string | null; + currentStep: number; + currentSection?: { title?: string; description?: string } | null; + onBack?: () => void; + saveStatus?: "saving" | "error" | "saved"; +} + +export const FormHeader = ({ title, formDescription, currentStep, currentSection, onBack, saveStatus }: FormHeaderProps) => { + return ( +
+ {(onBack || saveStatus) && ( +
+ {onBack && ( + + )} + {saveStatus && ( +
+ {saveStatus === "saving" ? ( + <> + + 儲存中 + + ) : saveStatus === "error" ? ( + <> + + 有問題 + + ) : ( + <> + + 已儲存 + + )} +
+ )} +
+ )} +

{title}

+ {currentStep === 0 && formDescription &&
} +

{currentSection?.title}

+ {currentSection?.description &&
} +
+ ); +}; diff --git a/src/features/form/components/FormDetail/components/FormPreviewSection/FormPreviewSection.module.css b/src/features/form/components/FormDetail/components/FormPreviewSection/FormPreviewSection.module.css new file mode 100644 index 0000000..af106fa --- /dev/null +++ b/src/features/form/components/FormDetail/components/FormPreviewSection/FormPreviewSection.module.css @@ -0,0 +1,66 @@ +.previewSection { + display: flex; + flex-direction: column; + gap: 4rem; +} + +.previewBlock { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.previewHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 0.75rem; +} + +.previewTitle { + font-size: 1.6rem; + font-weight: 520; + color: var(--color-heading); + margin: 0; +} + +.previewList { + list-style: disc; + padding-left: 1.5rem; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.previewList li { + font-size: 1rem; + color: var(--color-body); + line-height: 1.5; +} + +.previewList li strong { + color: var(--color-heading); + font-weight: 600; + margin-right: 0.5rem; +} + +.previewSectionTitle { + margin: 0; +} + +.previewAnswerLabel { + font-weight: 500; +} + +.previewAnswerEmpty { + color: var(--red); +} + +.requiredAsterisk { + color: var(--red); +} + +.caption { + color: var(--color-caption); +} diff --git a/src/features/form/components/FormDetail/components/FormPreviewSection/FormPreviewSection.tsx b/src/features/form/components/FormDetail/components/FormPreviewSection/FormPreviewSection.tsx new file mode 100644 index 0000000..506b1aa --- /dev/null +++ b/src/features/form/components/FormDetail/components/FormPreviewSection/FormPreviewSection.tsx @@ -0,0 +1,118 @@ +import { Button } from "@/shared/components"; +import { ResponsesSectionProgress, type FormsSection, type ResponsesResponseSections } from "@nycu-sdc/core-system-sdk"; +import styles from "./FormPreviewSection.module.css"; + +type ResponseModeProps = { + mode: "response"; + previewData: ResponsesResponseSections[] | null; + sections: FormsSection[]; + onSectionClick: (index: number) => void; +}; + +type LocalModeProps = { + mode: "local"; + localSections: FormsSection[]; + localAnswers: Record; + sections: FormsSection[]; + onSectionClick: (index: number) => void; +}; + +type FormPreviewSectionProps = ResponseModeProps | LocalModeProps; + +export const FormPreviewSection = (props: FormPreviewSectionProps) => { + const { sections, onSectionClick } = props; + + if (props.mode === "local") { + const displaySections = props.localSections.filter(s => s.id !== "preview"); + return ( +
+ {displaySections.map(section => ( +
+
+

{section.title}

+ +
+
    + {section.questions?.map((q, qi) => { + const raw = props.localAnswers[q.id] ?? ""; + const displayValue = raw + ? q.choices + ? raw + .split(",") + .map(id => q.choices?.find(c => c.id === id)?.name ?? id) + .join("、") + : raw + : ""; + return ( +
  • + + {q.title} + {q.required && *} + + + {displayValue || "未填寫"} +
  • + ); + })} +
+
+ ))} +
+ ); + } + + // mode === "response" + const { previewData } = props; + if (!previewData || previewData.length === 0) { + return

尚無填答資料

; + } + + return ( +
+ {previewData + .filter(section => section.progress !== ResponsesSectionProgress.SKIPPED) + .map(section => ( +
+
+

{section.title}

+ +
+
    + {section.answerDetails?.map((detail, questionIndex: number) => { + const isEmpty = !detail.payload?.displayValue; + const isRequiredAndEmpty = isEmpty && detail.question.required; + return ( +
  • + + {detail.question.title} + {detail.question.required && *} + + + {detail.payload?.displayValue || "未填寫"} +
  • + ); + }) || []} +
+
+ ))} +
+ ); +}; diff --git a/src/features/form/components/FormDetail/components/FormStructure/FormStructure.module.css b/src/features/form/components/FormDetail/components/FormStructure/FormStructure.module.css new file mode 100644 index 0000000..2d30cd9 --- /dev/null +++ b/src/features/form/components/FormDetail/components/FormStructure/FormStructure.module.css @@ -0,0 +1,85 @@ +.structure { + display: flex; + flex-direction: column; + gap: 1rem; + background-color: var(--background-color-tertiary); + padding: 1.5rem; + border-radius: 3px; +} + +.structureTitle { + display: flex; + align-items: flex-end; +} + +.structureTitle h2 { + font-size: 1.5rem; + margin-block: 0; +} + +.structureTitle p { + font-size: 1rem; +} + +.structureLegendRow { + display: flex; + gap: 0.625rem; +} + +.structureLegend { + display: flex; + font-size: 0.875rem; + color: var(--color-caption); + align-items: center; + gap: 0.3rem; +} + +.structureLegend span { + width: 0.75rem; + height: 0.75rem; +} + +.structureLegendDotCompleted { + background-color: var(--color-caption); +} + +.structureLegendDotPending { + background-color: var(--code-foreground); +} + +.structureLegendDotCurrent { + background-color: var(--form-theme-color, var(--orange)); +} + +.workflow { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.workflowButton { + padding: 0.5rem 1rem; + background-color: var(--code-foreground); + border: 0.0625rem solid var(--code-foreground); + color: var(--background); + font-size: 1rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.workflowButton.active { + background-color: var(--form-theme-color, var(--orange)); + border-color: var(--form-theme-color, var(--orange)); + color: var(--background); +} + +.workflowButton.completed { + background-color: var(--color-caption); + border-color: var(--color-caption); + color: var(--background); +} + +.workflowButton.completed.active { + background-color: var(--form-theme-color, var(--orange)); + border-color: var(--form-theme-color, var(--orange)); +} diff --git a/src/features/form/components/FormDetail/components/FormStructure/FormStructure.tsx b/src/features/form/components/FormDetail/components/FormStructure/FormStructure.tsx new file mode 100644 index 0000000..b6ef7e2 --- /dev/null +++ b/src/features/form/components/FormDetail/components/FormStructure/FormStructure.tsx @@ -0,0 +1,40 @@ +import type { FormsSection } from "@nycu-sdc/core-system-sdk"; +import styles from "./FormStructure.module.css"; + +interface FormStructureProps { + sections: FormsSection[]; + currentStep: number; + onSectionClick: (index: number) => void; +} + +export const FormStructure = ({ sections, currentStep, onSectionClick }: FormStructureProps) => { + return ( +
+
+

表單結構

+

(可點擊項目返回編輯)

+
+
+
+ +

完成填寫

+
+
+ +

待填寫

+
+
+ +

目前位置

+
+
+
+ {sections.map((section, index) => ( + + ))} +
+
+ ); +}; diff --git a/src/features/form/components/FormDetailPage.module.css b/src/features/form/components/FormFilloutPage.module.css similarity index 68% rename from src/features/form/components/FormDetailPage.module.css rename to src/features/form/components/FormFilloutPage.module.css index 3302392..745dfc3 100644 --- a/src/features/form/components/FormDetailPage.module.css +++ b/src/features/form/components/FormFilloutPage.module.css @@ -26,138 +26,10 @@ } } -.header { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.topBar { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 2rem; -} - -.saveStatus { - display: inline-flex; - align-items: center; - gap: 0.375rem; - color: var(--comment); - font-size: 0.9rem; -} - -.spinningIcon { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -.title { - margin-bottom: 1.5rem; - text-align: center; - font-family: var(--form-header-font, inherit); -} - -.description { - color: var(--color-caption); - font-size: 1rem; - margin-bottom: 1rem; -} - -.sectionHeader { - font-size: 1.6rem; - margin: 0; - color: var(--color-heading); - text-align: left; - font-family: var(--form-header-font, inherit); -} - -.sectionDescription { - color: var(--color-caption); - font-size: 1rem; -} - .fields label { font-family: var(--form-question-font, inherit); } -.structure { - display: flex; - flex-direction: column; - gap: 1rem; - background-color: var(--background-color-tertiary); - padding: 1.5rem; - border-radius: 3px; -} - -.structureTitle { - display: flex; - align-items: flex-end; -} - -.structureTitle h2 { - font-size: 1.5rem; - margin-block: 0; -} - -.structureTitle p { - font-size: 1rem; -} - -.structureLegend { - display: flex; - font-size: 0.875rem; - color: var(--color-caption); - align-items: center; - gap: 0.3rem; -} - -.structureLegend span { - width: 0.75rem; - height: 0.75rem; -} - -.workflow { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.workflowButton { - padding: 0.5rem 1rem; - background-color: var(--code-foreground); - border: 0.0625rem solid var(--code-foreground); - color: var(--background); - font-size: 1rem; - cursor: pointer; - transition: all 0.2s ease; -} - -.workflowButton.active { - background-color: var(--form-theme-color, var(--orange)); - border-color: var(--form-theme-color, var(--orange)); - color: var(--background); -} - -.workflowButton.completed { - background-color: var(--color-caption); - border-color: var(--color-caption); - color: var(--background); -} - -.workflowButton.completed.active { - background-color: var(--form-theme-color, var(--orange)); - border-color: var(--form-theme-color, var(--orange)); -} - .form { display: flex; flex-direction: column; @@ -438,23 +310,6 @@ color: var(--color-caption); } -.structureLegendRow { - display: flex; - gap: 0.625rem; -} - -.structureLegendDotCompleted { - background-color: var(--color-caption); -} - -.structureLegendDotPending { - background-color: var(--code-foreground); -} - -.structureLegendDotCurrent { - background-color: var(--form-theme-color, var(--orange)); -} - .loadingCenter { display: flex; justify-content: center; diff --git a/src/features/form/components/FormDetailPage.tsx b/src/features/form/components/FormFilloutPage.tsx similarity index 81% rename from src/features/form/components/FormDetailPage.tsx rename to src/features/form/components/FormFilloutPage.tsx index 70186f3..ccbc78b 100644 --- a/src/features/form/components/FormDetailPage.tsx +++ b/src/features/form/components/FormFilloutPage.tsx @@ -7,18 +7,13 @@ import { resolveVisibleSectionsFromWorkflow } from "@/features/form/utils/workfl import { SEO_CONFIG } from "@/seo/seo.config"; import { useSeo } from "@/seo/useSeo"; import { Button, LoadingSpinner, useToast } from "@/shared/components"; -import { - ResponsesResponseProgress, - ResponsesSectionProgress, - type FormsQuestionResponse, - type FormsSection, - type ResponsesResponseSections, - type ResponsesStringArrayAnswer -} from "@nycu-sdc/core-system-sdk"; -import { AlertCircle, Check, ChevronLeft, LoaderCircle } from "lucide-react"; +import { ResponsesResponseProgress, type FormsQuestionResponse, type FormsSection, type ResponsesResponseSections, type ResponsesStringArrayAnswer } from "@nycu-sdc/core-system-sdk"; import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import styles from "./FormDetailPage.module.css"; +import { FormHeader } from "./FormDetail/components/FormHeader/FormHeader"; +import { FormPreviewSection } from "./FormDetail/components/FormPreviewSection/FormPreviewSection"; +import { FormStructure } from "./FormDetail/components/FormStructure/FormStructure"; +import styles from "./FormFilloutPage.module.css"; import { FormQuestionRenderer } from "./FormQuestionRenderer"; type Section = FormsSection; @@ -39,7 +34,7 @@ const ensureEmfontStylesheet = (fontId: string) => { document.head.appendChild(link); }; -export const FormDetailPage = () => { +export const FormFilloutPage = () => { const { formId, responseId: urlResponseId } = useParams<{ formId: string; responseId: string }>(); const navigate = useNavigate(); const { pushToast } = useToast(); @@ -372,52 +367,6 @@ export const FormDetailPage = () => { popup.focus(); }; - const renderPreviewSection = () => { - if (!previewData || previewData.length === 0) { - return

尚無填答資料

; - } - - return ( -
- {previewData - .filter(section => section.progress !== ResponsesSectionProgress.SKIPPED) - .map(section => ( -
-
-

{section.title}

- -
-
    - {section.answerDetails?.map((detail, questionIndex: number) => { - const isEmpty = !detail.payload?.displayValue; - const isRequiredAndEmpty = isEmpty && detail.question.required; - return ( -
  • - - {detail.question.title} - {detail.question.required && *} - - - {detail.payload?.displayValue || "未填寫"} -
  • - ); - }) || []} -
-
- ))} -
- ); - }; - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -524,66 +473,16 @@ export const FormDetailPage = () => { {meta} {coverImageUrl && 表單封面 (e.currentTarget.style.display = "none")} />}
-
-
- - {urlResponseId && ( -
- {updateResponseMutation.isPending ? ( - <> - - 儲存中 - - ) : updateResponseMutation.isError ? ( - <> - - 有問題 - - ) : ( - <> - - 已儲存 - - )} -
- )} -
-

{form.title}

- {currentStep === 0 && form.description &&
} -

{currentSection.title}

- {currentSection.description &&
} -
+ navigate("/forms")} + saveStatus={urlResponseId ? (updateResponseMutation.isPending ? "saving" : updateResponseMutation.isError ? "error" : "saved") : undefined} + /> -
-
-

表單結構

-

(可點擊項目返回編輯)

-
-
-
- -

完成填寫

-
-
- -

待填寫

-
-
- -

目前位置

-
-
-
- {sections.map((section, index) => ( - - ))} -
-
+
{sections[currentStep] && ( @@ -595,7 +494,7 @@ export const FormDetailPage = () => {
) : ( - renderPreviewSection() + ) ) : ( <> diff --git a/src/features/form/components/FormQuestionRenderer.tsx b/src/features/form/components/FormQuestionRenderer.tsx index b943728..ccac71d 100644 --- a/src/features/form/components/FormQuestionRenderer.tsx +++ b/src/features/form/components/FormQuestionRenderer.tsx @@ -3,7 +3,7 @@ import { AccountButton, Checkbox, DateInput, DetailedCheckbox, DragToOrder, Inpu import type { FormsQuestionResponse } from "@nycu-sdc/core-system-sdk"; import { Chrome, Github, RefreshCw, Upload, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import styles from "./FormDetailPage.module.css"; +import styles from "./FormFilloutPage.module.css"; const isValidUrl = (url: string): boolean => { try { diff --git a/src/features/form/components/index.ts b/src/features/form/components/index.ts index 15f6181..7fb7fe2 100644 --- a/src/features/form/components/index.ts +++ b/src/features/form/components/index.ts @@ -1,7 +1,7 @@ export { AdminFormDetailPage } from "./AdminFormDetailPage"; export { AdminFormPreviewPage } from "./AdminFormPreviewPage"; export { AdminFormsPage } from "./AdminFormsPage"; -export { FormDetailPage } from "./FormDetailPage"; export { FormEntryPage } from "./FormEntryPage"; +export { FormFilloutPage } from "./FormFilloutPage"; export { FormsListPage } from "./FormsListPage"; export { OAuthConnectCallbackPage } from "./OAuthConnectCallbackPage"; diff --git a/src/routes/UserRouter.tsx b/src/routes/UserRouter.tsx index af3458e..c581263 100644 --- a/src/routes/UserRouter.tsx +++ b/src/routes/UserRouter.tsx @@ -10,7 +10,7 @@ const HomePage = lazy(() => import("@/features/auth/components/HomePage").then(m const LogoutPage = lazy(() => import("@/features/auth/components/LogoutPage").then(m => ({ default: m.LogoutPage }))); const NotFoundPage = lazy(() => import("@/features/auth/components/NotFoundPage").then(m => ({ default: m.NotFoundPage }))); const WelcomePage = lazy(() => import("@/features/auth/components/WelcomePage").then(m => ({ default: m.WelcomePage }))); -const FormDetailPage = lazy(() => import("@/features/form/components").then(m => ({ default: m.FormDetailPage }))); +const FormFilloutPage = lazy(() => import("@/features/form/components").then(m => ({ default: m.FormFilloutPage }))); const FormEntryPage = lazy(() => import("@/features/form/components").then(m => ({ default: m.FormEntryPage }))); const FormsListPage = lazy(() => import("@/features/form/components").then(m => ({ default: m.FormsListPage }))); const OAuthConnectCallbackPage = lazy(() => import("@/features/form/components").then(m => ({ default: m.OAuthConnectCallbackPage }))); @@ -50,7 +50,7 @@ export const UserRouter = () => { path="/forms/:formId/:responseId" element={ - + } />