/**
* Save manager - handleInlineSave and showInlineSuccess
*
* Reads (via globals set by earlier modules):
* SFE.Context - .activeEditor (r/w), .draftEditState (r/w),
* .pageRevisionToken (r/w), .uuidMap,
* .actionBar
* SFE.ElementState - .ElementState
* SFE.Api - .apiCall
* SFE.BlockSerializer - .buildBlockPayload
* SFE.ListBlockTracker
* SFE.ElementUpdater - .applyNewHTML
* SFE.TIMING
* SFE.SaveHelpers - .setButtonLoading, .clearButtonLoading,
* .lockSaveUI, .unlockSaveUI,
* .createSuccessElement,
* .handleRevisionConflict,
* .updatePageRevisionToken
* SFE.PostLockManager - .ensureLock
* SFE.ManagerData - .postId, .permissions
* SFE.restoreElementContent - set by EditorLifecycle.js
* SFE.rebindChildren - set by EditorLifecycle.js
* SFE.attachActionBarToElement - set by frontend-inline-edit.js (via HoverManager)
*
* Exposes: SFE.SaveManager { handleInlineSave, showInlineSuccess }
*/
(function() {
'use strict';
window.MWP = window.MWP || {};
window.MWP.SFE = window.MWP.SFE || {};
const SFE = window.MWP.SFE;
SFE.ManagerData = SFE.ManagerData || {};
function getSaveHooks() {
return SFE.SaveHooks || null;
}
function getProDraftApi() {
return SFE.PRO?.DraftApi || null;
}
function resolveSaveStrategy(editorState) {
let strategy = editorState?.saveStrategy || 'single';
const hooks = getSaveHooks();
if (hooks && typeof hooks.resolveStrategy === 'function') {
try {
const override = hooks.resolveStrategy({
strategy,
editorState
});
if (override === 'single' || override === 'batch') {
strategy = override;
}
} catch (error) {
console.warn('FrontEdit: save strategy hook failed', error);
}
}
return strategy;
}
async function runSaveHook(hookName, payload) {
const hooks = getSaveHooks();
if (!hooks || typeof hooks[hookName] !== 'function') return;
try {
await hooks[hookName](payload);
} catch (error) {
console.warn(`FrontEdit: save hook ${hookName} failed`, error);
}
}
/**
* Read the cached published baseline HTML for a block, if one has been
* captured during this page lifecycle.
*
* @param {string} uuid Block UUID.
* @returns {string} Cached outer HTML snapshot or an empty string.
*/
function getPublishedBaselineHTML(uuid) {
if (!uuid || !SFE.Context?.uuidMap?.[uuid]) return '';
const cached = SFE.Context.uuidMap[uuid].publishedBaselineOuterHTML;
return (typeof cached === 'string' && cached.trim()) ? cached : '';
}
/**
* Restore the draft-submit display element from the cached published
* baseline when available, falling back to the per-editor restore snapshot.
*
* @param {Object} editorState Editor state for the active save session.
* @returns {Element} The restored element.
*/
function restoreDraftSubmittedElement(editorState) {
const baselineHTML = getPublishedBaselineHTML(editorState?.uuid);
if (!baselineHTML) {
return SFE.restoreElementContent(editorState.element, editorState);
}
const temp = document.createElement('div');
temp.innerHTML = baselineHTML;
const restoredElement = temp.firstElementChild;
if (!restoredElement || !editorState?.element?.parentNode) {
return SFE.restoreElementContent(editorState.element, editorState);
}
editorState.element.parentNode.replaceChild(restoredElement, editorState.element);
return restoredElement;
}
/**
* Destroy persisted block edit sessions after one successful save lifecycle.
*
* Saving ends the block session entirely, so any cached undo history for the
* saved UUIDs must be discarded before the next editor open starts fresh.
*
* @param {string|string[]} uuids One UUID or a list of UUIDs to destroy.
* @returns {void}
*/
function destroySavedBlockSessions(uuids) {
const list = Array.isArray(uuids) ? uuids : [uuids];
if (typeof SFE.destroyPersistedBlockEditSessions === 'function') {
SFE.destroyPersistedBlockEditSessions(list);
return;
}
if (typeof SFE.destroyPersistedBlockEditSession !== 'function') {
return;
}
list.forEach((uuid) => {
SFE.destroyPersistedBlockEditSession(uuid);
});
}
async function handleInlineSave(editorState) {
const ctx = SFE.Context;
const publicApiBridge = SFE.PublicApiBridge || null;
const { apiCall, ensureResolvedMediaAttributes } = SFE.Api;
const { buildBlockPayload } = SFE.BlockSerializer;
const {
setButtonLoading,
clearButtonLoading,
lockSaveUI,
unlockSaveUI,
handleRevisionConflict,
updatePageRevisionToken,
fetchRenderedBlockHTML,
reloadAfterRefreshFailure
} = SFE.SaveHelpers;
const TIMING = SFE.TIMING;
const postId = SFE.ManagerData.postId;
const perms = SFE.ManagerData.permissions || {};
const uuidMap = ctx.uuidMap;
const batchManager = SFE.BatchEditManager || null;
const proDraftApi = getProDraftApi();
const saveStrategy = resolveSaveStrategy(editorState);
const strategyPayload = { strategy: saveStrategy, editorState };
if (publicApiBridge) {
publicApiBridge.emitSaveEvent('save:before', editorState, {
source: 'sfe',
saveStrategy,
});
}
await runSaveHook('beforeSave', strategyPayload);
if (
saveStrategy === 'batch' &&
batchManager &&
typeof batchManager.isSessionActive === 'function' &&
batchManager.isSessionActive()
) {
const saveBtn = editorState.actionsContainer?.querySelector('.mwp-sfe-btn-primary-inline') || null;
const cancelBtn = editorState.actionsContainer?.querySelector('.mwp-sfe-btn-secondary-inline') || null;
if (cancelBtn) cancelBtn.disabled = true;
try {
await batchManager.handleBatchSave(saveBtn);
if (publicApiBridge) {
publicApiBridge.emitSaveEvent('save:after', editorState, {
source: 'sfe',
saveStrategy,
success: true,
});
}
await runSaveHook('afterSave', { ...strategyPayload, success: true });
} finally {
// Re-enable the Cancel button in case save failed before showInlineSuccess could clean up the editor.
if (cancelBtn) cancelBtn.disabled = false;
}
return;
}
// Build canonical block payload via wp.blocks API (serialize → parse round-trip)
try {
if (typeof ensureResolvedMediaAttributes === 'function') {
await ensureResolvedMediaAttributes(editorState);
}
} catch (error) {
console.error('FrontEdit: resolveMediaAttributes failed:', error);
alert('Error saving: ' + error.message);
return;
}
let payloadContent;
try {
const blockStructure = buildBlockPayload( editorState.element, editorState );
if ( !blockStructure ) {
alert( 'Error saving: Could not build block structure. Please refresh and try again.' );
return;
}
payloadContent = JSON.stringify( blockStructure );
} catch ( error ) {
console.error( 'FrontEdit: buildBlockPayload failed:', error );
alert( 'Error saving: ' + error.message );
return;
}
// Show loading state
const saveBtn = editorState.actionsContainer.querySelector('.mwp-sfe-btn-primary-inline');
const cancelBtn = editorState.actionsContainer.querySelector('.mwp-sfe-btn-secondary-inline');
const originalText = saveBtn.textContent;
lockSaveUI(saveBtn);
if (cancelBtn) cancelBtn.disabled = true;
// Check if we're editing a draft
const draftEditState = ctx.draftEditState;
const isEditingDraft = draftEditState && draftEditState.draftElement === editorState.element;
saveBtn.textContent = perms.can_publish ? 'Saving...' : 'Submitting...';
// Helper to restore save button to clickable state on any failure path
const restoreSaveBtn = () => {
unlockSaveUI(saveBtn);
saveBtn.textContent = originalText;
if (cancelBtn) cancelBtn.disabled = false;
};
// Handle based on permissions and draft state
if (perms.can_publish) {
if (!await SFE.PostLockManager.ensureLock()) {
restoreSaveBtn();
return;
}
try {
if (isEditingDraft && draftEditState.version) {
if (!proDraftApi || typeof proDraftApi.approveWithEdit !== 'function') {
throw new Error('Draft approve-with-edit is unavailable.');
}
const approveResult = await proDraftApi.approveWithEdit({
post_id: postId,
element_uuid: editorState.uuid,
version: draftEditState.version,
new_content: payloadContent,
page_revision_token: ctx.pageRevisionToken // conflict detection
});
// Update token so subsequent saves don't false-conflict against our own revision.
updatePageRevisionToken(approveResult);
ctx.draftEditState = null;
} else {
const applyResult = await apiCall('/apply', {
post_id: postId,
element_uuid: editorState.uuid,
handler_id: editorState.handler.id,
edit_content: payloadContent,
page_revision_token: ctx.pageRevisionToken
});
// Update token to the revision the plugin just created, so subsequent
// inline saves in this session don't false-conflict against our own revision.
updatePageRevisionToken(applyResult);
}
const refreshedHTML = await fetchRenderedBlockHTML(editorState.uuid, { force: true });
if (refreshedHTML) {
destroySavedBlockSessions(editorState.uuid);
showInlineSuccess(editorState, 'Changes Saved', refreshedHTML);
if (publicApiBridge) {
publicApiBridge.emitSaveEvent('save:after', editorState, {
source: 'sfe',
saveStrategy,
success: true,
});
}
await runSaveHook('afterSave', { ...strategyPayload, success: true });
} else {
reloadAfterRefreshFailure('FrontEdit: Save succeeded but refreshed block HTML was unavailable.');
return;
}
} catch (error) {
if (error.message === 'POST_LOCKED') {
await SFE.PostLockManager?.handleLockedError(error);
restoreSaveBtn();
return;
}
if (error.message === 'BLOCK_HTML_REFRESH_FAILED') {
reloadAfterRefreshFailure('FrontEdit: Save succeeded but refreshed block HTML could not be extracted from the post render.');
return;
}
if (error.message === 'REVISION_CONFLICT') {
if (publicApiBridge) {
publicApiBridge.emitSaveEvent('save:error', editorState, {
source: 'sfe',
saveStrategy,
message: 'REVISION_CONFLICT',
});
}
const shouldRetry = await handleRevisionConflict(restoreSaveBtn);
if (!shouldRetry) return;
// Retry without token - server skips conflict check when token is absent.
// Use the same endpoint that originally triggered the conflict.
try {
if (isEditingDraft && draftEditState.version) {
if (!proDraftApi || typeof proDraftApi.approveWithEdit !== 'function') {
throw new Error('Draft approve-with-edit is unavailable.');
}
await proDraftApi.approveWithEdit({
post_id: postId,
element_uuid: editorState.uuid,
version: draftEditState.version,
new_content: payloadContent
// No token - force approve
});
ctx.draftEditState = null;
} else {
await apiCall('/apply', {
post_id: postId,
element_uuid: editorState.uuid,
handler_id: editorState.handler.id,
edit_content: payloadContent
// No token - force save
});
}
const refreshedHTML = await fetchRenderedBlockHTML(editorState.uuid, { force: true });
if (refreshedHTML) {
destroySavedBlockSessions(editorState.uuid);
showInlineSuccess(
editorState,
'Changes Saved',
refreshedHTML,
false,
'success',
{ reloadAfterSuccess: true }
);
if (publicApiBridge) {
publicApiBridge.emitSaveEvent('save:after', editorState, {
source: 'sfe',
saveStrategy,
success: true,
});
}
await runSaveHook('afterSave', { ...strategyPayload, success: true });
} else {
reloadAfterRefreshFailure('FrontEdit: Retry save succeeded but refreshed block HTML was unavailable.');
return;
}
} catch (retryError) {
if (retryError.message === 'BLOCK_HTML_REFRESH_FAILED') {
reloadAfterRefreshFailure('FrontEdit: Retry save succeeded but refreshed block HTML could not be extracted from the post render.');
return;
}
console.error('Save failed on retry:', retryError);
if (publicApiBridge) {
publicApiBridge.emitSaveEvent('save:error', editorState, {
source: 'sfe',
saveStrategy,
message: String(retryError.message || 'SAVE_FAILED'),
});
}
if ( retryError.message && retryError.message.indexOf( '403' ) !== -1 ) {
alert( 'You no longer have permission to save. Your publish access may have been changed. Please refresh the page.' );
} else {
alert('Error: ' + retryError.message);
}
restoreSaveBtn();
}
return;
}
console.error('Save failed:', error);
if (publicApiBridge) {
publicApiBridge.emitSaveEvent('save:error', editorState, {
source: 'sfe',
saveStrategy,
message: String(error.message || 'SAVE_FAILED'),
});
}
if ( error.message && error.message.indexOf( '403' ) !== -1 ) {
alert( 'You no longer have permission to save. Your publish access may have been changed. Please refresh the page.' );
} else {
alert('Error: ' + error.message);
}
restoreSaveBtn();
}
} else if (perms.can_draft) {
try {
if (!proDraftApi || typeof proDraftApi.submitDraft !== 'function') {
throw new Error('Draft submission is unavailable.');
}
const data = await proDraftApi.submitDraft({
post_id: postId,
element_uuid: editorState.uuid,
handler_id: editorState.handler.id,
edit_content: payloadContent
});
editorState.element.classList.add('mwp-sfe-status-pending');
if (uuidMap[editorState.uuid]) {
uuidMap[editorState.uuid].is_pending = true;
uuidMap[editorState.uuid].pending_info = {
version: data.entry.version,
user: data.entry.user_name,
date: new Date(data.entry.timestamp * 1000).toLocaleString()
};
}
showInlineSuccess(
editorState,
`Draft Submitted (Version ${data.entry.version})`,
null,
true,
'warning'
);
destroySavedBlockSessions(editorState.uuid);
if (publicApiBridge) {
publicApiBridge.emitSaveEvent('save:after', editorState, {
source: 'sfe',
saveStrategy,
success: true,
});
}
await runSaveHook('afterSave', { ...strategyPayload, success: true });
} catch (error) {
console.error('Draft submit failed:', error);
if (publicApiBridge) {
publicApiBridge.emitSaveEvent('save:error', editorState, {
source: 'sfe',
saveStrategy,
message: String(error.message || 'SAVE_FAILED'),
});
}
alert('Error: ' + error.message);
restoreSaveBtn();
}
}
}
/**
* Show success message and update element
* Handles both simple comments and complex save/draft operations
*/
function showInlineSuccess(editorState, message, newHTML, isDraft = false, variant = 'success', options = {}) {
const ctx = SFE.Context;
const TIMING = SFE.TIMING;
const ListBlockTracker = SFE.ListBlockTracker;
const { ElementState } = SFE.ElementState;
const { createSuccessElement } = SFE.SaveHelpers;
const { applyNewHTML } = SFE.ElementUpdater;
const actionBar = ctx.actionBar;
const uuidMap = ctx.uuidMap;
const shouldReloadAfterSuccess = !!options.reloadAfterSuccess;
const reloadAfterSuccess =
shouldReloadAfterSuccess &&
SFE.SaveHelpers &&
typeof SFE.SaveHelpers.reloadPageWithGuardBypass === 'function'
? SFE.SaveHelpers.reloadPageWithGuardBypass
: null;
// Detect if this is a simple comment (minimal editorState) vs complex save/draft
const isComment = !editorState.actionsContainer;
// Create and position success message (common to all types)
const successDiv = createSuccessElement(message, variant);
document.body.appendChild(successDiv);
// Force a reflow before allowing transitions again
successDiv.offsetHeight;
successDiv.style.transition = '';
// === SIMPLE PATH: Comments ===
if (isComment) {
// Comment cleanup already done by exitCommentMode
// Just show message, wait, and fade out
setTimeout(() => {
successDiv.style.opacity = '0';
setTimeout(() => successDiv.remove(), TIMING.SUCCESS_FADE);
}, TIMING.SUCCESS_DISPLAY);
return;
}
// === COMPLEX PATH: Save/Draft Operations ===
// Remove any wp-elements-* styles injected into
for this draft preview.
if (editorState.uuid) {
document.querySelectorAll(`style[data-mwp-sfe-draft-uuid="${editorState.uuid}"]`)
.forEach(el => el.remove());
}
// Store reference to this editor session for validation
const thisEditorSession = editorState;
// Cleanup editor resources
if (editorState.resizeObserver) {
editorState.resizeObserver.disconnect();
}
if (editorState.toolbarContainer) editorState.toolbarContainer.remove();
if (editorState.actionsContainer) {
editorState.actionsContainer.style.display = 'none';
}
if (editorState.previewOverlay) editorState.previewOverlay.remove();
if (editorState.updatePositions) {
window.removeEventListener('scroll', editorState.updatePositions, true);
window.removeEventListener('resize', editorState.updatePositions);
}
if (editorState.updatePreviewPosition) {
window.removeEventListener('scroll', editorState.updatePreviewPosition, true);
}
if (editorState.escapeHandler) {
document.removeEventListener('keydown', editorState.escapeHandler);
}
if (editorState.cleanupFocus) editorState.cleanupFocus();
const switchTarget = editorState._mwpComponentSwitchTarget || editorState.element || null;
if (editorState.componentSwitchHandler && switchTarget) {
switchTarget.removeEventListener('mousedown', editorState.componentSwitchHandler, true);
delete editorState.componentSwitchHandler;
}
if (editorState.componentClickGuard && switchTarget) {
switchTarget.removeEventListener('click', editorState.componentClickGuard, true);
delete editorState.componentClickGuard;
}
if (editorState.componentTabHandler && switchTarget) {
switchTarget.removeEventListener('keydown', editorState.componentTabHandler, true);
delete editorState.componentTabHandler;
}
delete editorState._mwpComponentSwitchTarget;
if (editorState._mwpSchemaMediaSession && typeof editorState._mwpSchemaMediaSession.cleanup === 'function') {
editorState._mwpSchemaMediaSession.cleanup({ preserveChanges: true });
delete editorState._mwpSchemaMediaSession;
}
if (Array.isArray(editorState.editableComponents)) {
const elementPrep = SFE.ElementPrep || null;
const textEditorHost = SFE.SchemaEditorHost?.resolveTextEditorHost?.(editorState) || null;
editorState.editableComponents.forEach(component => {
if (!component || !component.element) return;
component.element.classList.remove(
'mwp-sfe-inline-editor',
'mwp-sfe-component-active',
'mwp-sfe-editable-component'
);
if (elementPrep && typeof elementPrep.pruneEmptyClassAttribute === 'function') {
elementPrep.pruneEmptyClassAttribute(component.element);
}
component.element.removeAttribute('contenteditable');
component.element.removeAttribute('spellcheck');
component.element.removeAttribute('data-mwp-sfe-editable-component');
component.element.removeAttribute('data-mwp-sfe-active-component');
if (textEditorHost && component.element._mwpEditor === textEditorHost) {
delete component.element._mwpEditor;
}
});
}
// Cleanup list tracker
if (editorState.listTracker) {
ListBlockTracker.destroy(editorState.listTracker);
delete editorState.listTracker;
}
// Cleanup media focus manager (stored on bar, not editorState)
if (editorState.actionsContainer && editorState.actionsContainer._cleanupMediaFocus) {
editorState.actionsContainer._cleanupMediaFocus();
delete editorState.actionsContainer._cleanupMediaFocus;
}
if (editorState.textarea) editorState.textarea.remove();
if (editorState.contentWrapper) editorState.contentWrapper.remove();
// DON'T re-enable other elements yet - keep them disabled during animation
// Update element content based on operation type
if (isDraft) {
// For drafts: restore the block to the cached published baseline when
// available so repeated local draft edits do not overwrite the live
// visitor state shown after submission. Fall back to the editor's
// session snapshot if the baseline was never captured.
const restoredElement = restoreDraftSubmittedElement(editorState);
// Remove editing-active but KEEP element-active to prevent hover during success display.
// IMPORTANT: restoreElementContent reconstructs from originalOuterHTML which never had
// mwp-sfe-element-active. Without explicitly re-adding it here, the click guard
// (document.querySelector('.mwp-sfe-element-active')) immediately returns null,
// allowing the user to click any other element and interrupt the cleanup sequence.
restoredElement.classList.remove('mwp-sfe-editing-active');
restoredElement.classList.add('mwp-sfe-element-active');
if (editorState.clearPending) {
// Discard path: draft was rejected - element is no longer pending.
restoredElement.classList.remove('mwp-sfe-status-pending');
// Also clear uuidMap so the element becomes fully editable again.
const uuidEntry = uuidMap[editorState.uuid];
if (uuidEntry) {
uuidEntry.is_pending = false;
delete uuidEntry.pending_info;
}
} else {
// Save-as-draft path: element is still pending review.
restoredElement.classList.add('mwp-sfe-status-pending');
}
// Update reference for later use
editorState.element = restoredElement;
// NOTE: rebindChildren is intentionally deferred to the post-enableAllElements
// requestAnimationFrame below - same reasoning as the non-draft path (Bug 1 fix):
// children are new DOM nodes not in disabledElements and would become immediately
// interactive while the success banner is still visible.
} else {
// For published: Replace the entire element with server-rendered HTML.
// applyNewHTML handles the DOM swap, UUID attributes, and uuidMap cleanup.
const newElement = applyNewHTML(editorState.element, editorState.uuid, newHTML);
if (newElement) {
// Keep element marked as active during success display to prevent hover.
newElement.classList.add('mwp-sfe-element-active');
// Update reference for reattaching action bar
editorState.element = newElement;
// NOTE: Do NOT call rebindChildren here. The new element's inner blocks
// are fresh DOM nodes not in disabledElements, so they'd become interactive
// immediately while the success animation is still running. Rebind is deferred
// to the final cleanup block below, after enableAllElements fires.
}
}
// Handle additional elements supplied by batch save.
// Each item: { element, uuid, html } - the other dirty blocks beyond the active editor.
// These have no open editor to clean up; they only need a DOM swap + lock during banner.
const additionalNewEls = [];
const additionalItems = options.additionalElements || [];
for (const item of additionalItems) {
if (!item.element || !item.uuid || !item.html) continue;
const newEl = applyNewHTML(item.element, item.uuid, item.html);
if (!newEl) continue;
// Lock during success banner (same as the primary element above).
newEl.classList.add('mwp-sfe-element-active');
if (isDraft) {
newEl.classList.add('mwp-sfe-status-pending');
if (uuidMap[item.uuid]) {
uuidMap[item.uuid].is_pending = true;
}
}
additionalNewEls.push(newEl);
}
// Animate and final cleanup
// Store timeout ID for potential cleanup
const successTimeoutId = setTimeout(() => {
successDiv.style.opacity = '0';
setTimeout(() => {
successDiv.remove();
// Only cleanup if no new editor session has started
// We check if UUIDs match. Media editing uses a "mock" state object for saving
// that is a different object reference than activeEditor, so strictly checking (===) fails.
const activeEditor = ctx.activeEditor;
const isMatchingSession = activeEditor && (activeEditor === thisEditorSession || activeEditor.uuid === thisEditorSession.uuid);
if (activeEditor === null || isMatchingSession) {
// Now re-enable other elements
ElementState.enableAllElements();
// Centralized unlock
let triggerBtn = null;
if (editorState && editorState.actionsContainer) {
triggerBtn = editorState.actionsContainer.querySelector('.mwp-sfe-btn-primary-inline');
}
if (SFE.SaveHelpers && SFE.SaveHelpers.unlockSaveUI) {
SFE.SaveHelpers.unlockSaveUI(triggerBtn);
} else {
ctx.isSaving = false;
}
// Create cleanup callback (none needed for success state)
const successCleanup = () => {
// Hide bar using consolidated system
if (editorState.actionsContainer) {
actionBar.hide();
}
};
// Remove active class now that we're resetting
editorState.element.classList.remove('mwp-sfe-element-active');
// Use consolidated exit logic (will handle restoration and smart hiding)
actionBar.reset(
editorState.actionsContainer,
editorState.element,
editorState.uuid,
successCleanup
);
// Reattach click/hover action bar to the (possibly new) element
// and rebind inner blocks NOW - after enableAllElements has fired,
// so they enter the enabled state along with everything else.
requestAnimationFrame(() => {
try {
SFE.rebindChildren(editorState.element);
SFE.attachActionBarToElement(editorState.element);
} catch (err) {
console.error('FrontEdit: failed to reattach action bar after save', err);
}
// Rebind additional elements from batch save (if any).
for (const el of additionalNewEls) {
el.classList.remove('mwp-sfe-element-active');
try {
SFE.rebindChildren(el);
SFE.attachActionBarToElement(el);
} catch (err) {
console.error('FrontEdit: failed to rebind batch element after save', err);
}
}
});
// Force clear activeEditor if it matches this session
if (isMatchingSession) {
ctx.activeEditor = null;
}
if (reloadAfterSuccess) {
reloadAfterSuccess();
return;
}
}
// If a new editor session started, don't interfere
}, TIMING.SUCCESS_FADE);
}, TIMING.SUCCESS_DISPLAY);
// Store timeout ID on editorState for cleanup
if (editorState) {
editorState._successTimeoutId = successTimeoutId;
}
}
SFE.SaveManager = { handleInlineSave, showInlineSuccess };
})();
Master Fix & RepairSkip to content
Cracked screen, battery issue, or water damage? Master Fix & Repair brings every phone, tablet, and iPad back to life. Our certified technicians use genuine parts and precision tools to deliver fast, reliable repairs — serving Charleston, North Charleston, Downtown Charleston, and Valdosta.
At Master Fix & Repair, our certified technicians specialize in cell phone, tablet, and iPad repair of every kind. From cracked screens and battery replacements to software fixes and water damage repair, we restore your device quickly and affordably.
We believe your device deserves expert care, not shortcuts. That’s why we use genuine parts, modern tools, and proven repair techniques to make sure it works like new again. With us, every repair is handled with precision, care, and commitment.
Whether it’s a shattered screen, weak battery, or camera glitch, our skilled technicians can handle it all. Every service includes a free device health check and warranty coverage.
Book online or visit any of our locations — share what’s wrong, and we’ll confirm your slot right away.
Bring In Your Device
Drop your phone or tablet off with our team for a quick inspection and repair quote.
We Get to Work
Our certified experts handle every fix using genuine parts and precise repair tools.
Ready for Pickup
Once repaired, your device is cleaned, tested, and handed back — working perfectly again.
MOBILE PROTECTION
Protect Your Phone — And Keep It Working Like New
Your device deserves more than a quick fix. At Master Fix & Repair, we not only repair your phone or tablet but also help protect it from future damage. From screen protectors and battery replacements to full software diagnostics, our team ensures your device stays strong and secure.
Keep it safe, running smooth, and looking brand-new — every time you visit us.
Most screen replacements are completed within 30–45 minutes. However, the exact time can vary depending on your device model and whether additional damage needs attention. You’ll get an accurate time estimate after our technician performs the diagnostic check.
Do you provide a warranty with your phone repairs?
Yes! Every repair is backed by a one-year warranty covering both parts and labor. If an issue related to our repair occurs within that period, simply bring your device back — we’ll fix it at no extra cost.
Can you recover data from water-damaged phones or tablets?
In most cases, yes. Our technicians perform advanced data recovery and water-damage diagnostics to retrieve photos, contacts, and files whenever possible. The sooner you bring the device in, the higher the chance of successful recovery.
Should I back up my phone data before bringing it in for repair?
We recommend backing up your data whenever possible. While we handle every device with care, a backup ensures your information stays safe during any battery replacement, screen replacement, or motherboard repair process.
Do you use genuine or aftermarket replacement parts?
We use only OEM-grade or genuine parts that meet manufacturer standards. This guarantees your phone or tablet performs like new and maintains its original quality, durability, and safety.
TESTIMONIALS
Our Reputation Speaks for Itself
Posted on
Elmin Asgarov
Trustindex verifies that the original source of the review is Google.
Great service. highly recommended
Posted on
Will Jowers
Trustindex verifies that the original source of the review is Google.
Great job fixing my laptop monitor
Posted on
Aidan
Trustindex verifies that the original source of the review is Google.
Misha is fast and reliable. Apple refused to service my iPhone 14 Pro bought 18 months ago, claimed water damage (never happened) and refused to work on it. Misha did it for less than half and did it 5 minutes. Go here - it’s cheaper and faster - and he’s nicer! Apple on king street can’t compare to Master Fix
Posted on
Ish Evans
Trustindex verifies that the original source of the review is Google.
They fixed my PS5 on a good deal and got me in and out fast and they have great customer service
Posted on
Valerie Bazán
Trustindex verifies that the original source of the review is Google.
It was done quickly and professionally. i even was given a free screen protector!!!! worker was friendly and got my phone fixed within an hour or so!!
Posted on
Peter Gagliardi
Trustindex verifies that the original source of the review is Google.
The owner was super knowedgeable & the price was great. I was in & out in 10 minutes for my iphone screen replacement.
Posted on
Taylor Johnson
Trustindex verifies that the original source of the review is Google.
I took my phone to Master Fix after experiencing water damage from the persistent flooding. My phone was turning on and off every couple of minutes and there was visible moisture in the camera lens. I tried putting my phone in rice and allowing it to dry out, but still experienced issues after all of my efforts. The employee at Master Fix was very upfront with me about pricing and the time of service. He assured me he would call if the price was going to exceed the $125 cleaning fee. He called me back after a few hours and said that my phone was ready. The phone was practically brand new and I have had no issues since. Highly recommend Master Fix! Efficient, reasonable pricing, and honest!
Posted on
Jonathan Delaney
Trustindex verifies that the original source of the review is Google.
This is a real review I came in 8-19-25 my iPhone 14 Plus been boot lagging.. turning on and off randomly “Misha” the shop owner helped me and took his time fixing my phone I never had a problem so if you’re phone is turning on and off come here asap!!!! Very professional and patient and friendly… definitely we’ll go for any future problems with my phone screen battery etc.. come here !!!
Posted on
Ryan Burlyn
Trustindex verifies that the original source of the review is Google.
I brought my tpms, reprogramming tool in to have a the charge port replaced, and they had the part. I'm extremely happy with the service. 12/10. I put everything back in its case and the device charges again!