diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..8b80e5b --- /dev/null +++ b/biome.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", + "root": true, + "files": { + "includes": ["frontend/**"] + } +} diff --git a/frontend/biome.json b/frontend/biome.json index deced5f..44997c4 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", "root": false, "vcs": { "enabled": true, @@ -23,7 +23,23 @@ "options": { "ignore": ["tailwind"] } - } + }, + "noArrayIndexKey": "off" + }, + "a11y": { + "noSvgWithoutTitle": "off", + "noLabelWithoutControl": "off", + "noStaticElementInteractions": "off", + "useSemanticElements": "off" + }, + "complexity": { + "noImportantStyles": "off" + }, + "style": { + "noNonNullAssertion": "off" + }, + "correctness": { + "useExhaustiveDependencies": "warn" } } }, diff --git a/frontend/form/.env.example b/frontend/form/.env.example index 35a20b8..41ff2aa 100644 --- a/frontend/form/.env.example +++ b/frontend/form/.env.example @@ -1,4 +1,5 @@ VITE_FORM_SERVICE_URL=http://localhost:8002 VITE_USER_SERVICE_URL=http://localhost:8001 VITE_USER_SERVICE_TOKEN=dev-frontend-token -VITE_ADMIN_PASSWORD=change-me + + diff --git a/frontend/form/.env.prod.example b/frontend/form/.env.prod.example index d25c202..c1ce39d 100644 --- a/frontend/form/.env.prod.example +++ b/frontend/form/.env.prod.example @@ -1,4 +1,3 @@ VITE_FORM_SERVICE_URL=https://.vercel.app VITE_USER_SERVICE_URL=https://.vercel.app VITE_USER_SERVICE_TOKEN= -VITE_ADMIN_PASSWORD= diff --git a/frontend/form/index.html b/frontend/form/index.html index 6643b49..73cf202 100644 --- a/frontend/form/index.html +++ b/frontend/form/index.html @@ -3,13 +3,23 @@ + GDG Form Service + + + + + +
diff --git a/frontend/form/package-lock.json b/frontend/form/package-lock.json index 3f93c20..1444f8a 100644 --- a/frontend/form/package-lock.json +++ b/frontend/form/package-lock.json @@ -7,9 +7,15 @@ "": { "name": "form", "version": "0.0.0", + "license": "Apache-2.0", "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "@hookform/resolvers": "3.9.1", + "motion": "^12.35.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-hook-form": "^7.71.1", + "react-router-dom": "^7.13.0", + "zod": "3.24.4" }, "devDependencies": { "@biomejs/biome": "^2.3.11", @@ -68,7 +74,6 @@ "version": "7.29.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1022,6 +1027,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz", + "integrity": "sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "dev": true, @@ -1546,7 +1560,6 @@ "version": "24.10.12", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1555,7 +1568,6 @@ "version": "19.2.13", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1607,7 +1619,6 @@ "version": "8.54.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1833,7 +1844,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2027,7 +2037,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2168,6 +2177,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "dev": true, @@ -2302,7 +2324,6 @@ "version": "9.39.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2375,6 +2396,29 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, + "node_modules/eslint-plugin-react-hooks/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/eslint-plugin-react-refresh": { "version": "0.4.26", "dev": true, @@ -2604,6 +2648,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.35.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.35.0.tgz", + "integrity": "sha512-w8hghCMQ4oq10j6aZh3U2yeEQv5K69O/seDI/41PK4HtgkLrcBovUNc0ayBC3UyyU7V1mrY2yLzvYdWJX9pGZQ==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.35.0", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2793,13 +2864,13 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } }, "node_modules/js-tokens": { "version": "4.0.0", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -2906,16 +2977,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loose-envify": { - "version": "1.4.0", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "dev": true, @@ -2972,6 +3033,47 @@ "node": "*" } }, + "node_modules/motion": { + "version": "12.35.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.35.0.tgz", + "integrity": "sha512-BQUhNUIGvUcwXCzwmnT1JpjUqab34lIwxHnXUyWRht1WC1vAyp7/4qgMiUXxN3K6hgUhyoR+HNnLeQMwUZjVjw==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.35.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.35.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.35.0.tgz", + "integrity": "sha512-FFMLEnIejK/zDABn+vqGVAUN4T0+3fw+cVAY8MMT65yR+j5uMuvWdd4npACWhh94OVWQs79CrBBuwOwGRZAQiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "dev": true, @@ -3180,7 +3282,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3368,25 +3469,40 @@ "license": "MIT" }, "node_modules/react": { - "version": "18.3.1", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.2.4" + } + }, + "node_modules/react-hook-form": { + "version": "7.71.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "node_modules/react-refresh": { @@ -3397,6 +3513,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -3541,11 +3695,10 @@ } }, "node_modules/scheduler": { - "version": "0.23.2", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" }, "node_modules/semver": { "version": "6.3.1", @@ -3555,6 +3708,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "dev": true, @@ -3747,6 +3906,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -3762,7 +3927,6 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3846,7 +4010,6 @@ "version": "7.3.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3949,7 +4112,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -3972,24 +4134,13 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "dev": true, + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", + "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } } } } diff --git a/frontend/form/package.json b/frontend/form/package.json index ece30aa..5fa0499 100644 --- a/frontend/form/package.json +++ b/frontend/form/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@hookform/resolvers": "3.9.1", + "motion": "^12.35.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.1", @@ -21,7 +22,7 @@ "zod": "3.24.4" }, "devDependencies": { - "@biomejs/biome": "^2.3.11", + "@biomejs/biome": "^2.4.6", "@eslint/js": "^9.39.1", "@types/node": "^24.10.1", "@types/react": "^19.2.5", diff --git a/frontend/form/src/App.tsx b/frontend/form/src/App.tsx index 36dee27..53af6bd 100644 --- a/frontend/form/src/App.tsx +++ b/frontend/form/src/App.tsx @@ -1,14 +1,24 @@ +import { lazy, Suspense } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; -import AdminFormViewsPage from "./pages/AdminFormViewsPage"; -import FormSubmissionPage from "./pages/FormSubmissionPage"; -function Home() { +/** + * Lazy load non-critical routes to reduce initial bundle size + */ +const AdminFormViewsPage = lazy( + () => import("./pages/admin/AdminFormViewsPage"), +); +const AdminFormListPage = lazy(() => import("./pages/admin/AdminFormListPage")); +const FormEditorPage = lazy(() => import("./pages/admin/FormEditorPage")); +const FormSubmissionPage = lazy(() => import("./pages/FormSubmissionPage")); + +/** + * Loading fallback for lazy-loaded routes + */ +function RouteLoadingFallback() { return ( -
-
-

- GDG on Campus Yaşar Üniversitesi Form Uygulaması -

+
+
+ Loading...
); @@ -17,9 +27,41 @@ function Home() { function App() { return ( - } /> - } /> - } /> + {/* Home redirects to admin panel */} + } /> + {/* Lazy-loaded routes with Suspense fallback */} + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> } /> ); diff --git a/frontend/form/src/components/AdminPasswordGate.tsx b/frontend/form/src/components/AdminPasswordGate.tsx new file mode 100644 index 0000000..1157923 --- /dev/null +++ b/frontend/form/src/components/AdminPasswordGate.tsx @@ -0,0 +1,94 @@ +import { type FormEvent, useState } from "react"; +import { useAdminAuth } from "../hooks/useAdminAuth"; +import { verifyToken } from "../services/formService"; + +interface AdminTokenGateProps { + children: React.ReactNode; +} + +const AdminTokenGate = ({ children }: AdminTokenGateProps) => { + const { isAuthorized, authorize } = useAdminAuth(); + const [token, setToken] = useState(""); + const [authError, setAuthError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const onUnlock = async (event: FormEvent) => { + event.preventDefault(); + + const trimmedToken = token.trim(); + if (!trimmedToken) { + setAuthError("Geçersiz API token."); + return; + } + + setIsLoading(true); + setAuthError(null); + + const result = await verifyToken(trimmedToken); + + if (!result.valid) { + setAuthError(result.error || "Geçersiz API token."); + setIsLoading(false); + return; + } + + authorize(trimmedToken); + setIsLoading(false); + }; + + if (isAuthorized) { + return <>{children}; + } + + return ( +
+
+

+ Yönetici Erişimi +

+

+ Devam etmek için API token girin. +

+ +
+
+ + setToken(event.target.value)} + autoComplete="off" + className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200" + placeholder="Token" + /> +
+ + +
+ + {authError && ( +

+ {authError} +

+ )} +
+
+ ); +}; + +export default AdminTokenGate; + +// Backward compatible alias +export { AdminTokenGate as AdminPasswordGate }; diff --git a/frontend/form/src/components/AnimatedList.tsx b/frontend/form/src/components/AnimatedList.tsx new file mode 100644 index 0000000..4b31546 --- /dev/null +++ b/frontend/form/src/components/AnimatedList.tsx @@ -0,0 +1,195 @@ +import { motion, useInView } from "motion/react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +/** Custom scrollbar styles (webkit-specific) */ +const scrollbarStyles: React.CSSProperties = { + scrollbarWidth: "thin", + scrollbarColor: "#cbd5e1 #f1f5f9", +}; + +interface AnimatedItemProps { + children: React.ReactNode; + index: number; + onMouseEnter: () => void; + onClick: () => void; +} + +const AnimatedItem = ({ + children, + index, + onMouseEnter, + onClick, +}: AnimatedItemProps) => { + const ref = useRef(null); + const inView = useInView(ref, { amount: 0.5, once: false }); + return ( + + {children} + + ); +}; + +export interface AnimatedListItem { + value: string; + label: string; +} + +interface AnimatedListProps { + items: AnimatedListItem[]; + selectedValue?: string; + onItemSelect: (item: AnimatedListItem, index: number) => void; + showGradients?: boolean; + enableArrowNavigation?: boolean; + displayScrollbar?: boolean; +} + +const AnimatedList = ({ + items, + selectedValue, + onItemSelect, + showGradients = true, + enableArrowNavigation = true, + displayScrollbar = true, +}: AnimatedListProps) => { + const listRef = useRef(null); + const [selectedIndex, setSelectedIndex] = useState( + items.findIndex((item) => item.value === selectedValue), + ); + const [keyboardNav, setKeyboardNav] = useState(false); + const [topGradientOpacity, setTopGradientOpacity] = useState(0); + const [bottomGradientOpacity, setBottomGradientOpacity] = useState(1); + + useEffect(() => { + setSelectedIndex(items.findIndex((item) => item.value === selectedValue)); + }, [selectedValue, items]); + + const handleItemClick = useCallback( + (item: AnimatedListItem, index: number) => { + setSelectedIndex(index); + onItemSelect(item, index); + }, + [onItemSelect], + ); + + const handleScroll = (e: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = e.target as HTMLElement; + setTopGradientOpacity(Math.min(scrollTop / 50, 1)); + const bottomDistance = scrollHeight - (scrollTop + clientHeight); + setBottomGradientOpacity( + scrollHeight <= clientHeight ? 0 : Math.min(bottomDistance / 50, 1), + ); + }; + + useEffect(() => { + if (!enableArrowNavigation) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) { + e.preventDefault(); + setKeyboardNav(true); + setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1)); + } else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) { + e.preventDefault(); + setKeyboardNav(true); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === "Enter") { + if (selectedIndex >= 0 && selectedIndex < items.length) { + e.preventDefault(); + onItemSelect(items[selectedIndex], selectedIndex); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [items, selectedIndex, onItemSelect, enableArrowNavigation]); + + useEffect(() => { + if (!keyboardNav || selectedIndex < 0 || !listRef.current) return; + const container = listRef.current; + const selectedItem = container.querySelector( + `[data-index="${selectedIndex}"]`, + ) as HTMLElement | null; + if (selectedItem) { + const extraMargin = 50; + const containerScrollTop = container.scrollTop; + const containerHeight = container.clientHeight; + const itemTop = selectedItem.offsetTop; + const itemBottom = itemTop + selectedItem.offsetHeight; + if (itemTop < containerScrollTop + extraMargin) { + container.scrollTo({ top: itemTop - extraMargin, behavior: "smooth" }); + } else if ( + itemBottom > + containerScrollTop + containerHeight - extraMargin + ) { + container.scrollTo({ + top: itemBottom - containerHeight + extraMargin, + behavior: "smooth", + }); + } + } + setKeyboardNav(false); + }, [selectedIndex, keyboardNav]); + + return ( +
+
+ {items.map((item, index) => ( + setSelectedIndex(index)} + onClick={() => handleItemClick(item, index)} + > +
+

+ {item.label} +

+
+
+ ))} +
+ {showGradients && ( + <> +
+
+ + )} +
+ ); +}; + +export default AnimatedList; diff --git a/frontend/form/src/components/DatePicker.tsx b/frontend/form/src/components/DatePicker.tsx new file mode 100644 index 0000000..22433c7 --- /dev/null +++ b/frontend/form/src/components/DatePicker.tsx @@ -0,0 +1,289 @@ +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; + +interface DatePickerProps { + label: string; + value: string; + onChange: (value: string) => void; +} + +const MONTHS = [ + "Ocak", + "Şubat", + "Mart", + "Nisan", + "Mayıs", + "Haziran", + "Temmuz", + "Ağustos", + "Eylül", + "Ekim", + "Kasım", + "Aralık", +]; + +const DAY_LABELS = ["Pt", "Sa", "Ça", "Pe", "Cu", "Ct", "Pz"]; + +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +function getFirstDayOfMonth(year: number, month: number): number { + const day = new Date(year, month, 1).getDay(); + return day === 0 ? 6 : day - 1; +} + +function parseDate(value: string): Date | null { + if (!value) return null; + const d = new Date(`${value}T00:00:00`); + return Number.isNaN(d.getTime()) ? null : d; +} + +function toISODate(year: number, month: number, day: number): string { + return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +export function DatePicker({ label, value, onChange }: DatePickerProps) { + const [open, setOpen] = useState(false); + const today = new Date(); + const selected = parseDate(value); + + const [viewYear, setViewYear] = useState( + selected?.getFullYear() ?? today.getFullYear(), + ); + const [viewMonth, setViewMonth] = useState( + selected?.getMonth() ?? today.getMonth(), + ); + + const containerRef = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + function prevMonth() { + if (viewMonth === 0) { + setViewMonth(11); + setViewYear((y) => y - 1); + } else { + setViewMonth((m) => m - 1); + } + } + + function nextMonth() { + if (viewMonth === 11) { + setViewMonth(0); + setViewYear((y) => y + 1); + } else { + setViewMonth((m) => m + 1); + } + } + + function selectDay(day: number) { + onChange(toISODate(viewYear, viewMonth, day)); + setOpen(false); + } + + function selectToday() { + const t = new Date(); + setViewYear(t.getFullYear()); + setViewMonth(t.getMonth()); + onChange(toISODate(t.getFullYear(), t.getMonth(), t.getDate())); + setOpen(false); + } + + const displayValue = selected + ? `${String(selected.getDate()).padStart(2, "0")}/${String(selected.getMonth() + 1).padStart(2, "0")}/${selected.getFullYear()}` + : ""; + + const daysInMonth = getDaysInMonth(viewYear, viewMonth); + const firstDay = getFirstDayOfMonth(viewYear, viewMonth); + + return ( +
+ {/* Input field */} + + + {/* Calendar dropdown */} + + {open && ( + + {/* Month/Year navigation */} +
+ + + + {MONTHS[viewMonth]} {viewYear} + + + +
+ + {/* Day labels */} +
+ {DAY_LABELS.map((d) => ( +
+ {d} +
+ ))} +
+ + {/* Days grid */} +
+ {Array.from({ length: firstDay }, (_, i) => ( +
+ ))} + + {Array.from({ length: daysInMonth }, (_, i) => { + const day = i + 1; + const isSelected = + selected && + selected.getDate() === day && + selected.getMonth() === viewMonth && + selected.getFullYear() === viewYear; + const isToday = + today.getDate() === day && + today.getMonth() === viewMonth && + today.getFullYear() === viewYear; + + return ( + + ); + })} +
+ + {/* Footer */} +
+ + +
+ + )} + +
+ ); +} diff --git a/frontend/form/src/components/FieldEditorCard.tsx b/frontend/form/src/components/FieldEditorCard.tsx new file mode 100644 index 0000000..be6eea8 --- /dev/null +++ b/frontend/form/src/components/FieldEditorCard.tsx @@ -0,0 +1,515 @@ +import { useState } from "react"; +import { DEPARTMENTS } from "../constants/departments"; +import type { FieldType, FormFieldSchema } from "../types"; +import FieldTypeSelect from "./FieldTypeSelect"; + +const TYPES_WITH_OPTIONS: FieldType[] = ["select", "multiselect", "radio"]; + +function slugify(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[^a-z0-9\s_-]/g, "") + .replace(/\s+/g, "_") + .replace(/-+/g, "_") + .replace(/_+/g, "_") + .slice(0, 32); +} + +type NumericValidationKey = + | "min_length" + | "max_length" + | "min_value" + | "max_value"; + +const NUMBER_VALIDATION_FIELDS: { key: NumericValidationKey; label: string }[] = + [ + { key: "min_length", label: "Min Uzunluk" }, + { key: "max_length", label: "Max Uzunluk" }, + { key: "min_value", label: "Min Değer" }, + { key: "max_value", label: "Max Değer" }, + ]; + +const INPUT_CLASS = + "w-full rounded-2xl border-2 border-slate-200 px-4 pb-3 pt-5 text-sm transition-all duration-200 hover:border-slate-300 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"; + +const SMALL_INPUT_CLASS = + "mt-1 w-full rounded-xl border-2 border-slate-200 px-3 py-2 text-sm transition-all duration-200 hover:border-slate-300 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100"; + +const FLOATING_LABEL_CLASS = + "pointer-events-none absolute left-4 top-0 -translate-y-1/2 bg-white px-1 text-xs font-semibold text-slate-500 transition-colors duration-200 group-focus-within:text-violet-600"; + +interface FieldEditorCardProps { + field: FormFieldSchema; + index: number; + totalFields: number; + isNewField: boolean; + allFieldIds: string[]; + onChange: (field: FormFieldSchema) => void; + onMoveUp: () => void; + onMoveDown: () => void; + onRemove: () => void; +} + +const FieldEditorCard = ({ + field, + index, + totalFields, + isNewField, + allFieldIds, + onChange, + onMoveUp, + onMoveDown, + onRemove, +}: FieldEditorCardProps) => { + const [isExpanded, setIsExpanded] = useState(true); + const [labelValue, setLabelValue] = useState(field.label); + const [options, setOptions] = useState( + field.options?.length ? field.options : [""], + ); + + const showOptions = TYPES_WITH_OPTIONS.includes(field.field_type); + + const updateField = (updates: Partial) => { + onChange({ ...field, ...updates }); + }; + + const handleFieldTypeChange = (value: FieldType) => { + if (value === "department") { + setOptions(DEPARTMENTS); + updateField({ field_type: value, options: DEPARTMENTS }); + } else if (field.field_type === "department") { + setOptions([""]); + updateField({ field_type: value, options: undefined }); + } else { + updateField({ field_type: value }); + } + }; + + const handleLabelChange = (label: string) => { + setLabelValue(label); + const updates: Partial = { label }; + if (isNewField) { + updates.field_id = slugify(label); + } + updateField(updates); + }; + + const handleOptionChange = (i: number, value: string) => { + const next = options.map((o, idx) => (idx === i ? value : o)); + setOptions(next); + const filled = next.filter((o) => o.trim().length > 0); + updateField({ options: filled.length > 0 ? filled : undefined }); + }; + + const handleOptionAdd = () => setOptions((prev) => [...prev, ""]); + + const handleOptionRemove = (i: number) => { + const next = options.filter((_, idx) => idx !== i); + const safe = next.length > 0 ? next : [""]; + setOptions(safe); + const filled = safe.filter((o) => o.trim().length > 0); + updateField({ options: filled.length > 0 ? filled : undefined }); + }; + + const handleValidationChange = ( + key: keyof NonNullable, + value: string, + ) => { + const numValue = value === "" ? undefined : Number(value); + const currentValidation = field.validation ?? {}; + const newValidation = { ...currentValidation, [key]: numValue }; + + const hasValues = Object.values(newValidation).some( + (v) => v !== undefined && v !== "", + ); + updateField({ validation: hasValues ? newValidation : undefined }); + }; + + const handlePatternChange = (pattern: string) => { + const currentValidation = field.validation ?? {}; + const newValidation = { + ...currentValidation, + pattern: pattern || undefined, + }; + + const hasValues = Object.values(newValidation).some( + (v) => v !== undefined && v !== "", + ); + updateField({ validation: hasValues ? newValidation : undefined }); + }; + + const handleConditionChange = ( + key: "depends_on" | "values", + value: string, + ) => { + const currentCondition = field.condition ?? { depends_on: "", values: [] }; + + if (key === "depends_on") { + if (!value) { + updateField({ condition: undefined }); + return; + } + updateField({ + condition: { ...currentCondition, depends_on: value }, + }); + } else { + const values = value + .split(",") + .map((v) => v.trim()) + .filter((v) => v.length > 0); + if (values.length === 0 && !currentCondition.depends_on) { + updateField({ condition: undefined }); + return; + } + updateField({ + condition: { ...currentCondition, values }, + }); + } + }; + + const otherFieldIds = allFieldIds.filter((id) => id !== field.field_id); + + // GDG colors for borders + const gdgColors = ["#4285F4", "#EA4335", "#FBBC05", "#34A853"]; + const borderColor = gdgColors[index % 4]; + + return ( +
+
setIsExpanded(!isExpanded)} + onKeyDown={(e) => e.key === "Enter" && setIsExpanded(!isExpanded)} + > +
+ + {index + 1} + + + {field.label || "Başlıksız Alan"} + + {field.required && ( + + Zorunlu + + )} +
+
+ + + + + + +
+
+ + {isExpanded && ( +
+ {/* Satır 1: Alan Etiketi + Alan Tipi */} +
+
+ + handleLabelChange(e.target.value)} + className={INPUT_CLASS} + placeholder="Örn: Ad Soyad" + /> +
+ +
+ + {/* Satır 2: Alan ID + Placeholder */} +
+
+ + updateField({ field_id: e.target.value })} + disabled={!isNewField} + className={`${INPUT_CLASS} disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400`} + placeholder="alan_id" + /> +
+
+ + + updateField({ placeholder: e.target.value || undefined }) + } + className={INPUT_CLASS} + placeholder="Placeholder metni..." + /> +
+
+ + {/* Zorunlu Alan toggle */} +
+ updateField({ required: e.target.checked })} + className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-200" + /> + +
+ + {/* Seçenek listesi (select / multiselect / radio) */} + {showOptions && ( +
+

Seçenekler

+
+ {options.map((opt, i) => ( +
+ handleOptionChange(i, e.target.value)} + className="flex-1 rounded-2xl border-2 border-slate-200 px-4 py-3 text-sm transition-all duration-200 hover:border-slate-300 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100" + placeholder={`Seçenek ${i + 1}`} + /> + +
+ ))} + +
+
+ )} + + {/* Doğrulama Kuralları */} +
+ + Doğrulama Kuralları + +
+
+ {NUMBER_VALIDATION_FIELDS.map(({ key, label }) => ( +
+ + + handleValidationChange(key, e.target.value) + } + className={SMALL_INPUT_CLASS} + /> +
+ ))} +
+
+ + handlePatternChange(e.target.value)} + className={`${SMALL_INPUT_CLASS} font-mono`} + placeholder="^[A-Z].*" + /> +
+
+
+ + {/* Koşullu Görünürlük */} + {otherFieldIds.length > 0 && ( +
+ + Koşullu Görünürlük + +
+
+ + +
+ {field.condition?.depends_on && ( +
+ + + handleConditionChange("values", e.target.value) + } + className={SMALL_INPUT_CLASS} + placeholder="değer1, değer2" + /> +
+ )} +
+
+ )} +
+ )} +
+ ); +}; + +export default FieldEditorCard; diff --git a/frontend/form/src/components/FieldRenderer.tsx b/frontend/form/src/components/FieldRenderer.tsx index a690530..dee321f 100644 --- a/frontend/form/src/components/FieldRenderer.tsx +++ b/frontend/form/src/components/FieldRenderer.tsx @@ -50,6 +50,7 @@ const FieldRenderer = ({ field, registration, error }: FieldRendererProps) => { /> ); case "select": + case "department": return ( void; + label?: string; + required?: boolean; +} + +const FieldTypeSelect = ({ + value, + onChange, + label, + required, +}: FieldTypeSelectProps) => { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + const selectedLabel = + FIELD_TYPE_ITEMS.find((item) => item.value === value)?.label ?? value; + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setIsOpen(false); + } + }; + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen]); + + const handleSelect = (item: AnimatedListItem) => { + onChange(item.value as FieldType); + setIsOpen(false); + }; + + return ( +
+ {label && ( + + {label} + {required && *} + + )} + + + {isOpen && ( +
+ +
+ )} +
+ ); +}; + +export default FieldTypeSelect; diff --git a/frontend/form/src/components/FormFieldList.tsx b/frontend/form/src/components/FormFieldList.tsx new file mode 100644 index 0000000..83417e5 --- /dev/null +++ b/frontend/form/src/components/FormFieldList.tsx @@ -0,0 +1,79 @@ +import { useMemo } from "react"; +import type { FormFieldSchema } from "../types"; +import FieldEditorCard from "./FieldEditorCard"; + +interface FormFieldListProps { + fields: FormFieldSchema[]; + existingFieldIds: Set; + onChange: (fields: FormFieldSchema[]) => void; +} + +const FormFieldList = ({ + fields, + existingFieldIds, + onChange, +}: FormFieldListProps) => { + const allFieldIds = useMemo(() => fields.map((f) => f.field_id), [fields]); + + const handleFieldChange = (index: number, updatedField: FormFieldSchema) => { + const newFields = [...fields]; + newFields[index] = updatedField; + onChange(newFields); + }; + + const handleMoveUp = (index: number) => { + if (index === 0) return; + const newFields = [...fields]; + [newFields[index - 1], newFields[index]] = [ + newFields[index], + newFields[index - 1], + ]; + onChange(newFields); + }; + + const handleMoveDown = (index: number) => { + if (index === fields.length - 1) return; + const newFields = [...fields]; + [newFields[index], newFields[index + 1]] = [ + newFields[index + 1], + newFields[index], + ]; + onChange(newFields); + }; + + const handleRemove = (index: number) => { + const newFields = fields.filter((_, i) => i !== index); + onChange(newFields); + }; + + if (fields.length === 0) { + return ( +
+

+ Henüz alan eklenmedi. "Alan Ekle" butonuna tıklayarak başlayın. +

+
+ ); + } + + return ( +
+ {fields.map((field, index) => ( + handleFieldChange(index, updatedField)} + onMoveUp={() => handleMoveUp(index)} + onMoveDown={() => handleMoveDown(index)} + onRemove={() => handleRemove(index)} + /> + ))} +
+ ); +}; + +export default FormFieldList; diff --git a/frontend/form/src/components/FormPreview.tsx b/frontend/form/src/components/FormPreview.tsx new file mode 100644 index 0000000..e421e29 --- /dev/null +++ b/frontend/form/src/components/FormPreview.tsx @@ -0,0 +1,219 @@ +import type { FieldError, FieldErrors, UseFormRegister } from "react-hook-form"; +import type { FormFieldSchema, FormResponse } from "../types"; +import Banner from "./Banner"; +import FieldRenderer from "./FieldRenderer"; +import { PhoneMockup } from "./PhoneMockup"; + +type FormValues = Record; + +interface FormPreviewProps { + form: Pick< + FormResponse, + "id" | "title" | "description" | "questions" | "is_active" + >; + register: UseFormRegister; + errors: FieldErrors; +} + +const FormPreview = ({ form, register, errors }: FormPreviewProps) => { + return ( +
+ {/* Mobile Preview */} +
+
+

+ Mobil Görünüm +

+

+ Formunuz telefonda böyle görünecek +

+
+ +
+ {/* Simplified Mobile Banner */} +
+
+
+ GDG +
+
+

+ GDG on Campus +

+

+ Yaşar Üniversitesi +

+
+
+
+ + + + +
+
+
+

+ {form.title} +

+ {form.description && ( +

+ {form.description} +

+ )} +
+
+
+ + +
+
+ {form.questions.map((field: FormFieldSchema) => ( +
+ )[field.field_id] as + | FieldError + | undefined + } + /> +
+ ))} +
+ {form.questions.length === 0 && ( +
+

Henüz alan eklenmedi.

+
+ )} +
+ +
+
+
+
+
+ + {/* Divider */} +
+
+ WEB GÖRÜNÜMÜ +
+
+ + {/* Web Preview */} +
+
+

+ Masaüstü Görünüm +

+

+ Formunuz bilgisayarda böyle görünecek +

+
+
+
+
+ + +
+

+ {form.title} +

+ {form.description && ( +

+ {form.description} +

+ )} +
+ +
+
+ + +

+ Önizleme modunda e-posta devre dışıdır. +

+
+ +
+ {form.questions.map((field: FormFieldSchema) => ( + )[field.field_id] as + | FieldError + | undefined + } + /> + ))} +
+ + {form.questions.length === 0 && ( +
+

+ Henüz alan eklenmedi. "Düzenle" moduna geçerek alan + ekleyin. +

+
+ )} + +
+ + +
+
+
+

+ + GDG on Campus Yaşar Üniversitesi + {" "} + tarafından geliştirilmiştir. +

+
+
+
+
+ ); +}; + +export default FormPreview; diff --git a/frontend/form/src/components/PhoneMockup/PhoneMockup.tsx b/frontend/form/src/components/PhoneMockup/PhoneMockup.tsx new file mode 100644 index 0000000..a93db9f --- /dev/null +++ b/frontend/form/src/components/PhoneMockup/PhoneMockup.tsx @@ -0,0 +1,128 @@ +/** + * PhoneMockup - iPhone frame component for mobile preview + * + * Displays content inside a realistic iPhone X frame + */ + +import type { ReactNode } from "react"; + +interface PhoneMockupProps { + children: ReactNode; + className?: string; +} + +export function PhoneMockup({ children, className = "" }: PhoneMockupProps) { + return ( +
+ {/* Phone Frame */} +
+ {/* Outer frame - dark border */} +
+ {/* Inner frame - silver edge */} +
+ {/* Screen bezel */} +
+ {/* Notch */} +
+
+ {/* Speaker */} +
+ {/* Camera */} +
+
+
+
+
+ + {/* Screen Content */} +
+ {/* Status bar */} +
+ 9:41 +
+ {/* Signal */} + + {/* WiFi */} + + {/* Battery */} + +
+
+ + {/* Content area */} +
{children}
+ + {/* Home indicator */} +
+
+
+
+
+
+
+ + {/* Side buttons */} + {/* Volume buttons - left */} +
+
+
+ + {/* Power button - right */} +
+
+
+ ); +} + +export default PhoneMockup; diff --git a/frontend/form/src/components/PhoneMockup/index.ts b/frontend/form/src/components/PhoneMockup/index.ts new file mode 100644 index 0000000..09dbe53 --- /dev/null +++ b/frontend/form/src/components/PhoneMockup/index.ts @@ -0,0 +1 @@ +export { default, PhoneMockup } from "./PhoneMockup"; diff --git a/frontend/form/src/constants/departments.ts b/frontend/form/src/constants/departments.ts new file mode 100644 index 0000000..2ece8f2 --- /dev/null +++ b/frontend/form/src/constants/departments.ts @@ -0,0 +1,39 @@ +/** + * List of departments at Yaşar University. + * Used in form fields for department selection. + */ +export const DEPARTMENTS: string[] = [ + "Bilgisayar Mühendisliği", + "Çizgi Film ve Animasyon", + "Ekonomi", + "Elektrik-Elektronik Mühendisliği", + "Endüstri Mühendisliği", + "Endüstriyel Tasarım", + "Enerji Sistemleri Mühendisliği", + "Gastronomi ve Mutfak Sanatları", + "Görsel İletişim Tasarımı", + "Halkla İlişkiler ve Reklamcılık", + "Hukuk", + "İç Mimarlık ve Çevre Tasarımı", + "İngiliz Dili ve Edebiyatı", + "İngilizce Mütercim ve Tercümanlık", + "İnşaat Mühendisliği", + "İşletme", + "Lojistik Yönetimi", + "Makine Mühendisliği", + "Mimarlık", + "Psikoloji", + "Radyo, Televizyon ve Sinema", + "Tarım Ekonomisi", + "Tarım Makineleri ve Teknolojileri Mühendisliği", + "Turizm Rehberliği", + "Uluslararası İlişkiler", + "Uluslararası Ticaret ve Finansman", + "Yazılım Mühendisliği", + "Yeni Medya ve İletişim", + "Yönetim Bilişim Sistemleri", + "Diğer", +]; + +/** Maximum length for field IDs */ +export const MAX_FIELD_ID_LENGTH = 32; diff --git a/frontend/form/src/hooks/useAdminAuth.ts b/frontend/form/src/hooks/useAdminAuth.ts new file mode 100644 index 0000000..862bb84 --- /dev/null +++ b/frontend/form/src/hooks/useAdminAuth.ts @@ -0,0 +1,44 @@ +import { useCallback, useState } from "react"; + +const AUTH_KEY = "admin_authorized"; +const TOKEN_KEY = "admin_api_token"; + +function getStoredAuth(): boolean { + if (typeof window === "undefined") { + return false; + } + return sessionStorage.getItem(AUTH_KEY) === "true"; +} + +/** + * Get the stored admin API token. + * This is exported for use by API services. + */ +export function getAdminToken(): string | null { + if (typeof window === "undefined") { + return null; + } + return sessionStorage.getItem(TOKEN_KEY); +} + +export function useAdminAuth() { + const [isAuthorized, setIsAuthorized] = useState(getStoredAuth); + + const authorize = useCallback((token: string): boolean => { + if (token && token.trim().length > 0) { + sessionStorage.setItem(TOKEN_KEY, token.trim()); + sessionStorage.setItem(AUTH_KEY, "true"); + setIsAuthorized(true); + return true; + } + return false; + }, []); + + const logout = useCallback(() => { + sessionStorage.removeItem(AUTH_KEY); + sessionStorage.removeItem(TOKEN_KEY); + setIsAuthorized(false); + }, []); + + return { isAuthorized, authorize, logout }; +} diff --git a/frontend/form/src/hooks/useForms.ts b/frontend/form/src/hooks/useForms.ts new file mode 100644 index 0000000..f5ef6a3 --- /dev/null +++ b/frontend/form/src/hooks/useForms.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useState } from "react"; +import { listForms } from "../services/formService"; +import type { FormPreview } from "../types"; + +interface UseFormsResult { + forms: FormPreview[]; + total: number; + isLoading: boolean; + error: string | null; + refetch: () => void; +} + +export function useForms(skip = 0, limit = 20): UseFormsResult { + const [forms, setForms] = useState([]); + const [total, setTotal] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [_refreshKey, setRefreshKey] = useState(0); + + const refetch = useCallback(() => { + setRefreshKey((prev) => prev + 1); + }, []); + + useEffect(() => { + let isCancelled = false; + + async function fetchForms() { + setIsLoading(true); + setError(null); + + try { + const response = await listForms(skip, limit); + if (!isCancelled) { + setForms(response.forms); + setTotal(response.total); + } + } catch (err) { + if (!isCancelled) { + setError(err instanceof Error ? err.message : "Failed to load forms"); + } + } finally { + if (!isCancelled) { + setIsLoading(false); + } + } + } + + fetchForms(); + + return () => { + isCancelled = true; + }; + }, [skip, limit]); + + return { forms, total, isLoading, error, refetch }; +} diff --git a/frontend/form/src/index.css b/frontend/form/src/index.css index ddd038e..0b0a3f7 100644 --- a/frontend/form/src/index.css +++ b/frontend/form/src/index.css @@ -1,8 +1,47 @@ -html, -body, -#root { - @apply bg-white; -} @tailwind base; @tailwind components; @tailwind utilities; + +/** + * Global Styles + * Minimal, clean styling for the application + */ + +html, +body { + @apply bg-white; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +#root { + @apply bg-transparent; + min-height: 100vh; +} + +/* Simple fade-in animation for UI elements */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fadeIn { + animation: fadeIn 0.5s ease-out forwards; +} + +/* Respect user's motion preferences */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms; + animation-iteration-count: 1; + transition-duration: 0.01ms; + } +} diff --git a/frontend/form/src/pages/AdminFormViewsPage.tsx b/frontend/form/src/pages/AdminFormViewsPage.tsx deleted file mode 100644 index 6e6241c..0000000 --- a/frontend/form/src/pages/AdminFormViewsPage.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import { type FormEvent, useEffect, useMemo, useState } from "react"; -import { useParams } from "react-router-dom"; -import { getAllSubmissionsByForm, getFormById } from "../services/formService"; -import type { - FormFieldSchema, - FormResponse, - SubmissionResponse, -} from "../types"; - -const ADMIN_PASSWORD = import.meta.env.VITE_ADMIN_PASSWORD ?? ""; -const EMAIL_FIELD_KEYS = ["email", "e_mail", "mail"]; - -function normalizeKey(value: string): string { - return value.trim().toLowerCase().replaceAll("-", "_").replaceAll(" ", "_"); -} - -function matchesAnyKey(fieldId: string, keys: string[]): boolean { - return keys.includes(normalizeKey(fieldId)); -} - -function formatDateTime(value: string | undefined): string { - if (!value) { - return "-"; - } - - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - - return new Intl.DateTimeFormat("tr-TR", { - dateStyle: "medium", - timeStyle: "short", - }).format(date); -} - -function formatAnswer(value: unknown): string { - if (value === null || value === undefined) { - return "-"; - } - - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : "-"; - } - - if (typeof value === "number") { - return String(value); - } - - if (typeof value === "boolean") { - return value ? "Evet" : "Hayır"; - } - - if (Array.isArray(value)) { - if (value.length === 0) { - return "-"; - } - return value.map((item) => formatAnswer(item)).join(", "); - } - - try { - return JSON.stringify(value); - } catch { - return "-"; - } -} - -function formatError(error: unknown): string { - if (error instanceof Error) { - if (error.message.includes("404")) { - return "Form bulunamadı."; - } - return "Veriler yüklenirken bir hata oluştu."; - } - return "Beklenmeyen bir hata oluştu."; -} - -interface StatCardProps { - label: string; - value: string; -} - -function StatCard({ label, value }: StatCardProps) { - return ( -
-

{label}

-

{value}

-
- ); -} - -const AdminFormViewsPage = () => { - const { formId } = useParams<{ formId: string }>(); - const [password, setPassword] = useState(""); - const [isAuthorized, setIsAuthorized] = useState(false); - const [authError, setAuthError] = useState(null); - const [form, setForm] = useState(null); - const [submissions, setSubmissions] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const visibleQuestions = useMemo( - () => - form?.questions.filter( - (question) => !matchesAnyKey(question.field_id, EMAIL_FIELD_KEYS), - ) ?? [], - [form], - ); - - const uniqueEmailCount = useMemo(() => { - const uniqueEmails = new Set(); - for (const submission of submissions) { - const email = submission.respondent_email?.trim().toLowerCase(); - if (email) { - uniqueEmails.add(email); - } - } - return uniqueEmails.size; - }, [submissions]); - - const latestSubmissionTime = submissions[0]?.submitted_at; - - useEffect(() => { - if (!isAuthorized || !formId) { - return; - } - - let isCancelled = false; - - const loadData = async () => { - setIsLoading(true); - setError(null); - - try { - const [formData, submissionData] = await Promise.all([ - getFormById(formId), - getAllSubmissionsByForm(formId), - ]); - - if (isCancelled) { - return; - } - - setForm(formData); - setSubmissions(submissionData); - } catch (loadError) { - if (isCancelled) { - return; - } - setForm(null); - setSubmissions([]); - setError(formatError(loadError)); - } finally { - if (!isCancelled) { - setIsLoading(false); - } - } - }; - - void loadData(); - - return () => { - isCancelled = true; - }; - }, [formId, isAuthorized]); - - const onUnlock = (event: FormEvent) => { - event.preventDefault(); - - if (!ADMIN_PASSWORD) { - setAuthError( - "Yönetici şifresi tanımlı değil. Frontend .env dosyasında VITE_ADMIN_PASSWORD ayarlayın.", - ); - return; - } - - if (password === ADMIN_PASSWORD) { - setIsAuthorized(true); - setAuthError(null); - return; - } - - setAuthError("Yönetici şifresi hatalı."); - }; - - if (!formId) { - return ( -
-
-

- Geçersiz görüntüleme bağlantısı. -

-
-
- ); - } - - if (!isAuthorized) { - return ( -
-
-

- Yönetici Erişimi -

-

- Form görüntüleme panelini açmak için yönetici şifresini girin. -

- -
-
- - setPassword(event.target.value)} - autoComplete="off" - className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200" - placeholder="Şifre" - /> -
- - -
- - {authError && ( -

- {authError} -

- )} -
-
- ); - } - - return ( -
-
-
-

- Form Görüntüleme Paneli -

-

- {form?.title ?? "Form verileri yükleniyor..."} -

-

Form ID: {formId}

-
- - {isLoading ? ( -
-
-

Veriler yükleniyor...

-
- ) : null} - - {error && !isLoading ? ( -
- {error} -
- ) : null} - - {!isLoading && !error && form ? ( - <> -
- - - -
- -
-
- - - - - - {visibleQuestions.map((question: FormFieldSchema) => ( - - ))} - - - - {submissions.length === 0 ? ( - - - - ) : ( - submissions.map((submission) => ( - - - - {visibleQuestions.map((question) => ( - - ))} - - )) - )} - -
- Gönderim Zamanı - - E-posta - - {question.label} -
- Bu form için henüz gönderim yok. -
- {formatDateTime(submission.submitted_at)} - - {submission.respondent_email ?? "-"} - -
- {formatAnswer( - submission.answers[question.field_id], - )} -
-
-
-
- - ) : null} -
-
- ); -}; - -export default AdminFormViewsPage; diff --git a/frontend/form/src/pages/admin/AdminFormListPage.tsx b/frontend/form/src/pages/admin/AdminFormListPage.tsx new file mode 100644 index 0000000..172f4be --- /dev/null +++ b/frontend/form/src/pages/admin/AdminFormListPage.tsx @@ -0,0 +1,265 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import AdminPasswordGate from "../../components/AdminPasswordGate"; +import { useForms } from "../../hooks/useForms"; +import { deleteForm } from "../../services/formService"; + +function formatDate(value: string | undefined): string { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("tr-TR", { + dateStyle: "medium", + }).format(date); +} + +const PAGE_SIZE = 20; + +const AdminFormListPage = () => { + const navigate = useNavigate(); + const [page, setPage] = useState(0); + const [deletingId, setDeletingId] = useState(null); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [deleteError, setDeleteError] = useState(null); + + const skip = page * PAGE_SIZE; + const { forms, total, isLoading, error, refetch } = useForms(skip, PAGE_SIZE); + + const totalPages = Math.ceil(total / PAGE_SIZE); + + const handleDeleteClick = (formId: string) => { + if (confirmDeleteId === formId) { + // Second click - perform deletion + performDelete(formId); + } else { + // First click - show confirmation + setConfirmDeleteId(formId); + setDeleteError(null); + } + }; + + const handleCancelDelete = () => { + setConfirmDeleteId(null); + setDeleteError(null); + }; + + const performDelete = async (formId: string) => { + setDeletingId(formId); + setDeleteError(null); + try { + await deleteForm(formId); + setConfirmDeleteId(null); + refetch(); + } catch (err) { + setDeleteError(err instanceof Error ? err.message : "Bilinmeyen hata"); + } finally { + setDeletingId(null); + } + }; + + return ( + +
+
+
+
+

+ Form Yönetimi +

+

+ Tüm formları görüntüleyin, düzenleyin veya silin. +

+
+ +
+ +
+ {isLoading ? ( +
+
+ Yükleniyor... +
+ ) : error ? ( +
+

{error}

+ +
+ ) : forms.length === 0 ? ( +
+

Henüz form oluşturulmamış.

+
+ ) : ( + <> + + + + + + + + + + + + {forms.map((form) => ( + + + + + + + + ))} + +
+ Başlık + + Durum + + Son Tarih + + Oluşturulma + + İşlemler +
+ + {form.title} + + {form.description && ( +

+ {form.description} +

+ )} +
+ {form.is_active ? ( + + Aktif + + ) : ( + + Pasif + + )} + + {formatDate(form.deadline)} + + {formatDate(form.created_at)} + +
+ {confirmDeleteId === form.id ? ( + <> + + Silmek istediğinize emin misiniz? + + + + + ) : ( + <> + + + + + )} +
+ {deleteError && confirmDeleteId === form.id && ( +

+ {deleteError} +

+ )} +
+ + {totalPages > 1 && ( +
+

+ Toplam {total} form +

+
+ + + {page + 1} / {totalPages} + + +
+
+ )} + + )} +
+
+
+ + ); +}; + +export default AdminFormListPage; diff --git a/frontend/form/src/pages/admin/AdminFormViewsPage.tsx b/frontend/form/src/pages/admin/AdminFormViewsPage.tsx new file mode 100644 index 0000000..762dee3 --- /dev/null +++ b/frontend/form/src/pages/admin/AdminFormViewsPage.tsx @@ -0,0 +1,295 @@ +import { useEffect, useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import AdminPasswordGate from "../../components/AdminPasswordGate"; +import { useAdminAuth } from "../../hooks/useAdminAuth"; +import { + getAllSubmissionsByForm, + getFormById, +} from "../../services/formService"; +import type { + FormFieldSchema, + FormResponse, + SubmissionResponse, +} from "../../types"; + +const EMAIL_FIELD_KEYS = ["email", "e_mail", "mail"]; + +function normalizeKey(value: string): string { + return value.trim().toLowerCase().replaceAll("-", "_").replaceAll(" ", "_"); +} + +function matchesAnyKey(fieldId: string, keys: string[]): boolean { + return keys.includes(normalizeKey(fieldId)); +} + +function formatDateTime(value: string | undefined): string { + if (!value) { + return "-"; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat("tr-TR", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function formatAnswer(value: unknown): string { + if (value === null || value === undefined) { + return "-"; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : "-"; + } + + if (typeof value === "number") { + return String(value); + } + + if (typeof value === "boolean") { + return value ? "Evet" : "Hayır"; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return "-"; + } + return value.map((item) => formatAnswer(item)).join(", "); + } + + try { + return JSON.stringify(value); + } catch { + return "-"; + } +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + if (error.message.includes("404")) { + return "Form bulunamadı."; + } + return "Veriler yüklenirken bir hata oluştu."; + } + return "Beklenmeyen bir hata oluştu."; +} + +interface StatCardProps { + label: string; + value: string; +} + +function StatCard({ label, value }: StatCardProps) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +const AdminFormViewsPage = () => { + const { formId } = useParams<{ formId: string }>(); + const { isAuthorized } = useAdminAuth(); + const [form, setForm] = useState(null); + const [submissions, setSubmissions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const visibleQuestions = useMemo( + () => + form?.questions.filter( + (question) => !matchesAnyKey(question.field_id, EMAIL_FIELD_KEYS), + ) ?? [], + [form], + ); + + const uniqueEmailCount = useMemo(() => { + const uniqueEmails = new Set(); + for (const submission of submissions) { + const email = submission.respondent_email?.trim().toLowerCase(); + if (email) { + uniqueEmails.add(email); + } + } + return uniqueEmails.size; + }, [submissions]); + + const latestSubmissionTime = submissions[0]?.submitted_at; + + useEffect(() => { + if (!isAuthorized || !formId) { + return; + } + + let isCancelled = false; + + const loadData = async () => { + setIsLoading(true); + setError(null); + + try { + const [formData, submissionData] = await Promise.all([ + getFormById(formId), + getAllSubmissionsByForm(formId), + ]); + + if (isCancelled) { + return; + } + + setForm(formData); + setSubmissions(submissionData); + } catch (loadError) { + if (isCancelled) { + return; + } + setForm(null); + setSubmissions([]); + setError(formatError(loadError)); + } finally { + if (!isCancelled) { + setIsLoading(false); + } + } + }; + + void loadData(); + + return () => { + isCancelled = true; + }; + }, [formId, isAuthorized]); + + if (!formId) { + return ( +
+
+

+ Geçersiz görüntüleme bağlantısı. +

+
+
+ ); + } + + return ( + +
+
+
+

+ Form Görüntüleme Paneli +

+

+ {form?.title ?? "Form verileri yükleniyor..."} +

+

Form ID: {formId}

+
+ + {isLoading ? ( +
+
+

+ Veriler yükleniyor... +

+
+ ) : null} + + {error && !isLoading ? ( +
+ {error} +
+ ) : null} + + {!isLoading && !error && form ? ( + <> +
+ + + +
+ +
+
+ + + + + + {visibleQuestions.map((question: FormFieldSchema) => ( + + ))} + + + + {submissions.length === 0 ? ( + + + + ) : ( + submissions.map((submission) => ( + + + + {visibleQuestions.map((question) => ( + + ))} + + )) + )} + +
+ Gönderim Zamanı + + E-posta + + {question.label} +
+ Bu form için henüz gönderim yok. +
+ {formatDateTime(submission.submitted_at)} + + {submission.respondent_email ?? "-"} + +
+ {formatAnswer( + submission.answers[question.field_id], + )} +
+
+
+
+ + ) : null} +
+
+ + ); +}; + +export default AdminFormViewsPage; diff --git a/frontend/form/src/pages/admin/FormEditorPage.tsx b/frontend/form/src/pages/admin/FormEditorPage.tsx new file mode 100644 index 0000000..eee2fc7 --- /dev/null +++ b/frontend/form/src/pages/admin/FormEditorPage.tsx @@ -0,0 +1,540 @@ +import { useEffect, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import AdminPasswordGate from "../../components/AdminPasswordGate"; +import { DatePicker } from "../../components/DatePicker"; +import FormFieldList from "../../components/FormFieldList"; +import FormPreview from "../../components/FormPreview"; +import { DEPARTMENTS, MAX_FIELD_ID_LENGTH } from "../../constants/departments"; +import { + createForm, + getFormById, + updateForm, +} from "../../services/formService"; +import type { + FormCreate, + FormFieldSchema, + FormResponse, + FormUpdate, +} from "../../types"; + +type FormValues = Record; + +type EditorMode = "edit" | "preview"; + +function generateFieldId(): string { + return `field_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; +} + +function createEmptyField(): FormFieldSchema { + const key = generateFieldId(); + return { + _key: key, + field_id: key, + field_type: "text", + label: "", + required: false, + }; +} + +function createDefaultFields(): FormFieldSchema[] { + return [ + { + _key: generateFieldId(), + field_id: "isim_soyisim", + field_type: "text", + label: "İsim - Soyisim", + placeholder: "yanıtınız...", + required: true, + }, + { + _key: generateFieldId(), + field_id: "blm", + field_type: "department", + label: "Bölüm", + placeholder: "seçiniz...", + required: true, + options: [...DEPARTMENTS], + }, + { + _key: generateFieldId(), + field_id: "snf", + field_type: "select", + label: "Sınıf", + placeholder: "seçiniz...", + required: true, + options: ["Hazırlık", "1. Sınıf", "2. Sınıf", "3. Sınıf", "4. Sınıf"], + }, + { + _key: generateFieldId(), + field_id: "aktif_olarak_yasar_universitesi_ogrencisi_misiniz", + field_type: "radio", + label: "Aktif olarak Yaşar Üniversitesi öğrencisi misiniz?", + required: true, + options: ["Evet", "Hayır"], + }, + { + _key: generateFieldId(), + field_id: "etkinligimizi_nereden_duydunuz", + field_type: "radio", + label: "Etkinliğimizi nereden duydunuz?", + required: true, + options: [ + "Arkadaş Tavsiyesi", + "Üniversite İçindeki Afişlerden", + "Instagram", + "Whatsapp Grubu", + "LinkedIn", + "Davetiye", + "Mail", + ], + }, + ]; +} + +function cleanFieldForSave(field: FormFieldSchema): FormFieldSchema { + const cleaned: FormFieldSchema = { + field_id: field.field_id, + field_type: field.field_type, + label: field.label, + required: field.required, + }; + delete cleaned._key; + + if (field.placeholder?.trim()) { + cleaned.placeholder = field.placeholder.trim(); + } + + if (field.options && field.options.length > 0) { + cleaned.options = field.options; + } + + if (field.validation) { + const v = field.validation; + const hasValues = + v.min_length !== undefined || + v.max_length !== undefined || + v.min_value !== undefined || + v.max_value !== undefined || + v.pattern?.trim(); + if (hasValues) { + cleaned.validation = {}; + if (v.min_length !== undefined) + cleaned.validation.min_length = v.min_length; + if (v.max_length !== undefined) + cleaned.validation.max_length = v.max_length; + if (v.min_value !== undefined) cleaned.validation.min_value = v.min_value; + if (v.max_value !== undefined) cleaned.validation.max_value = v.max_value; + if (v.pattern?.trim()) cleaned.validation.pattern = v.pattern.trim(); + } + } + + if (field.condition?.depends_on && field.condition.values?.length > 0) { + cleaned.condition = { + depends_on: field.condition.depends_on, + values: field.condition.values, + }; + } + + return cleaned; +} + +const FormEditorPage = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const formId = searchParams.get("id"); + const isEditMode = Boolean(formId); + + const [mode, setMode] = useState("edit"); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [loadError, setLoadError] = useState(null); + const [saveError, setSaveError] = useState(null); + + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [startDate, setStartDate] = useState(""); + const [deadline, setDeadline] = useState(""); + const [isActive, setIsActive] = useState(true); + const [fields, setFields] = useState([]); + + const [existingFieldIds, setExistingFieldIds] = useState>( + new Set(), + ); + + const { + register, + reset, + formState: { errors }, + } = useForm(); + + useEffect(() => { + if (!formId) return; + + let isCancelled = false; + + async function loadForm() { + setIsLoading(true); + setLoadError(null); + + try { + const form: FormResponse = await getFormById(formId!); + if (isCancelled) return; + + setTitle(form.title); + setDescription(form.description ?? ""); + setStartDate(form.start_date?.split("T")[0] ?? ""); + setDeadline(form.deadline?.split("T")[0] ?? ""); + setIsActive(form.is_active); + setFields(form.questions); + setExistingFieldIds(new Set(form.questions.map((q) => q.field_id))); + + const defaults = form.questions.reduce((acc, field) => { + acc[field.field_id] = ""; + return acc; + }, {}); + reset(defaults); + } catch (err) { + if (isCancelled) return; + setLoadError(err instanceof Error ? err.message : "Form yüklenemedi"); + } finally { + if (!isCancelled) setIsLoading(false); + } + } + + loadForm(); + + return () => { + isCancelled = true; + }; + }, [formId, reset]); + + const handleAddField = () => { + setFields((prev) => [...prev, createEmptyField()]); + }; + + const handleAddDefaultFields = () => { + const defaultFieldIds = new Set([ + "isim_soyisim", + "blm", + "snf", + "aktif_olarak_yasar_universitesi_ogrencisi_misiniz", + "etkinligimizi_nereden_duydunuz", + ]); + + // Check if any default field already exists + const existingIds = new Set(fields.map((f) => f.field_id)); + const hasAnyDefault = [...defaultFieldIds].some((id) => + existingIds.has(id), + ); + + if (hasAnyDefault) { + return; // Early return - default fields already exist + } + + setFields((prev) => [...prev, ...createDefaultFields()]); + }; + + const validateForm = (): string | null => { + if (!title.trim()) { + return "Form başlığı zorunludur."; + } + + if (fields.length === 0) { + return "En az bir alan eklemelisiniz."; + } + + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + if (!field.label.trim()) { + return `Alan ${i + 1}: Etiket zorunludur.`; + } + if (!field.field_id.trim()) { + return `Alan ${i + 1}: Alan ID zorunludur.`; + } + if (field.field_id.length > MAX_FIELD_ID_LENGTH) { + return `Alan ${i + 1}: Alan ID en fazla ${MAX_FIELD_ID_LENGTH} karakter olabilir.`; + } + } + + const fieldIds = fields.map((f) => f.field_id); + const duplicates = fieldIds.filter( + (id, index) => fieldIds.indexOf(id) !== index, + ); + if (duplicates.length > 0) { + return `Birden fazla alan aynı ID'yi kullanıyor: ${duplicates[0]}`; + } + + return null; + }; + + const handleSave = async () => { + const validationError = validateForm(); + if (validationError) { + setSaveError(validationError); + return; + } + + setIsSaving(true); + setSaveError(null); + + try { + const payload: FormCreate = { + title: title.trim(), + description: description.trim() || null, + questions: fields.map(cleanFieldForSave), + is_active: isActive, + }; + + if (startDate) { + payload.start_date = new Date(startDate).toISOString(); + } + if (deadline) { + payload.deadline = new Date(deadline).toISOString(); + } + + if (isEditMode && formId) { + const updatePayload: FormUpdate = { + ...payload, + description: payload.description, + }; + await updateForm(formId, updatePayload); + } else { + await createForm(payload); + } + + navigate("/admin/forms"); + } catch (err) { + setSaveError(err instanceof Error ? err.message : "Kaydetme başarısız"); + } finally { + setIsSaving(false); + } + }; + + const previewForm = useMemo( + () => ({ + id: formId ?? "preview", + title: title || "Form Önizleme", + description: description || undefined, + questions: fields, + start_date: startDate || undefined, + deadline: deadline || undefined, + is_active: isActive, + created_at: new Date().toISOString(), + view_count: 0, + submission_count: 0, + }), + [title, description, fields, startDate, deadline, isActive, formId], + ); + + if (isLoading) { + return ( + +
+
+
+

Form yükleniyor...

+
+
+ + ); + } + + if (loadError) { + return ( + +
+
+

{loadError}

+ +
+
+
+ ); + } + + return ( + +
+
+
+
+

+ {isEditMode ? "Formu Düzenle" : "Yeni Form Oluştur"} +

+

+ {isEditMode + ? "Mevcut formu düzenleyin ve kaydedin." + : "Form alanlarını ekleyin ve yapılandırın."} +

+
+
+ + +
+
+ + {mode === "edit" ? ( + <> +
+

+ Form Bilgileri +

+ +
+ + setTitle(e.target.value)} + className="w-full rounded-2xl border-2 border-slate-200 px-4 pb-3 pt-5 text-sm transition-all duration-200 hover:border-slate-300 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-100" + placeholder="Form başlığı" + /> +
+ +
+ +