Commit iniziale di eminuxCRM: CRM Rails con pipeline, campagne email e Docker.
CI / scan_ruby (push) Failing after 11m20s
CI / scan_js (push) Successful in 10m35s
CI / lint (push) Has been cancelled

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:
2026-08-17 23:01:48 +02:00
co-authored by Cursor
commit c4e5f289cf
258 changed files with 11293 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
import "trix"
import "@hotwired/turbo-rails"
import "controllers"
if (window.Trix?.config?.lang) {
Object.assign(window.Trix.config.lang, {
attachFiles: "Inserisci immagine",
bold: "Grassetto",
bullets: "Elenco puntato",
captionPlaceholder: "Didascalia…",
code: "Codice",
heading1: "Titolo",
indent: "Aumenta rientro",
italic: "Corsivo",
link: "Link",
numbers: "Elenco numerato",
outdent: "Riduci rientro",
quote: "Citazione",
redo: "Ripeti",
remove: "Rimuovi",
strike: "Barrato",
undo: "Annulla",
unlink: "Togli link",
url: "URL",
urlPlaceholder: "https://…"
})
}
@@ -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!"
}
}
+4
View File
@@ -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
}
}