Skip to content

Commit 8ff81f1

Browse files
Merge pull request #32 from NYCU-SDC/fix/CORE-231-oauth-form-component
[CORE-231] Fix oAuth form component
2 parents 7268f96 + ab51d06 commit 8ff81f1

File tree

15 files changed

+658
-443
lines changed

15 files changed

+658
-443
lines changed

src/features/form/components/AdminFormDetailPage.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { SEO_CONFIG } from "@/seo/seo.config";
55
import { useSeo } from "@/seo/useSeo";
66
import { Button, ErrorMessage, LoadingSpinner, useToast } from "@/shared/components";
77
import { useIsMutating } from "@tanstack/react-query";
8-
import { Check, LoaderCircle } from "lucide-react";
8+
import { Check, Link, LoaderCircle } from "lucide-react";
99
import { useState } from "react";
1010
import { useLocation, useNavigate, useParams } from "react-router-dom";
1111
import styles from "./AdminFormDetailPage.module.css";
@@ -58,6 +58,17 @@ export const AdminFormDetailPage = () => {
5858
window.open(`/forms/${formid}`, "_blank", "noopener,noreferrer");
5959
};
6060

61+
const handleCopyFormLink = async () => {
62+
if (!formid) return;
63+
const formUrl = `${window.location.origin}/forms/${formid}`;
64+
try {
65+
await navigator.clipboard.writeText(formUrl);
66+
pushToast({ title: "已複製填寫連結", variant: "success" });
67+
} catch {
68+
pushToast({ title: "複製失敗", variant: "error" });
69+
}
70+
};
71+
6172
if (formQuery.isLoading) {
6273
return (
6374
<AdminLayout>
@@ -94,12 +105,20 @@ export const AdminFormDetailPage = () => {
94105
<Button onClick={handlePublish} disabled={publishFormMutation.isPending || formQuery.data.status !== "DRAFT"}>
95106
{formQuery.data.status === "DRAFT" ? "立即發佈表單" : "已發布"}
96107
</Button>
97-
<Button variant="secondary" onClick={() => window.open(`/orgs/${orgSlug}/forms/${formid}/preview`, "_blank", "noopener,noreferrer")}>
98-
預覽表單(beta)
99-
</Button>
100-
<Button variant="secondary" onClick={handleViewForm} disabled={formQuery.data.status === "DRAFT"}>
101-
檢視表單
102-
</Button>
108+
{formQuery.data.status === "DRAFT" ? (
109+
<Button variant="secondary" onClick={() => window.open(`/orgs/${orgSlug}/forms/${formid}/preview`, "_blank", "noopener,noreferrer")}>
110+
預覽表單(beta)
111+
</Button>
112+
) : (
113+
<>
114+
<Button variant="secondary" onClick={handleViewForm}>
115+
檢視表單
116+
</Button>
117+
<Button variant="secondary" onClick={handleCopyFormLink} title="點擊按鈕以複製表單連結">
118+
<Link size={16} />
119+
</Button>
120+
</>
121+
)}
103122
</div>
104123
</div>
105124

src/features/form/components/AdminFormPreviewPage.tsx

Lines changed: 8 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import type { FormsSection } from "@nycu-sdc/core-system-sdk";
77
import { useEffect, useMemo, useState, type CSSProperties } from "react";
88
import { useParams } from "react-router-dom";
99
import styles from "./AdminFormPreviewPage.module.css";
10-
import formStyles from "./FormDetailPage.module.css";
10+
import { FormHeader } from "./FormDetail/components/FormHeader/FormHeader";
11+
import { FormPreviewSection } from "./FormDetail/components/FormPreviewSection/FormPreviewSection";
12+
import { FormStructure } from "./FormDetail/components/FormStructure/FormStructure";
13+
import formStyles from "./FormFilloutPage.module.css";
1114
import { FormQuestionRenderer } from "./FormQuestionRenderer";
1215

1316
const ensureEmfontStylesheet = (fontId: string) => {
@@ -60,6 +63,7 @@ export const AdminFormPreviewPage = () => {
6063
}, [sections]);
6164

6265
const safeCurrentStep = sections.length > 0 ? Math.min(currentStep, sections.length - 1) : currentStep;
66+
const currentSection = sections[safeCurrentStep];
6367
const isFirstStep = safeCurrentStep === 0;
6468
const isLastStep = sections.length === 0 || safeCurrentStep === sections.length - 1;
6569
const isOnPreviewStep = isLastStep && sections[safeCurrentStep]?.id === "preview";
@@ -128,73 +132,16 @@ export const AdminFormPreviewPage = () => {
128132
<div className={styles.content}>
129133
{coverImageUrl && <img src={coverImageUrl} className={formStyles.cover} alt="表單封面" onError={e => (e.currentTarget.style.display = "none")} />}
130134
<div className={formStyles.container} style={themedContainerStyle}>
131-
<div className={formStyles.header}>
132-
<h1 className={formStyles.title}>{form.title}</h1>
133-
{safeCurrentStep === 0 ? <p className={formStyles.description}>{form.description}</p> : <h2 className={formStyles.sectionHeader}>{sections[safeCurrentStep]?.title}</h2>}
134-
</div>
135+
<FormHeader title={form.title} formDescription={form.description} currentStep={safeCurrentStep} currentSection={currentSection} />
135136

136-
<div className={formStyles.structure}>
137-
<div className={formStyles.structureTitle}>
138-
<h2>表單結構</h2>
139-
</div>
140-
<div className={formStyles.workflow}>
141-
{sections.map((section, index) => (
142-
<button key={section.id} type="button" className={`${formStyles.workflowButton} ${index === safeCurrentStep ? formStyles.active : ""}`} onClick={() => goToStep(index)}>
143-
{section.title}
144-
</button>
145-
))}
146-
</div>
147-
</div>
137+
<FormStructure sections={sections} currentStep={safeCurrentStep} onSectionClick={goToStep} />
148138

149139
<div className={formStyles.form}>
150140
{sections[safeCurrentStep] && (
151141
<div className={formStyles.section}>
152142
<div className={formStyles.fields}>
153143
{isOnPreviewStep ? (
154-
<div className={formStyles.previewSection}>
155-
{sections
156-
.filter(s => s.id !== "preview")
157-
.map(section => (
158-
<div key={section.id} className={formStyles.previewBlock}>
159-
<div className={formStyles.previewHeader}>
160-
<h3 className={formStyles.previewSectionTitle}>{section.title}</h3>
161-
<Button
162-
type="button"
163-
variant="secondary"
164-
onClick={() => {
165-
const idx = sections.findIndex(s => s.id === section.id);
166-
if (idx >= 0) goToStep(idx);
167-
}}
168-
>
169-
修改
170-
</Button>
171-
</div>
172-
<ul className={formStyles.previewList}>
173-
{section.questions?.map((q, qi) => {
174-
const raw = answers[q.id] ?? "";
175-
const displayValue = raw
176-
? q.choices
177-
? raw
178-
.split(",")
179-
.map(id => q.choices?.find(c => c.id === id)?.name ?? id)
180-
.join("、")
181-
: raw
182-
: "";
183-
return (
184-
<li key={qi}>
185-
<span className={formStyles.previewAnswerLabel}>
186-
{q.title}
187-
{q.required && <span className={formStyles.requiredAsterisk}> *</span>}
188-
</span>
189-
<span></span>
190-
<span className={!displayValue && q.required ? formStyles.previewAnswerEmpty : ""}>{displayValue || "未填寫"}</span>
191-
</li>
192-
);
193-
})}
194-
</ul>
195-
</div>
196-
))}
197-
</div>
144+
<FormPreviewSection mode="local" localSections={sections} localAnswers={answers} sections={sections} onSectionClick={goToStep} />
198145
) : (
199146
<>
200147
{sections[safeCurrentStep].questions?.map(question => (
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
.header {
2+
display: flex;
3+
flex-direction: column;
4+
gap: 0.75rem;
5+
}
6+
7+
.topBar {
8+
display: flex;
9+
justify-content: space-between;
10+
align-items: center;
11+
margin-bottom: 2rem;
12+
}
13+
14+
.saveStatus {
15+
display: inline-flex;
16+
align-items: center;
17+
gap: 0.375rem;
18+
color: var(--comment);
19+
font-size: 0.9rem;
20+
}
21+
22+
.spinningIcon {
23+
animation: spin 1s linear infinite;
24+
}
25+
26+
@keyframes spin {
27+
from {
28+
transform: rotate(0deg);
29+
}
30+
to {
31+
transform: rotate(360deg);
32+
}
33+
}
34+
35+
.title {
36+
margin-bottom: 1.5rem;
37+
text-align: center;
38+
font-family: var(--form-header-font, inherit);
39+
}
40+
41+
.description {
42+
color: var(--color-caption);
43+
font-size: 1rem;
44+
margin-bottom: 1rem;
45+
}
46+
47+
.sectionHeader {
48+
font-size: 1.6rem;
49+
margin: 0;
50+
color: var(--color-heading);
51+
text-align: left;
52+
font-family: var(--form-header-font, inherit);
53+
}
54+
55+
.sectionDescription {
56+
color: var(--color-caption);
57+
font-size: 1rem;
58+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Button } from "@/shared/components";
2+
import { AlertCircle, Check, ChevronLeft, LoaderCircle } from "lucide-react";
3+
import styles from "./FormHeader.module.css";
4+
5+
interface FormHeaderProps {
6+
title: string;
7+
formDescription?: string | null;
8+
currentStep: number;
9+
currentSection?: { title?: string; description?: string } | null;
10+
onBack?: () => void;
11+
saveStatus?: "saving" | "error" | "saved";
12+
}
13+
14+
export const FormHeader = ({ title, formDescription, currentStep, currentSection, onBack, saveStatus }: FormHeaderProps) => {
15+
return (
16+
<div className={styles.header}>
17+
{(onBack || saveStatus) && (
18+
<div className={styles.topBar}>
19+
{onBack && (
20+
<Button type="button" onClick={onBack} themeColor="var(--foreground)">
21+
<ChevronLeft size={16} />
22+
返回表單列表
23+
</Button>
24+
)}
25+
{saveStatus && (
26+
<div className={styles.saveStatus} aria-live="polite">
27+
{saveStatus === "saving" ? (
28+
<>
29+
<LoaderCircle size={16} className={styles.spinningIcon} />
30+
<span>儲存中</span>
31+
</>
32+
) : saveStatus === "error" ? (
33+
<>
34+
<AlertCircle size={16} />
35+
<span>有問題</span>
36+
</>
37+
) : (
38+
<>
39+
<Check size={16} />
40+
<span>已儲存</span>
41+
</>
42+
)}
43+
</div>
44+
)}
45+
</div>
46+
)}
47+
<h1 className={styles.title}>{title}</h1>
48+
{currentStep === 0 && formDescription && <div className={styles.description} dangerouslySetInnerHTML={{ __html: formDescription }} />}
49+
<h2 className={styles.sectionHeader}>{currentSection?.title}</h2>
50+
{currentSection?.description && <div className={styles.sectionDescription} dangerouslySetInnerHTML={{ __html: currentSection.description }} />}
51+
</div>
52+
);
53+
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
.previewSection {
2+
display: flex;
3+
flex-direction: column;
4+
gap: 4rem;
5+
}
6+
7+
.previewBlock {
8+
display: flex;
9+
flex-direction: column;
10+
gap: 1rem;
11+
}
12+
13+
.previewHeader {
14+
display: flex;
15+
justify-content: space-between;
16+
align-items: center;
17+
padding-bottom: 0.75rem;
18+
}
19+
20+
.previewTitle {
21+
font-size: 1.6rem;
22+
font-weight: 520;
23+
color: var(--color-heading);
24+
margin: 0;
25+
}
26+
27+
.previewList {
28+
list-style: disc;
29+
padding-left: 1.5rem;
30+
margin: 0;
31+
display: flex;
32+
flex-direction: column;
33+
gap: 0.75rem;
34+
}
35+
36+
.previewList li {
37+
font-size: 1rem;
38+
color: var(--color-body);
39+
line-height: 1.5;
40+
}
41+
42+
.previewList li strong {
43+
color: var(--color-heading);
44+
font-weight: 600;
45+
margin-right: 0.5rem;
46+
}
47+
48+
.previewSectionTitle {
49+
margin: 0;
50+
}
51+
52+
.previewAnswerLabel {
53+
font-weight: 500;
54+
}
55+
56+
.previewAnswerEmpty {
57+
color: var(--red);
58+
}
59+
60+
.requiredAsterisk {
61+
color: var(--red);
62+
}
63+
64+
.caption {
65+
color: var(--color-caption);
66+
}

0 commit comments

Comments
 (0)