<body>
<form method="post" action="dump.php">
<div id="tiny-ui">
<textarea id="comments-embedded" style="width: 100%; height: 500px;">
<h1>Proposal Title</h1>
<p>Dear {Proposal_Fund_Contact},</p>
<p>After exploring your call for proposals, My name is {Contact_Name} and as a representative for {company_name}, the following outlines how our digital marketing service and <span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_34420829621663914009497" data-mce-annotation="tinycomments">decades of experience can significantly</span> increase your site traffic, drawing new audiences to the museum.</p>
<p>With our experience, we can help you meet and exceed the 20% increase proposed as an initial target.</p>
<h2>Summary</h2>
<p>Our services are summarized under three key service areas:</p>
<ul>
<li>Blog Content</li>
<li>Video Production</li>
<li>Lead Magnets</li>
</ul>
<h2>Blog Content</h2>
<p>Our expertise takes content writers from the audience analysis, research and writing stage, through review and editing, and finally to publication. Previous audits into past work for clients have shown significant increases in traffic, for example, <span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_13629681821663664152152" data-mce-annotation="tinycomments">a result of 11,200 views per quarter to 14,883 views per quarter for one previous client</span> (Testimonies and Results, Appendix A).</p>
<h3>Video Production</h3>
<p>With sharp design and editing our video production team have produced trailers, sizzle reels, and demos across a variety of industries, increasing followers across video social media sources up to 55% in some instances.</p>
<h3>Lead Magnets</h3>
<p>A good lead magnet provides an essential entry point for attracting qualified leads. We can set up lead magnets, and produce valuable content to target your audience, garnering their respect and collecting contact information for follow up.</p>
<h3>Background </h3>
<ul>
<li>
<p>WIP</p>
</li>
</ul>
<h3>Objectives</h3>
<ul>
<li>
<p><span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_32185374131663914463261" data-mce-annotation="tinycomments">WIP</span></p>
</li>
</ul>
<h3>Methods</h3>
<ul>
<li>
<p>WIP</p>
</li>
</ul>
<h3><span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_36510162521663724338871" data-mce-annotation="tinycomments">Budgets and Schedules</span></h3>
<p> </p>
<p> </p>
<div align="left">
<table style="border-collapse: collapse; width: 99.9976%;" border="1"><colgroup><col style="width: 25.9099%;"><col style="width: 61.6685%;"><col style="width: 12.421%;"></colgroup>
<tbody>
<tr>
<td>
<p>Blog Creation</p>
</td>
<td>
<p>Research will inform blog topics and content relevant to your industry and target audience.</p>
</td>
<td>
<p><span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_35814127541663915116369" data-mce-annotation="tinycomments">$450</span></p>
</td>
</tr>
<tr>
<td>
<p>Lead Magnets</p>
</td>
<td>
<p>Valuable long-form content relevant to your industry and targeted audience, including individual landing pages and lead forms.</p>
</td>
<td>
<p>$1900</p>
</td>
</tr>
</tbody>
</table>
</div>
<p> </p>
<h3>Appendices</h3>
<p>- <span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_10601721751663915458087" data-mce-annotation="tinycomments">Testimonies and Results</span></p>
<!--tinycomments|2.1|data:application/json;base64,eyJtY2UtY29udmVyc2F0aW9uXzM0NDIwODI5NjIxNjYzOTE0MDA5NDk3Ijp7InVpZCI6Im1jZS1jb252ZXJzYXRpb25fMzQ0MjA4Mjk2MjE2NjM5MTQwMDk0OTciLCJjb21tZW50cyI6W3sidWlkIjoibWNlLWNvbnZlcnNhdGlvbl8zNDQyMDgyOTYyMTY2MzkxNDAwOTQ5NyIsImF1dGhvciI6Ik0uV29vbGZAZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvMVhUcXpiNWRFaUpkYkc0N05rcFdDci9lZjY5MTU5NzI5NTEzMTA1MDllYzAwMDgxMGQ2NzEyMy9pbWctbWFydGhhLXdvb2xmLWNvbW1lbnRzLWRlbW8ucG5nP2g9NTAwIiwiYXV0aG9yTmFtZSI6Ik1hcnRoYSBXb29sZiIsImNvbnRlbnQiOiJJIHRoaW5rIHRoaXMgc2hvdWxkIGJlIGEgbmV3IHNlbnRlbmNlIiwiY3JlYXRlZEF0IjoiMjAyMi0wOS0xOFQwMTozODo1OC44ODRaIiwibW9kaWZpZWRBdCI6IjIwMjItMDktMThUMDE6Mzg6NTguODg0WiJ9LHsidWlkIjoibWNlLWNvbW1lbnRfMjAxODIxNDI0OTMyMTQ4NjQxNTM3ODMiLCJhdXRob3IiOiJNLldvb2xmQGVtYWlsLmNvbSIsImF1dGhvck5hbWUiOiJNYXJ0aGEgV29vbGYiLCJhdXRob3JBdmF0YXIiOiJodHRwczovL2ltYWdlcy5jdGZhc3NldHMubmV0L3M2MDBqajQxZ3NleC8xWFRxemI1ZEVpSmRiRzQ3TmtwV0NyL2VmNjkxNTk3Mjk1MTMxMDUwOWVjMDAwODEwZDY3MTIzL2ltZy1tYXJ0aGEtd29vbGYtY29tbWVudHMtZGVtby5wbmc/aD01MDAiLCJjb250ZW50IjoiU29tZXRoaW5nIGxpa2Ug4oCYLiBPdXIgRGVjYWRlcyBvZiBleHBlcmllbmNlIGNhbuKApuKAmSBUaG91Z2h0cz8iLCJjcmVhdGVkQXQiOiIyMDIyLTA5LTE5VDAxOjQwOjU4Ljg4NFoiLCJtb2RpZmllZEF0IjoiMjAyMi0wOS0xOVQwMTozODo1OC44ODRaIn0seyJ1aWQiOiJtY2UtY29tbWVudF8zMjEyMTQyNDk0ODYyMDE4NDE1Mzc4MyIsImF1dGhvciI6IkguTGVobWVuQGVtYWlsLmNvbSIsImF1dGhvckF2YXRhciI6Imh0dHBzOi8vaW1hZ2VzLmN0ZmFzc2V0cy5uZXQvczYwMGpqNDFnc2V4Lzdzajd0SjZuNkU5blprME1GVHBuakkvMTI0MTUxMWRlMWZiMjQzMTA4ZmM5NjgzMWRhOTllNjgvaW1nLWhvbmV5LWxlaG1lbi1jb21tZW50cy1kZW1vLmpwZz9oPTUwMCIsImF1dGhvck5hbWUiOiJIb25leSBMZWhtZW4iLCJjb250ZW50IjoiSeKAmWxsIHdyaXRlIHVwIHNvbWUgc2FtcGxlIHNlbnRlbmNlcyIsImNyZWF0ZWRBdCI6IjIwMjItMDktMTlUMDI6NDA6NTguODg0WiIsIm1vZGlmaWVkQXQiOiIyMDIyLTA5LTE5VDAyOjM4OjU4Ljg4NFoifV19LCJtY2UtY29udmVyc2F0aW9uXzEzNjI5NjgxODIxNjYzNjY0MTUyMTUyIjp7InVpZCI6Im1jZS1jb252ZXJzYXRpb25fMTM2Mjk2ODE4MjE2NjM2NjQxNTIxNTIiLCJjb21tZW50cyI6W3sidWlkIjoibWNlLWNvbnZlcnNhdGlvbl8xMzYyOTY4MTgyMTY2MzY2NDE1MjE1MiIsImF1dGhvciI6Ik0uV29vbGZAZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvMVhUcXpiNWRFaUpkYkc0N05rcFdDci9lZjY5MTU5NzI5NTEzMTA1MDllYzAwMDgxMGQ2NzEyMy9pbWctbWFydGhhLXdvb2xmLWNvbW1lbnRzLWRlbW8ucG5nP2g9NTAwIiwiYXV0aG9yTmFtZSI6Ik1hcnRoYSBXb29sZiIsImNvbnRlbnQiOiJJcyB0aGlzIG1ldGhvZG9sb2d5IHdlbGwgdGhvdWdodCBvdXQgYW5kIGNsZWFyPyBJIHRoaW5rIHRoZXJl4oCZcyBzb21lIGJldHRlciByZXN1bHRzIHdlIGNhbiB1c2UiLCJjcmVhdGVkQXQiOiIyMDIyLTA5LTE5VDAxOjM4OjU4Ljg4NFoiLCJtb2RpZmllZEF0IjoiMjAyMi0wOS0xOVQwMTozODo1OC44ODRaIn0seyJ1aWQiOiJtY2UtY29tbWVudF8xNDgzNzIwMTgyMTI4MzQ2NDE1MjQ5MyIsImF1dGhvciI6IkcuQWxiZWVAZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvM092V0pLSkNvQ2d4NDMwcm5BY1dJaS83ZTdhYWIyNmJlNGMzOWY0ZjhkOTBmOWI0ZTljZDFlZS9pbWctZ2VvcmdlLWFsYmVlLWNvbW1lbnRzLWRlbW8ucG5nP2g9NTAwIiwiYXV0aG9yTmFtZSI6Ikdlb3JnZSBBbGJlZSIsImNvbnRlbnQiOiJBIGdvb2Qgc3VnZ2VzdGlvbiwgY2FuIHdlIHBlcmhhcHMgcHV0IGluIHRocmVlIGV4YW1wbGVzIHRvIGJldHRlciBiYWNrIHVwIHRoZSBhcmd1bWVudCBoZXJlLiIsImNyZWF0ZWRBdCI6IjIwMjItMDktMjBUMDE6Mzg6NTguODg0WiIsIm1vZGlmaWVkQXQiOiIyMDIyLTA5LTIwVDAxOjM4OjU4Ljg4NFoifV19LCJtY2UtY29udmVyc2F0aW9uXzMyMTg1Mzc0MTMxNjYzOTE0NDYzMjYxIjp7InVpZCI6Im1jZS1jb252ZXJzYXRpb25fMzIxODUzNzQxMzE2NjM5MTQ0NjMyNjEiLCJjb21tZW50cyI6W3sidWlkIjoibWNlLWNvbnZlcnNhdGlvbl8zMjE4NTM3NDEzMTY2MzkxNDQ2MzI2MSIsImF1dGhvciI6IkguTGVobWVuQGVtYWlsLmNvbSIsImF1dGhvckF2YXRhciI6Imh0dHBzOi8vaW1hZ2VzLmN0ZmFzc2V0cy5uZXQvczYwMGpqNDFnc2V4Lzdzajd0SjZuNkU5blprME1GVHBuakkvMTI0MTUxMWRlMWZiMjQzMTA4ZmM5NjgzMWRhOTllNjgvaW1nLWhvbmV5LWxlaG1lbi1jb21tZW50cy1kZW1vLmpwZz9oPTUwMCIsImF1dGhvck5hbWUiOiJIb25leSBMZWhtZW4iLCJjb250ZW50IjoiSSBoYXZlIHNvbWUgaWRlYXMgZm9yIHRoaXMsIEnigJlsbCBzZW5kIHRoZW0gdGhyb3VnaCBzb29uIiwiY3JlYXRlZEF0IjoiMjAyMi0wOS0yMFQwMTozODo1OC44ODRaIiwibW9kaWZpZWRBdCI6IjIwMjItMDktMjBUMDE6Mzg6NTguODg0WiJ9LHsidWlkIjoibWNlLWNvbW1lbnRfMjE0ODY0MjAxODIxNDI0OTMxNTM3ODMiLCJhdXRob3IiOiJNLldvb2xmQGVtYWlsLmNvbSIsImF1dGhvckF2YXRhciI6Imh0dHBzOi8vaW1hZ2VzLmN0ZmFzc2V0cy5uZXQvczYwMGpqNDFnc2V4LzFYVHF6YjVkRWlKZGJHNDdOa3BXQ3IvZWY2OTE1OTcyOTUxMzEwNTA5ZWMwMDA4MTBkNjcxMjMvaW1nLW1hcnRoYS13b29sZi1jb21tZW50cy1kZW1vLnBuZz9oPTUwMCIsImF1dGhvck5hbWUiOiJNYXJ0aGEgV29vbGYiLCJjb250ZW50IjoiRG9u4oCZdCBmb3JnZXQgdGhlIG5ldyBpbml0aWF0aXZlcyBwbGFubmVkIGZvciBuZXh0IHllYXIg4oCTIHNlZSB0aGUgR29vZ2xlIERyaXZlIiwiY3JlYXRlZEF0IjoiMjAyMi0wOS0yMVQwMTo0MDo1OC44ODRaIiwibW9kaWZpZWRBdCI6IjIwMjItMDktMjFUMDE6Mzg6NTguODg0WiJ9LHsidWlkIjoibWNlLWNvbW1lbnRfOTQ4NjIwMTg0MzIxMjE0MjQxNTM3ODMiLCJhdXRob3IiOiJILkxlaG1lbkBlbWFpbC5jb20iLCJhdXRob3JBdmF0YXIiOiJodHRwczovL2ltYWdlcy5jdGZhc3NldHMubmV0L3M2MDBqajQxZ3NleC83c2o3dEo2bjZFOW5aazBNRlRwbmpJLzEyNDE1MTFkZTFmYjI0MzEwOGZjOTY4MzFkYTk5ZTY4L2ltZy1ob25leS1sZWhtZW4tY29tbWVudHMtZGVtby5qcGc/aD01MDAiLCJhdXRob3JOYW1lIjoiSG9uZXkgTGVobWVuIiwiY29udGVudCI6IkdvdCBpdCwgdGhhbmtzIE1hcnRoYSA6KSIsImNyZWF0ZWRBdCI6IjIwMjItMDktMjFUMDI6NDA6NTguODg0WiIsIm1vZGlmaWVkQXQiOiIyMDIyLTA5LTIxVDAyOjM4OjU4Ljg4NFoifV19LCJtY2UtY29udmVyc2F0aW9uXzM1ODE0MTI3NTQxNjYzOTE1MTE2MzY5Ijp7InVpZCI6Im1jZS1jb252ZXJzYXRpb25fMzU4MTQxMjc1NDE2NjM5MTUxMTYzNjkiLCJjb21tZW50cyI6W3sidWlkIjoibWNlLWNvbnZlcnNhdGlvbl8zNTgxNDEyNzU0MTY2MzkxNTExNjM2OSIsImF1dGhvciI6IkguTGVobWVuQGVtYWlsLmNvbSIsImF1dGhvckF2YXRhciI6Imh0dHBzOi8vaW1hZ2VzLmN0ZmFzc2V0cy5uZXQvczYwMGpqNDFnc2V4Lzdzajd0SjZuNkU5blprME1GVHBuakkvMTI0MTUxMWRlMWZiMjQzMTA4ZmM5NjgzMWRhOTllNjgvaW1nLWhvbmV5LWxlaG1lbi1jb21tZW50cy1kZW1vLmpwZz9oPTUwMCIsImF1dGhvck5hbWUiOiJIb25leSBMZWhtZW4iLCJjb250ZW50IjoiSSBqdXN0IGNoZWNrZWQgaW4gd2l0aCBmaW5hbmNlLCBhbmQgd2UgbWF5IG5lZWQgdG8gdXBkYXRlIHRoaXMiLCJjcmVhdGVkQXQiOiIyMDIyLTA5LTIwVDAxOjM4OjU4Ljg4NFoiLCJtb2RpZmllZEF0IjoiMjAyMi0wOS0yMFQwMTozODo1OC44ODRaIn0seyJ1aWQiOiJtY2UtY29tbWVudF8yMTE1NDIwMTgyMTQ4NjQ5MzQyMzc4MyIsImF1dGhvciI6Ik0uV29vbGZAZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvMVhUcXpiNWRFaUpkYkc0N05rcFdDci9lZjY5MTU5NzI5NTEzMTA1MDllYzAwMDgxMGQ2NzEyMy9pbWctbWFydGhhLXdvb2xmLWNvbW1lbnRzLWRlbW8ucG5nP2g9NTAwIiwiYXV0aG9yTmFtZSI6Ik1hcnRoYSBXb29sZiIsImNvbnRlbnQiOiJKdXN0IGNvbmZpcm1pbmcgdGhpcyBpcyB0aGUgY2FzZT8gSeKAmW0gYSBiaXQgY29uZnVzZWTigKYiLCJjcmVhdGVkQXQiOiIyMDIyLTA5LTIxVDAxOjQwOjU4Ljg4NFoiLCJtb2RpZmllZEF0IjoiMjAyMi0wOS0yMVQwMTozODo1OC44ODRaIn0seyJ1aWQiOiJtY2UtY29tbWVudF85NDg2MjAxODQzMjEyMTQyNDE1Mzc4MyIsImF1dGhvciI6IkcuQWxiZWVAZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvM092V0pLSkNvQ2d4NDMwcm5BY1dJaS83ZTdhYWIyNmJlNGMzOWY0ZjhkOTBmOWI0ZTljZDFlZS9pbWctZ2VvcmdlLWFsYmVlLWNvbW1lbnRzLWRlbW8ucG5nP2g9NTAwIiwiYXV0aG9yTmFtZSI6Ikdlb3JnZSBBbGJlZSIsImNvbnRlbnQiOiJJIHRoaW5rIGl04oCZcyB0aGUgc2FtZSByYXRlPyIsImNyZWF0ZWRBdCI6IjIwMjItMDktMjJUMDI6NDA6NTguODg0WiIsIm1vZGlmaWVkQXQiOiIyMDIyLTA5LTIyVDAyOjM4OjU4Ljg4NFoifSx7InVpZCI6Im1jZS1jb21tZW50XzEyMzIxMjE0MjQxNTM3ODYyMDE4NDgzIiwiYXV0aG9yIjoiSC5MZWhtZW5AZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvN3NqN3RKNm42RTluWmswTUZUcG5qSS8xMjQxNTExZGUxZmIyNDMxMDhmYzk2ODMxZGE5OWU2OC9pbWctaG9uZXktbGVobWVuLWNvbW1lbnRzLWRlbW8uanBnP2g9NTAwIiwiYXV0aG9yTmFtZSI6IkhvbmV5IExlaG1lbiIsImNvbnRlbnQiOiJBaCEgWW914oCZcmUgcmlnaHQsIHNvcnJ5IHdyb25nIGNvbnRyYWN0LiBGZWVsIGZyZWUgdG8gZGVsZXRlLiIsImNyZWF0ZWRBdCI6IjIwMjItMDktMjNUMDI6NDA6NTguODg0WiIsIm1vZGlmaWVkQXQiOiIyMDIyLTA5LTIzVDAyOjM4OjU4Ljg4NFoifV19LCJtY2UtY29udmVyc2F0aW9uXzM2NTEwMTYyNTIxNjYzNzI0MzM4ODcxIjp7InVpZCI6Im1jZS1jb252ZXJzYXRpb25fMzY1MTAxNjI1MjE2NjM3MjQzMzg4NzEiLCJjb21tZW50cyI6W3sidWlkIjoibWNlLWNvbnZlcnNhdGlvbl8xOTY3OTYwMDIyMTYyMTM5OTcwMzkxNSIsImF1dGhvciI6IkcuQWxiZWVAZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvM092V0pLSkNvQ2d4NDMwcm5BY1dJaS83ZTdhYWIyNmJlNGMzOWY0ZjhkOTBmOWI0ZTljZDFlZS9pbWctZ2VvcmdlLWFsYmVlLWNvbW1lbnRzLWRlbW8ucG5nP2g9NTAwIiwiYXV0aG9yTmFtZSI6Ikdlb3JnZSBBbGJlZSIsImNvbnRlbnQiOiJJcyB0aGUgcHJvcG9zYWwgYnVkZ2V0IHJlYXNvbmFibGU/IiwiY3JlYXRlZEF0IjoiMjAyMi0wOS0yMFQwMTozODo1OC44ODRaIiwibW9kaWZpZWRBdCI6IjIwMjItMDktMjBUMDE6Mzg6NTguODg0WiJ9LHsidWlkIjoibWNlLWNvbW1lbnRfMTAyOTU2MTkzODQ1ODM0OTM4NDYyOTMiLCJhdXRob3IiOiJNLldvb2xmQGVtYWlsLmNvbSIsImF1dGhvckF2YXRhciI6Imh0dHBzOi8vaW1hZ2VzLmN0ZmFzc2V0cy5uZXQvczYwMGpqNDFnc2V4LzFYVHF6YjVkRWlKZGJHNDdOa3BXQ3IvZWY2OTE1OTcyOTUxMzEwNTA5ZWMwMDA4MTBkNjcxMjMvaW1nLW1hcnRoYS13b29sZi1jb21tZW50cy1kZW1vLnBuZz9oPTUwMCIsImF1dGhvck5hbWUiOiJNYXJ0aGEgV29vbGYiLCJjb250ZW50IjoiWWVzIEnigJl2ZSBoYWQgaXQgY2hlY2tlZCB0aHJvdWdoIGJ5IHRoZSBhdWRpdG9yIHN1Z2dlc3RlZC4gSXMgdGhlcmUgYW55IG1vcmUgdG8gYWRkIGhlcmUgb3IgY2FuIHdlIHJlc29sdmUgdGhlIGRpc2N1c3Npb24/IiwiY3JlYXRlZEF0IjoiMjAyMi0wOS0yMVQwMTozODo1OC44ODRaIiwibW9kaWZpZWRBdCI6IjIwMjItMDktMjFUMDE6Mzg6NTguODg0WiJ9XX0sIm1jZS1jb252ZXJzYXRpb25fMTA2MDE3MjE3NTE2NjM5MTU0NTgwODciOnsidWlkIjoibWNlLWNvbnZlcnNhdGlvbl8xMDYwMTcyMTc1MTY2MzkxNTQ1ODA4NyIsImNvbW1lbnRzIjpbeyJ1aWQiOiJtY2UtY29udmVyc2F0aW9uXzEwNjAxNzIxNzUxNjYzOTE1NDU4MDg3IiwiYXV0aG9yIjoiRy5BbGJlZUBlbWFpbC5jb20iLCJhdXRob3JBdmF0YXIiOiJodHRwczovL2ltYWdlcy5jdGZhc3NldHMubmV0L3M2MDBqajQxZ3NleC8zT3ZXSktKQ29DZ3g0MzBybkFjV0lpLzdlN2FhYjI2YmU0YzM5ZjRmOGQ5MGY5YjRlOWNkMWVlL2ltZy1nZW9yZ2UtYWxiZWUtY29tbWVudHMtZGVtby5wbmc/aD01MDAiLCJhdXRob3JOYW1lIjoiR2VvcmdlIEFsYmVlIiwiY29udGVudCI6IkkganVzdCBnb3QgYSBuZXcgdGVzdGltb25pYWwgZnJvbSBvdXIgbGFzdCBjdXN0b21lci4gSeKAmWxsIGZvcndhcmQgdG8gdGhlIFNsYWNrIGNoYXQgZm9yIGFwcHJvdmFsIiwiY3JlYXRlZEF0IjoiMjAyMi0wOS0yMlQwMTozODo1OC44ODRaIiwibW9kaWZpZWRBdCI6IjIwMjItMDktMjJUMDE6Mzg6NTguODg0WiJ9LHsidWlkIjoibWNlLWNvbW1lbnRfMTkzOTMwMjk1NjE4NDYyOTg0NTgzNDMiLCJhdXRob3IiOiJNLldvb2xmQGVtYWlsLmNvbSIsImF1dGhvckF2YXRhciI6Imh0dHBzOi8vaW1hZ2VzLmN0ZmFzc2V0cy5uZXQvczYwMGpqNDFnc2V4LzFYVHF6YjVkRWlKZGJHNDdOa3BXQ3IvZWY2OTE1OTcyOTUxMzEwNTA5ZWMwMDA4MTBkNjcxMjMvaW1nLW1hcnRoYS13b29sZi1jb21tZW50cy1kZW1vLnBuZz9oPTUwMCIsImF1dGhvck5hbWUiOiJNYXJ0aGEgV29vbGYiLCJjb250ZW50IjoiTmljZSB3b3JrISIsImNyZWF0ZWRBdCI6IjIwMjItMDktMjJUMDI6Mzg6NTguODg0WiIsIm1vZGlmaWVkQXQiOiIyMDIyLTA5LTIyVDAyOjM4OjU4Ljg4NFoifSx7InVpZCI6Im1jZS1jb21tZW50XzQ2MTkyODQ4MTkzOTM4NzQ2NjE4MzQzIiwiYXV0aG9yIjoiSC5MZWhtZW5AZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvN3NqN3RKNm42RTluWmswTUZUcG5qSS8xMjQxNTExZGUxZmIyNDMxMDhmYzk2ODMxZGE5OWU2OC9pbWctaG9uZXktbGVobWVuLWNvbW1lbnRzLWRlbW8uanBnP2g9NTAwIiwiYXV0aG9yTmFtZSI6IkhvbmV5IExlaG1lbiIsImNvbnRlbnQiOiIqYXBwbGF1c2UgYXBwbGF1c2UqIiwiY3JlYXRlZEF0IjoiMjAyMi0wOS0yMlQwMzozODo1OC44ODRaIiwibW9kaWZpZWRBdCI6IjIwMjItMDktMjJUMDM6Mzg6NTguODg0WiJ9XX19-->
</textarea>
</div>
</form>
</body>
</html>
// Demo backend data
const currentAuthor = {
author: 'M.Woolf@email.com',
authorName: 'Martha Woolf'
};
const authorAvatars = {
'Martha Woolf': 'https://images.ctfassets.net/s600jj41gsex/1XTqzb5dEiJdbG47NkpWCr/ef6915972951310509ec000810d67123/img-martha-woolf-comments-demo.png?h=500',
'George Albee': 'https://images.ctfassets.net/s600jj41gsex/3OvWJKJCoCgx430rnAcWIi/7e7aab26be4c39f4f8d90f9b4e9cd1ee/img-george-albee-comments-demo.png?h=500',
'Honey Lehmen': 'https://images.ctfassets.net/s600jj41gsex/7sj7tJ6n6E9nZk0MFTpnjI/1241511de1fb243108fc96831da99e68/img-honey-lehmen-comments-demo.jpg?h=500'
};
const admin = 'M.Woolf@email.com';
const docName = "Proposal Template";
// Time since last save
let documentSaveTimestamp = new Date();
// Frontend functionality
// This is the eventLog API, as it is available through the `editor` API object
const getEventLog = (editor) => {
return editor.plugins.tinycomments.getEventLog({
after: documentSaveTimestamp.toISOString()
});
};
const timestampToLocalString = (timestamp) => new Date(timestamp).toDateString();
// Retrieve conversation data from the eventLog
const getRecipientData = (eventLog) => {
const recipientData = {}; // Record of recipients by id (author)
const conversationData = {}; // Record of conversations by Id
// Removes conversation from log in entirety, and handles when recipients no longer need updates
const removeConversationFromLog = (convId) => {
delete conversationData[convId];
Object.keys(recipientData).forEach((id) => {
// Iterate through each key in recipientData
if (recipientData[id].conversations.has(convId)) {
// Remove conversation from recipient
recipientData[id].conversations.delete(convId);
if (recipientData[id].conversations.size === 0) {
// Remove recipient if they no longer have any conversation updates
delete recipientData[id];
}
}
});
};
// Adds conversation information from event to conversationData, and updates recipientLog
const conversationEvent = (event) => {
const convId = event.conversationUid;
const eventTime = timestampToLocalString(event.timestamp);
const eventType = event.type;
// Adds convAuthor as recipient to changes in this conversation
const addConversationToRecipient = (convAuthor) => {
// convAuthor is an object with `author` and `authorName`,
// consistent with the eventLog and `currentAuthor` above
if (Object.keys(recipientData).includes(convAuthor.author)) {
// The `author` value is used as an id for the `recipientData` object
recipientData[convAuthor.author].conversations.add(convId);
} else {
recipientData[convAuthor.author] = {
name: convAuthor.authorName,
// Set of conversation Ids related to the recipient, for inclusion after eventLog is parsed
conversations: new Set([convId]),
// Placeholder for inserting the correct conversationData after eventLog is parsed
conversationData: {}
};
}
};
const createConversation = (convStatus, convAuthor) => {
conversationData[convId] = {
time: eventTime,
context: event.conversationContext,
status: convStatus,
commentLog: {}
};
conversationData[convId].commentLog[convId] = {
status: convStatus,
comment: event.conversationContent,
authorName: convAuthor.authorName,
time: eventTime,
};
addConversationToRecipient(convAuthor);
};
const existingConversation = (convAuthor) => {
const conversationTime = timestampToLocalString(event.conversationCreatedAt);
conversationData[convId] = {
time: conversationTime,
context: event.conversationContext,
status: "exist",
commentLog: {}
};
conversationData[convId].commentLog[convId] = {
status: "exist",
comment: event.conversationContent,
authorName: convAuthor.authorName,
time: conversationTime
};
addConversationToRecipient(convAuthor);
};
// Type: 'delete', 'resolve'
const deleteResolveConversation = () => {
if (conversationData[convId].status === "create") {
// Remove new conversation in entirety
removeConversationFromLog(convId);
} else {
// Modify conversation time and status
conversationData[convId].status = eventType;
conversationData[convId].time = eventTime;
}
};
// Type: 'reply', 'edit-comment', 'delete-comment'
const commentEvent = () => {
if (!Object.keys(conversationData[convId].commentLog).includes(event.commentUid)) {
// Add new comment to commentLog
conversationData[convId].commentLog[event.commentUid] = {
status: eventType,
comment: event.commentContent,
authorName: currentAuthor.authorName,
time: eventTime
};
} else if (eventType === "delete-comment" && conversationData[convId].commentLog[event.commentUid].status === "reply") {
// Comment created in this session is deleted
delete conversationData[convId].commentLog[event.commentUid];
if (conversationData[convId].status === "exist" && Object.keys(conversationData[convId].commentLog).length === 1) {
// Existing conversation returns to state at page open
removeConversationFromLog(convId);
}
} else {
// Comment updated in same session
conversationData[convId].commentLog[event.commentUid].comment = event.commentContent;
conversationData[convId].commentLog[event.commentUid].time = eventTime;
}
};
// Event is handled after this point
if (eventType === "create") {
// Conversation creation event
createConversation(eventType, currentAuthor);
} else {
// Existing conversation,
if (!Object.keys(conversationData).includes(convId)) {
// add a new empty conversation record
existingConversation(event.conversationAuthor);
}
// Conversation in Log after this point, and not create event
if (eventType === "delete" || eventType === "resolve") {
deleteResolveConversation();
} else {
commentEvent();
}
}
};
// Called for each event in eventLog
const handleEvent = (event) => {
const eventTime = timestampToLocalString(event.timestamp);
if (event.type === "delete-all-conversations") {
/* If this event is triggered, only those which are
already recorded in conversation log will be visible.
This means that there will be no information about
the deletion of existing conversations unless they
are first replied to or modified in any other way.
*/
Object.keys(conversationData).forEach((convId) => {
if (conversationData[convId].status === "create") {
// new conversations are removed from log
removeConversationFromLog(convId);
} else {
// Existing conversations remain in the log
conversationData[convId].status = "delete";
conversationData[convId].time = eventTime;
}
});
} else {
// Conversation event, event has conversation information
conversationEvent(event);
}
};
// Handle each event in the eventLog
eventLog.events.forEach(handleEvent);
// Use recipient id (email) to store email content
Object.keys(recipientData).forEach((id) => {
const recipient = recipientData[id];
Array.from(recipient.conversations).forEach((convId) => {
recipientData[id].conversationData[convId] = conversationData[convId]
});
delete recipientData[id].conversations;
});
return recipientData;
};
// Builds email content from recipients and conversationLog objects
const getEmails = (recipientData) => {
const emailCSS = `<style>
p, div {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
}
div.email-template {
background: #E8F1F8;
border-radius: 20px;
font-style: normal;
padding: 17px 43px 40px 35px;
display: flex;
flex-direction: column;
flex-grow: 1;
align-content: space-between;
gap: 20px;
}
div.email-conversation-header, div.email-comment {
padding-bottom: 16px;
}
div.email-conversation {
font-size: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 16px;
background: #FFFFFF;
border-radius: 10px;
}
div.email-comment {
display: flex;
flex-direction: column;
align-items: flex-start;
border-bottom: 1px solid #E8F1F8;
}
div.email-conversation-header {
border-bottom: 1px solid #E8F1F8;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
flex: none;
order: 0;
}
div.email-conversation-header, div.email-comment, div.email-comment-data, div.email-comment-author {
align-self: stretch;
flex-grow: 0;
}
div.email-conversation-title {
font-weight: 700;
}
div.email-conversation-time, div.email-comment-time {
font-weight: 400;
color: #646D78;
font-size: 13px;
}
div.email-comment-data {
}
div.email-comment-name {
font-weight: 700;
}
p.email-conversation-link {
font-weight: 400;
font-size: 13px;
line-height: 18px;
color: #4099FF;
margin-bottom: 0px;
}
div.email-comment-author img {
margin-right: 7px;
float: left;
border-radius: 50%;
}
</style>`;
// Reference object for replacing conversation status text
const conversationStatus = {
'create': 'Created ',
'exist': 'Created ',
'delete': 'Deleted ',
'resolve': 'Resolved '
};
// Reference object for replacing comment status text
const commentStatus = {
'create': 'Started conversation',
'exist': 'Started conversation',
'reply': 'Replied',
'delete-comment': 'Deleted',
'edit-comment': 'Edited'
};
// Takes commentLog and returns function which takes commentId which:
// Returns a comment from the commentLog as html using the commentId
const getCommentHtml = (commentLog) => (commentId) => {
const commentData = commentLog[commentId];
return `<div class="email-comment" id="${commentId}">
<div class="email-comment-data"><p>${commentData.comment}</p></div>
<div class="email-comment-author">
<img src="${authorAvatars[commentData.authorName]}" width=32px height=32px/>
<div class="email-comment-name">${commentData.authorName}</div>
<div class="email-comment-time">${commentStatus[commentData.status]} ${commentData.time}</div>
</div>
</div>`;
};
// Returns a conversation from the conversationLog as html using the convId
const getConversationHtml = (conversationData) => (convId) => {
const conversation = conversationData[convId];
const commentLog = conversation.commentLog;
const commentHtml = Object.keys(commentLog).map(getCommentHtml(commentLog)).join(`\n`);
return `<div class="email-conversation" id="${convId}">
<div class="email-conversation-header">
<div class="email-conversation-title">"${conversation.context}" conversation</div>
<div class="email-conversation-time">${conversationStatus[conversation.status]}${conversation.time}</div>
</div>
${commentHtml}
<p class="email-conversation-link">Open conversation</p>
</div>`;
};
// Store email content in an object
const recipientEmails = {};
// Use recipient id (email) to store email content
Object.keys(recipientData).forEach((id) => {
const recipient = recipientData[id];
const conversationData = recipient.conversationData;
const conversationHtml = Object.keys(conversationData).map(getConversationHtml(conversationData)).join(`\n`);
recipientEmails[id] = emailCSS + `<div class="email-template">
<div class="email-header">Hi ${recipient.name}, here is an update about the document <strong>"${docName}"</strong></div>
${conversationHtml}
</div>`;
});
return recipientEmails;
};
// Builds a dialog for displaying email content for each recipient, for demonstration purposes
const getEmailDialogConfig = (recipientData) => {
// Retrieves email content for each recipient
const recipientEmails = getEmails(recipientData);
// Builds array for displaying recipients in dialog selectbox
const recipientItems = Object.keys(recipientData).map((id) => {
const recipient = recipientData[id];
return {
value: id,
text: recipient.name
};
});
// Returned schema for the dialog opened by the "Email" button, containing:
// Selectbox of recipients; Iframe of email html content, submit button for sending the email.
const emailDialogConfig = {
title: 'Email Preview',
size : 'large',
body: {
type: 'panel',
title: 'Comments Update - Email Notification',
items: [
{
type: 'selectbox',
name: 'recipient',
label: 'Email Recipient',
maximized: true,
items: recipientItems
},
{
type: 'iframe',
name: 'emailTemplate',
label: 'Email Template'
}
]
},
buttons: [
{
type: 'cancel',
name: 'closeButton',
text: 'Close'
},
{
type: 'submit',
name: 'outputButton',
text: 'Output to Log',
buttonType: 'primary'
}
],
initialData: {
emailTemplate: recipientEmails[recipientItems[0].value]
},
onChange: (dialogApi, details) => {
// onChange called when new recipient is selected
const data = dialogApi.getData();
dialogApi.setData({
emailTemplate: recipientEmails[data.recipient]
});
},
onSubmit: (dialogApi) => {
// onSubmit called when 'Save and Send Email' button is pressed
// Two methods for retrieving the email html within this dialog:
const data = dialogApi.getData();
// Second retrieves the email html using the `getEmailHtml` function;
// this method doesn't require the dialog to contain an iframe.
console.log(recipientEmails[data.recipient]);
dialogApi.close();
}
};
return emailDialogConfig;
};
// This dialog allows the user to choose to open a dialog to preview emails
const getPreviewDialogConfig = (showEmail) => {
const previewDialogConfig = {
title: 'Show Preview',
size : 'medium',
body: {
type: 'panel',
title: 'Show Preview Question',
items: [
{
type: 'htmlpanel',
html: '<div>Would you like to preview the emails which have been sent out to other participants in this document?</div>'
},
{
type: 'alertbanner',
level: 'info',
icon: 'help',
text: 'This preview is for demonstration purposes only.'
}
]
},
buttons: [
{
type: 'cancel',
name: 'noButton',
text: 'No'
},
{
type: 'submit',
name: 'yesButton',
text: 'Yes',
buttonType: 'primary'
}
],
onSubmit: (dialogApi) => {
dialogApi.close();
showEmail();
}
}
return previewDialogConfig;
};
// This is the configuration of TinyMCE in this demo
tinymce.init({
selector: 'textarea#comments-embedded',
plugins: 'code tinycomments powerpaste advcode table save',
toolbar: 'bold italic underline | addcomment showcomments | saveComments | code',
// Comments mode, uses comments information as embedded into the document itself (as a comment tag)
tinycomments_mode: 'embedded',
// The currentAuthor object is as represented in the comments eventLog
tinycomments_author: currentAuthor.author,
tinycomments_author_name: currentAuthor.authorName,
tinycomments_author_avatar: authorAvatars[currentAuthor.authorName],
// These options define an author who is able to resolve/delete conversations
tinycomments_can_resolve: (req, done, fail) => {
const allowed = req.comments.length > 0 && req.comments[0].author === currentAuthor.author;
done({ canResolve: allowed || currentAuthor.author === admin });
},
tinycomments_can_delete: (req, done, fail) => {
const allowed = req.comments.length > 0 && req.comments[0].author === currentAuthor.author;
done({ canDelete: allowed || currentAuthor.author === admin });
},
// These options are for style, read more about them in our documentation
content_css: 'document',
sidebar_show: 'showcomments',
// The setup function allows direct use of APIs available via the `editor` object
setup: (editor) => {
// This function creates a button for retrieving and displaying the email dialog
editor.ui.registry.addButton('saveComments', {
icon: 'upload',
text: 'Save',
onAction: () => {
// This function retrieves data from the eventLog API and returns a dialog config
const eventLog = getEventLog(editor);
/* This 'mceSave' command will save any changes made to the document, including any comments.
Any update email should only be sent after saving the document and comments.
*/
// editor.execCommand('mceSave');
// Update the latest save time
documentSaveTimestamp = new Date();
// This function retrieves relevant data in a readable object
const recipientData = getRecipientData(eventLog);
// If there are recipients (conversations have been updated) ask to open a dialog to preview email notifications
if (Object.keys(recipientData).length !== 0) {
console.log(recipientData);
editor.notificationManager.open({
text: 'Your changes have been saved. Email notifications have been submitted successfully, and can be viewed via the console log.',
timeout: 5000,
type: 'success'
});
// Opens a dialog to ask the user whether they would like to see the emails
editor.windowManager.open(
getPreviewDialogConfig(() => {
// Builds a dialog for displaying email content
const emailDialogConfig = getEmailDialogConfig(recipientData);
editor.windowManager.open(emailDialogConfig)
})
)
} else {
editor.notificationManager.open({
text: 'Your changes have been saved.',
timeout: 5000,
type: 'success'
});
}
}
});
// Read more about building custom toolbar buttons, dialogs, and other UI components in TinyMCE
// at our documentation website https://www.tiny.cloud/docs/tinymce/6/
}
});
This Pen doesn't use any external CSS resources.