| 1 |
import React, { useMemo, useState } from "react"; |
| 2 |
import { __ } from "@wordpress/i18n"; |
| 3 |
import { v4 } from "uuid"; |
| 4 |
import { useBuilderContext, withLabel } from "quickbuilder"; |
| 5 |
import ReactModal from "react-modal"; |
| 6 |
import Swal from "sweetalert2"; |
| 7 |
import CloseIcon from "../components/CloseIcon"; |
| 8 |
import { apiFetch, toastAlert } from "../../utils"; |
| 9 |
|
| 10 |
// ── Personalize → Write with AI instruction sets ───────────────────────────── |
| 11 |
// List + modal editor, mirroring the Access & Restrictions "rules" repeater. |
| 12 |
// The "Default/Core" set (id === 'default') is always applied as the base system |
| 13 |
// prompt: it can be edited but never deleted, and its title is locked. Other sets |
| 14 |
// can be added, edited, and removed. Editing happens in a popup (title + |
| 15 |
// instructions + .md upload), persisted with the page's main Save button. |
| 16 |
const DEFAULT_ID = "default"; |
| 17 |
|
| 18 |
// Row action icons (match BetterRepeaterValueShow.js). |
| 19 |
const EditIcon = ({ onClick }) => ( |
| 20 |
<svg xmlns="http://www.w3.org/2000/svg" width={12} height={12} viewBox="0 0 12 12" fill="none" onClick={onClick}> |
| 21 |
<path fillRule="evenodd" clipRule="evenodd" d="M8.60581 0.582347C8.97868 0.209473 9.48441 0 10.0117 0C10.2729 0 10.5314 0.0514267 10.7726 0.151347C11.0139 0.251267 11.233 0.39772 11.4177 0.582347C11.6023 0.766973 11.7487 0.98616 11.8487 1.22739C11.9486 1.46861 12 1.72716 12 1.98826C12 2.24936 11.9486 2.50791 11.8487 2.74913C11.7487 2.99036 11.6023 3.20955 11.4177 3.39417L10.6821 4.12977C10.4217 4.39013 9.99961 4.39013 9.73928 4.12978L7.87021 2.26076C7.60988 2.00041 7.60988 1.5783 7.87021 1.31795L8.60581 0.582347ZM6.92741 3.20357C6.66708 2.94322 6.24495 2.94322 5.98461 3.20357L1.34057 7.8476C0.913375 8.2748 0.610308 8.81007 0.463781 9.3962L0.0199214 11.1717C-0.0368786 11.3988 0.0296881 11.6391 0.195275 11.8047C0.360861 11.9703 0.601188 12.0369 0.828375 11.9801L2.60382 11.5362C3.18993 11.3897 3.72519 11.0867 4.15239 10.6595L8.79641 6.0154C9.05681 5.75507 9.05681 5.33293 8.79641 5.0726L6.92741 3.20357Z" fill="#718096" /> |
| 22 |
<path fillRule="evenodd" clipRule="evenodd" d="M6 11.3346C6 10.9664 6.29847 10.668 6.66667 10.668H11.3333C11.7015 10.668 12 10.9664 12 11.3346C12 11.7028 11.7015 12.0013 11.3333 12.0013H6.66667C6.29847 12.0013 6 11.7028 6 11.3346Z" fill="#718096" /> |
| 23 |
</svg> |
| 24 |
); |
| 25 |
|
| 26 |
const DeleteIcon = ({ onClick }) => ( |
| 27 |
<svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} viewBox="0 0 16 16" fill="none" onClick={onClick}> |
| 28 |
<path d="M2 3.9987H3.33333M3.33333 3.9987H14M3.33333 3.9987V13.332C3.33333 13.6857 3.47381 14.0248 3.72386 14.2748C3.97391 14.5249 4.31304 14.6654 4.66667 14.6654H11.3333C11.687 14.6654 12.0261 14.5249 12.2761 14.2748C12.5262 14.0248 12.6667 13.6857 12.6667 13.332V3.9987H3.33333ZM5.33333 3.9987V2.66536C5.33333 2.31174 5.47381 1.9726 5.72386 1.72256C5.97391 1.47251 6.31304 1.33203 6.66667 1.33203H9.33333C9.68696 1.33203 10.0261 1.47251 10.2761 1.72256C10.5262 1.9726 10.6667 2.31174 10.6667 2.66536V3.9987M6.66667 7.33203V11.332M9.33333 7.33203V11.332" stroke="#98A2B3" strokeWidth={1.66667} strokeLinecap="round" strokeLinejoin="round" /> |
| 29 |
</svg> |
| 30 |
); |
| 31 |
|
| 32 |
const formStyles = { |
| 33 |
label: { display: "block", fontSize: 13, fontWeight: 600, color: "#1e2329", margin: "0 0 6px" }, |
| 34 |
input: { |
| 35 |
width: "100%", boxSizing: "border-box", border: "1px solid #d8dde2", borderRadius: 7, |
| 36 |
padding: "10px 12px", fontSize: 14, color: "#1e2329", outline: "none", |
| 37 |
}, |
| 38 |
textarea: { |
| 39 |
width: "100%", boxSizing: "border-box", minHeight: 220, resize: "vertical", |
| 40 |
border: "1px solid #d8dde2", borderRadius: 8, padding: "11px 13px", fontSize: 13, lineHeight: 1.55, |
| 41 |
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace", |
| 42 |
color: "#1e2329", outline: "none", |
| 43 |
}, |
| 44 |
upload: { |
| 45 |
display: "inline-flex", alignItems: "center", gap: 7, fontSize: 13, fontWeight: 600, |
| 46 |
color: "#475467", background: "#f4f6f8", border: "1px solid #d8dde2", borderRadius: 7, |
| 47 |
padding: "8px 13px", cursor: "pointer", |
| 48 |
}, |
| 49 |
group: { marginBottom: 18 }, |
| 50 |
lockedNote: { fontSize: 12.5, color: "#667085", marginTop: 6 }, |
| 51 |
footer: { |
| 52 |
display: "flex", justifyContent: "flex-end", alignItems: "center", |
| 53 |
gap: 10, marginTop: 26, |
| 54 |
}, |
| 55 |
btnCancel: { |
| 56 |
background: "#fff", border: "1px solid #d0d5dd", color: "#344054", |
| 57 |
borderRadius: 8, padding: "10px 20px", fontSize: 14, fontWeight: 600, |
| 58 |
cursor: "pointer", lineHeight: 1.42, |
| 59 |
}, |
| 60 |
btnSave: { |
| 61 |
background: "#00b884", border: "none", color: "#fff", |
| 62 |
borderRadius: 8, padding: "10px 24px", fontSize: 14, fontWeight: 600, |
| 63 |
cursor: "pointer", lineHeight: 1.42, |
| 64 |
}, |
| 65 |
}; |
| 66 |
|
| 67 |
// react-modal overlay + content for the shared FAQ-builder modal shell |
| 68 |
// (.bd-faq-ctg-modal), matching CategoryModal so the modal reads identically. |
| 69 |
const customStyles = { |
| 70 |
overlay: { |
| 71 |
position: "fixed", top: 0, left: 0, right: 0, bottom: 0, |
| 72 |
backgroundColor: "rgba(35, 40, 45, 0.5)", zIndex: 99999, |
| 73 |
padding: 50, paddingTop: 70, display: "flex", flexDirection: "column", overflow: "auto", |
| 74 |
}, |
| 75 |
content: { |
| 76 |
width: "40rem", maxWidth: "90%", position: "static", height: "auto", overflow: "initial", margin: "auto", |
| 77 |
}, |
| 78 |
}; |
| 79 |
|
| 80 |
const noteStyle = { margin: "6px 0 0", fontSize: 12.5, lineHeight: 1.45, color: "var(--text-color-600, #667085)" }; |
| 81 |
|
| 82 |
const WwaInstructions = (props) => { |
| 83 |
const builderContext = useBuilderContext(); |
| 84 |
const fieldName = props.name; |
| 85 |
|
| 86 |
const raw = builderContext.values?.[fieldName]; |
| 87 |
const items = useMemo(() => { |
| 88 |
const source = Array.isArray(raw) ? raw : Array.isArray(props.value) ? props.value : []; |
| 89 |
return source.map((it) => ({ |
| 90 |
id: it?.id || v4(), |
| 91 |
title: it?.title ?? "", |
| 92 |
content: it?.content ?? "", |
| 93 |
})); |
| 94 |
}, [raw, props.value]); |
| 95 |
|
| 96 |
// editing: { index, isNew, draft } | null |
| 97 |
const [editing, setEditing] = useState(null); |
| 98 |
|
| 99 |
// Update the form state for immediate UI feedback, then persist this single |
| 100 |
// field via the existing settings endpoint (it merges per-key). This lets the |
| 101 |
// Personalize tab self-save from the modal — no page-level Save button needed. |
| 102 |
const persist = (next) => { |
| 103 |
builderContext.setFieldValue(fieldName, next); |
| 104 |
apiFetch |
| 105 |
.post("settings", { [fieldName]: next, _active_tab: "tab-betterdocs-ai" }) |
| 106 |
.then((res) => { |
| 107 |
if (res?.success) { |
| 108 |
toastAlert("success", __("Instructions saved.", "betterdocs")); |
| 109 |
} else { |
| 110 |
toastAlert( |
| 111 |
res?.code === "nothing_changed" ? "info" : "error", |
| 112 |
__(res?.message ?? "Couldn't save instructions.", "betterdocs") |
| 113 |
); |
| 114 |
} |
| 115 |
}); |
| 116 |
}; |
| 117 |
|
| 118 |
const toast = (type, msg) => { |
| 119 |
if (props?.context?.alerts?.toast) props.context.alerts.toast(type, msg); |
| 120 |
else toastAlert(type, msg); |
| 121 |
}; |
| 122 |
|
| 123 |
const openEdit = (index) => { |
| 124 |
setEditing({ index, isNew: false, draft: { ...items[index] } }); |
| 125 |
}; |
| 126 |
|
| 127 |
const openAdd = () => { |
| 128 |
setEditing({ index: -1, isNew: true, draft: { id: v4(), title: "", content: "" } }); |
| 129 |
}; |
| 130 |
|
| 131 |
const closeModal = () => setEditing(null); |
| 132 |
|
| 133 |
const saveModal = () => { |
| 134 |
if (!editing) return; |
| 135 |
const draft = { |
| 136 |
id: editing.draft.id || v4(), |
| 137 |
title: (editing.draft.title || "").trim(), |
| 138 |
content: editing.draft.content || "", |
| 139 |
}; |
| 140 |
const isDefault = draft.id === DEFAULT_ID; |
| 141 |
if (!isDefault && draft.title === "") { |
| 142 |
toast("error", __("Please add a title for this instruction.", "betterdocs")); |
| 143 |
return; |
| 144 |
} |
| 145 |
if (draft.content.trim() === "") { |
| 146 |
toast("error", __("Instructions can't be empty.", "betterdocs")); |
| 147 |
return; |
| 148 |
} |
| 149 |
|
| 150 |
if (editing.isNew) { |
| 151 |
persist([...items, draft]); |
| 152 |
} else { |
| 153 |
persist(items.map((it, i) => (i === editing.index ? draft : it))); |
| 154 |
} |
| 155 |
setEditing(null); |
| 156 |
}; |
| 157 |
|
| 158 |
const onDelete = (index) => { |
| 159 |
if (items[index]?.id === DEFAULT_ID) return; |
| 160 |
Swal.fire({ |
| 161 |
text: __("Are you sure you want to delete this instruction?", "betterdocs"), |
| 162 |
icon: "warning", |
| 163 |
showConfirmButton: true, |
| 164 |
showCancelButton: true, |
| 165 |
confirmButtonText: __("Yes", "betterdocs"), |
| 166 |
cancelButtonText: __("No", "betterdocs"), |
| 167 |
customClass: { popup: "betterdocs-bulk-action-popup" }, |
| 168 |
}).then((result) => { |
| 169 |
if (result?.isConfirmed) { |
| 170 |
persist(items.filter((_, i) => i !== index)); |
| 171 |
} |
| 172 |
}); |
| 173 |
}; |
| 174 |
|
| 175 |
const setDraft = (patch) => |
| 176 |
setEditing((e) => (e ? { ...e, draft: { ...e.draft, ...patch } } : e)); |
| 177 |
|
| 178 |
const handleUpload = (e) => { |
| 179 |
const file = e.target.files && e.target.files[0]; |
| 180 |
e.target.value = ""; |
| 181 |
if (!file) return; |
| 182 |
const name = (file.name || "").toLowerCase(); |
| 183 |
const isMd = |
| 184 |
name.endsWith(".md") || name.endsWith(".markdown") || |
| 185 |
file.type === "text/markdown" || file.type === "text/plain" || file.type === "text/x-markdown"; |
| 186 |
if (!isMd) { |
| 187 |
toast("error", __("Please upload a .md (Markdown) file.", "betterdocs")); |
| 188 |
return; |
| 189 |
} |
| 190 |
if (file.size === 0) { |
| 191 |
toast("error", __("File can't be empty.", "betterdocs")); |
| 192 |
return; |
| 193 |
} |
| 194 |
const reader = new FileReader(); |
| 195 |
reader.onload = (event) => setDraft({ content: String(event?.target?.result || "") }); |
| 196 |
reader.readAsText(file); |
| 197 |
}; |
| 198 |
|
| 199 |
const editingIsDefault = editing && editing.draft.id === DEFAULT_ID; |
| 200 |
|
| 201 |
return ( |
| 202 |
<div className="wprf-control-field wwa-instructions-field"> |
| 203 |
<div className="wprf-repeater-control"> |
| 204 |
<div className="wprf-repeater-content"> |
| 205 |
{items.map((item, index) => { |
| 206 |
const isDefault = item.id === DEFAULT_ID; |
| 207 |
return ( |
| 208 |
<div className="wprf-repeater-field" key={item.id}> |
| 209 |
<div className="wprf-repeater-inner-field betterdocs"> |
| 210 |
<div className="wprf-repeater-inner-field-item"> |
| 211 |
<p>{item.title || __("(Untitled)", "betterdocs")}</p> |
| 212 |
</div> |
| 213 |
<div className="betterdocs-action-toggle-wrapper"> |
| 214 |
<div className="betterdocs-cookies-list-action"> |
| 215 |
<ul id="betterdocs-admin-actions-ul"> |
| 216 |
<EditIcon onClick={() => openEdit(index)} /> |
| 217 |
{!isDefault && ( |
| 218 |
<DeleteIcon onClick={() => onDelete(index)} /> |
| 219 |
)} |
| 220 |
</ul> |
| 221 |
</div> |
| 222 |
</div> |
| 223 |
</div> |
| 224 |
</div> |
| 225 |
); |
| 226 |
})} |
| 227 |
</div> |
| 228 |
|
| 229 |
<div className="wprf-repeater-label" onClick={openAdd}> |
| 230 |
<button type="button" className="wprf-repeater-button" onClick={openAdd}> |
| 231 |
+ {__("Add Instruction", "betterdocs")} |
| 232 |
</button> |
| 233 |
</div> |
| 234 |
</div> |
| 235 |
|
| 236 |
<ReactModal |
| 237 |
isOpen={!!editing} |
| 238 |
ariaHideApp={false} |
| 239 |
className="bd-faq-ctg-modal" |
| 240 |
onRequestClose={closeModal} |
| 241 |
style={customStyles} |
| 242 |
> |
| 243 |
<div className="bd-faq-ctg-header"> |
| 244 |
<span |
| 245 |
className="bd-faq-stat-card__icon" |
| 246 |
style={{ "--bd-icon-tint": "#7f56d9", "--bd-icon-tint-rgb": "127, 86, 217" }} |
| 247 |
> |
| 248 |
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round"> |
| 249 |
<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z" /> |
| 250 |
<path d="M19 15l.9 2.4L22 18l-2.1.6L19 21l-.9-2.4L16 18l2.1-.6z" /> |
| 251 |
</svg> |
| 252 |
</span> |
| 253 |
<div className="bd-faq-ctg-heading"> |
| 254 |
<h3 className="bd-faq-ctg-title"> |
| 255 |
{editing && editing.isNew ? __("Add Instruction", "betterdocs") : __("Edit Instruction", "betterdocs")} |
| 256 |
</h3> |
| 257 |
<p className="bd-faq-ctg-subtitle"> |
| 258 |
{__("Give this instruction set a title and write (or upload) the instructions BetterDocs AI should follow.", "betterdocs")} |
| 259 |
</p> |
| 260 |
</div> |
| 261 |
</div> |
| 262 |
|
| 263 |
{editing && ( |
| 264 |
<div className="bd-faq-ctg-body"> |
| 265 |
<div className="bd-faq-cgt-field"> |
| 266 |
<label className="bd-faq-label">{__("Title", "betterdocs")}</label> |
| 267 |
<input |
| 268 |
type="text" |
| 269 |
className="bd-faq-input" |
| 270 |
style={editingIsDefault ? { background: "var(--text-color-100)", color: "var(--text-color-600)", cursor: "not-allowed" } : undefined} |
| 271 |
value={editing.draft.title} |
| 272 |
readOnly={editingIsDefault} |
| 273 |
placeholder={__("e.g. API Reference voice", "betterdocs")} |
| 274 |
onChange={(e) => setDraft({ title: e.target.value })} |
| 275 |
/> |
| 276 |
{editingIsDefault && ( |
| 277 |
<p style={noteStyle}> |
| 278 |
{__("The Default/Core set is always applied as the base prompt and cannot be renamed or deleted.", "betterdocs")} |
| 279 |
</p> |
| 280 |
)} |
| 281 |
</div> |
| 282 |
|
| 283 |
<div className="bd-faq-cgt-field"> |
| 284 |
<label className="bd-faq-label">{__("Instructions", "betterdocs")}</label> |
| 285 |
<textarea |
| 286 |
className="bd-faq-textarea" |
| 287 |
rows={6} |
| 288 |
value={editing.draft.content} |
| 289 |
placeholder={__("Write instructions, or paste Markdown here…", "betterdocs")} |
| 290 |
onChange={(e) => setDraft({ content: e.target.value })} |
| 291 |
/> |
| 292 |
<label |
| 293 |
style={{ |
| 294 |
display: "inline-flex", alignItems: "center", gap: 8, |
| 295 |
alignSelf: "flex-start", marginTop: 10, cursor: "pointer", |
| 296 |
height: 38, padding: "0 15px", |
| 297 |
border: "1px dashed var(--text-color-300, #dde2e4)", borderRadius: 8, |
| 298 |
background: "var(--base-background, #fff)", |
| 299 |
fontSize: 13, fontWeight: 600, color: "var(--text-color-700, #344054)", |
| 300 |
transition: "border-color .15s, color .15s, background .15s", |
| 301 |
}} |
| 302 |
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "var(--base-color-700, #00b884)"; e.currentTarget.style.background = "var(--base-color-75, #ecfdf3)"; }} |
| 303 |
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--text-color-300, #dde2e4)"; e.currentTarget.style.background = "var(--base-background, #fff)"; }} |
| 304 |
> |
| 305 |
<span style={{ display: "flex", color: "var(--base-color-700, #00b884)" }}> |
| 306 |
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> |
| 307 |
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> |
| 308 |
<path d="M17 8l-5-5-5 5" /> |
| 309 |
<path d="M12 3v12" /> |
| 310 |
</svg> |
| 311 |
</span> |
| 312 |
{__("Upload a .md file", "betterdocs")} |
| 313 |
<input |
| 314 |
type="file" |
| 315 |
accept=".md,.markdown,text/markdown,text/plain" |
| 316 |
style={{ display: "none" }} |
| 317 |
onChange={handleUpload} |
| 318 |
/> |
| 319 |
</label> |
| 320 |
</div> |
| 321 |
</div> |
| 322 |
)} |
| 323 |
|
| 324 |
<div className="bd-faq-ctg-footer"> |
| 325 |
<button type="button" className="bd-faq-button button-ghost" onClick={closeModal}> |
| 326 |
<span className="bd-faq-text">{__("Cancel", "betterdocs")}</span> |
| 327 |
</button> |
| 328 |
<button type="button" className="bd-faq-button button-success" onClick={saveModal}> |
| 329 |
<span className="bd-faq-text">{editing && editing.isNew ? __("Save", "betterdocs") : __("Update", "betterdocs")}</span> |
| 330 |
</button> |
| 331 |
</div> |
| 332 |
|
| 333 |
<button type="button" className="bd-faq-modal-close-button" onClick={closeModal}> |
| 334 |
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 335 |
<path d="M1.69687 14L0.296875 12.6L5.89687 7L0.296875 1.4L1.69687 0L7.29688 5.6L12.8969 0L14.2969 1.4L8.69688 7L14.2969 12.6L12.8969 14L7.29688 8.4L1.69687 14Z" fill="currentColor" /> |
| 336 |
</svg> |
| 337 |
</button> |
| 338 |
</ReactModal> |
| 339 |
</div> |
| 340 |
); |
| 341 |
}; |
| 342 |
|
| 343 |
export default withLabel(React.memo(WwaInstructions)); |
| 344 |
|