Commit iniziale di eminuxCRM: CRM Rails con pipeline, campagne email e Docker.
Include autenticazione, progetti isolati, mail marketing HTML con SMTP, test A/B e editor WYSIWYG. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["checkbox", "panel", "grid"]
|
||||
|
||||
connect() {
|
||||
this.sync()
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.sync()
|
||||
}
|
||||
|
||||
sync() {
|
||||
if (!this.hasCheckboxTarget) return
|
||||
|
||||
const on = this.checkboxTarget.checked
|
||||
this.panelTargets.forEach((el) => el.classList.toggle("hidden", !on))
|
||||
if (this.hasGridTarget) this.gridTarget.classList.toggle("lg:grid-cols-2", on)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Application } from "@hotwired/stimulus"
|
||||
|
||||
const application = Application.start()
|
||||
|
||||
// Configure Stimulus development experience
|
||||
application.debug = false
|
||||
window.Stimulus = application
|
||||
|
||||
export { application }
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["source", "checkbox"]
|
||||
|
||||
toggle() {
|
||||
const checked = this.sourceTarget.checked
|
||||
this.checkboxTargets.forEach((el) => {
|
||||
if (!el.disabled) el.checked = checked
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["menu"]
|
||||
|
||||
toggle(event) {
|
||||
event.stopPropagation()
|
||||
this.menuTarget.classList.toggle("hidden")
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.boundClose = this.close.bind(this)
|
||||
document.addEventListener("click", this.boundClose)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener("click", this.boundClose)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.menuTarget.classList.add("hidden")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.textContent = "Hello World!"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Import and register all your controllers from the importmap via controllers/**/*_controller
|
||||
import { application } from "controllers/application"
|
||||
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
|
||||
eagerLoadControllersFrom("controllers", application)
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { projectCode: String }
|
||||
|
||||
dragstart(event) {
|
||||
event.dataTransfer.setData("text/plain", event.target.dataset.opportunityId)
|
||||
event.dataTransfer.effectAllowed = "move"
|
||||
}
|
||||
|
||||
dragover(event) {
|
||||
event.preventDefault()
|
||||
event.currentTarget.classList.add("ring-2", "ring-emerald-400")
|
||||
}
|
||||
|
||||
dragleave(event) {
|
||||
event.currentTarget.classList.remove("ring-2", "ring-emerald-400")
|
||||
}
|
||||
|
||||
async drop(event) {
|
||||
event.preventDefault()
|
||||
event.currentTarget.classList.remove("ring-2", "ring-emerald-400")
|
||||
|
||||
const opportunityId = event.dataTransfer.getData("text/plain")
|
||||
const stage = event.currentTarget.dataset.stage
|
||||
if (!opportunityId || !stage) return
|
||||
|
||||
if (stage === "lost") {
|
||||
const reason = prompt("Motivo perdita (no_response, not_interested, price, competitor, no_streaming, timing, technical, deferred, other):", "other")
|
||||
if (!reason) return
|
||||
await this.updateStage(opportunityId, stage, reason)
|
||||
} else {
|
||||
await this.updateStage(opportunityId, stage)
|
||||
}
|
||||
}
|
||||
|
||||
async updateStage(opportunityId, stage, lostReason = null) {
|
||||
const token = document.querySelector("meta[name='csrf-token']").content
|
||||
const body = { pipeline_stage: stage }
|
||||
if (lostReason) body.lost_reason = lostReason
|
||||
|
||||
const response = await fetch(`/p/${this.projectCodeValue}/opportunities/${opportunityId}/update_stage`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": token,
|
||||
"Accept": "application/json"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
alert(data.errors?.join(", ") || "Impossibile aggiornare lo stage")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { insertMergeToken } from "controllers/wysiwyg_controller"
|
||||
|
||||
export default class extends Controller {
|
||||
insert(event) {
|
||||
event.preventDefault()
|
||||
insertMergeToken(`{{${event.params.token}}}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["panel"]
|
||||
static values = { open: Boolean }
|
||||
|
||||
connect() {
|
||||
if (this.openValue) this.show()
|
||||
this.boundKey = this.onKey.bind(this)
|
||||
document.addEventListener("keydown", this.boundKey)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener("keydown", this.boundKey)
|
||||
}
|
||||
|
||||
show() {
|
||||
this.panelTarget.classList.remove("hidden")
|
||||
this.panelTarget.querySelector("input, textarea, select")?.focus()
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.panelTarget.classList.add("hidden")
|
||||
}
|
||||
|
||||
onKey(event) {
|
||||
if (event.key === "Escape") this.hide()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
toggle() {
|
||||
const next = document.documentElement.classList.contains("dark") ? "light" : "dark"
|
||||
this.apply(next)
|
||||
}
|
||||
|
||||
apply(theme) {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark")
|
||||
try { localStorage.setItem("theme", theme) } catch (_) { /* ignore */ }
|
||||
document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
let activeEditor = null
|
||||
|
||||
function insertAtCursor(field, text) {
|
||||
const start = field.selectionStart ?? field.value.length
|
||||
const end = field.selectionEnd ?? field.value.length
|
||||
field.value = `${field.value.slice(0, start)}${text}${field.value.slice(end)}`
|
||||
const pos = start + text.length
|
||||
field.setSelectionRange(pos, pos)
|
||||
field.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
field.focus()
|
||||
}
|
||||
|
||||
export function insertMergeToken(token) {
|
||||
const field = document.activeElement
|
||||
if (field && field.matches("input:not([type=hidden]):not([type=checkbox]):not([type=radio]), textarea")) {
|
||||
insertAtCursor(field, token)
|
||||
return
|
||||
}
|
||||
|
||||
if (activeEditor) {
|
||||
activeEditor.insertString(token)
|
||||
return
|
||||
}
|
||||
|
||||
document.querySelector("trix-editor")?.editor?.insertString(token)
|
||||
}
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "editor"]
|
||||
static values = { uploadUrl: String }
|
||||
|
||||
connect() {
|
||||
this.onFocus = () => {
|
||||
activeEditor = this.editorTarget.editor
|
||||
}
|
||||
this.onEditorClick = this.onEditorClick.bind(this)
|
||||
this.onHandlePointerDown = this.onHandlePointerDown.bind(this)
|
||||
this.onPointerMove = this.onPointerMove.bind(this)
|
||||
this.onPointerUp = this.onPointerUp.bind(this)
|
||||
|
||||
this.editorTarget.addEventListener("trix-focus", this.onFocus)
|
||||
this.editorTarget.addEventListener("click", this.onEditorClick)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.editorTarget.removeEventListener("trix-focus", this.onFocus)
|
||||
this.editorTarget.removeEventListener("click", this.onEditorClick)
|
||||
this.teardownResize()
|
||||
if (activeEditor === this.editorTarget.editor) activeEditor = null
|
||||
}
|
||||
|
||||
acceptFile(event) {
|
||||
const file = event.file
|
||||
if (file && file.type.startsWith("image/")) return
|
||||
|
||||
event.preventDefault()
|
||||
window.alert("Puoi inserire solo immagini (JPG, PNG, GIF o WebP).")
|
||||
}
|
||||
|
||||
async upload(event) {
|
||||
const attachment = event.attachment
|
||||
if (!attachment.file) return
|
||||
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content
|
||||
const form = new FormData()
|
||||
form.append("file", attachment.file)
|
||||
|
||||
try {
|
||||
const response = await fetch(this.uploadUrlValue, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-CSRF-Token": csrf
|
||||
},
|
||||
body: form
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
attachment.remove()
|
||||
window.alert(data.error || "Upload immagine non riuscito.")
|
||||
return
|
||||
}
|
||||
|
||||
attachment.setAttributes({
|
||||
url: data.url,
|
||||
href: data.url
|
||||
})
|
||||
} catch (_error) {
|
||||
attachment.remove()
|
||||
window.alert("Upload immagine non riuscito.")
|
||||
}
|
||||
}
|
||||
|
||||
onEditorClick(event) {
|
||||
if (event.target.closest(".trix-resize-handle")) return
|
||||
|
||||
const figure = event.target.closest("figure.attachment")
|
||||
if (figure && this.editorTarget.contains(figure) && figure.querySelector("img")) {
|
||||
this.showHandles(figure)
|
||||
return
|
||||
}
|
||||
|
||||
this.clearHandles()
|
||||
}
|
||||
|
||||
showHandles(figure) {
|
||||
this.clearHandles()
|
||||
figure.classList.add("is-resizing")
|
||||
;["nw", "ne", "sw", "se"].forEach((corner) => {
|
||||
const handle = document.createElement("span")
|
||||
handle.className = `trix-resize-handle trix-resize-handle--${corner}`
|
||||
handle.dataset.corner = corner
|
||||
handle.addEventListener("pointerdown", this.onHandlePointerDown)
|
||||
figure.appendChild(handle)
|
||||
})
|
||||
this.resizeFigure = figure
|
||||
}
|
||||
|
||||
clearHandles() {
|
||||
this.editorTarget.querySelectorAll(".trix-resize-handle").forEach((handle) => handle.remove())
|
||||
this.editorTarget.querySelectorAll("figure.attachment.is-resizing").forEach((figure) => {
|
||||
figure.classList.remove("is-resizing")
|
||||
})
|
||||
this.resizeFigure = null
|
||||
}
|
||||
|
||||
onHandlePointerDown(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const figure = event.currentTarget.closest("figure.attachment")
|
||||
const img = figure?.querySelector("img")
|
||||
if (!img) return
|
||||
|
||||
const rect = img.getBoundingClientRect()
|
||||
this.drag = {
|
||||
figure,
|
||||
img,
|
||||
corner: event.currentTarget.dataset.corner,
|
||||
startX: event.clientX,
|
||||
startW: rect.width,
|
||||
ratio: rect.height > 0 ? rect.width / rect.height : 1
|
||||
}
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
window.addEventListener("pointermove", this.onPointerMove)
|
||||
window.addEventListener("pointerup", this.onPointerUp)
|
||||
}
|
||||
|
||||
onPointerMove(event) {
|
||||
const drag = this.drag
|
||||
if (!drag) return
|
||||
|
||||
const dx = event.clientX - drag.startX
|
||||
const grow = drag.corner.includes("e") ? dx : -dx
|
||||
const max = Math.max(80, this.editorTarget.clientWidth - 32)
|
||||
const width = Math.round(Math.min(max, Math.max(64, drag.startW + grow)))
|
||||
const height = Math.round(width / drag.ratio)
|
||||
|
||||
drag.img.style.width = `${width}px`
|
||||
drag.img.style.height = "auto"
|
||||
drag.img.setAttribute("width", String(width))
|
||||
drag.img.setAttribute("height", String(height))
|
||||
drag.width = width
|
||||
drag.height = height
|
||||
}
|
||||
|
||||
onPointerUp() {
|
||||
window.removeEventListener("pointermove", this.onPointerMove)
|
||||
window.removeEventListener("pointerup", this.onPointerUp)
|
||||
this.commitResize()
|
||||
}
|
||||
|
||||
commitResize() {
|
||||
const drag = this.drag
|
||||
this.drag = null
|
||||
if (!drag?.figure || !drag.width) return
|
||||
|
||||
const editor = this.editorTarget.editor
|
||||
editor?.recordUndoEntry?.("Ridimensiona immagine")
|
||||
|
||||
const attachment = this.findAttachment(drag.figure)
|
||||
if (attachment) {
|
||||
attachment.setAttributes({ width: drag.width, height: drag.height })
|
||||
requestAnimationFrame(() => {
|
||||
const next = this.findFigureByUrl(attachment.getURL?.() || attachment.getAttributes?.().url)
|
||||
if (next) this.showHandles(next)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
drag.img.style.width = `${drag.width}px`
|
||||
drag.img.style.height = "auto"
|
||||
this.editorTarget.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
}
|
||||
|
||||
findAttachment(figure) {
|
||||
const raw = figure.getAttribute("data-trix-attachment")
|
||||
if (!raw) return null
|
||||
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch (_error) {
|
||||
return null
|
||||
}
|
||||
|
||||
const attachments = this.editorTarget.editor?.getDocument?.().getAttachments?.() || []
|
||||
return attachments.find((attachment) => {
|
||||
const url = attachment.getURL?.() || attachment.getAttributes?.().url
|
||||
return url && data.url && url === data.url
|
||||
})
|
||||
}
|
||||
|
||||
findFigureByUrl(url) {
|
||||
if (!url) return null
|
||||
return Array.from(this.editorTarget.querySelectorAll("figure.attachment")).find((figure) => {
|
||||
const raw = figure.getAttribute("data-trix-attachment") || ""
|
||||
return raw.includes(url)
|
||||
})
|
||||
}
|
||||
|
||||
teardownResize() {
|
||||
this.clearHandles()
|
||||
window.removeEventListener("pointermove", this.onPointerMove)
|
||||
window.removeEventListener("pointerup", this.onPointerUp)
|
||||
this.drag = null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user