No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

1322 líneas
43KB

  1. package handlers
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "net/http"
  6. "net/url"
  7. "regexp"
  8. "sort"
  9. "strconv"
  10. "strings"
  11. "unicode"
  12. "github.com/go-chi/chi/v5"
  13. "qctextbuilder/internal/buildsvc"
  14. "qctextbuilder/internal/config"
  15. "qctextbuilder/internal/domain"
  16. "qctextbuilder/internal/draftsvc"
  17. "qctextbuilder/internal/onboarding"
  18. "qctextbuilder/internal/templatesvc"
  19. )
  20. type UI struct {
  21. templateSvc *templatesvc.Service
  22. onboardSvc *onboarding.Service
  23. draftSvc *draftsvc.Service
  24. buildSvc buildsvc.Service
  25. cfg config.Config
  26. render htmlRenderer
  27. }
  28. type htmlRenderer interface {
  29. Render(w http.ResponseWriter, name string, data any)
  30. }
  31. type pageData struct {
  32. Title string
  33. Msg string
  34. Err string
  35. Current string
  36. }
  37. type homePageData struct {
  38. pageData
  39. TemplateCount int
  40. }
  41. type settingsPageData struct {
  42. pageData
  43. QCBaseURL string
  44. PollIntervalSeconds int
  45. PollTimeoutSeconds int
  46. PollMaxConcurrent int
  47. TokenConfigured bool
  48. LanguageOutputMode string
  49. }
  50. type templatesPageData struct {
  51. pageData
  52. Templates []domain.Template
  53. }
  54. type templateFieldView struct {
  55. Path string
  56. FieldKind string
  57. IsEnabled bool
  58. IsRequiredByUs bool
  59. DisplayLabel string
  60. DisplayOrder int
  61. WebsiteSection string
  62. Notes string
  63. SampleValue string
  64. }
  65. type websiteSectionOptionView struct {
  66. Value string
  67. Label string
  68. }
  69. type templateDetailPageData struct {
  70. pageData
  71. Detail *templatesvc.TemplateDetail
  72. Fields []templateFieldView
  73. WebsiteSectionOptions []websiteSectionOptionView
  74. }
  75. type buildFieldView struct {
  76. Index int
  77. Path string
  78. DisplayLabel string
  79. SampleValue string
  80. Value string
  81. }
  82. type buildFieldGroupView struct {
  83. Title string
  84. Fields []buildFieldView
  85. }
  86. type buildFieldSectionView struct {
  87. Key string
  88. Title string
  89. Description string
  90. EditableGroups []buildFieldGroupView
  91. EditableFields []buildFieldView
  92. DisabledFields []buildFieldView
  93. }
  94. type pendingField struct {
  95. Field domain.TemplateField
  96. View buildFieldView
  97. }
  98. type fieldRole struct {
  99. Label string
  100. Order int
  101. }
  102. var blockIDPattern = regexp.MustCompile(`(?i)(?:^|[_.])([mcr]\d{3,})(?:[_.]|$)`)
  103. var looseBlockIDPattern = regexp.MustCompile(`(?i)([mcr]\d{3,})`)
  104. var knownBlockAreas = map[string]string{
  105. "m1710": "Hero / Haupttitel",
  106. "c7886": "Intro / Einleitung",
  107. "r4830": "Services",
  108. "m4178": "Gallery / Medien",
  109. "c2929": "Ueber uns / About",
  110. "r4748": "Team",
  111. "r1508": "Testimonials",
  112. "c1165": "CTA / Highlight / Banner",
  113. }
  114. type buildNewPageData struct {
  115. pageData
  116. Templates []domain.Template
  117. Drafts []domain.BuildDraft
  118. SelectedDraftID string
  119. SelectedTemplateID int64
  120. SelectedManifestID string
  121. FieldSections []buildFieldSectionView
  122. EditableFields []buildFieldView
  123. EnabledFields []buildFieldView
  124. Form buildFormInput
  125. }
  126. type buildFormInput struct {
  127. DraftID string
  128. DraftSource string
  129. DraftStatus string
  130. DraftNotes string
  131. RequestName string
  132. CompanyName string
  133. BusinessType string
  134. Username string
  135. Email string
  136. Phone string
  137. OrgNumber string
  138. StartDate string
  139. Mission string
  140. DescriptionShort string
  141. DescriptionLong string
  142. SiteLanguage string
  143. AddressLine1 string
  144. AddressLine2 string
  145. AddressCity string
  146. AddressRegion string
  147. AddressZIP string
  148. AddressCountry string
  149. WebsiteURL string
  150. WebsiteSummary string
  151. LocaleStyle string
  152. MarketStyle string
  153. AddressMode string
  154. ContentTone string
  155. PromptInstructions string
  156. }
  157. type buildDetailPageData struct {
  158. pageData
  159. Build *domain.SiteBuild
  160. EffectiveGlobal []byte
  161. CanPoll bool
  162. CanFetchEditorURL bool
  163. AutoRefreshSeconds int
  164. }
  165. func NewUI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, draftSvc *draftsvc.Service, buildSvc buildsvc.Service, cfg config.Config, render htmlRenderer) *UI {
  166. return &UI{templateSvc: templateSvc, onboardSvc: onboardSvc, draftSvc: draftSvc, buildSvc: buildSvc, cfg: cfg, render: render}
  167. }
  168. func (u *UI) Home(w http.ResponseWriter, r *http.Request) {
  169. templates, err := u.templateSvc.ListTemplates(r.Context())
  170. if err != nil {
  171. u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: 0})
  172. return
  173. }
  174. u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: len(templates)})
  175. }
  176. func (u *UI) Settings(w http.ResponseWriter, r *http.Request) {
  177. u.render.Render(w, "settings", settingsPageData{
  178. pageData: basePageData(r, "Settings", "/settings"),
  179. QCBaseURL: u.cfg.QCBaseURL,
  180. PollIntervalSeconds: u.cfg.PollIntervalSeconds,
  181. PollTimeoutSeconds: u.cfg.PollTimeoutSeconds,
  182. PollMaxConcurrent: u.cfg.PollMaxConcurrent,
  183. TokenConfigured: strings.TrimSpace(u.cfg.QCToken) != "",
  184. LanguageOutputMode: "EN",
  185. })
  186. }
  187. func (u *UI) Templates(w http.ResponseWriter, r *http.Request) {
  188. templates, err := u.templateSvc.ListTemplates(r.Context())
  189. if err != nil {
  190. http.Error(w, err.Error(), http.StatusBadRequest)
  191. return
  192. }
  193. u.render.Render(w, "templates", templatesPageData{pageData: basePageData(r, "Templates", "/templates"), Templates: templates})
  194. }
  195. func (u *UI) SyncTemplates(w http.ResponseWriter, r *http.Request) {
  196. if _, err := u.templateSvc.SyncAITemplates(r.Context()); err != nil {
  197. http.Redirect(w, r, "/templates?err="+urlQuery(err.Error()), http.StatusSeeOther)
  198. return
  199. }
  200. http.Redirect(w, r, "/templates?msg=sync+done", http.StatusSeeOther)
  201. }
  202. func (u *UI) TemplateDetail(w http.ResponseWriter, r *http.Request) {
  203. templateID, ok := parseTemplateID(w, r)
  204. if !ok {
  205. return
  206. }
  207. detail, err := u.templateSvc.GetTemplateDetail(r.Context(), templateID)
  208. if err != nil {
  209. http.Error(w, err.Error(), http.StatusNotFound)
  210. return
  211. }
  212. fields := make([]templateFieldView, 0, len(detail.Fields))
  213. for _, f := range detail.Fields {
  214. fields = append(fields, templateFieldView{
  215. Path: f.Path,
  216. FieldKind: f.FieldKind,
  217. IsEnabled: f.IsEnabled,
  218. IsRequiredByUs: f.IsRequiredByUs,
  219. DisplayLabel: f.DisplayLabel,
  220. DisplayOrder: f.DisplayOrder,
  221. WebsiteSection: domain.NormalizeWebsiteSection(f.WebsiteSection),
  222. Notes: f.Notes,
  223. SampleValue: f.SampleValue,
  224. })
  225. }
  226. u.render.Render(w, "template_detail", templateDetailPageData{
  227. pageData: basePageData(r, "Template Detail", "/templates"),
  228. Detail: detail,
  229. Fields: fields,
  230. WebsiteSectionOptions: websiteSectionOptions(),
  231. })
  232. }
  233. func (u *UI) OnboardTemplate(w http.ResponseWriter, r *http.Request) {
  234. templateID, ok := parseTemplateID(w, r)
  235. if !ok {
  236. return
  237. }
  238. if _, _, err := u.onboardSvc.OnboardTemplate(r.Context(), templateID); err != nil {
  239. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther)
  240. return
  241. }
  242. http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=onboarding+done", templateID), http.StatusSeeOther)
  243. }
  244. func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) {
  245. templateID, ok := parseTemplateID(w, r)
  246. if !ok {
  247. return
  248. }
  249. if err := r.ParseForm(); err != nil {
  250. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid form")), http.StatusSeeOther)
  251. return
  252. }
  253. count, _ := strconv.Atoi(r.FormValue("field_count"))
  254. patches := make([]onboarding.FieldPatch, 0, count)
  255. for i := 0; i < count; i++ {
  256. path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i)))
  257. if path == "" {
  258. continue
  259. }
  260. enabled := r.FormValue(fmt.Sprintf("field_enabled_%d", i)) == "on"
  261. required := r.FormValue(fmt.Sprintf("field_required_%d", i)) == "on"
  262. label := r.FormValue(fmt.Sprintf("field_label_%d", i))
  263. notes := r.FormValue(fmt.Sprintf("field_notes_%d", i))
  264. websiteSection := r.FormValue(fmt.Sprintf("field_website_section_%d", i))
  265. order, err := strconv.Atoi(strings.TrimSpace(r.FormValue(fmt.Sprintf("field_order_%d", i))))
  266. if err != nil {
  267. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid display order")), http.StatusSeeOther)
  268. return
  269. }
  270. patches = append(patches, onboarding.FieldPatch{
  271. Path: path,
  272. IsEnabled: boolPtr(enabled),
  273. IsRequiredByUs: boolPtr(required),
  274. DisplayLabel: strPtr(label),
  275. DisplayOrder: intPtr(order),
  276. WebsiteSection: strPtr(websiteSection),
  277. Notes: strPtr(notes),
  278. })
  279. }
  280. manifestID := r.FormValue("manifest_id")
  281. if _, _, err := u.onboardSvc.UpdateTemplateFields(r.Context(), templateID, manifestID, patches); err != nil {
  282. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther)
  283. return
  284. }
  285. http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=fields+saved", templateID), http.StatusSeeOther)
  286. }
  287. func (u *UI) BuildNew(w http.ResponseWriter, r *http.Request) {
  288. selectedTemplateID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("template_id")), 10, 64)
  289. selectedDraftID := strings.TrimSpace(r.URL.Query().Get("draft_id"))
  290. form := buildFormInput{
  291. DraftID: selectedDraftID,
  292. DraftSource: "ui",
  293. DraftStatus: "draft",
  294. }
  295. fieldValues := map[string]string{}
  296. if selectedDraftID != "" {
  297. draft, err := u.draftSvc.GetDraft(r.Context(), selectedDraftID)
  298. if err == nil {
  299. selectedTemplateID = draft.TemplateID
  300. form = buildFormInputFromDraft(draft)
  301. fieldValues = parseFieldValuesJSON(draft.FieldValuesJSON)
  302. }
  303. }
  304. data, err := u.loadBuildNewPageData(r, basePageData(r, "New Build", "/builds/new"), selectedDraftID, selectedTemplateID, form, fieldValues)
  305. if err != nil {
  306. http.Error(w, err.Error(), http.StatusBadRequest)
  307. return
  308. }
  309. u.render.Render(w, "build_new", data)
  310. }
  311. func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) {
  312. if err := r.ParseForm(); err != nil {
  313. http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
  314. return
  315. }
  316. form := buildFormInputFromRequest(r)
  317. fieldValues := parseBuildFieldValues(r)
  318. globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
  319. CompanyName: form.CompanyName,
  320. BusinessType: form.BusinessType,
  321. Username: form.Username,
  322. Email: form.Email,
  323. Phone: form.Phone,
  324. OrgNumber: form.OrgNumber,
  325. StartDate: form.StartDate,
  326. Mission: form.Mission,
  327. DescriptionShort: form.DescriptionShort,
  328. DescriptionLong: form.DescriptionLong,
  329. SiteLanguage: form.SiteLanguage,
  330. AddressLine1: form.AddressLine1,
  331. AddressLine2: form.AddressLine2,
  332. AddressCity: form.AddressCity,
  333. AddressRegion: form.AddressRegion,
  334. AddressZIP: form.AddressZIP,
  335. AddressCountry: form.AddressCountry,
  336. })
  337. templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
  338. if err != nil || templateID <= 0 {
  339. http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
  340. return
  341. }
  342. result, err := u.buildSvc.StartBuild(r.Context(), buildsvc.StartBuildRequest{
  343. TemplateID: templateID,
  344. RequestName: form.RequestName,
  345. GlobalData: globalData,
  346. FieldValues: fieldValues,
  347. })
  348. if err != nil {
  349. data, loadErr := u.loadBuildNewPageData(r, pageData{
  350. Title: "New Build",
  351. Err: err.Error(),
  352. Current: "/builds/new",
  353. }, form.DraftID, templateID, form, fieldValues)
  354. if loadErr != nil {
  355. http.Error(w, loadErr.Error(), http.StatusBadRequest)
  356. return
  357. }
  358. u.render.Render(w, "build_new", data)
  359. return
  360. }
  361. if form.DraftID != "" {
  362. _, _ = u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
  363. DraftID: form.DraftID,
  364. TemplateID: int64Ptr(templateID),
  365. ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
  366. Source: form.DraftSource,
  367. RequestName: form.RequestName,
  368. GlobalData: globalData,
  369. FieldValues: fieldValues,
  370. DraftContext: buildDraftContextFromForm(form, globalData),
  371. Status: "submitted",
  372. Notes: form.DraftNotes,
  373. })
  374. }
  375. http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=build+started", result.BuildID), http.StatusSeeOther)
  376. }
  377. func (u *UI) SaveDraft(w http.ResponseWriter, r *http.Request) {
  378. if err := r.ParseForm(); err != nil {
  379. http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
  380. return
  381. }
  382. form := buildFormInputFromRequest(r)
  383. fieldValues := parseBuildFieldValues(r)
  384. templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
  385. if err != nil || templateID <= 0 {
  386. http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
  387. return
  388. }
  389. globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
  390. CompanyName: form.CompanyName,
  391. BusinessType: form.BusinessType,
  392. Username: form.Username,
  393. Email: form.Email,
  394. Phone: form.Phone,
  395. OrgNumber: form.OrgNumber,
  396. StartDate: form.StartDate,
  397. Mission: form.Mission,
  398. DescriptionShort: form.DescriptionShort,
  399. DescriptionLong: form.DescriptionLong,
  400. SiteLanguage: form.SiteLanguage,
  401. AddressLine1: form.AddressLine1,
  402. AddressLine2: form.AddressLine2,
  403. AddressCity: form.AddressCity,
  404. AddressRegion: form.AddressRegion,
  405. AddressZIP: form.AddressZIP,
  406. AddressCountry: form.AddressCountry,
  407. })
  408. draft, err := u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
  409. DraftID: form.DraftID,
  410. TemplateID: int64Ptr(templateID),
  411. ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
  412. Source: form.DraftSource,
  413. RequestName: form.RequestName,
  414. GlobalData: globalData,
  415. FieldValues: fieldValues,
  416. DraftContext: buildDraftContextFromForm(form, globalData),
  417. Status: defaultDraftStatus(form.DraftStatus),
  418. Notes: form.DraftNotes,
  419. })
  420. if err != nil {
  421. data, loadErr := u.loadBuildNewPageData(r, pageData{
  422. Title: "New Build",
  423. Err: err.Error(),
  424. Current: "/builds/new",
  425. }, form.DraftID, templateID, form, fieldValues)
  426. if loadErr != nil {
  427. http.Error(w, loadErr.Error(), http.StatusBadRequest)
  428. return
  429. }
  430. u.render.Render(w, "build_new", data)
  431. return
  432. }
  433. http.Redirect(w, r, fmt.Sprintf("/builds/new?template_id=%d&draft_id=%s&msg=draft+saved", templateID, urlQuery(draft.ID)), http.StatusSeeOther)
  434. }
  435. func (u *UI) BuildDetail(w http.ResponseWriter, r *http.Request) {
  436. buildID := strings.TrimSpace(chi.URLParam(r, "id"))
  437. build, err := u.buildSvc.GetBuild(r.Context(), buildID)
  438. if err != nil {
  439. http.Error(w, err.Error(), http.StatusNotFound)
  440. return
  441. }
  442. status := strings.ToLower(strings.TrimSpace(build.QCStatus))
  443. canPoll := status == "queued" || status == "processing"
  444. canFetchEditor := (status == "done" || status == "failed" || status == "timeout") &&
  445. build.QCSiteID != nil &&
  446. strings.TrimSpace(build.QCEditorURL) == ""
  447. autoRefresh := 0
  448. if canPoll && u.cfg.PollIntervalSeconds > 0 {
  449. autoRefresh = u.cfg.PollIntervalSeconds
  450. }
  451. effectiveGlobal := build.GlobalDataJSON
  452. if payloadGlobal, err := extractGlobalDataFromFinalPayload(build.FinalSitesPayload); err == nil && len(payloadGlobal) > 0 {
  453. effectiveGlobal = payloadGlobal
  454. }
  455. u.render.Render(w, "build_detail", buildDetailPageData{
  456. pageData: basePageData(r, "Build Detail", "/builds"),
  457. Build: build,
  458. EffectiveGlobal: effectiveGlobal,
  459. CanPoll: canPoll,
  460. CanFetchEditorURL: canFetchEditor,
  461. AutoRefreshSeconds: autoRefresh,
  462. })
  463. }
  464. func (u *UI) PollBuild(w http.ResponseWriter, r *http.Request) {
  465. buildID := strings.TrimSpace(chi.URLParam(r, "id"))
  466. if err := u.buildSvc.PollOnce(r.Context(), buildID); err != nil {
  467. http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther)
  468. return
  469. }
  470. http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=poll+done", buildID), http.StatusSeeOther)
  471. }
  472. func (u *UI) FetchEditorURL(w http.ResponseWriter, r *http.Request) {
  473. buildID := strings.TrimSpace(chi.URLParam(r, "id"))
  474. if err := u.buildSvc.FetchEditorURL(r.Context(), buildID); err != nil {
  475. http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther)
  476. return
  477. }
  478. http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=editor+url+loaded", buildID), http.StatusSeeOther)
  479. }
  480. func basePageData(r *http.Request, title, current string) pageData {
  481. q := r.URL.Query()
  482. return pageData{Title: title, Msg: q.Get("msg"), Err: q.Get("err"), Current: current}
  483. }
  484. func parseTemplateID(w http.ResponseWriter, r *http.Request) (int64, bool) {
  485. rawID := chi.URLParam(r, "id")
  486. templateID, err := strconv.ParseInt(rawID, 10, 64)
  487. if err != nil {
  488. http.Error(w, "invalid template id", http.StatusBadRequest)
  489. return 0, false
  490. }
  491. return templateID, true
  492. }
  493. func urlQuery(s string) string {
  494. return url.QueryEscape(s)
  495. }
  496. func boolPtr(v bool) *bool { return &v }
  497. func intPtr(v int) *int { return &v }
  498. func int64Ptr(v int64) *int64 { return &v }
  499. func strPtr(v string) *string {
  500. return &v
  501. }
  502. func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftID string, selectedTemplateID int64, form buildFormInput, fieldValues map[string]string) (buildNewPageData, error) {
  503. templates, err := u.templateSvc.ListTemplates(r.Context())
  504. if err != nil {
  505. return buildNewPageData{}, err
  506. }
  507. drafts, err := u.draftSvc.ListDrafts(r.Context(), 50)
  508. if err != nil {
  509. return buildNewPageData{}, err
  510. }
  511. data := buildNewPageData{
  512. pageData: page,
  513. Templates: templates,
  514. Drafts: drafts,
  515. SelectedDraftID: selectedDraftID,
  516. SelectedTemplateID: selectedTemplateID,
  517. Form: form,
  518. }
  519. if selectedTemplateID <= 0 {
  520. return data, nil
  521. }
  522. detail, err := u.templateSvc.GetTemplateDetail(r.Context(), selectedTemplateID)
  523. if err != nil || detail.Manifest == nil {
  524. return data, nil
  525. }
  526. data.SelectedManifestID = detail.Manifest.ID
  527. data.EditableFields, data.FieldSections = buildFieldSections(detail.Fields, fieldValues)
  528. data.EnabledFields = data.EditableFields
  529. return data, nil
  530. }
  531. func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]string) ([]buildFieldView, []buildFieldSectionView) {
  532. sectionOrder := []string{
  533. domain.WebsiteSectionHero,
  534. domain.WebsiteSectionIntro,
  535. domain.WebsiteSectionServices,
  536. domain.WebsiteSectionAbout,
  537. domain.WebsiteSectionTeam,
  538. domain.WebsiteSectionTestimonials,
  539. domain.WebsiteSectionCTA,
  540. domain.WebsiteSectionContact,
  541. domain.WebsiteSectionGallery,
  542. domain.WebsiteSectionFooter,
  543. domain.WebsiteSectionOther,
  544. }
  545. sectionDescriptions := map[string]string{
  546. domain.WebsiteSectionHero: "Headline-nahe Felder, bevorzugt nach Block-ID gruppiert.",
  547. domain.WebsiteSectionIntro: "Intro-/Einleitungs-Felder, bevorzugt nach Block-ID gruppiert.",
  548. domain.WebsiteSectionServices: "Services-Felder, bevorzugt nach Block-ID gruppiert.",
  549. domain.WebsiteSectionAbout: "About-Felder, bevorzugt nach Block-ID gruppiert.",
  550. domain.WebsiteSectionTeam: "Team-Felder, bevorzugt nach Block-ID gruppiert.",
  551. domain.WebsiteSectionTestimonials: "Testimonial-Felder, bevorzugt nach Block-ID gruppiert.",
  552. domain.WebsiteSectionCTA: "CTA-/Highlight-Felder, bevorzugt nach Block-ID gruppiert.",
  553. domain.WebsiteSectionContact: "Kontakt-Felder, bevorzugt nach Block-ID gruppiert.",
  554. domain.WebsiteSectionGallery: "Media/Gallery-Felder, Bildfelder bleiben im MVP nicht editierbar.",
  555. domain.WebsiteSectionFooter: "Footer-Felder, bevorzugt nach Block-ID gruppiert.",
  556. domain.WebsiteSectionOther: "Aktive Textfelder ausserhalb der Kern-Sections, bevorzugt nach Block-ID gruppiert.",
  557. }
  558. sectionsByKey := make(map[string]buildFieldSectionView, len(sectionOrder))
  559. pendingByKey := make(map[string][]pendingField, len(sectionOrder))
  560. for _, key := range sectionOrder {
  561. sectionsByKey[key] = buildFieldSectionView{
  562. Key: key,
  563. Title: domain.WebsiteSectionLabel(key),
  564. Description: sectionDescriptions[key],
  565. }
  566. }
  567. for _, f := range fields {
  568. targetSection := preferredBuildSection(f)
  569. if isMediaOrGalleryField(f) || targetSection == domain.WebsiteSectionGallery {
  570. labelFallback := domain.WebsiteSectionLabel(domain.WebsiteSectionGallery) + " - " + humanizeKey(f.KeyName)
  571. if blockID := extractBlockID(f); blockID != "" {
  572. labelFallback = "Media - " + blockGroupTitle(blockID)
  573. }
  574. media := sectionsByKey[domain.WebsiteSectionGallery]
  575. media.DisabledFields = append(media.DisabledFields, buildFieldView{
  576. Path: f.Path,
  577. DisplayLabel: effectiveLabel(f, labelFallback),
  578. SampleValue: f.SampleValue,
  579. Value: "",
  580. })
  581. sectionsByKey[domain.WebsiteSectionGallery] = media
  582. continue
  583. }
  584. if !f.IsEnabled || !strings.EqualFold(strings.TrimSpace(f.FieldKind), "text") {
  585. continue
  586. }
  587. pf := pendingField{
  588. Field: f,
  589. View: buildFieldView{
  590. Path: f.Path,
  591. DisplayLabel: effectiveLabel(f, humanizeKey(f.KeyName)),
  592. SampleValue: f.SampleValue,
  593. Value: strings.TrimSpace(fieldValues[f.Path]),
  594. },
  595. }
  596. pendingByKey[targetSection] = append(pendingByKey[targetSection], pf)
  597. }
  598. for _, key := range sectionOrder {
  599. section := sectionsByKey[key]
  600. items := pendingByKey[key]
  601. switch key {
  602. case domain.WebsiteSectionServices:
  603. section = applyServicesGrouping(section, items)
  604. case domain.WebsiteSectionTestimonials:
  605. section = applyTestimonialsGrouping(section, items)
  606. case domain.WebsiteSectionHero, domain.WebsiteSectionIntro, domain.WebsiteSectionAbout, domain.WebsiteSectionTeam, domain.WebsiteSectionCTA, domain.WebsiteSectionContact, domain.WebsiteSectionFooter:
  607. section = applyTextGrouping(section, items)
  608. case domain.WebsiteSectionOther:
  609. section = applyOtherGrouping(section, items)
  610. case domain.WebsiteSectionGallery:
  611. // Gallery fields are handled as disabled entries only in this MVP.
  612. default:
  613. section = applyOtherGrouping(section, items)
  614. }
  615. sectionsByKey[key] = section
  616. }
  617. sections := make([]buildFieldSectionView, 0, len(sectionOrder))
  618. for _, key := range sectionOrder {
  619. sections = append(sections, sectionsByKey[key])
  620. }
  621. return assignEditableIndexes(sections)
  622. }
  623. func applyServicesGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  624. return applyBlockFirstGrouping(section, fields, "Services", applyServicesGroupingFallback)
  625. }
  626. func applyServicesGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  627. titles := make([]pendingField, 0)
  628. descriptions := make([]pendingField, 0)
  629. other := make([]buildFieldView, 0)
  630. for _, pf := range fields {
  631. key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
  632. switch {
  633. case strings.HasPrefix(key, "servicestitle_") || strings.HasPrefix(key, "servicestitle"):
  634. titles = append(titles, pf)
  635. case strings.HasPrefix(key, "servicesdescription_") || strings.HasPrefix(key, "servicesdescription"):
  636. descriptions = append(descriptions, pf)
  637. default:
  638. pf.View.DisplayLabel = effectiveLabel(pf.Field, "Services - "+humanizeKey(pf.Field.KeyName))
  639. other = append(other, pf.View)
  640. }
  641. }
  642. maxCount := len(titles)
  643. if len(descriptions) > maxCount {
  644. maxCount = len(descriptions)
  645. }
  646. for i := 0; i < maxCount; i++ {
  647. block := buildFieldGroupView{Title: fmt.Sprintf("Service %d", i+1)}
  648. if i < len(titles) {
  649. item := titles[i]
  650. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Titel", i+1))
  651. block.Fields = append(block.Fields, item.View)
  652. }
  653. if i < len(descriptions) {
  654. item := descriptions[i]
  655. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Beschreibung", i+1))
  656. block.Fields = append(block.Fields, item.View)
  657. }
  658. if len(block.Fields) > 0 {
  659. section.EditableGroups = append(section.EditableGroups, block)
  660. }
  661. }
  662. section.EditableFields = append(section.EditableFields, other...)
  663. return section
  664. }
  665. func applyTestimonialsGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  666. return applyBlockFirstGrouping(section, fields, "Testimonials", applyTestimonialsGroupingFallback)
  667. }
  668. func applyTestimonialsGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  669. names := make([]pendingField, 0)
  670. titles := make([]pendingField, 0)
  671. descriptions := make([]pendingField, 0)
  672. other := make([]buildFieldView, 0)
  673. for _, pf := range fields {
  674. key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
  675. switch {
  676. case strings.HasPrefix(key, "testimonialsname_") || strings.HasPrefix(key, "testimonialsname"):
  677. names = append(names, pf)
  678. case strings.HasPrefix(key, "testimonialstitle_") || strings.HasPrefix(key, "testimonialstitle"):
  679. titles = append(titles, pf)
  680. case strings.HasPrefix(key, "testimonialsdescription_") || strings.HasPrefix(key, "testimonialsdescription"):
  681. descriptions = append(descriptions, pf)
  682. default:
  683. pf.View.DisplayLabel = effectiveLabel(pf.Field, "Testimonials - "+humanizeKey(pf.Field.KeyName))
  684. other = append(other, pf.View)
  685. }
  686. }
  687. maxCount := len(names)
  688. if len(titles) > maxCount {
  689. maxCount = len(titles)
  690. }
  691. if len(descriptions) > maxCount {
  692. maxCount = len(descriptions)
  693. }
  694. for i := 0; i < maxCount; i++ {
  695. block := buildFieldGroupView{Title: fmt.Sprintf("Testimonial %d", i+1)}
  696. if i < len(names) {
  697. item := names[i]
  698. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Name", i+1))
  699. block.Fields = append(block.Fields, item.View)
  700. }
  701. if i < len(titles) {
  702. item := titles[i]
  703. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Titel", i+1))
  704. block.Fields = append(block.Fields, item.View)
  705. }
  706. if i < len(descriptions) {
  707. item := descriptions[i]
  708. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Beschreibung", i+1))
  709. block.Fields = append(block.Fields, item.View)
  710. }
  711. if len(block.Fields) > 0 {
  712. section.EditableGroups = append(section.EditableGroups, block)
  713. }
  714. }
  715. section.EditableFields = append(section.EditableFields, other...)
  716. return section
  717. }
  718. func applyTextGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  719. return applyBlockFirstGrouping(section, fields, "Text", applyTextGroupingFallback)
  720. }
  721. func applyTextGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  722. titles := make([]pendingField, 0)
  723. descriptions := make([]pendingField, 0)
  724. names := make([]pendingField, 0)
  725. other := make([]buildFieldView, 0)
  726. for _, pf := range fields {
  727. key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
  728. switch {
  729. case strings.HasPrefix(key, "texttitle_") || strings.HasPrefix(key, "texttitle") || strings.HasPrefix(key, "exttitle_") || strings.HasPrefix(key, "exttitle"):
  730. titles = append(titles, pf)
  731. case strings.HasPrefix(key, "textdescription_") || strings.HasPrefix(key, "textdescription") || strings.HasPrefix(key, "extdescription_") || strings.HasPrefix(key, "extdescription"):
  732. descriptions = append(descriptions, pf)
  733. case strings.HasPrefix(key, "textname_") || strings.HasPrefix(key, "textname") || strings.HasPrefix(key, "extname_") || strings.HasPrefix(key, "extname"):
  734. names = append(names, pf)
  735. default:
  736. pf.View.DisplayLabel = effectiveLabel(pf.Field, "Text - "+humanizeKey(pf.Field.KeyName))
  737. other = append(other, pf.View)
  738. }
  739. }
  740. maxCount := len(titles)
  741. if len(descriptions) > maxCount {
  742. maxCount = len(descriptions)
  743. }
  744. if len(names) > maxCount {
  745. maxCount = len(names)
  746. }
  747. for i := 0; i < maxCount; i++ {
  748. block := buildFieldGroupView{Title: fmt.Sprintf("Textblock %d", i+1)}
  749. if i < len(titles) {
  750. item := titles[i]
  751. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Titel", i+1))
  752. block.Fields = append(block.Fields, item.View)
  753. }
  754. if i < len(descriptions) {
  755. item := descriptions[i]
  756. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Beschreibung", i+1))
  757. block.Fields = append(block.Fields, item.View)
  758. }
  759. if i < len(names) {
  760. item := names[i]
  761. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Name", i+1))
  762. block.Fields = append(block.Fields, item.View)
  763. }
  764. if len(block.Fields) > 0 {
  765. section.EditableGroups = append(section.EditableGroups, block)
  766. }
  767. }
  768. section.EditableFields = append(section.EditableFields, other...)
  769. return section
  770. }
  771. func applyOtherGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  772. return applyBlockFirstGrouping(section, fields, "", applyOtherGroupingFallback)
  773. }
  774. func applyOtherGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  775. for _, pf := range fields {
  776. pf.View.DisplayLabel = effectiveLabel(pf.Field, normalizedSectionTitle(pf.Field.Section)+" - "+humanizeKey(pf.Field.KeyName))
  777. section.EditableFields = append(section.EditableFields, pf.View)
  778. }
  779. sort.SliceStable(section.EditableFields, func(i, j int) bool {
  780. return section.EditableFields[i].Path < section.EditableFields[j].Path
  781. })
  782. return section
  783. }
  784. func applyBlockFirstGrouping(section buildFieldSectionView, fields []pendingField, fallbackPrefix string, fallback func(buildFieldSectionView, []pendingField) buildFieldSectionView) buildFieldSectionView {
  785. grouped := map[string][]pendingField{}
  786. withoutBlockID := make([]pendingField, 0)
  787. for _, pf := range fields {
  788. blockID := extractBlockID(pf.Field)
  789. if blockID == "" {
  790. withoutBlockID = append(withoutBlockID, pf)
  791. continue
  792. }
  793. grouped[blockID] = append(grouped[blockID], pf)
  794. }
  795. if len(grouped) > 0 {
  796. blockIDs := make([]string, 0, len(grouped))
  797. for blockID := range grouped {
  798. blockIDs = append(blockIDs, blockID)
  799. }
  800. sort.SliceStable(blockIDs, func(i, j int) bool {
  801. li, lj := blockSortRank(blockIDs[i]), blockSortRank(blockIDs[j])
  802. if li != lj {
  803. return li < lj
  804. }
  805. return blockIDs[i] < blockIDs[j]
  806. })
  807. for _, blockID := range blockIDs {
  808. items := grouped[blockID]
  809. sort.SliceStable(items, func(i, j int) bool {
  810. ri := deriveFieldRole(items[i].Field.KeyName)
  811. rj := deriveFieldRole(items[j].Field.KeyName)
  812. if ri.Order != rj.Order {
  813. return ri.Order < rj.Order
  814. }
  815. return items[i].Field.Path < items[j].Field.Path
  816. })
  817. group := buildFieldGroupView{Title: blockGroupTitle(blockID)}
  818. for _, item := range items {
  819. role := deriveFieldRole(item.Field.KeyName)
  820. fallbackLabel := role.Label
  821. if fallbackLabel == "" {
  822. fallbackLabel = humanizeKey(item.Field.KeyName)
  823. }
  824. item.View.DisplayLabel = effectiveLabel(item.Field, fallbackLabel)
  825. group.Fields = append(group.Fields, item.View)
  826. }
  827. if len(group.Fields) > 0 {
  828. section.EditableGroups = append(section.EditableGroups, group)
  829. }
  830. }
  831. }
  832. if len(withoutBlockID) > 0 {
  833. if fallback != nil {
  834. return fallback(section, withoutBlockID)
  835. }
  836. for _, pf := range withoutBlockID {
  837. labelPrefix := fallbackPrefix
  838. if labelPrefix == "" {
  839. labelPrefix = normalizedSectionTitle(pf.Field.Section)
  840. }
  841. pf.View.DisplayLabel = effectiveLabel(pf.Field, labelPrefix+" - "+humanizeKey(pf.Field.KeyName))
  842. section.EditableFields = append(section.EditableFields, pf.View)
  843. }
  844. }
  845. return section
  846. }
  847. func extractBlockID(f domain.TemplateField) string {
  848. candidates := []string{f.KeyName, f.Path}
  849. for _, candidate := range candidates {
  850. normalized := strings.ToLower(strings.TrimSpace(candidate))
  851. if normalized == "" {
  852. continue
  853. }
  854. if match := blockIDPattern.FindStringSubmatch(normalized); len(match) > 1 {
  855. return match[1]
  856. }
  857. if match := looseBlockIDPattern.FindStringSubmatch(normalized); len(match) > 1 {
  858. return match[1]
  859. }
  860. }
  861. return ""
  862. }
  863. func blockGroupTitle(blockID string) string {
  864. blockID = strings.ToLower(strings.TrimSpace(blockID))
  865. if blockID == "" {
  866. return "Unbekannter Block"
  867. }
  868. if known, ok := knownBlockAreas[blockID]; ok {
  869. return fmt.Sprintf("%s (%s)", known, blockID)
  870. }
  871. return "Block " + blockID
  872. }
  873. func blockSortRank(blockID string) int {
  874. switch strings.ToLower(strings.TrimSpace(blockID)) {
  875. case "m1710":
  876. return 10
  877. case "c7886":
  878. return 20
  879. case "r4830":
  880. return 30
  881. case "c2929":
  882. return 40
  883. case "r4748":
  884. return 50
  885. case "r1508":
  886. return 60
  887. case "c1165":
  888. return 70
  889. case "m4178":
  890. return 80
  891. default:
  892. return 1000
  893. }
  894. }
  895. func deriveFieldRole(key string) fieldRole {
  896. normalized := strings.ToLower(strings.TrimSpace(key))
  897. switch {
  898. case strings.Contains(normalized, "subtitle"):
  899. return fieldRole{Label: "Untertitel", Order: 15}
  900. case strings.Contains(normalized, "title"):
  901. return fieldRole{Label: "Titel", Order: 20}
  902. case strings.Contains(normalized, "description"):
  903. return fieldRole{Label: "Beschreibung", Order: 30}
  904. case strings.Contains(normalized, "name"):
  905. return fieldRole{Label: "Name", Order: 40}
  906. case strings.Contains(normalized, "button") || strings.Contains(normalized, "cta"):
  907. return fieldRole{Label: "CTA Text", Order: 50}
  908. default:
  909. return fieldRole{Label: humanizeKey(key), Order: 100}
  910. }
  911. }
  912. func assignEditableIndexes(sections []buildFieldSectionView) ([]buildFieldView, []buildFieldSectionView) {
  913. editable := make([]buildFieldView, 0)
  914. nextIndex := 0
  915. for si := range sections {
  916. for gi := range sections[si].EditableGroups {
  917. for fi := range sections[si].EditableGroups[gi].Fields {
  918. sections[si].EditableGroups[gi].Fields[fi].Index = nextIndex
  919. editable = append(editable, sections[si].EditableGroups[gi].Fields[fi])
  920. nextIndex++
  921. }
  922. }
  923. for fi := range sections[si].EditableFields {
  924. sections[si].EditableFields[fi].Index = nextIndex
  925. editable = append(editable, sections[si].EditableFields[fi])
  926. nextIndex++
  927. }
  928. }
  929. return editable, sections
  930. }
  931. func preferredBuildSection(f domain.TemplateField) string {
  932. websiteSection := strings.TrimSpace(f.WebsiteSection)
  933. if websiteSection != "" {
  934. normalized := domain.NormalizeWebsiteSection(websiteSection)
  935. if normalized == domain.WebsiteSectionServiceItem {
  936. return domain.WebsiteSectionServices
  937. }
  938. return normalized
  939. }
  940. return fallbackBuildSection(f)
  941. }
  942. func fallbackBuildSection(f domain.TemplateField) string {
  943. switch normalizedSection(f.Section) {
  944. case "services":
  945. return domain.WebsiteSectionServices
  946. case "testimonials":
  947. return domain.WebsiteSectionTestimonials
  948. case "text":
  949. if isMediaOrGalleryField(f) {
  950. return domain.WebsiteSectionGallery
  951. }
  952. return domain.WebsiteSectionOther
  953. default:
  954. if isMediaOrGalleryField(f) {
  955. return domain.WebsiteSectionGallery
  956. }
  957. return domain.WebsiteSectionOther
  958. }
  959. }
  960. func normalizedSection(raw string) string {
  961. section := strings.ToLower(strings.TrimSpace(raw))
  962. switch section {
  963. case "ext":
  964. return "text"
  965. default:
  966. return section
  967. }
  968. }
  969. func normalizedSectionTitle(raw string) string {
  970. switch normalizedSection(raw) {
  971. case "text":
  972. return "Text"
  973. case "services":
  974. return "Services"
  975. case "testimonials":
  976. return "Testimonials"
  977. case "gallery", "media":
  978. return "Media"
  979. default:
  980. return "Feld"
  981. }
  982. }
  983. func isMediaOrGalleryField(f domain.TemplateField) bool {
  984. if strings.EqualFold(strings.TrimSpace(f.FieldKind), "image") {
  985. return true
  986. }
  987. section := strings.ToLower(strings.TrimSpace(f.Section))
  988. key := strings.ToLower(strings.TrimSpace(f.KeyName))
  989. path := strings.ToLower(strings.TrimSpace(f.Path))
  990. if section == "gallery" || section == "media" {
  991. return true
  992. }
  993. hints := []string{"gallery", "image", "img", "photo", "picture"}
  994. for _, hint := range hints {
  995. if strings.Contains(section, hint) || strings.Contains(key, hint) || strings.Contains(path, hint) {
  996. return true
  997. }
  998. }
  999. return false
  1000. }
  1001. func effectiveLabel(f domain.TemplateField, fallback string) string {
  1002. if !isRawPathLikeLabel(f.DisplayLabel, f.Path) {
  1003. return strings.TrimSpace(f.DisplayLabel)
  1004. }
  1005. return strings.TrimSpace(fallback)
  1006. }
  1007. func isRawPathLikeLabel(label string, path string) bool {
  1008. l := strings.TrimSpace(label)
  1009. if l == "" {
  1010. return true
  1011. }
  1012. if strings.EqualFold(l, strings.TrimSpace(path)) {
  1013. return true
  1014. }
  1015. if strings.Contains(l, ".") || strings.Contains(l, "_") {
  1016. return true
  1017. }
  1018. return false
  1019. }
  1020. func humanizeKey(key string) string {
  1021. raw := strings.TrimSpace(key)
  1022. if raw == "" {
  1023. return "Inhalt"
  1024. }
  1025. base := raw
  1026. if idx := strings.Index(base, "_"); idx > 0 {
  1027. base = base[:idx]
  1028. }
  1029. runes := make([]rune, 0, len(base)+4)
  1030. for i, r := range base {
  1031. if i > 0 && unicode.IsUpper(r) {
  1032. runes = append(runes, ' ')
  1033. }
  1034. runes = append(runes, r)
  1035. }
  1036. human := strings.TrimSpace(string(runes))
  1037. if human == "" {
  1038. return "Inhalt"
  1039. }
  1040. words := strings.Fields(strings.ToLower(human))
  1041. for i := range words {
  1042. if len(words[i]) > 0 {
  1043. words[i] = strings.ToUpper(words[i][:1]) + words[i][1:]
  1044. }
  1045. }
  1046. return strings.Join(words, " ")
  1047. }
  1048. func buildFormInputFromRequest(r *http.Request) buildFormInput {
  1049. return buildFormInput{
  1050. DraftID: strings.TrimSpace(r.FormValue("draft_id")),
  1051. DraftSource: strings.TrimSpace(r.FormValue("draft_source")),
  1052. DraftStatus: strings.TrimSpace(r.FormValue("draft_status")),
  1053. DraftNotes: strings.TrimSpace(r.FormValue("draft_notes")),
  1054. RequestName: strings.TrimSpace(r.FormValue("request_name")),
  1055. CompanyName: strings.TrimSpace(r.FormValue("company_name")),
  1056. BusinessType: strings.TrimSpace(r.FormValue("business_type")),
  1057. Username: strings.TrimSpace(r.FormValue("username")),
  1058. Email: strings.TrimSpace(r.FormValue("email")),
  1059. Phone: strings.TrimSpace(r.FormValue("phone")),
  1060. OrgNumber: strings.TrimSpace(r.FormValue("org_number")),
  1061. StartDate: strings.TrimSpace(r.FormValue("start_date")),
  1062. Mission: strings.TrimSpace(r.FormValue("mission")),
  1063. DescriptionShort: strings.TrimSpace(r.FormValue("description_short")),
  1064. DescriptionLong: strings.TrimSpace(r.FormValue("description_long")),
  1065. SiteLanguage: strings.TrimSpace(r.FormValue("site_language")),
  1066. AddressLine1: strings.TrimSpace(r.FormValue("address_line1")),
  1067. AddressLine2: strings.TrimSpace(r.FormValue("address_line2")),
  1068. AddressCity: strings.TrimSpace(r.FormValue("address_city")),
  1069. AddressRegion: strings.TrimSpace(r.FormValue("address_region")),
  1070. AddressZIP: strings.TrimSpace(r.FormValue("address_zip")),
  1071. AddressCountry: strings.TrimSpace(r.FormValue("address_country")),
  1072. WebsiteURL: strings.TrimSpace(r.FormValue("website_url")),
  1073. WebsiteSummary: strings.TrimSpace(r.FormValue("website_summary")),
  1074. LocaleStyle: strings.TrimSpace(r.FormValue("locale_style")),
  1075. MarketStyle: strings.TrimSpace(r.FormValue("market_style")),
  1076. AddressMode: strings.TrimSpace(r.FormValue("address_mode")),
  1077. ContentTone: strings.TrimSpace(r.FormValue("content_tone")),
  1078. PromptInstructions: strings.TrimSpace(r.FormValue("prompt_instructions")),
  1079. }
  1080. }
  1081. func buildFormInputFromDraft(draft *domain.BuildDraft) buildFormInput {
  1082. form := buildFormInput{
  1083. DraftID: draft.ID,
  1084. DraftSource: draft.Source,
  1085. DraftStatus: draft.Status,
  1086. DraftNotes: draft.Notes,
  1087. RequestName: draft.RequestName,
  1088. }
  1089. mergeGlobalDataIntoForm(&form, draft.GlobalDataJSON)
  1090. mergeDraftContextIntoForm(&form, draft.DraftContextJSON)
  1091. return form
  1092. }
  1093. func parseBuildFieldValues(r *http.Request) map[string]string {
  1094. fieldValues := map[string]string{}
  1095. count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("field_count")))
  1096. for i := 0; i < count; i++ {
  1097. path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i)))
  1098. value := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_value_%d", i)))
  1099. if path != "" {
  1100. fieldValues[path] = value
  1101. }
  1102. }
  1103. return fieldValues
  1104. }
  1105. func extractGlobalDataFromFinalPayload(raw []byte) ([]byte, error) {
  1106. if len(raw) == 0 {
  1107. return nil, nil
  1108. }
  1109. var payload struct {
  1110. GlobalData map[string]any `json:"globalData"`
  1111. }
  1112. if err := json.Unmarshal(raw, &payload); err != nil {
  1113. return nil, err
  1114. }
  1115. if len(payload.GlobalData) == 0 {
  1116. return nil, nil
  1117. }
  1118. data, err := json.Marshal(payload.GlobalData)
  1119. if err != nil {
  1120. return nil, err
  1121. }
  1122. return data, nil
  1123. }
  1124. func parseFieldValuesJSON(raw []byte) map[string]string {
  1125. out := map[string]string{}
  1126. if len(raw) == 0 {
  1127. return out
  1128. }
  1129. _ = json.Unmarshal(raw, &out)
  1130. return out
  1131. }
  1132. func mergeGlobalDataIntoForm(form *buildFormInput, raw []byte) {
  1133. if form == nil || len(raw) == 0 {
  1134. return
  1135. }
  1136. var global map[string]any
  1137. if err := json.Unmarshal(raw, &global); err != nil {
  1138. return
  1139. }
  1140. form.CompanyName = getString(global["companyName"])
  1141. form.BusinessType = getString(global["businessType"])
  1142. form.Username = getString(global["username"])
  1143. form.Email = getString(global["email"])
  1144. form.Phone = getString(global["phone"])
  1145. form.OrgNumber = getString(global["orgNumber"])
  1146. form.StartDate = getString(global["startDate"])
  1147. form.Mission = getString(global["mission"])
  1148. form.DescriptionShort = getString(global["descriptionShort"])
  1149. form.DescriptionLong = getString(global["descriptionLong"])
  1150. form.SiteLanguage = getString(global["siteLanguage"])
  1151. address, _ := global["address"].(map[string]any)
  1152. form.AddressLine1 = getString(address["line1"])
  1153. form.AddressLine2 = getString(address["line2"])
  1154. form.AddressCity = getString(address["city"])
  1155. form.AddressRegion = getString(address["region"])
  1156. form.AddressZIP = getString(address["zip"])
  1157. form.AddressCountry = getString(address["country"])
  1158. }
  1159. func mergeDraftContextIntoForm(form *buildFormInput, raw []byte) {
  1160. if form == nil || len(raw) == 0 {
  1161. return
  1162. }
  1163. var ctx domain.DraftContext
  1164. if err := json.Unmarshal(raw, &ctx); err != nil {
  1165. return
  1166. }
  1167. if strings.TrimSpace(form.BusinessType) == "" {
  1168. form.BusinessType = strings.TrimSpace(ctx.LLM.BusinessType)
  1169. }
  1170. form.WebsiteURL = strings.TrimSpace(ctx.LLM.WebsiteURL)
  1171. form.WebsiteSummary = strings.TrimSpace(ctx.LLM.WebsiteSummary)
  1172. form.LocaleStyle = strings.TrimSpace(ctx.LLM.StyleProfile.LocaleStyle)
  1173. form.MarketStyle = strings.TrimSpace(ctx.LLM.StyleProfile.MarketStyle)
  1174. form.AddressMode = strings.TrimSpace(ctx.LLM.StyleProfile.AddressMode)
  1175. form.ContentTone = strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone)
  1176. form.PromptInstructions = strings.TrimSpace(ctx.LLM.StyleProfile.PromptInstructions)
  1177. }
  1178. func buildDraftContextFromForm(form buildFormInput, globalData map[string]any) *domain.DraftContext {
  1179. businessType := strings.TrimSpace(form.BusinessType)
  1180. if businessType == "" {
  1181. businessType = strings.TrimSpace(getString(globalData["businessType"]))
  1182. }
  1183. return &domain.DraftContext{
  1184. IntakeSource: strings.TrimSpace(form.DraftSource),
  1185. LLM: domain.DraftLLMContext{
  1186. BusinessType: businessType,
  1187. WebsiteURL: strings.TrimSpace(form.WebsiteURL),
  1188. WebsiteSummary: strings.TrimSpace(form.WebsiteSummary),
  1189. StyleProfile: domain.DraftStyleProfile{
  1190. LocaleStyle: strings.TrimSpace(form.LocaleStyle),
  1191. MarketStyle: strings.TrimSpace(form.MarketStyle),
  1192. AddressMode: strings.TrimSpace(form.AddressMode),
  1193. ContentTone: strings.TrimSpace(form.ContentTone),
  1194. PromptInstructions: strings.TrimSpace(form.PromptInstructions),
  1195. },
  1196. },
  1197. }
  1198. }
  1199. func getString(v any) string {
  1200. s, _ := v.(string)
  1201. return strings.TrimSpace(s)
  1202. }
  1203. func defaultDraftStatus(status string) string {
  1204. switch strings.ToLower(strings.TrimSpace(status)) {
  1205. case "reviewed", "submitted":
  1206. return strings.ToLower(strings.TrimSpace(status))
  1207. default:
  1208. return "draft"
  1209. }
  1210. }
  1211. func websiteSectionOptions() []websiteSectionOptionView {
  1212. values := domain.WebsiteSectionOptions()
  1213. out := make([]websiteSectionOptionView, 0, len(values))
  1214. for _, value := range values {
  1215. out = append(out, websiteSectionOptionView{
  1216. Value: value,
  1217. Label: domain.WebsiteSectionLabel(value),
  1218. })
  1219. }
  1220. return out
  1221. }