| 1 |
import React, { useState, useEffect, useLayoutEffect } from "react"; |
| 2 |
import { __ } from "@wordpress/i18n"; |
| 3 |
import { decodeEntities } from "@wordpress/html-entities"; |
| 4 |
import { toast } from "react-toastify"; |
| 5 |
import Modal from "react-modal"; |
| 6 |
import ErrorMessage from "../utils/ErrorMessage"; |
| 7 |
import { validateFormFields } from "../utils/validations"; |
| 8 |
import { |
| 9 |
createFaqCategory, |
| 10 |
updateFaqCategory, |
| 11 |
mediaUpload, |
| 12 |
getProductCategories, |
| 13 |
searchProducts, |
| 14 |
} from "../utils/function"; |
| 15 |
import { useFAQScope } from "../context/ScopeContext"; |
| 16 |
import { useFAQ } from "../../components/FAQContext"; |
| 17 |
import GroupMultiSelect from "../woocommerce/GroupMultiSelect"; |
| 18 |
|
| 19 |
const GROUP_PRODUCT_CATS_META = "_betterdocs_faq_group_product_cats"; |
| 20 |
const GROUP_PRODUCTS_META = "_betterdocs_faq_group_products"; |
| 21 |
const GROUP_ALL_PRODUCTS_META = "_betterdocs_faq_group_all_products"; |
| 22 |
|
| 23 |
// WordPress term `name` column is varchar(200); longer names cause a DB insert |
| 24 |
// error, so cap the FAQ group title input here (server also enforces this). |
| 25 |
const MAX_GROUP_NAME = 200; |
| 26 |
|
| 27 |
// Custom modal styles |
| 28 |
const customStyles = { |
| 29 |
overlay: { |
| 30 |
position: "fixed", |
| 31 |
top: 0, |
| 32 |
left: 0, |
| 33 |
right: 0, |
| 34 |
bottom: 0, |
| 35 |
backgroundColor: "rgba(35, 40, 45, 0.5)", |
| 36 |
zIndex: 9990, |
| 37 |
padding: 50, |
| 38 |
paddingTop: 70, |
| 39 |
display: "flex", |
| 40 |
flexDirection: "column", |
| 41 |
overflow: "auto", |
| 42 |
}, |
| 43 |
content: { |
| 44 |
width: "40rem", |
| 45 |
maxWidth: "90%", |
| 46 |
position: "static", |
| 47 |
height: "auto", |
| 48 |
overflow: "initial", |
| 49 |
margin: "auto", |
| 50 |
}, |
| 51 |
}; |
| 52 |
|
| 53 |
const CategoryModal = ({ |
| 54 |
open, |
| 55 |
reFetchCategories, |
| 56 |
setShowCategoryModal, |
| 57 |
updateCategory, |
| 58 |
setUpdateCategory, |
| 59 |
}) => { |
| 60 |
const { taxonomy, isProduct } = useFAQScope(); |
| 61 |
const { setFocusTarget } = useFAQ(); |
| 62 |
const [formFields, setFormFields] = useState({ |
| 63 |
title: "", |
| 64 |
icon: "", // Added for storing icon URL |
| 65 |
}); |
| 66 |
const [errors, setErrors] = useState({ |
| 67 |
title: "", |
| 68 |
}); |
| 69 |
|
| 70 |
// Product FAQ group assignments (only used when scope is the Product taxonomy). |
| 71 |
const [catOptions, setCatOptions] = useState([]); |
| 72 |
const [catOptionsLoading, setCatOptionsLoading] = useState(true); |
| 73 |
const [assignedCats, setAssignedCats] = useState([]); |
| 74 |
const [assignedProducts, setAssignedProducts] = useState([]); |
| 75 |
// Targeting scope: "all" (show on every product page — the store-wide group) or |
| 76 |
// "specific" (the category/product selects below). Mutually exclusive, so a generated |
| 77 |
// store-wide group can be switched to specific products and back. |
| 78 |
const [assignScope, setAssignScope] = useState("specific"); |
| 79 |
// QA-002: guards the Create/Update button against double submission. |
| 80 |
const [submitting, setSubmitting] = useState(false); |
| 81 |
// Start in the loading state when the group being edited already has saved |
| 82 |
// products, so the placeholder chips reserve the row on the very first paint |
| 83 |
// (matching `catOptionsLoading`) instead of popping in a frame later. |
| 84 |
const [productsLoading, setProductsLoading] = useState( |
| 85 |
() => Boolean(updateCategory?.meta?.[GROUP_PRODUCTS_META]?.length) |
| 86 |
); |
| 87 |
|
| 88 |
useEffect(() => { |
| 89 |
if (updateCategory !== null) { |
| 90 |
setFormFields({ |
| 91 |
...formFields, |
| 92 |
title: decodeEntities(updateCategory?.name), |
| 93 |
icon: updateCategory?.meta?.faq_group_icon[0] || "", |
| 94 |
}); |
| 95 |
} else { |
| 96 |
resetFormField(); |
| 97 |
} |
| 98 |
}, [open, updateCategory]); |
| 99 |
|
| 100 |
// Load product category options once when the Product modal is in use. |
| 101 |
useEffect(() => { |
| 102 |
if (!isProduct) return; |
| 103 |
setCatOptionsLoading(true); |
| 104 |
getProductCategories() |
| 105 |
.then((res) => |
| 106 |
setCatOptions( |
| 107 |
(res?.data || []).map((c) => ({ |
| 108 |
value: c.id, |
| 109 |
label: decodeEntities(c.name), |
| 110 |
})) |
| 111 |
) |
| 112 |
) |
| 113 |
.catch(() => setCatOptions([])) |
| 114 |
.finally(() => setCatOptionsLoading(false)); |
| 115 |
}, [isProduct]); |
| 116 |
|
| 117 |
// Prefill assigned categories from the group meta (resolved via options). |
| 118 |
// useLayoutEffect so the resolved chips (or, while options load, the skeleton |
| 119 |
// driven by `catOptionsLoading`) are in place before the first paint — no |
| 120 |
// post-paint reflow of the chip row. |
| 121 |
useLayoutEffect(() => { |
| 122 |
if (!isProduct || !open) return; |
| 123 |
if (updateCategory) { |
| 124 |
const ids = updateCategory?.meta?.[GROUP_PRODUCT_CATS_META] || []; |
| 125 |
setAssignedCats(catOptions.filter((o) => ids.includes(o.value))); |
| 126 |
} else { |
| 127 |
setAssignedCats([]); |
| 128 |
} |
| 129 |
}, [open, updateCategory, isProduct, catOptions]); |
| 130 |
|
| 131 |
// Reflect the group's saved scope so a store-wide group opens on "All products" |
| 132 |
// (and can be switched to specific), and a new group defaults to "specific". |
| 133 |
useLayoutEffect(() => { |
| 134 |
if (!isProduct || !open) return; |
| 135 |
setAssignScope(updateCategory?.meta?.[GROUP_ALL_PRODUCTS_META] ? "all" : "specific"); |
| 136 |
}, [open, updateCategory, isProduct]); |
| 137 |
|
| 138 |
// Prefill assigned products by resolving their IDs to { id, name }. |
| 139 |
// useLayoutEffect so `productsLoading` flips to `true` (and the skeleton chips |
| 140 |
// reserve their row height + margin) before the browser paints, rather than a |
| 141 |
// frame after — otherwise the row renders empty first and the modal jumps. |
| 142 |
useLayoutEffect(() => { |
| 143 |
if (!isProduct || !open) return; |
| 144 |
const ids = updateCategory?.meta?.[GROUP_PRODUCTS_META] || []; |
| 145 |
if (updateCategory && ids.length) { |
| 146 |
setProductsLoading(true); |
| 147 |
searchProducts({ include: ids.join(",") }) |
| 148 |
.then((res) => |
| 149 |
setAssignedProducts( |
| 150 |
(res?.data || []).map((p) => ({ |
| 151 |
value: p.id, |
| 152 |
label: decodeEntities(p.name), |
| 153 |
})) |
| 154 |
) |
| 155 |
) |
| 156 |
.catch(() => setAssignedProducts([])) |
| 157 |
.finally(() => setProductsLoading(false)); |
| 158 |
} else { |
| 159 |
setAssignedProducts([]); |
| 160 |
setProductsLoading(false); |
| 161 |
} |
| 162 |
}, [open, updateCategory, isProduct]); |
| 163 |
|
| 164 |
const loadProductOptions = (input) => |
| 165 |
searchProducts({ q: input }) |
| 166 |
.then((res) => |
| 167 |
(res?.data || []).map((p) => ({ |
| 168 |
value: p.id, |
| 169 |
label: decodeEntities(p.name), |
| 170 |
})) |
| 171 |
) |
| 172 |
.catch(() => []); |
| 173 |
|
| 174 |
const resetFormField = () => { |
| 175 |
setFormFields({ |
| 176 |
title: "", |
| 177 |
icon: "", |
| 178 |
}); |
| 179 |
setAssignedCats([]); |
| 180 |
setAssignedProducts([]); |
| 181 |
}; |
| 182 |
|
| 183 |
const resetErrors = () => { |
| 184 |
setErrors({ |
| 185 |
title: "", |
| 186 |
}); |
| 187 |
}; |
| 188 |
|
| 189 |
const handleCancel = () => { |
| 190 |
resetErrors(); |
| 191 |
setShowCategoryModal(false); |
| 192 |
setUpdateCategory(null); |
| 193 |
}; |
| 194 |
|
| 195 |
const validationRules = () => { |
| 196 |
return { |
| 197 |
title: { |
| 198 |
required: { |
| 199 |
value: true, |
| 200 |
message: __("Please Insert FAQ Group Title.", "betterdocs"), |
| 201 |
}, |
| 202 |
}, |
| 203 |
}; |
| 204 |
}; |
| 205 |
|
| 206 |
const handleIconUpload = () => { |
| 207 |
mediaUpload((imageUrl, id) => { |
| 208 |
setFormFields({ ...formFields, icon: imageUrl }); |
| 209 |
}); |
| 210 |
}; |
| 211 |
|
| 212 |
const handleIconRemove = () => { |
| 213 |
setFormFields({ ...formFields, icon: "" }); |
| 214 |
}; |
| 215 |
|
| 216 |
const handleSubmit = (e) => { |
| 217 |
e.preventDefault(); |
| 218 |
// QA-002: ignore repeat submits while a request is in flight. |
| 219 |
if (submitting) { |
| 220 |
return; |
| 221 |
} |
| 222 |
const validated = validateFormFields(formFields, validationRules()); |
| 223 |
if (!validated.valid) { |
| 224 |
setErrors(validated.errors); |
| 225 |
return; |
| 226 |
} |
| 227 |
const data = { |
| 228 |
title: formFields?.title, |
| 229 |
icon: formFields?.icon, |
| 230 |
}; |
| 231 |
|
| 232 |
if (isProduct) { |
| 233 |
const allProducts = assignScope === "all"; |
| 234 |
data.all_products = allProducts ? 1 : 0; |
| 235 |
// The scopes are mutually exclusive; don't send category/product targets when the |
| 236 |
// group is set to show on all products (the server clears them anyway). |
| 237 |
data.product_cats = allProducts ? [] : assignedCats.map((o) => o.value); |
| 238 |
data.products = allProducts ? [] : assignedProducts.map((o) => o.value); |
| 239 |
} |
| 240 |
|
| 241 |
setSubmitting(true); |
| 242 |
if (updateCategory !== null) { |
| 243 |
updateFaqCategory({ ...data, term_id: updateCategory?.id }, taxonomy) |
| 244 |
.then((res) => { |
| 245 |
if (res) { |
| 246 |
resetErrors(); |
| 247 |
reFetchCategories(); |
| 248 |
toast.success(__("FAQ Group updated.", "betterdocs")); |
| 249 |
} |
| 250 |
}) |
| 251 |
.catch((err) => { |
| 252 |
const isDuplicate = |
| 253 |
err?.code === "term_exists" || |
| 254 |
/already exists/i.test(err?.message || ""); |
| 255 |
toast.error( |
| 256 |
isDuplicate |
| 257 |
? __("Name already exists. Try a different one!", "betterdocs") |
| 258 |
: err?.message || __("Something went wrong!", "betterdocs") |
| 259 |
); |
| 260 |
}) |
| 261 |
.finally(() => { |
| 262 |
setSubmitting(false); |
| 263 |
setShowCategoryModal(false); |
| 264 |
setUpdateCategory(null); |
| 265 |
}); |
| 266 |
} else { |
| 267 |
createFaqCategory(data, taxonomy) |
| 268 |
.then((res) => { |
| 269 |
if (res) { |
| 270 |
resetErrors(); |
| 271 |
reFetchCategories(); |
| 272 |
toast.success(__("FAQ Group created.", "betterdocs")); |
| 273 |
// Reveal the new group: jump to the pagination page it lands on |
| 274 |
// (under the active order) and flash it — same one-shot focus |
| 275 |
// signal the new-FAQ (?faq_action=create) flow uses. |
| 276 |
if (res?.term_id) { |
| 277 |
setFocusTarget({ groupId: res.term_id }); |
| 278 |
} |
| 279 |
} |
| 280 |
}) |
| 281 |
.catch((err) => { |
| 282 |
const isDuplicate = |
| 283 |
err?.code === "term_exists" || |
| 284 |
/already exists/i.test(err?.message || ""); |
| 285 |
toast.error( |
| 286 |
isDuplicate |
| 287 |
? __("Name already exists. Try a different one!", "betterdocs") |
| 288 |
: err?.message || __("Something went wrong!", "betterdocs") |
| 289 |
); |
| 290 |
}) |
| 291 |
.finally(() => { |
| 292 |
setSubmitting(false); |
| 293 |
setShowCategoryModal(false); |
| 294 |
}); |
| 295 |
} |
| 296 |
}; |
| 297 |
|
| 298 |
return ( |
| 299 |
<Modal |
| 300 |
isOpen={open} |
| 301 |
className="bd-faq-ctg-modal" |
| 302 |
onRequestClose={() => handleCancel()} |
| 303 |
style={customStyles} |
| 304 |
> |
| 305 |
<form onSubmit={handleSubmit}> |
| 306 |
<div className="bd-faq-ctg-header"> |
| 307 |
<span |
| 308 |
className="bd-faq-stat-card__icon" |
| 309 |
style={{ "--bd-icon-tint": "#00B884", "--bd-icon-tint-rgb": "0, 184, 132" }} |
| 310 |
> |
| 311 |
<svg |
| 312 |
width={20} |
| 313 |
height={20} |
| 314 |
viewBox="0 0 24 24" |
| 315 |
fill="none" |
| 316 |
stroke="currentColor" |
| 317 |
strokeWidth={1.7} |
| 318 |
strokeLinecap="round" |
| 319 |
strokeLinejoin="round" |
| 320 |
> |
| 321 |
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20V3H6.5A2.5 2.5 0 0 0 4 5.5v14zM4 19.5A2.5 2.5 0 0 0 6.5 22H20v-5" /> |
| 322 |
</svg> |
| 323 |
</span> |
| 324 |
<div className="bd-faq-ctg-heading"> |
| 325 |
<h3 className="bd-faq-ctg-title"> |
| 326 |
{updateCategory |
| 327 |
? (isProduct |
| 328 |
? __("Update Product FAQ Group", "betterdocs") |
| 329 |
: __("Update FAQ Group", "betterdocs")) |
| 330 |
: (isProduct |
| 331 |
? __("Create a New Product FAQ Group", "betterdocs") |
| 332 |
: __("Create New FAQ Group", "betterdocs"))} |
| 333 |
</h3> |
| 334 |
<p className="bd-faq-ctg-subtitle"> |
| 335 |
{__("Organize related FAQs into a group.", "betterdocs")} |
| 336 |
</p> |
| 337 |
</div> |
| 338 |
</div> |
| 339 |
<div className="bd-faq-ctg-body"> |
| 340 |
<div className="bd-faq-cgt-field"> |
| 341 |
<label className="bd-faq-label"> |
| 342 |
{__("FAQ Group Title", "betterdocs")} |
| 343 |
</label> |
| 344 |
<input |
| 345 |
type="text" |
| 346 |
className="bd-faq-input" |
| 347 |
maxLength={MAX_GROUP_NAME} |
| 348 |
value={formFields?.title} |
| 349 |
onChange={(e) => |
| 350 |
setFormFields({ |
| 351 |
...formFields, |
| 352 |
title: e.target.value.replace(/^\s+/, "").slice(0, MAX_GROUP_NAME), |
| 353 |
}) |
| 354 |
} |
| 355 |
placeholder={__("FAQ Group Title...", "betterdocs")} |
| 356 |
/> |
| 357 |
{formFields?.title?.length >= MAX_GROUP_NAME && ( |
| 358 |
<p className="bd-faq-max-length-note"> |
| 359 |
{__("Maximum character limit reached", "betterdocs")} |
| 360 |
</p> |
| 361 |
)} |
| 362 |
<ErrorMessage error={errors.title} /> |
| 363 |
</div> |
| 364 |
|
| 365 |
{/* Icon Upload Section */} |
| 366 |
<div className="bd-faq-cgt-field bd-faq-icon-field"> |
| 367 |
<label className="bd-faq-label">{__("FAQ Group Icon", "betterdocs")}</label> |
| 368 |
{formFields.icon ? ( |
| 369 |
<div className="bd-faq-control bd-faq-media"> |
| 370 |
<div className="bd-faq-image-preview"> |
| 371 |
<img src={formFields.icon} alt="Selected Icon" /> |
| 372 |
</div> |
| 373 |
<div className="bd-faq-image-uploader uploaded"> |
| 374 |
<div className="bd-faq_image_overlay"> |
| 375 |
<button |
| 376 |
type="button" |
| 377 |
className="bd-faq-btn bd-faq-image-change-btn" |
| 378 |
onClick={handleIconUpload} |
| 379 |
> |
| 380 |
<i className="btd-icon btd-upload"></i> |
| 381 |
</button> |
| 382 |
<button |
| 383 |
type="button" |
| 384 |
className="bd-faq-btn bd-faq-image-remove-btn" |
| 385 |
onClick={handleIconRemove} |
| 386 |
> |
| 387 |
<i className="btd-icon btd-delete"></i> |
| 388 |
</button> |
| 389 |
</div> |
| 390 |
</div> |
| 391 |
</div> |
| 392 |
) : ( |
| 393 |
<div className="bd-faq-control bd-faq-media"> |
| 394 |
<div className="bd-faq-image-uploader"> |
| 395 |
<button |
| 396 |
type="button" |
| 397 |
className="bd-faq-btn bd-faq-image-upload-btn" |
| 398 |
onClick={handleIconUpload} |
| 399 |
> |
| 400 |
<span className="icon"> |
| 401 |
<i className="btd-icon btd-upload"></i> |
| 402 |
</span> |
| 403 |
<span className="title">{__("Click to upload", "betterdocs")}</span> |
| 404 |
<span className="info"> |
| 405 |
{__("SVG, PNG, JPG or GIF", "betterdocs")} |
| 406 |
</span> |
| 407 |
</button> |
| 408 |
</div> |
| 409 |
</div> |
| 410 |
)} |
| 411 |
</div> |
| 412 |
|
| 413 |
{isProduct && ( |
| 414 |
<> |
| 415 |
<div className="bd-faq-cgt-field"> |
| 416 |
<label className="bd-faq-label"> |
| 417 |
{__("Where should this group show?", "betterdocs")} |
| 418 |
</label> |
| 419 |
<div className="bd-faq-scope-choices"> |
| 420 |
<label className="bd-faq-scope-choice"> |
| 421 |
<input |
| 422 |
type="radio" |
| 423 |
name="bd-faq-assign-scope" |
| 424 |
value="all" |
| 425 |
checked={assignScope === "all"} |
| 426 |
onChange={() => setAssignScope("all")} |
| 427 |
/> |
| 428 |
<span> |
| 429 |
<strong>{__("All products", "betterdocs")}</strong> |
| 430 |
<em>{__("Show this FAQ on every product page.", "betterdocs")}</em> |
| 431 |
</span> |
| 432 |
</label> |
| 433 |
<label className="bd-faq-scope-choice"> |
| 434 |
<input |
| 435 |
type="radio" |
| 436 |
name="bd-faq-assign-scope" |
| 437 |
value="specific" |
| 438 |
checked={assignScope === "specific"} |
| 439 |
onChange={() => setAssignScope("specific")} |
| 440 |
/> |
| 441 |
<span> |
| 442 |
<strong>{__("Specific products", "betterdocs")}</strong> |
| 443 |
<em>{__("Only the product categories or products you choose.", "betterdocs")}</em> |
| 444 |
</span> |
| 445 |
</label> |
| 446 |
</div> |
| 447 |
</div> |
| 448 |
|
| 449 |
{assignScope === "specific" && ( |
| 450 |
<> |
| 451 |
<div className="bd-faq-cgt-field"> |
| 452 |
<label className="bd-faq-label"> |
| 453 |
{__("Assign to Product Categories", "betterdocs")} |
| 454 |
</label> |
| 455 |
<GroupMultiSelect |
| 456 |
options={catOptions} |
| 457 |
value={assignedCats} |
| 458 |
onChange={setAssignedCats} |
| 459 |
loading={catOptionsLoading} |
| 460 |
loadingCount={ |
| 461 |
(updateCategory?.meta?.[GROUP_PRODUCT_CATS_META] || []).length |
| 462 |
} |
| 463 |
placeholder={__("Select product categories…", "betterdocs")} |
| 464 |
noOptionsMessage={() => |
| 465 |
__("No product categories found", "betterdocs") |
| 466 |
} |
| 467 |
/> |
| 468 |
</div> |
| 469 |
|
| 470 |
<div className="bd-faq-cgt-field"> |
| 471 |
<label className="bd-faq-label"> |
| 472 |
{__("Assign to Products", "betterdocs")} |
| 473 |
</label> |
| 474 |
<GroupMultiSelect |
| 475 |
loadOptions={loadProductOptions} |
| 476 |
value={assignedProducts} |
| 477 |
onChange={setAssignedProducts} |
| 478 |
loading={productsLoading} |
| 479 |
loadingCount={ |
| 480 |
(updateCategory?.meta?.[GROUP_PRODUCTS_META] || []).length |
| 481 |
} |
| 482 |
placeholder={__("Search products…", "betterdocs")} |
| 483 |
noOptionsMessage={() => __("No products found", "betterdocs")} |
| 484 |
/> |
| 485 |
</div> |
| 486 |
</> |
| 487 |
)} |
| 488 |
</> |
| 489 |
)} |
| 490 |
</div> |
| 491 |
<div className="bd-faq-ctg-footer"> |
| 492 |
<button type="submit" className="bd-faq-button button-success" disabled={submitting}> |
| 493 |
<span className="bd-faq-text"> |
| 494 |
{submitting |
| 495 |
? __("Saving…", "betterdocs") |
| 496 |
: updateCategory |
| 497 |
? (isProduct |
| 498 |
? __("Update Product FAQ Group", "betterdocs") |
| 499 |
: __("Update FAQ Group", "betterdocs")) |
| 500 |
: (isProduct |
| 501 |
? __("Create Product FAQ Group", "betterdocs") |
| 502 |
: __("Create FAQ Group", "betterdocs"))} |
| 503 |
</span> |
| 504 |
</button> |
| 505 |
</div> |
| 506 |
</form> |
| 507 |
<button |
| 508 |
type="button" |
| 509 |
className="bd-faq-modal-close-button" |
| 510 |
onClick={() => handleCancel()} |
| 511 |
> |
| 512 |
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 513 |
<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" /> |
| 514 |
</svg> |
| 515 |
</button> |
| 516 |
</Modal> |
| 517 |
); |
| 518 |
}; |
| 519 |
|
| 520 |
export default CategoryModal; |
| 521 |
|