mirror of
https://github.com/OpenSignLabs/OpenSign.git
synced 2026-08-17 21:25:54 +02:00
Merge pull request #2130 from OpenSignLabs/staging
This commit is contained in:
Generated
+17956
File diff suppressed because it is too large
Load Diff
+44
-23
@@ -1,34 +1,53 @@
|
||||
{
|
||||
"name": "open_sign",
|
||||
"version": "2.35.0",
|
||||
"version": "2.37.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@imgly/background-removal": "^1.7.0",
|
||||
"@lottiefiles/dotlottie-react": "^0.17.13",
|
||||
"@lottiefiles/dotlottie-react": "^0.18.3",
|
||||
"@mui/icons-material": "^5.15.10",
|
||||
"@mui/material": "^5.15.10",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@radix-ui/themes": "^3.3.0",
|
||||
"@reactour/tour": "^3.8.0",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"axios": "^1.13.4",
|
||||
"body-scroll-lock": "^4.0.0-beta.0",
|
||||
"@usewaypoint/block-avatar": "^0.0.3",
|
||||
"@usewaypoint/block-button": "^0.0.3",
|
||||
"@usewaypoint/block-columns-container": "^0.0.3",
|
||||
"@usewaypoint/block-container": "^0.0.2",
|
||||
"@usewaypoint/block-divider": "^0.0.4",
|
||||
"@usewaypoint/block-heading": "^0.0.3",
|
||||
"@usewaypoint/block-html": "^0.0.3",
|
||||
"@usewaypoint/block-image": "^0.0.5",
|
||||
"@usewaypoint/block-spacer": "^0.0.3",
|
||||
"@usewaypoint/block-text": "^0.0.7",
|
||||
"@usewaypoint/document-core": "^0.0.6",
|
||||
"@usewaypoint/email-builder": "^0.0.9",
|
||||
"axios": "^1.13.5",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"i18next": "^25.8.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"highlight.js": "^11.9.0",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"juice": "^11.1.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"moment": "^2.30.1",
|
||||
"parse": "^8.0.3",
|
||||
"parse": "^8.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pkijs": "^3.3.3",
|
||||
"prettier": "^3.8.1",
|
||||
"print-js": "^1.6.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"quill-html-edit-button": "^3.0.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-confetti": "^6.4.0",
|
||||
"react-datepicker": "^9.1.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
@@ -37,23 +56,24 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-gtm-module": "^2.0.11",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-konva": "^19.2.1",
|
||||
"react-pdf": "^10.2.0",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"react-konva": "^19.2.2",
|
||||
"react-pdf": "^10.4.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-router": "^7.13.0",
|
||||
"react-router": "^7.13.1",
|
||||
"react-scrollbars-custom": "^4.1.1",
|
||||
"react-select": "^5.10.2",
|
||||
"react-signature-canvas": "^1.1.0-alpha.2",
|
||||
"react-timezone-select": "^3.2.8",
|
||||
"react-to-print": "^3.2.0",
|
||||
"react-to-print": "^3.3.0",
|
||||
"react-tooltip": "^5.30.0",
|
||||
"redux": "^5.0.1",
|
||||
"regex-parser": "^2.3.1",
|
||||
"serve": "^14.2.5",
|
||||
"web-vitals": "^5.1.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.5.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run version && NODE_OPTIONS=\"--max-old-space-size=8192\" vite build",
|
||||
@@ -100,33 +120,34 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.10",
|
||||
"@vitejs/plugin-react": "^5.1.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"babel-loader": "^10.0.0",
|
||||
"commitizen": "^4.3.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"css-loader": "^7.1.3",
|
||||
"css-loader": "^7.1.4",
|
||||
"daisyui": "^4.12.24",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"jsdom": "^28.0.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"pretty-quick": "^4.2.2",
|
||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tmp": "^0.2.5",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vite-tsconfig-paths": "^6.0.5",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"overrides": {
|
||||
"tmp": "$tmp"
|
||||
"tmp": "$tmp",
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || 22"
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"password-length": "Das Passwort sollte 8 Zeichen lang sein",
|
||||
"password-case": "Das Passwort sollte einen Großbuchstaben, einen Kleinbuchstaben und eine Zahl enthalten",
|
||||
"password-special-char": "Das Passwort sollte ein Sonderzeichen enthalten",
|
||||
"agreee": "Ich stimme den",
|
||||
"agree": "Ich stimme den",
|
||||
"term": "Nutzungsbedingungen zu",
|
||||
"subscribe-to-opensign": "Abonnieren Sie den OpenSign-Newsletter",
|
||||
"register": "Registrieren",
|
||||
@@ -241,7 +241,6 @@
|
||||
"auto-reminder": "Automatische Erinnerung",
|
||||
"remind-once": "Einmal erinnern alle (Tage)",
|
||||
"next": "Weiter",
|
||||
"select-folder": "Ordner auswählen",
|
||||
"OpenSign-drive": "{{appName}} Drive",
|
||||
"drive-document-status": {
|
||||
"Completed": "Abgeschlossen",
|
||||
@@ -296,7 +295,7 @@
|
||||
"edit-team": "Team bearbeiten",
|
||||
"name-of-team": "Name des Teams",
|
||||
"prev": "Zurück",
|
||||
"no-data-avaliable": "Keine Daten verfügbar",
|
||||
"no-data-available": "Keine Daten verfügbar",
|
||||
"add-team": "Team hinzufügen",
|
||||
"page-not-found": "Seite nicht gefunden",
|
||||
"users-from-teams": "Benutzer aus Teams",
|
||||
@@ -307,7 +306,7 @@
|
||||
"delete-user": "Benutzer löschen",
|
||||
"delete": "Löschen",
|
||||
"add-user": "Benutzer hinzufügen",
|
||||
"password-generateed": "Das Passwort wird nur einmal generiert; stellen Sie sicher, dass Sie es kopieren.",
|
||||
"password-generated": "Das Passwort wird nur einmal generiert; stellen Sie sicher, dass Sie es kopieren.",
|
||||
"Team status": "Teamstatus",
|
||||
"user-deactivated": "Benutzer deaktiviert.",
|
||||
"user-activated": "Benutzer aktiviert.",
|
||||
@@ -337,7 +336,7 @@
|
||||
"quick-send-alert-1": "Alle Rollen in diesem Dokument sind derzeit mit Kontakten verknüpft. Um Kopien dieser Vorlage schnell an mehrere Unterzeichner zu senden, stellen Sie sicher, dass mindestens eine Rolle keinem Kontakt zugeordnet ist.",
|
||||
"quick-send-alert-2": "Bitte stellen Sie sicher, dass für alle Empfänger mindestens ein Signatur-Widget hinzugefügt wurde.",
|
||||
"quick-send-alert-3": "Bitte fügen Sie diesem template mindestens eine Rolle hinzu.",
|
||||
"quick-send-alert-4": "Limit für {{flow}} erreicht.",
|
||||
"quick-send-alert-4": "{{flow}} unterstützt bis zu {{count}} Unterzeichner pro Batch. Teilen Sie Ihre Liste auf und versuchen Sie es erneut.",
|
||||
"copy-link": "Link kopieren",
|
||||
"copy": "Kopieren",
|
||||
"revoke-document": "Dokument widerrufen",
|
||||
@@ -438,13 +437,15 @@
|
||||
"reset-password-alert-1": "Ein Link zum Zurücksetzen des Passworts wurde an Ihre E-Mail-Adresse gesendet.",
|
||||
"reset-password-alert-2": "Bitte richten Sie den E-Mail-Adapter ein.",
|
||||
"reset-password-alert-3": "Setzen Sie Ihr Passwort zurück",
|
||||
"faild-animation": "Animation konnte nicht geladen werden",
|
||||
"failed-animation": "Animation konnte nicht geladen werden",
|
||||
"apply": "Anwenden",
|
||||
"select-columns": "Spalten auswählen",
|
||||
"copy-type": {
|
||||
"All pages": "Alle Seiten",
|
||||
"All pages but last": "Alle Seiten außer der letzten",
|
||||
"All pages but first": "Alle Seiten außer der ersten",
|
||||
"Next to current widget": "Neben dem aktuellen Widget"
|
||||
"Next to current widget": "Neben dem aktuellen Widget",
|
||||
"Page range": "Seitenbereich"
|
||||
},
|
||||
"options": "Optionen",
|
||||
"minimum-check": "Minimale Anzahl",
|
||||
@@ -626,7 +627,7 @@
|
||||
"date": "Datum",
|
||||
"report-not-found": "Bericht nicht gefunden",
|
||||
"please-wait": "Bitte warten",
|
||||
"select-foler": "Ordner auswählen",
|
||||
"select-folder": "Ordner auswählen",
|
||||
"generated-on": "Erstellt am",
|
||||
"text-field": "Textfeld",
|
||||
"font-size": "Schriftgröße",
|
||||
@@ -685,13 +686,13 @@
|
||||
"public-template": "Dieses Video zeigt Ihnen, wie Sie Ihr personalisiertes öffentliches Profil einrichten, z. B. 'https://opensign.me/Ihr-Benutzername'. Sie lernen auch, wie Sie Ihren Slogan anpassen und Ihre Vorlagen für öffentliche Signaturen verfügbar machen.",
|
||||
"allowModify-widgets": "Sie können jedes dieser Felder per Drag & Drop in das Dokument ziehen, zusätzlich zu den Feldern, die der Dokumentersteller bereits für Sie festgelegt hat."
|
||||
},
|
||||
"enter-email-plaholder": "E-Mail-Adresse hinzufügen und Enter drücken",
|
||||
"enter-email-placeholder": "E-Mail-Adresse hinzufügen und Enter drücken",
|
||||
"success-email-alert": "E-Mail erfolgreich gesendet!",
|
||||
"expired-doc-title": "Abgelaufenes Dokument",
|
||||
"expired-on-mssg": "Dieses Dokument ist am {{expiredDate}} abgelaufen und nicht mehr verfügbar zum Unterzeichnen.",
|
||||
"signature-validate-alert": "Bitte bestätigen Sie, dass Sie mindestens {{minRequiredCount}} Kontrollkästchen ausgewählt haben.",
|
||||
"signature-validate-alert-2": "Stellen Sie sicher, dass dieses Feld korrekt ausgefüllt ist und alle Anforderungen erfüllt.",
|
||||
"remaing-users": "Verbleibende Benutzer",
|
||||
"remaining-users": "Verbleibende Benutzer",
|
||||
"Role": "Rolle",
|
||||
"additional-users": "Bitte kaufen Sie zusätzliche Benutzer hinzu.",
|
||||
"Quantity-of-users": "Anzahl der Benutzer",
|
||||
@@ -713,7 +714,7 @@
|
||||
"rotate-left": "Nach links drehen",
|
||||
"rotate-alert-mssg": "Alle Widgets auf dieser Seite gehen verloren. Sind Sie sicher, dass Sie fortfahren möchten?",
|
||||
"templateid": "Vorlagen-ID",
|
||||
"bulk-send-subcription-alert": "Bitte upgraden Sie auf den Professional- oder Team-Plan, um den Massenversand zu nutzen.",
|
||||
"bulk-send-subscription-alert": "Bitte upgraden Sie auf den Professional- oder Team-Plan, um den Massenversand zu nutzen.",
|
||||
"generate-test-token": "Test-Token generieren",
|
||||
"regenerate-test-token": "Test-Token neu generieren",
|
||||
"help-test-token": "Dieses Token kann verwendet werden, um die APIs unter https://sandbox.opensignlabs.com/api/v1 zu testen und unbegrenzt Dokumente zu signieren. Beachten Sie, dass die Sandbox-API Ihre Dokumente mit selbstsignierten Zertifikaten signiert, die möglicherweise von Adobe nicht als gültig erkannt werden. Nach Abschluss Ihrer Tests können Sie auf einen unserer kostenpflichtigen Pläne upgraden, um ein Produktionstoken zu generieren.",
|
||||
@@ -733,8 +734,9 @@
|
||||
"copied-code": "KOPIERT",
|
||||
"Installation": "Installation",
|
||||
"Usage": "Nutzung",
|
||||
"insufficient-credits": "Nicht genügend Signatur-Credits",
|
||||
"insufficient-credits-mssg": "Der Besitzer dieses Dokuments verfügt derzeit nicht über die notwendigen OpenSign-Credits, damit Sie unterzeichnen können. Bitte wenden Sie sich an den Besitzer, wenn Sie weitere Informationen benötigen.",
|
||||
"insufficient-credits": "Unzureichende Signaturguthaben. Bitte kontaktieren Sie {{ownerEmail}}",
|
||||
"insufficient-credits-title": "Nicht genügend Signatur-Credits",
|
||||
"insufficient-credits-mssg": "Der Besitzer dieses Dokuments verfügt derzeit nicht über die notwendigen {{appName}}-Credits, damit Sie unterzeichnen können. Bitte wenden Sie sich an den Besitzer, wenn Sie weitere Informationen benötigen.",
|
||||
"angular-npm-mssg-1": "Um OpenSign in Ihr Angular-Projekt zu integrieren, führen Sie einfach den folgenden Befehl aus:",
|
||||
"quota-mail-info-head": "Monatliches E-Mail-Limit",
|
||||
"quota-mail-info": "Um die Servicequalität aufrechtzuerhalten und Spam zu verhindern, erlaubt OpenSign bis zu 15 E-Mails pro Monat im kostenlosen Plan. Upgraden Sie jetzt für unbegrenzten E-Mail-Versand.",
|
||||
@@ -866,7 +868,6 @@
|
||||
"js-snippet-msg-1": "Die TemplateId erhalten Sie von der",
|
||||
"js-snippet-msg-2": "Vorlagen verwalten",
|
||||
"js-snippet-msg-3": "Seite.",
|
||||
"agrrement-alert": "Um fortzufahren, müssen Sie der Offenlegung über elektronische Aufzeichnungen und Unterschriften zustimmen.",
|
||||
"webhook-already-exists": "Die URL existiert bereits! Bitte versuchen Sie eine andere.",
|
||||
"webhook-must-be-secure": "Die Webhook-URL muss sicher sein und https:// verwenden.",
|
||||
"provide-test-token": "Bitte generieren Sie einen API-Test-Token.",
|
||||
@@ -1030,7 +1031,7 @@
|
||||
},
|
||||
"signer-already-present": "Unterzeichner bereits vorhanden",
|
||||
"kiosk-sign": "Kiosk-Unterschrift",
|
||||
"dont-have-access-to-template": "Das template wurde gelöscht oder Sie haben keinen Zugriff. Bitte kontaktieren Sie den Absender.",
|
||||
"do-not-have-access-to-template": "Das template wurde gelöscht oder Sie haben keinen Zugriff. Bitte kontaktieren Sie den Absender.",
|
||||
"kiosk-info": "Kiosk Modus ermöglicht es Ihnen, persönliche Unterschriften schnell und effizient zu erfassen. Ideal für Messen, Veranstaltungen oder Laufkundschaft, bei denen alle Unterzeichner physisch anwesend sind. ",
|
||||
"learn-more": "Mehr erfahren",
|
||||
"finish-mssg": "Sind Sie sicher, dass Sie das Dokument abschließen möchten?",
|
||||
@@ -1086,8 +1087,6 @@
|
||||
"search-contacts": "Kontakte durchsuchen…",
|
||||
"edit-draft": "Entwurf bearbeiten",
|
||||
"add-role-alert": "Bitte fügen Sie mindestens eine Rolle hinzu",
|
||||
"invalid-email-found": "Ungültige E-Mail gefunden: {{email}}",
|
||||
"duplicate-email-found": "Doppelte E-Mail gefunden: {{email}}",
|
||||
"vertical": "Vertikal",
|
||||
"horizontal": "Horizontal",
|
||||
"billing": "Abrechnung",
|
||||
@@ -1101,7 +1100,7 @@
|
||||
"access-denied": "Zugriff verweigert",
|
||||
"upgrade": "Upgrade",
|
||||
"do-not-access-app": "Sie haben keinen Zugriff auf diese Anwendung.",
|
||||
"dont-have-access": "Sie haben keinen Zugriff.",
|
||||
"do-not-have-access": "Sie haben keinen Zugriff.",
|
||||
"valid-email-alert": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
|
||||
"otp-not-validate": "OTP ist ungültig.",
|
||||
"domain-not-allowed": "Diese Domain ist nicht erlaubt",
|
||||
@@ -1118,10 +1117,12 @@
|
||||
"branding-help": "Branding ermöglicht White-Labeling für Ihre App",
|
||||
"custom-sub-domain": "Benutzerdefinierte Subdomain",
|
||||
"app-name": "App Name",
|
||||
"provide-domain-name": "Geben Sie Ihren Domainnamen an",
|
||||
"provide-domain-name": "Es sind nur Subdomains erlaubt (z. B. app.deinedomain.com). Primär-/Root-Domains wie deinedomain.com werden nicht unterstützt.",
|
||||
"provide-app-name-help": "Geben Sie einen eindeutigen App-Namen an. Er wird als Ihr Markenname in der Anwendung sowie in allen E-Mails angezeigt, überall dort, wo das Branding angewendet wird.",
|
||||
"upload-app-logo": "Laden Sie ein Logo im .png-, .jpg- oder .svg-Format hoch. Empfohlene Größe: {{size}} Pixel (Seitenverhältnis 5:2) für beste Anzeigequalität.",
|
||||
"upload-app-favicon": "Laden Sie ein quadratisches Favicon-Bild (.ico, .png oder .svg) hoch. Empfohlene Größe: {{size}} Pixel für beste Browserkompatibilität.",
|
||||
"provide-app-name": "Geben Sie Ihren App-Namen an",
|
||||
"logo": "Logo",
|
||||
"upload-app-logo": "Laden Sie Ihr App-Logo hoch",
|
||||
"prefill-unfilled-widget": "Die folgenden Pflichtfelder dürfen nicht leer sein: {{emptyWidget}}. Bitte füllen Sie diese aus, um fortzufahren.",
|
||||
"Dashboard": "Armaturenbrett",
|
||||
"Analytics": "Analytik",
|
||||
@@ -1345,8 +1346,8 @@
|
||||
"file-selected": "Datei ausgewählt",
|
||||
"bulk-send-csv-helper": "Verwenden Sie Header für Unterzeichnerrollen (Rollen-E-Mail/Name/Telefon) und Vorabfüllwerte (Vorabfüllung: Feldname). Erforderliche Vorabfüllfelder müssen ausgefüllt sein.",
|
||||
"bulk-send-csv-helper-note": "Hinweis: Für Vorabfüllfelder vom Typ Datum in der CSV muss der Wert in folgendem Format angegeben werden: {{value}} (Beispiel: {{example}}).",
|
||||
"bulk-send-csv-summary": "{{total}} Zeilen importiert. {{invalid}} ungültige Zeilen wurden übersprungen.",
|
||||
"bulk-send-csv-missing-headers": "Erforderliche CSV-Header fehlen. Rollen: {{roles}}. Vorabfüllung: {{prefill}}.",
|
||||
"bulk-send-csv-summary": "{{total}} Zeilen aus der CSV importiert.",
|
||||
"bulk-send-csv-missing-headers": "Erforderliche CSV-Header fehlen. Rollen: {{roles}}.",
|
||||
"bulk-send-no-records": "Bitte fügen Sie vor dem Senden mindestens einen Empfänger hinzu.",
|
||||
"csv-only-support": "Es werden nur CSV-Dateien unterstützt.",
|
||||
"csv-no-records": "Keine Datensätze in der CSV gefunden.",
|
||||
@@ -1360,5 +1361,40 @@
|
||||
"enter-daily-email-quota": "Tägliches E-Mail-Kontingent eingeben",
|
||||
"enter-custom-regular-expression": "Benutzerdefinierten regulären Ausdruck eingeben",
|
||||
"enter-value": "{{value}} eingeben",
|
||||
"are-you-sure-want-to-send-document": "Sind Sie sicher, dass Sie das Dokument senden möchten?"
|
||||
"are-you-sure-want-to-send-document": "Sind Sie sicher, dass Sie das Dokument senden möchten?",
|
||||
"favicon": "Favicon",
|
||||
"delete-two-factor-authentication": "Sind Sie sicher, dass Sie die Zwei-Faktor-Authentifizierung löschen möchten?",
|
||||
"paste-html-here": "HTML hier einfügen",
|
||||
"open-email-builder": "Sie können eine E-Mail-Vorlage mit der <1>E-Mail-Builder</1>-Plattform erstellen und den HTML-Code kopieren.",
|
||||
"contact-saved": "Kontakt erfolgreich gespeichert.",
|
||||
"remove-limit-of-user": "Sind Sie sicher, dass Sie das Limit dieses Benutzers aufheben möchten? Der Benutzer kann dann alle in Ihrer Organisation verfügbaren Premium-Credits nutzen, die unter allen Benutzern geteilt werden.",
|
||||
"current-expiry-date": "Aktuelles Ablaufdatum",
|
||||
"reminder-error": "„Einmal alle (Tage) erinnern“ darf die „Zeit zur Fertigstellung (Tage)“ nicht überschreiten.",
|
||||
"showing-to-of-records": "Zeige {{record}} bis {{recordPerPage}} von {{totalRows}} Einträgen",
|
||||
"records-per-page": "Einträge pro Seite",
|
||||
"email-not-found-in-row": "E-Mail in Zeile {{row}} nicht gefunden.",
|
||||
"invalid-email-found-in-row": "Ungültige E-Mail in Zeile {{row}} gefunden: {{email}}",
|
||||
"duplicate-email-found-in-row": "Doppelte E-Mail in Zeile {{row}} gefunden: {{email}}",
|
||||
"prefill": "Vorabfüllung",
|
||||
"recipients-&-data": "Empfänger & Daten",
|
||||
"response": "Antwort",
|
||||
"prefill-tab-help": "Dies sind die gemeinsamen Vorlagenfelder, die auf alle Dokumente in diesem Massenversand angewendet werden.",
|
||||
"recipient-tab-help-p1": "Im nächsten Schritt werden Sie:",
|
||||
"recipient-tab-help-p2": "Eine CSV mit Empfängerdetails hochladen oder",
|
||||
"recipient-tab-help-p3": "Empfängerinformationen manuell eingeben",
|
||||
"recipient-tab-help-p4": "Jeder Empfänger erhält sein eigenes Dokument, das aus dieser Vorlage generiert wird.",
|
||||
"response-tab-help": "Überprüfen Sie Ihre Antworten.",
|
||||
"summary": "Zusammenfassung",
|
||||
"prefill-fields": "Vorabfüllfelder",
|
||||
"documents": "Dokumente",
|
||||
"message": "Nachricht",
|
||||
"formula-help-p1": "Erfassen Sie numerische Eingaben und führen Sie dynamische Berechnungen mit Formeln durch.",
|
||||
"example": "Beispiel",
|
||||
"formula-help-p2": "Um eine Summe in number-3 anzuzeigen, verwenden Sie {{number-1}} + {{number-2}}.",
|
||||
"formula-help-p3": "Um einen Wert in number-4 zu berechnen, verwenden Sie ({{number-1}} * {{number-2}}) / {{number-3}}.",
|
||||
"formula-help-p4": "Jedes Zahlen-Widget muss einen eindeutigen Namen haben (z. B. number-1), da Formeln auf diesen Bezeichnern basieren.",
|
||||
"note-consume-premium-credits": "Hinweis: Diese Funktion verbraucht Premium-Credits.",
|
||||
"note-consume-premium-credits-on-document-send": "Hinweis: Diese Funktion verbraucht Premium-Credits. Sie werden jedes Mal verbraucht, wenn ein Dokument versendet wird – unabhängig davon, ob es unterschrieben wird oder nicht.",
|
||||
"webhook-authentication-help": "{{appName}} sichert jeden Webhook, indem eine HMAC-SHA256-Signatur mit Ihrem Webhook-Sicherheitsschlüssel und dem unveränderten Request-Payload generiert wird. Diese Signatur wird im Header x-webhook-signature gesendet. Ihr Server muss dieselbe Signatur mit dem empfangenen Payload und Ihrem geheimen Schlüssel generieren. Stimmen beide Signaturen überein, ist die Webhook-Anfrage authentisch und wurde nicht verändert.",
|
||||
"invalid-widget-found-in-row": "Ungültiger Wert in Zeile {{row}} für Widget {{widget}} erkannt."
|
||||
}
|
||||
@@ -68,14 +68,14 @@
|
||||
"loading": "Loading...",
|
||||
"of": "of",
|
||||
"sign-SSO": "Sign in with SSO",
|
||||
"login-page": "Login P+age",
|
||||
"login-page": "Login Page",
|
||||
"or": "OR",
|
||||
"signup-page": "Signup page",
|
||||
"password-match-length": "New password and confirmation password should match",
|
||||
"password-length": "Password should be 8 characters long",
|
||||
"password-case": "Password should contain uppercase letter, lowercase letter, digit",
|
||||
"password-special-char": " Password should contain special character",
|
||||
"agreee": "I agree to the",
|
||||
"agree": "I agree to the",
|
||||
"term": "Terms of Service",
|
||||
"subscribe-to-opensign": "Subscribe to OpenSign newsletter",
|
||||
"register": "Register",
|
||||
@@ -241,7 +241,6 @@
|
||||
"auto-reminder": "Auto reminder",
|
||||
"remind-once": " Remind once in every (Days)",
|
||||
"next": "Next",
|
||||
"select-folder": "Select folder",
|
||||
"OpenSign-drive": "{{appName}} Drive",
|
||||
"drive-document-status": {
|
||||
"Completed": "Completed",
|
||||
@@ -296,7 +295,7 @@
|
||||
"edit-team": "Edit team",
|
||||
"name-of-team": "Name of team",
|
||||
"prev": "Prev",
|
||||
"no-data-avaliable": "No data available",
|
||||
"no-data-available": "No data available",
|
||||
"add-team": "Add team",
|
||||
"page-not-found": "Page not found",
|
||||
"users-from-teams": "users from Teams",
|
||||
@@ -307,7 +306,7 @@
|
||||
"delete-user": "Delete user",
|
||||
"delete": "Delete",
|
||||
"add-user": "Add user",
|
||||
"password-generateed": "Password will only be generated once; make sure to copy it.",
|
||||
"password-generated": "Password will only be generated once; make sure to copy it.",
|
||||
"Team status": "Team status",
|
||||
"user-deactivated": "User deactivated.",
|
||||
"user-activated": "User activated.",
|
||||
@@ -337,7 +336,7 @@
|
||||
"quick-send-alert-1": "All roles in this document are currently linked to contacts. To quick send copies of this template to multiple signers, please ensure that at least one role is not linked to any contact.",
|
||||
"quick-send-alert-2": "Please ensure there's at least one signature widget added for all recipients.",
|
||||
"quick-send-alert-3": "Please add at least one role to this template.",
|
||||
"quick-send-alert-4": "Limit reached for {{flow}}.",
|
||||
"quick-send-alert-4": "{{flow}} supports up to {{count}} signers per batch. Split your list and try again.",
|
||||
"copy-link": "Copy link",
|
||||
"copy": "Copy",
|
||||
"revoke-document": "Revoke document",
|
||||
@@ -348,7 +347,7 @@
|
||||
"body": "Body",
|
||||
"add-contact": "Add contact",
|
||||
"edit-contact": "Edit contact",
|
||||
"add-signer-alert": "Contact already exist! Please select it from 'Signers' dropdown",
|
||||
"add-signer-alert": "Contact already exists! Please select it from 'Signers' dropdown",
|
||||
"record-delete-alert": "Record deleted successfully!",
|
||||
"record-revoke-alert": "Record revoked successfully!",
|
||||
"mail-sent-alert": "Mail sent successfully.",
|
||||
@@ -438,14 +437,15 @@
|
||||
"reset-password-alert-1": "Reset password link has been sent to your email id",
|
||||
"reset-password-alert-2": "Please setup email adapter ",
|
||||
"reset-password-alert-3": "Reset Your Password",
|
||||
"faild-animation": "Failed to load animation",
|
||||
"failed-animation": "Failed to load animation",
|
||||
"apply": "Apply",
|
||||
"select-columns": "Select columns",
|
||||
"copy-type": {
|
||||
"All pages": "All pages",
|
||||
"All pages but last": "All pages but last",
|
||||
"All pages but first": "All pages but first",
|
||||
"Next to current widget": "Next to current widget"
|
||||
"Next to current widget": "Next to current widget",
|
||||
"Page range": "Page range"
|
||||
},
|
||||
"options": "Options",
|
||||
"minimum-check": "Minimum check",
|
||||
@@ -525,7 +525,7 @@
|
||||
"email-body": "email body",
|
||||
"email-placeholder": "add body of email",
|
||||
"reset-to-default": "Reset to default",
|
||||
"customize-email": "Customize email",
|
||||
"customize-email": "Customize email",
|
||||
"upgrade-to-customize-email": "Upgrade to customize Email",
|
||||
"sign-url": "Sign url",
|
||||
"mails-sent": "Mails Sent",
|
||||
@@ -542,12 +542,12 @@
|
||||
"sign here": "Sign here",
|
||||
"guest-email-alert": "verification code is sent to your email",
|
||||
"get-otp-alert": "you will get a verification code via Email",
|
||||
"user-already-exist": "User already exist.",
|
||||
"user-already-exist": "User already exists.",
|
||||
"server-error": "Internal server error !",
|
||||
"fill-required-details!": "Please fill required details!",
|
||||
"sign-up-form": "Sign up form",
|
||||
"sign-up": "Sign up",
|
||||
"folder-already-exist!": "folder already exist!",
|
||||
"folder-already-exist!": "folder already exists!",
|
||||
"please-select-position!": "please select position!",
|
||||
"default-sign-alert": "Are you sure you want to auto-sign all requested {{widgetsType}} widgets?",
|
||||
"Your-Signature": "Your signature",
|
||||
@@ -556,7 +556,7 @@
|
||||
"pdf-download": "PDF download",
|
||||
"generate-certificate-alert": " Your completion certificate is being generated. Please wait momentarily. If the download doesn't start shortly, click the button again.",
|
||||
"generate-certificate-err": "We are currently experiencing an issue with certificate generation. Please try again later.",
|
||||
"folder-already-exist": "Folder already exist!",
|
||||
"folder-already-exist": "Folder already exists!",
|
||||
"folder-created-successfully": "Folder created successfully!",
|
||||
"fill-folder-name": "Please fill folder name",
|
||||
"already-exists-this-username": "User already exists with this username!",
|
||||
@@ -567,7 +567,7 @@
|
||||
"add-admin": "Add admin",
|
||||
"opensign-setup": "{{appName}} setup",
|
||||
"join-discord": "Join our discord server",
|
||||
"admin-already-exist": "Admin already exist.",
|
||||
"admin-already-exist": "Admin already exists.",
|
||||
"password-update-alert-1": "Password updated successfully.",
|
||||
"password-update-alert-2": "Your current password is missing or incorrect.",
|
||||
"password-update-alert-3": "Your current password is missing or incorrect.",
|
||||
@@ -627,7 +627,7 @@
|
||||
"date": "Date",
|
||||
"report-not-found": "Report not found",
|
||||
"please-wait": "please wait",
|
||||
"select-foler": "Select folder",
|
||||
"select-folder": "Select folder",
|
||||
"generated-on": "Generated on",
|
||||
"text-field": "Text field",
|
||||
"font-size": "Font size",
|
||||
@@ -686,13 +686,13 @@
|
||||
"public-template": "This video demonstrates how to set up your personalized public profile, such as 'https://opensign.me/your-username'. You'll also learn how to customize your tagline and make your templates available for public signing.",
|
||||
"allowModify-widgets": "You can drag and drop any of these fields onto the document, in addition to the fields already designated for you by the document creator."
|
||||
},
|
||||
"enter-email-plaholder": "Add an email address and hit enter",
|
||||
"enter-email-placeholder": "Add an email address and hit enter",
|
||||
"success-email-alert": "Email sent successfully!",
|
||||
"expired-doc-title": "Expired document",
|
||||
"expired-on-mssg": "This document expired on {{expiredDate}} and is no longer available to sign.",
|
||||
"signature-validate-alert": "Please confirm that you have selected at least {{minRequiredCount}} checkboxes.",
|
||||
"signature-validate-alert-2": "Ensure this field is accurately filled and meets all requirements.",
|
||||
"remaing-users": "Remaining users",
|
||||
"remaining-users": "Remaining users",
|
||||
"Role": "Role",
|
||||
"additional-users": "Please purchase add-on users.",
|
||||
"Quantity-of-users": "Quantity of users",
|
||||
@@ -701,7 +701,7 @@
|
||||
"Proceed": "Proceed",
|
||||
"Select": "Select",
|
||||
"Add-seats": "Add seats",
|
||||
"user-name-exist": "user name already exist",
|
||||
"user-name-exist": "user name already exists",
|
||||
"format": "format",
|
||||
"select-date-format": "Select a date format",
|
||||
"quantity-of-credits": "Quantity of premium credits",
|
||||
@@ -714,7 +714,7 @@
|
||||
"rotate-left": "Rotate left",
|
||||
"rotate-alert-mssg": "All widgets on this page will be lost. Are you sure you want to proceed?",
|
||||
"templateid": "Template-Id",
|
||||
"bulk-send-subcription-alert": "Please upgrade to Professional or Team plan to use bulk send.",
|
||||
"bulk-send-subscription-alert": "Please upgrade to Professional or Team plan to use bulk send.",
|
||||
"generate-test-token": "Generate test token",
|
||||
"regenerate-test-token": "Regenerate test token",
|
||||
"help-test-token": "This token can be used to test the APIs at the https://sandbox.opensignlabs.com/api/v1 endpoint, allowing you to conduct unlimited document signatures. Please note that the sandbox API will sign your documents with self-signed certificates, which may not be recognized as valid by Adobe. Once you've completed your testing, you can upgrade to one of our paid plans to generate a production token.",
|
||||
@@ -734,8 +734,9 @@
|
||||
"copied-code": "COPIED",
|
||||
"Installation": "Installation",
|
||||
"Usage": "Usage",
|
||||
"insufficient-credits": "Insufficient signing credits",
|
||||
"insufficient-credits-mssg": "The owner of this document currently lacks the necessary OpenSign credits for you to sign. Please reach out to the owner if you require further details.",
|
||||
"insufficient-credits": "Insufficient signing credits. Please contact {{ownerEmail}}",
|
||||
"insufficient-credits-title": "Insufficient signing credits",
|
||||
"insufficient-credits-mssg": "The owner of this document currently lacks the necessary {{appName}} credits for you to sign. Please reach out to the owner if you require further details.",
|
||||
"angular-npm-mssg-1": "To integrate OpenSign into your Angular project, simply run the following command:",
|
||||
"quota-mail-info-head": "Monthly Email Limit",
|
||||
"quota-mail-info": "To maintain service quality and prevent spam, OpenSign allows up to 15 emails per month on the free plan. Upgrade now for unlimited email sending.",
|
||||
@@ -867,7 +868,6 @@
|
||||
"js-snippet-msg-1": "You can get the TemplateId from the",
|
||||
"js-snippet-msg-2": "Manage Templates",
|
||||
"js-snippet-msg-3": "page.",
|
||||
"agrrement-alert": "To proceed, you must consent to the electronic record and signature disclosure.",
|
||||
"webhook-already-exists": "The URL already exists! Please try a different one.",
|
||||
"webhook-must-be-secure": "The webhook URL must be secure and use https://",
|
||||
"provide-test-token": "Please generate api test token",
|
||||
@@ -1031,7 +1031,7 @@
|
||||
},
|
||||
"signer-already-present": "Signer already present",
|
||||
"kiosk-sign": "Kiosk Sign",
|
||||
"dont-have-access-to-template": "The template has been deleted or you don't have access. Please contact the sender.",
|
||||
"do-not-have-access-to-template": "The template has been deleted or you don't have access. Please contact the sender.",
|
||||
"kiosk-info": "Kiosk Mode lets you collect in-person signatures quickly and efficiently. Ideal for trade shows, events, or walk-in scenarios where all signers are physically present. ",
|
||||
"learn-more": "Learn more",
|
||||
"finish-mssg": " Are you sure you want to finish the document ?",
|
||||
@@ -1087,8 +1087,6 @@
|
||||
"search-contacts": "Search contacts…",
|
||||
"add-role-alert": "Please add at least one role",
|
||||
"edit-draft": "Edit draft",
|
||||
"invalid-email-found": "Invalid email found: {{email}}",
|
||||
"duplicate-email-found": "Duplicate email found: {{email}}",
|
||||
"vertical": "Vertical",
|
||||
"horizontal": "Horizontal",
|
||||
"billing": "Billing",
|
||||
@@ -1102,7 +1100,7 @@
|
||||
"access-denied": "Access denied",
|
||||
"upgrade": "Upgrade",
|
||||
"do-not-access-app": "You don't have access to this application.",
|
||||
"dont-have-access": "You don't have access.",
|
||||
"do-not-have-access": "You don't have access.",
|
||||
"valid-email-alert": "Please enter a valid email address.",
|
||||
"otp-not-validate": "OTP is not valid.",
|
||||
"domain-not-allowed": "This domain is not allowed",
|
||||
@@ -1119,10 +1117,12 @@
|
||||
"branding-help": "Branding provides white labelling to your app",
|
||||
"custom-sub-domain": "Custom sub-domain",
|
||||
"app-name": "App Name",
|
||||
"provide-domain-name": "provide your domain name",
|
||||
"provide-domain-name": "Only subdomains are allowed (e.g., app.yourdomain.com). Primary/root domains like yourdomain.com are not supported.",
|
||||
"provide-app-name-help": "Provide a unique app name. It will be displayed as your brand name within the application and in all emails wherever the branding is applied.",
|
||||
"upload-app-logo": "Upload a logo in .png, .jpg, or .svg format. Recommended size: {{size}} pixels (5:2 aspect ratio) for best display quality.",
|
||||
"upload-app-favicon": "Upload a square favicon image (.ico, .png, or .svg). Recommended size is {{size}} pixels for best browser compatibility.",
|
||||
"provide-app-name": "provide your app name",
|
||||
"logo": "Logo",
|
||||
"upload-app-logo": "upload your app logo",
|
||||
"prefill-unfilled-widget": "The following required field(s) cannot be left empty: {{emptyWidget}} Please fill them out to proceed.",
|
||||
"Dashboard": "Dashboard",
|
||||
"Analytics": "Analytics",
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"file-selected": "file selected",
|
||||
"bulk-send-csv-helper": "Use headers for signer roles (Role Email/Name/Phone) and prefill values (Prefill: Field Name). Required prefill fields must be completed.",
|
||||
"bulk-send-csv-helper-note": "Note: For Date-type prefill fields in the CSV, the value must be provided in this format: {{value}} (example: {{example}}).",
|
||||
"bulk-send-csv-summary": "Imported {{total}} rows. {{invalid}} invalid rows were skipped.",
|
||||
"bulk-send-csv-missing-headers": "Missing required CSV headers. Roles: {{roles}}. Prefill: {{prefill}}.",
|
||||
"bulk-send-csv-summary": "Imported {{total}} rows from CSV.",
|
||||
"bulk-send-csv-missing-headers": "Missing required CSV headers. Roles: {{roles}}.",
|
||||
"bulk-send-no-records": "Please add at least one recipient before sending.",
|
||||
"csv-only-support": "Only CSV files are supported.",
|
||||
"csv-no-records": "No records found in the CSV.",
|
||||
@@ -1361,5 +1361,40 @@
|
||||
"enter-daily-email-quota": "Enter daily email quota",
|
||||
"enter-custom-regular-expression": "Enter custom regular expression",
|
||||
"enter-value": "Enter {{value}}",
|
||||
"are-you-sure-want-to-send-document": "Are you sure want to send document?"
|
||||
"are-you-sure-want-to-send-document": "Are you sure want to send document?",
|
||||
"favicon": "Favicon",
|
||||
"delete-two-factor-authentication": "Are you sure you want to delete two-factor authentication?",
|
||||
"paste-html-here": "Paste HTML Here",
|
||||
"open-email-builder": "You can create email template using <1>email builder</1> platform and copy html code.",
|
||||
"contact-saved": "Contact saved successfully.",
|
||||
"remove-limit-of-user": "Are you sure you want to remove this user's limit? They will be able to consume all your organization's available premium credits that are shared across all users.",
|
||||
"current-expiry-date": "Current expiry date",
|
||||
"reminder-error": "Remind once in every (Days) must not exceed Time to complete (Days)",
|
||||
"showing-to-of-records": "Showing {{record}} to {{recordPerPage}} of {{totalRows}} records",
|
||||
"records-per-page": "Records per page",
|
||||
"email-not-found-in-row": "Email not found in row {{row}}",
|
||||
"invalid-email-found-in-row": "Invalid email found in row {{row}}: {{email}}",
|
||||
"duplicate-email-found-in-row": "Duplicate email found in row {{row}}: {{email}}",
|
||||
"prefill": "Prefill",
|
||||
"recipients-&-data": "Recipients & Data",
|
||||
"response": "Response",
|
||||
"prefill-tab-help": "These are the common template fields that will apply to all documents in this bulk send.",
|
||||
"recipient-tab-help-p1": "In this next step, you'll",
|
||||
"recipient-tab-help-p2": "Upload a CSV with recipient details or",
|
||||
"recipient-tab-help-p3": "Manually enter recipient information",
|
||||
"recipient-tab-help-p4": "Each recipient will receive their own document generated from this template.",
|
||||
"response-tab-help": "Review your responses.",
|
||||
"summary": "Summary",
|
||||
"prefill-fields": "Prefill fields",
|
||||
"documents": "Documents",
|
||||
"message": "Message",
|
||||
"formula-help-p1": "Collect numeric input and perform dynamic calculations using formulas.",
|
||||
"example": "Example",
|
||||
"formula-help-p2": "To display a total in number-3, use {{number-1}} + {{number-2}}.",
|
||||
"formula-help-p3": "To calculate a value in number-4, use ({{number-1}} * {{number-2}}) / {{number-3}}.",
|
||||
"formula-help-p4": "Each number widget must have a unique name (e.g., number-1), as formulas rely on these identifiers.",
|
||||
"note-consume-premium-credits": "Note: This feature consumes premium credits.",
|
||||
"note-consume-premium-credits-on-document-send": "Note: This feature consumes premium credits. It is consumed any time a document is sent out, regardless of whether it is signed or not.",
|
||||
"webhook-authentication-help": "{{appName}} secures every webhook by generating an HMAC-SHA256 signature using your webhook security key and the raw request payload. This signature is sent in the x-webhook-signature header. Your server must generate the same signature using the received payload and your secret key. If both signatures match, the webhook request is authentic and has not been modified.",
|
||||
"invalid-widget-found-in-row": "Invalid value detected in row {{row}} for widget {{widget}}."
|
||||
}
|
||||
@@ -75,7 +75,7 @@
|
||||
"password-length": "La contraseña debe tener 8 caracteres",
|
||||
"password-case": "La contraseña debe incluir mayúsculas, minúsculas y números",
|
||||
"password-special-char": " La contraseña debe incluir caracteres especiales",
|
||||
"agreee": "Estoy de acuerdo con los",
|
||||
"agree": "Estoy de acuerdo con los",
|
||||
"term": "Términos de Servicio",
|
||||
"subscribe-to-opensign": "Suscribirse al boletín de OpenSign",
|
||||
"register": "Registrarse",
|
||||
@@ -241,7 +241,6 @@
|
||||
"auto-reminder": "Recordatorio automático",
|
||||
"remind-once": " Recordar una vez cada (Días)",
|
||||
"next": "Siguiente",
|
||||
"select-folder": "Seleccionar carpeta",
|
||||
"OpenSign-drive": "{{appName}} Drive",
|
||||
"drive-document-status": {
|
||||
"Completed": "Completado",
|
||||
@@ -296,7 +295,7 @@
|
||||
"edit-team": "Editar equipo",
|
||||
"name-of-team": "Nombre del equipo",
|
||||
"prev": "Anterior",
|
||||
"no-data-avaliable": "No hay datos disponibles",
|
||||
"no-data-available": "No hay datos disponibles",
|
||||
"add-team": "Agregar equipo",
|
||||
"page-not-found": "Página no encontrada",
|
||||
"users-from-teams": "usuarios de equipos",
|
||||
@@ -307,7 +306,7 @@
|
||||
"delete-user": "Eliminar usuario",
|
||||
"delete": "Eliminar",
|
||||
"add-user": "Agregar usuario",
|
||||
"password-generateed": "La contraseña solo será generada una vez; asegúrate de copiarla.",
|
||||
"password-generated": "La contraseña solo será generada una vez; asegúrate de copiarla.",
|
||||
"Team status": "Estado del equipo",
|
||||
"user-deactivated": "Usuario desactivado.",
|
||||
"user-activated": "Usuario activado.",
|
||||
@@ -337,7 +336,7 @@
|
||||
"quick-send-alert-1": "Todos los roles de este documento están actualmente vinculados a contactos. Para enviar rápidamente copias de esta plantilla a varios firmantes, por favor, asegúrate de que al menos un rol no esté vinculado a ningún contacto.",
|
||||
"quick-send-alert-2": "Por favor, asegúrate de que hay al menos un widget de firma añadido para cada destinatario.",
|
||||
"quick-send-alert-3": "Veuillez ajouter au moins un rôle à ce template.",
|
||||
"quick-send-alert-4": "Se alcanzó el límite para {{flow}}.",
|
||||
"quick-send-alert-4": "{{flow}} admite hasta {{count}} firmantes por lote. Divida su lista e inténtelo nuevamente.",
|
||||
"copy-link": "Copiar enlace",
|
||||
"copy": "Copiar",
|
||||
"revoke-document": "Revocar documento",
|
||||
@@ -438,14 +437,15 @@
|
||||
"reset-password-alert-1": "El enlace para restablecer la contraseña ha sido enviado a tu correo electrónico",
|
||||
"reset-password-alert-2": "Por favor, configura un adaptador de correo ",
|
||||
"reset-password-alert-3": "Restablece tu contraseña",
|
||||
"faild-animation": "Error al cargar la animación",
|
||||
"failed-animation": "Error al cargar la animación",
|
||||
"apply": "Aplicar",
|
||||
"select-columns": "Seleccionar columnas",
|
||||
"copy-type": {
|
||||
"All pages": "Todas las páginas",
|
||||
"All pages but last": "Todas las páginas menos la última",
|
||||
"All pages but first": "Todas las páginas menos la primera",
|
||||
"Next to current widget": "Junto al widget actual"
|
||||
"Next to current widget": "Junto al widget actual",
|
||||
"Page range": "Rango de páginas"
|
||||
},
|
||||
"options": "Opciones",
|
||||
"minimum-check": "Chequeo mínimo",
|
||||
@@ -627,7 +627,7 @@
|
||||
"date": "Fecha",
|
||||
"report-not-found": "Reporte no encontrado",
|
||||
"please-wait": "por favor, espera",
|
||||
"select-foler": "Seleccionar carpeta",
|
||||
"select-folder": "Seleccionar carpeta",
|
||||
"generated-on": "Generado el",
|
||||
"text-field": "Campo de texto",
|
||||
"font-size": "Tamaño de la fuente",
|
||||
@@ -686,13 +686,13 @@
|
||||
"public-template": "Este video muestra cómo puedes configurar tu perfil público personalizado, como «https://opensign.me/tu-nombre-de-usuario». También aprenderás cómo personalizar tu eslogan y cómo hacer tus plantillas disponibles para su firmado público.",
|
||||
"allowModify-widgets": "Puede arrastrar y soltar cualquiera de estos campos en el documento, además de los campos ya designados para usted por el creador del documento"
|
||||
},
|
||||
"enter-email-plaholder": "Agrega una dirección de correo y presiona «Enter»",
|
||||
"enter-email-placeholder": "Agrega una dirección de correo y presiona «Enter»",
|
||||
"success-email-alert": "¡Correo enviado exitosamente!",
|
||||
"expired-doc-title": "Documento expirado",
|
||||
"expired-on-mssg": "Este documento expiro el {{expiredDate}} y ya no está disponible para ser firmado.",
|
||||
"signature-validate-alert": "Por favor, confirma que has seleccionado al menos {{minRequiredCount}} casillas.",
|
||||
"signature-validate-alert-2": "Asegúrate de que este campo esté correctamente rellenado y que cumpla con todos los requisitos.",
|
||||
"remaing-users": "Usuarios restantes",
|
||||
"remaining-users": "Usuarios restantes",
|
||||
"Role": "Rol",
|
||||
"additional-users": "Por favor, compra el complemento Usuarios.",
|
||||
"Quantity-of-users": "Cantidad de usuarios",
|
||||
@@ -714,7 +714,7 @@
|
||||
"rotate-left": "Rotar a la izquierda",
|
||||
"rotate-alert-mssg": "Todos los widgets de esta página se perderán. ¿En definitiva quieres proceder?",
|
||||
"templateid": "ID de plantilla",
|
||||
"bulk-send-subcription-alert": "Por favor, mejora al plan a Professional o Team para usar envío en masa.",
|
||||
"bulk-send-subscription-alert": "Por favor, mejora al plan a Professional o Team para usar envío en masa.",
|
||||
"generate-test-token": "Generar token de prueba",
|
||||
"regenerate-test-token": "Regenerar token de prueba",
|
||||
"help-test-token": "Este token puede ser usado para probar las API en el punto de acceso «https://sandbox.opensignlabs.com/api/v1», permitiéndote realizar firmas de documentos ilimitadas. Por favor, ten en cuenta que el API de pruebas firmará tus documentos con certificados autofirmados, que podrían no ser reconocidos por Adobe como válidos. Una vez hayas completado tus pruebas, puedes mejorar a uno de nuestros planes de pago para generar un token de producción.",
|
||||
@@ -734,8 +734,9 @@
|
||||
"copied-code": "COPIADO",
|
||||
"Installation": "Instalación",
|
||||
"Usage": "Uso",
|
||||
"insufficient-credits": "Créditos de firma insuficientes",
|
||||
"insufficient-credits-mssg": "El propietario de este documento actualmente carece de los créditos OpenSign necesarios para que puedas firmarlo. Por favor, contacta al propietario si necesitas más detalles.",
|
||||
"insufficient-credits": "Créditos de firma insuficientes. Por favor contacte con {{ownerEmail}}",
|
||||
"insufficient-credits-title": "Créditos de firma insuficientes",
|
||||
"insufficient-credits-mssg": "El propietario de este documento actualmente carece de los créditos {{appName}} necesarios para que puedas firmarlo. Por favor, contacta al propietario si necesitas más detalles.",
|
||||
"angular-npm-mssg-1": "Para integrar OpenSign a tu proyecto Angular, simplemente ejecuta los siguientes comandos:",
|
||||
"quota-mail-info-head": "Límite mensual de correos electrónicos",
|
||||
"quota-mail-info": "Para mantener la calidad del servicio y prevenir el spam, OpenSign permite hasta 15 correos electrónicos por mes en el plan gratuito. Mejora tu plan ahora para envíos ilimitados de correos electrónicos.",
|
||||
@@ -867,7 +868,6 @@
|
||||
"js-snippet-msg-1": "Puede obtener el TemplateId en la página Administrar plantillas",
|
||||
"js-snippet-msg-2": "Administrar plantillas",
|
||||
"js-snippet-msg-3": "página.",
|
||||
"agrrement-alert": "Para continuar, debe consentir la divulgación de registros y firmas electrónicas.",
|
||||
"webhook-already-exists": "¡La URL ya existe! Pruebe con uno diferente.",
|
||||
"webhook-must-be-secure": "La URL del webhook debe ser segura y utilizar https://",
|
||||
"provide-test-token": "Por favor genere un token de prueba de API",
|
||||
@@ -1031,7 +1031,7 @@
|
||||
},
|
||||
"signer-already-present": "Firmante ya presente",
|
||||
"kiosk-sign": "Firma en quiosco",
|
||||
"dont-have-access-to-template": "El template ha sido eliminado o no tiene acceso. Por favor, contacte al remitente.",
|
||||
"do-not-have-access-to-template": "El template ha sido eliminado o no tiene acceso. Por favor, contacte al remitente.",
|
||||
"kiosk-info": "El Modo Kiosco le permite recopilar firmas en persona de forma rápida y eficiente. Ideal para ferias, eventos o situaciones con personas que firman en el lugar. ",
|
||||
"learn-more": "Más información",
|
||||
"finish-mssg": "¿Está seguro de que desea finalizar el documento?",
|
||||
@@ -1085,8 +1085,6 @@
|
||||
"search-templates": "Buscar plantillas…",
|
||||
"search-documents": "Buscar documentos…",
|
||||
"search-contacts": "Buscar contactos…",
|
||||
"invalid-email-found": "Correo electrónico no válido encontrado: {{email}}",
|
||||
"duplicate-email-found": "Correo electrónico duplicado encontrado: {{email}}",
|
||||
"vertical": "Vertical",
|
||||
"add-role-alert": "Por favor, agregue al menos un rol",
|
||||
"edit-draft": "Editar borrador",
|
||||
@@ -1102,7 +1100,7 @@
|
||||
"access-denied": "Acceso denegado",
|
||||
"upgrade": "Actualizar",
|
||||
"do-not-access-app": "No tienes acceso a esta aplicación.",
|
||||
"dont-have-access": "No tienes acceso.",
|
||||
"do-not-have-access": "No tienes acceso.",
|
||||
"valid-email-alert": "Por favor ingresa una dirección de correo electrónico válida.",
|
||||
"otp-not-validate": "OTP no es válido.",
|
||||
"domain-not-allowed": "Este dominio no está permitido",
|
||||
@@ -1119,10 +1117,12 @@
|
||||
"branding-help": "El branding permite el white labelling de su aplicación",
|
||||
"custom-sub-domain": "Subdominio personalizado",
|
||||
"app-name": "Nombre de la aplicación",
|
||||
"provide-domain-name": "Proporcione su nombre de dominio",
|
||||
"provide-domain-name": "Solo se permiten subdominios (p. ej., app.tudominio.com). No se admiten dominios principales/raíz como tudominio.com.",
|
||||
"provide-app-name-help": "Indique un nombre único para la aplicación. Se mostrará como el nombre de su marca dentro de la aplicación y en todos los correos electrónicos donde se aplique la marca.",
|
||||
"upload-app-logo": "Sube un logotipo en formato .png, .jpg o .svg. Tamaño recomendado: {{size}} píxeles (relación de aspecto 5:2) para la mejor calidad de visualización.",
|
||||
"upload-app-favicon": "Sube una imagen cuadrada de favicon (.ico, .png o .svg). El tamaño recomendado es {{size}} píxeles para la mejor compatibilidad con el navegador.",
|
||||
"provide-app-name": "Proporcione el nombre de su aplicación",
|
||||
"logo": "Logo",
|
||||
"upload-app-logo": "Suba el logotipo de su aplicación",
|
||||
"prefill-unfilled-widget": "Los siguientes campos obligatorios no pueden quedar vacíos: {{emptyWidget}}. Por favor, complételos para continuar.",
|
||||
"Dashboard": "Panel",
|
||||
"Analytics": "Analítica",
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"file-selected": "archivo seleccionado",
|
||||
"bulk-send-csv-helper": "Use encabezados para los roles de firmantes (Correo/Nombre/Teléfono del rol) y valores de prellenado (Prellenado: Nombre del campo). Los campos de prellenado obligatorios deben completarse.",
|
||||
"bulk-send-csv-helper-note": "Nota: Para los campos de prellenado de tipo fecha en el CSV, el valor debe proporcionarse en el siguiente formato: {{value}} (ejemplo: {{example}}).",
|
||||
"bulk-send-csv-summary": "{{total}} filas importadas. {{invalid}} filas no válidas fueron omitidas.",
|
||||
"bulk-send-csv-missing-headers": "Faltan encabezados obligatorios del CSV. Roles: {{roles}}. Prellenado: {{prefill}}.",
|
||||
"bulk-send-csv-summary": "Se importaron {{total}} filas del CSV.",
|
||||
"bulk-send-csv-missing-headers": "Faltan encabezados obligatorios del CSV. Roles: {{roles}}.",
|
||||
"bulk-send-no-records": "Agregue al menos un destinatario antes de enviar.",
|
||||
"csv-only-support": "Solo se admiten archivos CSV.",
|
||||
"csv-no-records": "No se encontraron registros en el CSV.",
|
||||
@@ -1361,5 +1361,40 @@
|
||||
"enter-daily-email-quota": "Ingrese la cuota diaria de correo electrónico",
|
||||
"enter-custom-regular-expression": "Ingrese una expresión regular personalizada",
|
||||
"enter-value": "Ingrese {{value}}",
|
||||
"are-you-sure-want-to-send-document": "¿Está seguro de que desea enviar el documento?"
|
||||
"are-you-sure-want-to-send-document": "¿Está seguro de que desea enviar el documento?",
|
||||
"favicon": "Favicon",
|
||||
"delete-two-factor-authentication": "¿Está seguro de que desea eliminar la autenticación de dos factores?",
|
||||
"paste-html-here": "Pegar HTML aquí",
|
||||
"open-email-builder": "Puede crear una plantilla de correo electrónico utilizando la plataforma <1>email builder</1> y copiar el código HTML.",
|
||||
"contact-saved": "Contacto guardado correctamente.",
|
||||
"remove-limit-of-user": "¿Está seguro de que desea eliminar el límite de este usuario? Podrá consumir todos los créditos premium disponibles de su organización que se comparten entre todos los usuarios.",
|
||||
"current-expiry-date": "Fecha de vencimiento actual",
|
||||
"reminder-error": "“Recordar una vez cada (días)” no debe exceder el “Tiempo para completar (días)”.",
|
||||
"showing-to-of-records": "Mostrando {{record}} a {{recordPerPage}} de {{totalRows}} registros",
|
||||
"records-per-page": "Registros por página",
|
||||
"email-not-found-in-row": "No se encontró correo electrónico en la fila {{row}}.",
|
||||
"invalid-email-found-in-row": "Correo electrónico no válido encontrado en la fila {{row}}: {{email}}",
|
||||
"duplicate-email-found-in-row": "Correo electrónico duplicado encontrado en la fila {{row}}: {{email}}",
|
||||
"prefill": "Prellenado",
|
||||
"recipients-&-data": "Destinatarios y datos",
|
||||
"response": "Respuesta",
|
||||
"prefill-tab-help": "Estos son los campos comunes de la plantilla que se aplicarán a todos los documentos en este envío masivo.",
|
||||
"recipient-tab-help-p1": "En este siguiente paso, usted:",
|
||||
"recipient-tab-help-p2": "Subirá un CSV con los detalles de los destinatarios o",
|
||||
"recipient-tab-help-p3": "Ingresará manualmente la información de los destinatarios",
|
||||
"recipient-tab-help-p4": "Cada destinatario recibirá su propio documento generado a partir de esta plantilla.",
|
||||
"response-tab-help": "Revise sus respuestas.",
|
||||
"summary": "Resumen",
|
||||
"prefill-fields": "Campos de prellenado",
|
||||
"documents": "Documentos",
|
||||
"message": "Mensaje",
|
||||
"formula-help-p1": "Recopile entradas numéricas y realice cálculos dinámicos usando fórmulas.",
|
||||
"example": "Ejemplo",
|
||||
"formula-help-p2": "Para mostrar un total en number-3, use {{number-1}} + {{number-2}}.",
|
||||
"formula-help-p3": "Para calcular un valor en number-4, use ({{number-1}} * {{number-2}}) / {{number-3}}.",
|
||||
"formula-help-p4": "Cada widget numérico debe tener un nombre único (por ejemplo, number-1), ya que las fórmulas dependen de estos identificadores.",
|
||||
"note-consume-premium-credits": "Nota: Esta función consume créditos premium.",
|
||||
"note-consume-premium-credits-on-document-send": "Nota: Esta función consume créditos premium. Se consumen cada vez que se envía un documento, independientemente de si se firma o no.",
|
||||
"webhook-authentication-help": "{{appName}} protege cada webhook generando una firma HMAC-SHA256 utilizando su clave de seguridad del webhook y el payload original de la solicitud. Esta firma se envía en el encabezado x-webhook-signature. Su servidor debe generar la misma firma utilizando el payload recibido y su clave secreta. Si ambas firmas coinciden, la solicitud del webhook es auténtica y no ha sido modificada.",
|
||||
"invalid-widget-found-in-row": "Se detectó un valor no válido en la fila {{row}} para el widget {{widget}}."
|
||||
}
|
||||
@@ -75,7 +75,7 @@
|
||||
"password-length": "Le mot de passe doit être de plus de 8 caractères",
|
||||
"password-case": "Le mot de passe doit contenir une lettre majuscule, minuscule et un chiffre ",
|
||||
"password-special-char": "Le mot de passe doit contenir un caractère spécial",
|
||||
"agreee": "Je suis d'accord avec le",
|
||||
"agree": "Je suis d'accord avec le",
|
||||
"term": "Conditions d'utilisation",
|
||||
"subscribe-to-opensign": "Abonnez-vous à la newsletter OpenSign",
|
||||
"register": "S'enregistrer ",
|
||||
@@ -241,7 +241,6 @@
|
||||
"auto-reminder": "Rappel automatique",
|
||||
"remind-once": "Rappeler une fois tous les (jours)",
|
||||
"next": "Suivant",
|
||||
"select-folder": "Sélectionner le dossier",
|
||||
"OpenSign-drive": "Lecteur {{appName}}",
|
||||
"drive-document-status": {
|
||||
"Completed": "Complété",
|
||||
@@ -296,7 +295,7 @@
|
||||
"edit-team": "Modifier l'équipe",
|
||||
"name-of-team": "Nom de l'équipe",
|
||||
"prev": "précédent",
|
||||
"no-data-avaliable": "pas de données disponibles",
|
||||
"no-data-available": "pas de données disponibles",
|
||||
"add-team": "Ajouter une équipe",
|
||||
"page-not-found": "Page non trouvée",
|
||||
"users-from-teams": "utilisateurs de votre Équipe",
|
||||
@@ -307,7 +306,7 @@
|
||||
"delete-user": "Supprimer l'utilisateur",
|
||||
"delete": "Supprimer",
|
||||
"add-user": "Ajouter un utilisateur",
|
||||
"password-generateed": "Le mot de passe ne sera généré qu'une seule fois ; assurez-vous de le copier.",
|
||||
"password-generated": "Le mot de passe ne sera généré qu'une seule fois ; assurez-vous de le copier.",
|
||||
"Team status": "Statut de l'équipe",
|
||||
"user-deactivated": "Utilisateur désactivé.",
|
||||
"user-activated": "Activé par l'utilisateur.",
|
||||
@@ -337,7 +336,7 @@
|
||||
"quick-send-alert-1": "Tous les rôles dans ce document sont actuellement liés à des contacts. Pour envoyer rapidement des copies de ce modèle à plusieurs signataires, veuillez vous assurer qu'au moins un rôle n'est lié à aucun contact.",
|
||||
"quick-send-alert-2": "Veuillez vous assurer qu'au moins un widget de signature est ajouté pour tous les destinataires.",
|
||||
"quick-send-alert-3": "Veuillez ajouter au moins un rôle à ce template.",
|
||||
"quick-send-alert-4": "Limite atteint pour {{flow}}.",
|
||||
"quick-send-alert-4": "{{flow}} prend en charge jusqu'à {{count}} signataires par lot. Divisez votre liste et réessayez.",
|
||||
"copy-link": "Copier le lien",
|
||||
"copy": "Copier",
|
||||
"revoke-document": "Révoquer le document",
|
||||
@@ -438,13 +437,15 @@
|
||||
"reset-password-alert-1": "Le lien de réinitialisation du mot de passe a été envoyé à votre identifiant de messagerie",
|
||||
"reset-password-alert-2": "Veuillez configurer l'adaptateur de messagerie",
|
||||
"reset-password-alert-3": "Réinitialisez votre mot de passe",
|
||||
"faild-animation": "Échec du chargement de l'animation",
|
||||
"failed-animation": "Échec du chargement de l'animation",
|
||||
"apply": "Appliquer",
|
||||
"select-columns": "Sélectionner les colonnes",
|
||||
"copy-type": {
|
||||
"All pages": "Toutes les pages",
|
||||
"All pages but last": "Toutes les pages sauf la dernière",
|
||||
"All pages but first": "Toutes les pages sauf la première",
|
||||
"Next to current widget": "À côté du widget actuel"
|
||||
"Next to current widget": "À côté du widget actuel",
|
||||
"Page range": "Plage de pages"
|
||||
},
|
||||
"options": "Possibilités",
|
||||
"minimum-check": "Vérification minimale",
|
||||
@@ -626,7 +627,7 @@
|
||||
"date": "Date",
|
||||
"report-not-found": "Rapport introuvable",
|
||||
"please-wait": "S'il vous plaît, attendez",
|
||||
"select-foler": "Sélectionner le dossier",
|
||||
"select-folder": "Sélectionner le dossier",
|
||||
"generated-on": "Généré le",
|
||||
"text-field": "Champ de texte",
|
||||
"font-size": "Taille de police",
|
||||
@@ -685,14 +686,14 @@
|
||||
"public-template": "Cette vidéo montre comment configurer votre profil public personnalisé, tel que 'https://opensign.me/your-username'. Vous apprendrez également comment personnaliser votre slogan et rendre vos modèles disponibles pour la signature publique.",
|
||||
"allowModify-widgets": "Vous pouvez faire glisser et déposer n'importe lequel de ces champs sur le document, en plus des champs déjà désignés pour vous par le créateur du document."
|
||||
},
|
||||
"enter-email-plaholder": "Ajoutez une adresse email et appuyez sur Entrée",
|
||||
"enter-email-placeholder": "Ajoutez une adresse email et appuyez sur Entrée",
|
||||
"success-email-alert": "E-mail envoyé avec succès!",
|
||||
"expired-doc-title": "Document expiré",
|
||||
"expired-on-mssg": "Ce document a expiré le {{expiredDate}} et n'est plus disponible pour signature.",
|
||||
"signature-validate-alert": "Veuillez confirmer que vous avez coché au moins {{minRequiredCount}} cases.",
|
||||
"signature-validate-alert-2": "Assurez-vous que ce champ est rempli avec précision et répond à toutes les exigences.",
|
||||
"user-name-exist": "le nom d'utilisateur existe déjà",
|
||||
"remaing-users": "Utilisateurs restants",
|
||||
"remaining-users": "Utilisateurs restants",
|
||||
"Role": "Rôle",
|
||||
"additional-users": "Veuillez acheter des utilisateurs supplémentaires.",
|
||||
"Quantity-of-users": "Quantité d'utilisateurs",
|
||||
@@ -713,7 +714,7 @@
|
||||
"rotate-left": "Faire pivoter à gauche",
|
||||
"rotate-alert-mssg": "Tous les widgets de cette page seront perdus. Êtes-vous sûr de vouloir continuer ?",
|
||||
"templateid": "ID de modèle",
|
||||
"bulk-send-subcription-alert": "Veuillez passer au forfait Professionnel ou Équipe pour utiliser Quicksend.",
|
||||
"bulk-send-subscription-alert": "Veuillez passer au forfait Professionnel ou Équipe pour utiliser Quicksend.",
|
||||
"generate-test-token": "Générer jeton de test",
|
||||
"regenerate-test-token": "Régénérer le jeton de test",
|
||||
"help-test-token": "Ce jeton peut être utilisé pour tester les API au niveau du point de terminaison https://sandbox.opensignlabs.com/api/v1, vous permettant ainsi d'effectuer un nombre illimité de signatures de documents. Veuillez noter que l'API sandbox signera vos documents avec des certificats auto-signés, qui peuvent ne pas être reconnus comme valides par Adobe. Une fois vos tests terminés, vous pouvez passer à l'un de nos forfaits payants pour générer un jeton de production.",
|
||||
@@ -733,8 +734,9 @@
|
||||
"copied-code": "COPIÉ",
|
||||
"Installation": "Installation",
|
||||
"Usage": "Usage",
|
||||
"insufficient-credits": "Crédits de signature insuffisants",
|
||||
"insufficient-credits-mssg": "Le propriétaire de ce document ne dispose actuellement pas des crédits OpenSign nécessaires pour que vous puissiez le signer. Veuillez contacter le propriétaire si vous avez besoin de plus de détails.",
|
||||
"insufficient-credits": "Crédits de signature insuffisants. Veuillez contacter {{ownerEmail}}",
|
||||
"insufficient-credits-title": "Crédits de signature insuffisants",
|
||||
"insufficient-credits-mssg": "Le propriétaire de ce document ne dispose actuellement pas des crédits {{appName}} nécessaires pour que vous puissiez le signer. Veuillez contacter le propriétaire si vous avez besoin de plus de détails.",
|
||||
"angular-npm-mssg-1": "Pour intégrer OpenSign dans votre projet Angular, exécutez simplement la commande suivante :",
|
||||
"quota-mail-info-head": "Limite mensuelle d'e-mails",
|
||||
"quota-mail-info": "Pour maintenir la qualité du service et prévenir le spam, OpenSign permet jusqu'à 15 e-mails par mois avec le plan gratuit. Passez à l'offre supérieure pour un envoi illimité d'e-mails.",
|
||||
@@ -866,7 +868,6 @@
|
||||
"term-cond-p30": " ou par email",
|
||||
"js-snippet-msg": "Pour intégrer des modèles {{appName}} dans vos sites Web ou pages de destination HTML, vous pouvez utiliser le code ci-dessous:",
|
||||
"js-snippet-msg-1": "Vous pouvez obtenir le TemplateId à partir de la page Gérer les modèles.",
|
||||
"agrrement-alert": "Pour procéder, vous devez consentir à la divulgation des enregistrements et signatures électroniques.",
|
||||
"webhook-already-exists": "L'URL existe déjà ! Veuillez en essayer un autre.",
|
||||
"webhook-must-be-secure": "L'URL du webhook doit être sécurisée et utiliser https://",
|
||||
"provide-test-token": "Veuillez générer un jeton de test API",
|
||||
@@ -1030,7 +1031,7 @@
|
||||
},
|
||||
"signer-already-present": "Signataire déjà présent",
|
||||
"kiosk-sign": "Signature sur kiosque",
|
||||
"dont-have-access-to-template": "Le template a été supprimé ou vous n'y avez pas accès. Veuillez contacter l'expéditeur.",
|
||||
"do-not-have-access-to-template": "Le template a été supprimé ou vous n'y avez pas accès. Veuillez contacter l'expéditeur.",
|
||||
"kiosk-info": "Le Mode Kiosque vous permet de recueillir des signatures en personne rapidement et efficacement. Idéal pour les salons, événements ou situations où tous les signataires sont physiquement présents. ",
|
||||
"learn-more": "En savoir plus",
|
||||
"finish-mssg": "Êtes-vous sûr de vouloir terminer le document ?",
|
||||
@@ -1086,8 +1087,6 @@
|
||||
"search-contacts": "Rechercher des contacts…",
|
||||
"add-role-alert": "Veuillez ajouter au moins un rôle",
|
||||
"edit-draft": "Modifier le brouillon",
|
||||
"invalid-email-found": "Adresse e-mail invalide trouvée : {{email}}",
|
||||
"duplicate-email-found": "Adresse e-mail en double trouvée : {{email}}",
|
||||
"vertical": "Vertical",
|
||||
"horizontal": "Horizontal",
|
||||
"billing": "Facturation",
|
||||
@@ -1101,7 +1100,7 @@
|
||||
"access-denied": "Accès refusé",
|
||||
"upgrade": "Mettre à jour",
|
||||
"do-not-access-app": "Vous n'avez pas accès à cette application.",
|
||||
"dont-have-access": "Vous n'avez pas accès.",
|
||||
"do-not-have-access": "Vous n'avez pas accès.",
|
||||
"valid-email-alert": "Veuillez saisir une adresse e-mail valide.",
|
||||
"otp-not-validate": "OTP non valide.",
|
||||
"domain-not-allowed": "Ce domaine n'est pas autorisé",
|
||||
@@ -1118,10 +1117,12 @@
|
||||
"branding-help": "Le branding permet le white labelling de votre application",
|
||||
"custom-sub-domain": "Sous-domaine personnalisé",
|
||||
"app-name": "Nom de l'application",
|
||||
"provide-domain-name": "Fournissez votre nom de domaine",
|
||||
"provide-domain-name": "Seuls les sous-domaines sont autorisés (p. ex., app.votredomaine.com). Les domaines principaux/racine comme votredomaine.com ne sont pas pris en charge.",
|
||||
"provide-app-name-help": "Indiquez un nom d'application unique. Il sera affiché comme le nom de votre marque dans l'application et dans tous les e-mails partout où l'image de marque est appliquée.",
|
||||
"upload-app-logo": "Téléversez un logo au format .png, .jpg ou .svg. Taille recommandée : {{size}} pixels (ratio 5:2) pour une meilleure qualité d'affichage.",
|
||||
"upload-app-favicon": "Téléversez une image de favicon carrée (.ico, .png ou .svg). La taille recommandée est de {{size}} pixels pour une meilleure compatibilité avec les navigateurs.",
|
||||
"provide-app-name": "Fournissez le nom de votre application",
|
||||
"logo": "Logo",
|
||||
"upload-app-logo": "Téléchargez le logo de votre application",
|
||||
"prefill-unfilled-widget": "Les champs obligatoires suivants ne peuvent pas être vides : {{emptyWidget}}. Veuillez les remplir pour continuer.",
|
||||
"Dashboard": "Tableau de bord",
|
||||
"Analytics": "Analytique",
|
||||
@@ -1345,8 +1346,8 @@
|
||||
"file-selected": "fichier sélectionné",
|
||||
"bulk-send-csv-helper": "Utilisez des en-têtes pour les rôles des signataires (E-mail/Nom/Téléphone du rôle) et les valeurs de préremplissage (Préremplissage : Nom du champ). Les champs de préremplissage requis doivent être complétés.",
|
||||
"bulk-send-csv-helper-note": "Remarque : Pour les champs de préremplissage de type date dans le CSV, la valeur doit être fournie dans le format suivant : {{value}} (exemple : {{example}}).",
|
||||
"bulk-send-csv-summary": "{{total}} lignes importées. {{invalid}} lignes non valides ont été ignorées.",
|
||||
"bulk-send-csv-missing-headers": "En-têtes CSV requis manquants. Rôles : {{roles}}. Préremplissage : {{prefill}}.",
|
||||
"bulk-send-csv-summary": "{{total}} lignes importées depuis le CSV.",
|
||||
"bulk-send-csv-missing-headers": "En-têtes CSV requis manquants. Rôles : {{roles}}.",
|
||||
"bulk-send-no-records": "Veuillez ajouter au moins un destinataire avant l'envoi.",
|
||||
"csv-only-support": "Seuls les fichiers CSV sont pris en charge.",
|
||||
"csv-no-records": "Aucun enregistrement trouvé dans le CSV.",
|
||||
@@ -1360,5 +1361,40 @@
|
||||
"enter-daily-email-quota": "Saisissez le quota quotidien d'e-mails",
|
||||
"enter-custom-regular-expression": "Saisissez une expression régulière personnalisée",
|
||||
"enter-value": "Saisissez {{value}}",
|
||||
"are-you-sure-want-to-send-document": "Êtes-vous sûr de vouloir envoyer le document ?"
|
||||
"are-you-sure-want-to-send-document": "Êtes-vous sûr de vouloir envoyer le document ?",
|
||||
"favicon": "Favicon",
|
||||
"delete-two-factor-authentication": "Êtes-vous sûr de vouloir supprimer l'authentification à deux facteurs ?",
|
||||
"paste-html-here": "Collez le HTML ici",
|
||||
"open-email-builder": "Vous pouvez créer un modèle d'e-mail à l'aide de la plateforme <1>email builder</1> et copier le code HTML.",
|
||||
"contact-saved": "Contact enregistré avec succès.",
|
||||
"remove-limit-of-user": "Êtes-vous sûr de vouloir supprimer la limite de cet utilisateur ? Il pourra alors utiliser tous les crédits premium disponibles de votre organisation, partagés entre tous les utilisateurs.",
|
||||
"current-expiry-date": "Date d'expiration actuelle",
|
||||
"reminder-error": "« Rappeler une fois tous les (jours) » ne doit pas dépasser le « Temps pour terminer (jours) ».",
|
||||
"showing-to-of-records": "Affichage de {{record}} à {{recordPerPage}} sur {{totalRows}} enregistrements",
|
||||
"records-per-page": "Enregistrements par page",
|
||||
"email-not-found-in-row": "Aucun e-mail trouvé à la ligne {{row}}.",
|
||||
"invalid-email-found-in-row": "E-mail invalide trouvé à la ligne {{row}} : {{email}}",
|
||||
"duplicate-email-found-in-row": "E-mail en double trouvé à la ligne {{row}} : {{email}}",
|
||||
"prefill": "Préremplissage",
|
||||
"recipients-&-data": "Destinataires et données",
|
||||
"response": "Réponse",
|
||||
"prefill-tab-help": "Ce sont les champs communs du modèle qui s'appliqueront à tous les documents de cet envoi groupé.",
|
||||
"recipient-tab-help-p1": "Dans cette étape suivante, vous allez :",
|
||||
"recipient-tab-help-p2": "Téléverser un CSV avec les détails des destinataires ou",
|
||||
"recipient-tab-help-p3": "Saisir manuellement les informations des destinataires",
|
||||
"recipient-tab-help-p4": "Chaque destinataire recevra son propre document généré à partir de ce modèle.",
|
||||
"response-tab-help": "Vérifiez vos réponses.",
|
||||
"summary": "Résumé",
|
||||
"prefill-fields": "Champs de préremplissage",
|
||||
"documents": "Documents",
|
||||
"message": "Message",
|
||||
"formula-help-p1": "Collectez des entrées numériques et effectuez des calculs dynamiques à l'aide de formules.",
|
||||
"example": "Exemple",
|
||||
"formula-help-p2": "Pour afficher un total dans number-3, utilisez {{number-1}} + {{number-2}}.",
|
||||
"formula-help-p3": "Pour calculer une valeur dans number-4, utilisez ({{number-1}} * {{number-2}}) / {{number-3}}.",
|
||||
"formula-help-p4": "Chaque widget numérique doit avoir un nom unique (par ex. number-1), car les formules dépendent de ces identifiants.",
|
||||
"note-consume-premium-credits": "Remarque : Cette fonctionnalité consomme des crédits premium.",
|
||||
"note-consume-premium-credits-on-document-send": "Remarque : Cette fonctionnalité consomme des crédits premium. Les crédits sont consommés chaque fois qu'un document est envoyé, qu'il soit signé ou non.",
|
||||
"webhook-authentication-help": "{{appName}} sécurise chaque webhook en générant une signature HMAC-SHA256 à l'aide de votre clé de sécurité webhook et du payload brut de la requête. Cette signature est envoyée dans l'en-tête x-webhook-signature. Votre serveur doit générer la même signature en utilisant le payload reçu et votre clé secrète. Si les deux signatures correspondent, la requête webhook est authentique et n'a pas été modifiée.",
|
||||
"invalid-widget-found-in-row": "Valeur invalide détectée à la ligne {{row}} pour le widget {{widget}}."
|
||||
}
|
||||
@@ -75,7 +75,7 @@
|
||||
"password-length": "पासवर्ड 8 वर्ण लंबा होना चाहिए",
|
||||
"password-case": "पासवर्ड में अपरकेस अक्षर, लोअरकेस अक्षर, अंक होना चाहिए",
|
||||
"password-special-char": " पासवर्ड में विशेष वर्ण होना चाहिए",
|
||||
"agreee": "मैं सहमत हूँ",
|
||||
"agree": "मैं सहमत हूँ",
|
||||
"term": "सेवा की शर्तें",
|
||||
"subscribe-to-opensign": "OpenSign न्यूज़लेटर की सदस्यता लें",
|
||||
"register": "पंजीकृत करें",
|
||||
@@ -241,7 +241,6 @@
|
||||
"auto-reminder": "स्वतः अनुस्मारक",
|
||||
"remind-once": " प्रत्येक (दिन) में एक बार याद दिलाएं",
|
||||
"next": "अगला",
|
||||
"select-folder": "फ़ोल्डर चुनें",
|
||||
"OpenSign-drive": "{{appName}} ड्राइव",
|
||||
"drive-document-status": {
|
||||
"Completed": "पूरा हुआ",
|
||||
@@ -296,7 +295,7 @@
|
||||
"edit-team": "टीम संपादित करें",
|
||||
"name-of-team": "टीम का नाम",
|
||||
"prev": "पिछला",
|
||||
"no-data-avaliable": "कोई डेटा उपलब्ध नहीं है",
|
||||
"no-data-available": "कोई डेटा उपलब्ध नहीं है",
|
||||
"add-team": "टीम जोड़ें",
|
||||
"page-not-found": "पृष्ठ नहीं मिला",
|
||||
"users-from-teams": "टीमों से उपयोगकर्ता",
|
||||
@@ -307,7 +306,7 @@
|
||||
"delete-user": "उपयोगकर्ता हटाएं",
|
||||
"delete": "हटाएं",
|
||||
"add-user": "उपयोगकर्ता जोड़ें",
|
||||
"password-generateed": "पासवर्ड केवल एक बार उत्पन्न होगा; इसे कॉपी करना सुनिश्चित करें।",
|
||||
"password-generated": "पासवर्ड केवल एक बार उत्पन्न होगा; इसे कॉपी करना सुनिश्चित करें।",
|
||||
"Team status": "टीम स्थिति",
|
||||
"user-deactivated": "उपयोगकर्ता निष्क्रिय।",
|
||||
"user-activated": "उपयोगकर्ता सक्रिय।",
|
||||
@@ -337,7 +336,7 @@
|
||||
"quick-send-alert-1": "इस दस्तावेज़ में सभी भूमिकाएँ वर्तमान में संपर्कों से जुड़ी हुई हैं। इस टेम्पलेट की प्रतियां कई हस्ताक्षरकर्ताओं को त्वरित रूप से भेजने के लिए, कृपया सुनिश्चित करें कि कम से कम एक भूमिका किसी भी संपर्क से जुड़ी नहीं है।",
|
||||
"quick-send-alert-2": "कृपया सुनिश्चित करें कि सभी प्राप्तकर्ताओं के लिए कम से कम एक हस्ताक्षर विजेट जोड़ा गया है।",
|
||||
"quick-send-alert-3": "कृपया इस टेम्पलेट में कम से कम एक भूमिका जोड़ें।",
|
||||
"quick-send-alert-4": "{{flow}} के लिए सीमा पूरी हो गई है।",
|
||||
"quick-send-alert-4": "{{flow}} प्रति बैच अधिकतम {{count}} साइनरों का समर्थन करता है। अपनी सूची विभाजित करें और पुनः प्रयास करें।",
|
||||
"copy-link": "लिंक कॉपी करें",
|
||||
"copy": "कॉपी करें",
|
||||
"revoke-document": "दस्तावेज़ रद्द करें",
|
||||
@@ -438,13 +437,15 @@
|
||||
"reset-password-alert-1": "पासवर्ड रीसेट लिंक आपके ईमेल आईडी पर भेज दिया गया है",
|
||||
"reset-password-alert-2": "कृपया ईमेल एडॉप्टर सेटअप करें ",
|
||||
"reset-password-alert-3": "अपना पासवर्ड रीसेट करें",
|
||||
"faild-animation": "एनिमेशन लोड करने में विफल",
|
||||
"failed-animation": "एनिमेशन लोड करने में विफल",
|
||||
"apply": "लागू करें",
|
||||
"select-columns": "स्तंभ चुनें",
|
||||
"copy-type": {
|
||||
"All pages": "सभी पृष्ठ",
|
||||
"All pages but last": "अंतिम को छोड़कर सभी पृष्ठ",
|
||||
"All pages but first": "पहले को छोड़कर सभी पृष्ठ",
|
||||
"Next to current widget": "वर्तमान विजेट के आगे"
|
||||
"Next to current widget": "वर्तमान विजेट के आगे",
|
||||
"Page range": "पृष्ठ सीमा"
|
||||
},
|
||||
"options": "विकल्प",
|
||||
"minimum-check": "न्यूनतम जाँच",
|
||||
@@ -626,7 +627,7 @@
|
||||
"date": "दिनांक",
|
||||
"report-not-found": "रिपोर्ट नहीं मिली",
|
||||
"please-wait": "कृपया प्रतीक्षा करें",
|
||||
"select-foler": "फ़ोल्डर चुनें",
|
||||
"select-folder": "फ़ोल्डर चुनें",
|
||||
"generated-on": "पर उत्पन्न",
|
||||
"text-field": "पाठ क्षेत्र",
|
||||
"font-size": "फ़ॉन्ट आकार",
|
||||
@@ -685,13 +686,13 @@
|
||||
"public-template": "यह वीडियो दर्शाता है कि आप अपना व्यक्तिगत सार्वजनिक प्रोफ़ाइल कैसे सेट अप कर सकते हैं, जैसे 'https://opensign.me/your-username'। आप यह भी सीखेंगे कि अपनी टैगलाइन को कैसे अनुकूलित करें और अपने टेम्पलेट्स को सार्वजनिक हस्ताक्षर के लिए उपलब्ध कराएं।",
|
||||
"allowModify-widgets": "आप इन फ़ील्ड में से किसी को भी दस्तावेज़ पर खींच और छोड़ सकते हैं, इसके अतिरिक्त दस्तावेज़ निर्माता द्वारा आपके लिए पहले से निर्दिष्ट फ़ील्ड के। "
|
||||
},
|
||||
"enter-email-plaholder": "एक ईमेल पता जोड़ें और एंटर दबाएं",
|
||||
"enter-email-placeholder": "एक ईमेल पता जोड़ें और एंटर दबाएं",
|
||||
"success-email-alert": "ईमेल सफलतापूर्वक भेजा गया!",
|
||||
"expired-doc-title": "समाप्त दस्तावेज़",
|
||||
"expired-on-mssg": "यह दस्तावेज़ {{expiredDate}} को समाप्त हो गया है और अब हस्ताक्षर के लिए उपलब्ध नहीं है।",
|
||||
"signature-validate-alert": "कृपया पुष्टि करें कि आपने कम से कम {{minRequiredCount}} चेकबॉक्स चुने हैं।",
|
||||
"signature-validate-alert-2": "सुनिश्चित करें कि यह फ़ील्ड सटीक रूप से भरा गया है और सभी आवश्यकताओं को पूरा करता है।",
|
||||
"remaing-users": "शेष उपयोगकर्ता",
|
||||
"remaining-users": "शेष उपयोगकर्ता",
|
||||
"Role": "भूमिका",
|
||||
"additional-users": "कृपया ऐड-ऑन उपयोगकर्ता खरीदें।",
|
||||
"Quantity-of-users": "उपयोगकर्ताओं की मात्रा",
|
||||
@@ -713,7 +714,7 @@
|
||||
"rotate-left": "बाईं ओर घुमाएँ",
|
||||
"rotate-alert-mssg": "इस पृष्ठ पर सभी विजेट खो जाएंगे। क्या आप वाकई आगे बढ़ना चाहते हैं?",
|
||||
"templateid": "टेम्पलेट-आईडी",
|
||||
"bulk-send-subcription-alert": "थोक भेजने का उपयोग करने के लिए कृपया व्यावसायिक या टीम योजना में अपग्रेड करें।",
|
||||
"bulk-send-subscription-alert": "थोक भेजने का उपयोग करने के लिए कृपया व्यावसायिक या टीम योजना में अपग्रेड करें।",
|
||||
"generate-test-token": "परीक्षण टोकन उत्पन्न करें",
|
||||
"regenerate-test-token": "परीक्षण टोकन पुनः उत्पन्न करें",
|
||||
"help-test-token": "इस टोकन का उपयोग https://sandbox.opensignlabs.com/api/v1 समापन बिंदु पर एपीआई का परीक्षण करने के लिए किया जा सकता है, जिससे आप असीमित दस्तावेज़ हस्ताक्षर कर सकते हैं। कृपया ध्यान दें कि सैंडबॉक्स एपीआई आपके दस्तावेज़ों पर स्व-हस्ताक्षरित प्रमाणपत्रों के साथ हस्ताक्षर करेगा, जिन्हें एडोब द्वारा मान्य के रूप में मान्यता नहीं दी जा सकती है। एक बार जब आप अपना परीक्षण पूरा कर लेते हैं, तो आप उत्पादन टोकन उत्पन्न करने के लिए हमारी किसी भुगतान योजना में अपग्रेड कर सकते हैं।",
|
||||
@@ -733,8 +734,9 @@
|
||||
"copied-code": "कॉपी किया गया",
|
||||
"Installation": "इंस्टॉलेशन",
|
||||
"Usage": "उपयोग",
|
||||
"insufficient-credits": "अपर्याप्त हस्ताक्षर क्रेडिट",
|
||||
"insufficient-credits-mssg": "इस दस्तावेज़ के स्वामी के पास वर्तमान में आपके हस्ताक्षर करने के लिए आवश्यक OpenSign क्रेडिट की कमी है। यदि आपको अधिक विवरण चाहिए तो कृपया स्वामी से संपर्क करें।",
|
||||
"insufficient-credits": "हस्ताक्षर क्रेडिट अपर्याप्त हैं। कृपया {{ownerEmail}} से संपर्क करें",
|
||||
"insufficient-credits-title": "अपर्याप्त हस्ताक्षर क्रेडिट",
|
||||
"insufficient-credits-mssg": "इस दस्तावेज़ के स्वामी के पास वर्तमान में आपके हस्ताक्षर करने के लिए आवश्यक {{appName}} क्रेडिट की कमी है। यदि आपको अधिक विवरण चाहिए तो कृपया स्वामी से संपर्क करें।",
|
||||
"angular-npm-mssg-1": "OpenSign को अपने Angular प्रोजेक्ट में एकीकृत करने के लिए, बस निम्नलिखित कमांड चलाएँ:",
|
||||
"quota-mail-info-head": "मासिक ईमेल सीमा",
|
||||
"quota-mail-info": "सेवा की गुणवत्ता बनाए रखने और स्पैम को रोकने के लिए, OpenSign निःशुल्क योजना पर प्रति माह 15 ईमेल तक की अनुमति देता है। असीमित ईमेल भेजने के लिए अभी अपग्रेड करें।",
|
||||
@@ -866,7 +868,6 @@
|
||||
"js-snippet-msg-1": "आप टेम्पलेट आईडी इससे प्राप्त कर सकते हैं",
|
||||
"js-snippet-msg-2": "टेम्पलेट प्रबंधित करें",
|
||||
"js-snippet-msg-3": "पृष्ठ।",
|
||||
"agrrement-alert": "आगे बढ़ने के लिए, आपको इलेक्ट्रॉनिक रिकॉर्ड और हस्ताक्षर प्रकटीकरण के लिए सहमति देनी होगी।",
|
||||
"webhook-already-exists": "URL पहले से मौजूद है! कृपया कोई दूसरा प्रयास करें।",
|
||||
"webhook-must-be-secure": "वेबहूक URL सुरक्षित होना चाहिए और https:// का उपयोग करना चाहिए",
|
||||
"provide-test-token": "कृपया एपीआई परीक्षण टोकन उत्पन्न करें",
|
||||
@@ -1030,7 +1031,7 @@
|
||||
},
|
||||
"signer-already-present": "हस्ताक्षरकर्ता पहले से मौजूद है",
|
||||
"kiosk-sign": "कियोस्क साइन",
|
||||
"dont-have-access-to-template": "टेम्पलेट हटा दिया गया है या आपके पास पहुंच नहीं है। कृपया प्रेषक से संपर्क करें।",
|
||||
"do-not-have-access-to-template": "टेम्पलेट हटा दिया गया है या आपके पास पहुंच नहीं है। कृपया प्रेषक से संपर्क करें।",
|
||||
"kiosk-info": "कियोस्क मोड आपको व्यक्तिगत रूप से हस्ताक्षर शीघ्रता और कुशलता से एकत्र करने देता है। ट्रेड शो, कार्यक्रमों या वॉक-इन परिदृश्यों के लिए आदर्श जहां सभी हस्ताक्षरकर्ता शारीरिक रूप से मौजूद हों। ",
|
||||
"learn-more": "और जानें",
|
||||
"finish-mssg": " क्या आप वाकई दस्तावेज़ समाप्त करना चाहते हैं?",
|
||||
@@ -1086,8 +1087,6 @@
|
||||
"search-contacts": "संपर्क खोजें…",
|
||||
"add-role-alert": "कृपया कम से कम एक भूमिका जोड़ें",
|
||||
"edit-draft": "ड्राफ्ट संपादित करें",
|
||||
"invalid-email-found": "अमान्य ईमेल पाया गया: {{email}}",
|
||||
"duplicate-email-found": "डुप्लिकेट ईमेल पाया गया: {{email}}",
|
||||
"vertical": "वर्टिकल",
|
||||
"horizontal": "हॉरिज़ॉन्टल",
|
||||
"billing": "बिलिंग",
|
||||
@@ -1101,7 +1100,7 @@
|
||||
"access-denied": "पहुंच अस्वीकृत",
|
||||
"upgrade": "अपग्रेड",
|
||||
"do-not-access-app": "आपको इस एप्लिकेशन तक पहुंच नहीं है।",
|
||||
"dont-have-access": "आपको पहुंच नहीं है।",
|
||||
"do-not-have-access": "आपको पहुंच नहीं है।",
|
||||
"valid-email-alert": "कृपया एक मान्य ईमेल पता दर्ज करें।",
|
||||
"otp-not-validate": "ओटीपी मान्य नहीं है।",
|
||||
"domain-not-allowed": "यह डोमेन अनुमति नहीं है",
|
||||
@@ -1118,10 +1117,12 @@
|
||||
"branding-help": "ब्रांडिंग आपकी ऐप में वाइट लेबलिंग की सुविधा देती है",
|
||||
"custom-sub-domain": "कस्टम सब-डोमेन",
|
||||
"app-name": "ऐप का नाम",
|
||||
"provide-domain-name": "अपना डोमेन नाम दर्ज करें",
|
||||
"provide-domain-name": "केवल सबडोमेन की अनुमति है (उदा., app.yourdomain.com)। yourdomain.com जैसे प्राथमिक/रूट डोमेन समर्थित नहीं हैं।",
|
||||
"provide-app-name-help": "एक अनोखा ऐप नाम प्रदान करें। यह एप्लिकेशन के भीतर आपके ब्रांड नाम के रूप में और सभी ईमेल में, जहाँ भी ब्रांडिंग लागू हो, प्रदर्शित होगा।",
|
||||
"upload-app-logo": ".png, .jpg या .svg फ़ॉर्मेट में लोगो अपलोड करें। सर्वोत्तम डिस्प्ले गुणवत्ता के लिए अनुशंसित आकार: {{size}} पिक्सेल (5:2 आस्पेक्ट रेशियो)।",
|
||||
"upload-app-favicon": "एक चौकोर फ़ेविकॉन इमेज (.ico, .png या .svg) अपलोड करें। सर्वोत्तम ब्राउज़र अनुकूलता के लिए अनुशंसित आकार: {{size}} पिक्सेल।",
|
||||
"provide-app-name": "अपना ऐप नाम दर्ज करें",
|
||||
"logo": "लोगो",
|
||||
"upload-app-logo": "अपने ऐप का लोगो अपलोड करें",
|
||||
"prefill-unfilled-widget": "निम्न अनिवार्य फ़ील्ड खाली नहीं छोड़े जा सकते: {{emptyWidget}}। कृपया आगे बढ़ने के लिए इन्हें भरें।",
|
||||
"Dashboard": "डैशबोर्ड",
|
||||
"Analytics": "विश्लेषिकी",
|
||||
@@ -1345,8 +1346,8 @@
|
||||
"file-selected": "फ़ाइल चयनित",
|
||||
"bulk-send-csv-helper": "साइनर भूमिकाओं (भूमिका ईमेल/नाम/फ़ोन) और प्रीफ़िल मानों (प्रीफ़िल: फ़ील्ड नाम) के लिए हेडर का उपयोग करें। आवश्यक प्रीफ़िल फ़ील्ड भरना अनिवार्य है।",
|
||||
"bulk-send-csv-helper-note": "नोट: CSV में तिथि-प्रकार के प्रीफ़िल फ़ील्ड्स के लिए मान इस प्रारूप में प्रदान किया जाना चाहिए: {{value}} (उदाहरण: {{example}})।",
|
||||
"bulk-send-csv-summary": "{{total}} पंक्तियाँ आयात की गईं। {{invalid}} अमान्य पंक्तियाँ छोड़ दी गईं।",
|
||||
"bulk-send-csv-missing-headers": "आवश्यक CSV हेडर अनुपलब्ध हैं। भूमिकाएँ: {{roles}}। प्रीफ़िल: {{prefill}}।",
|
||||
"bulk-send-csv-summary": "CSV से {{total}} पंक्तियाँ आयात की गईं।",
|
||||
"bulk-send-csv-missing-headers": "आवश्यक CSV हेडर अनुपलब्ध हैं। भूमिकाएँ: {{roles}}।",
|
||||
"bulk-send-no-records": "भेजने से पहले कृपया कम से कम एक प्राप्तकर्ता जोड़ें।",
|
||||
"csv-only-support": "केवल CSV फ़ाइलें समर्थित हैं।",
|
||||
"csv-no-records": "CSV में कोई रिकॉर्ड नहीं मिला।",
|
||||
@@ -1360,5 +1361,40 @@
|
||||
"enter-daily-email-quota": "दैनिक ईमेल कोटा दर्ज करें",
|
||||
"enter-custom-regular-expression": "कस्टम रेगुलर एक्सप्रेशन दर्ज करें",
|
||||
"enter-value": "{{value}} दर्ज करें",
|
||||
"are-you-sure-want-to-send-document": "क्या आप वाकई दस्तावेज़ भेजना चाहते हैं?"
|
||||
"are-you-sure-want-to-send-document": "क्या आप वाकई दस्तावेज़ भेजना चाहते हैं?",
|
||||
"favicon": "फ़ेविकॉन",
|
||||
"delete-two-factor-authentication": "क्या आप वाकई दो-कारक प्रमाणीकरण हटाना चाहते हैं?",
|
||||
"paste-html-here": "यहाँ HTML पेस्ट करें",
|
||||
"open-email-builder": "आप <1>email builder</1> प्लेटफ़ॉर्म का उपयोग करके ईमेल टेम्पलेट बना सकते हैं और HTML कोड कॉपी कर सकते हैं।",
|
||||
"contact-saved": "संपर्क सफलतापूर्वक सहेजा गया।",
|
||||
"remove-limit-of-user": "क्या आप वाकई इस उपयोगकर्ता की सीमा हटाना चाहते हैं? इसके बाद यह उपयोगकर्ता आपकी संस्था के सभी उपलब्ध प्रीमियम क्रेडिट का उपयोग कर सकेगा, जो सभी उपयोगकर्ताओं के बीच साझा किए जाते हैं।",
|
||||
"current-expiry-date": "वर्तमान समाप्ति तिथि",
|
||||
"reminder-error": "“हर (दिन) में एक बार याद दिलाएं” का मान “पूरा करने का समय (दिन)” से अधिक नहीं होना चाहिए।",
|
||||
"showing-to-of-records": "{{totalRows}} रिकॉर्ड में से {{record}} से {{recordPerPage}} तक दिखाया जा रहा है",
|
||||
"records-per-page": "प्रति पृष्ठ रिकॉर्ड",
|
||||
"email-not-found-in-row": "पंक्ति {{row}} में ईमेल नहीं मिला।",
|
||||
"invalid-email-found-in-row": "पंक्ति {{row}} में अमान्य ईमेल मिला: {{email}}",
|
||||
"duplicate-email-found-in-row": "पंक्ति {{row}} में डुप्लिकेट ईमेल मिला: {{email}}",
|
||||
"prefill": "प्रीफ़िल",
|
||||
"recipients-&-data": "प्राप्तकर्ता और डेटा",
|
||||
"response": "प्रतिक्रिया",
|
||||
"prefill-tab-help": "ये सामान्य टेम्पलेट फ़ील्ड हैं जो इस बल्क सेंड के सभी दस्तावेज़ों पर लागू होंगे।",
|
||||
"recipient-tab-help-p1": "इस अगले चरण में, आप:",
|
||||
"recipient-tab-help-p2": "प्राप्तकर्ता विवरण के साथ CSV अपलोड करेंगे या",
|
||||
"recipient-tab-help-p3": "प्राप्तकर्ता जानकारी मैन्युअल रूप से दर्ज करेंगे",
|
||||
"recipient-tab-help-p4": "प्रत्येक प्राप्तकर्ता को इस टेम्पलेट से जनरेट किया गया अपना दस्तावेज़ प्राप्त होगा।",
|
||||
"response-tab-help": "अपनी प्रतिक्रियाओं की समीक्षा करें।",
|
||||
"summary": "सारांश",
|
||||
"prefill-fields": "प्रीफ़िल फ़ील्ड",
|
||||
"documents": "दस्तावेज़",
|
||||
"message": "संदेश",
|
||||
"formula-help-p1": "संख्यात्मक इनपुट एकत्र करें और सूत्रों का उपयोग करके डायनामिक गणनाएँ करें।",
|
||||
"example": "उदाहरण",
|
||||
"formula-help-p2": "number-3 में कुल दिखाने के लिए {{number-1}} + {{number-2}} का उपयोग करें।",
|
||||
"formula-help-p3": "number-4 में मान निकालने के लिए ({{number-1}} * {{number-2}}) / {{number-3}} का उपयोग करें।",
|
||||
"formula-help-p4": "प्रत्येक नंबर विजेट का एक अद्वितीय नाम होना चाहिए (जैसे number-1), क्योंकि सूत्र इन पहचानकर्ताओं पर निर्भर करते हैं।",
|
||||
"note-consume-premium-credits": "नोट: यह सुविधा प्रीमियम क्रेडिट का उपयोग करती है।",
|
||||
"note-consume-premium-credits-on-document-send": "नोट: यह सुविधा प्रीमियम क्रेडिट का उपयोग करती है। दस्तावेज़ भेजे जाने पर हर बार क्रेडिट उपयोग किए जाते हैं, चाहे दस्तावेज़ पर हस्ताक्षर किए जाएँ या नहीं।",
|
||||
"webhook-authentication-help": "{{appName}} प्रत्येक वेबहुक को सुरक्षित करता है, आपके वेबहुक सुरक्षा कुंजी और मूल अनुरोध पेलोड का उपयोग करके HMAC-SHA256 हस्ताक्षर उत्पन्न करके। यह हस्ताक्षर x-webhook-signature हेडर में भेजा जाता है। आपके सर्वर को प्राप्त पेलोड और आपकी सीक्रेट कुंजी का उपयोग करके वही हस्ताक्षर उत्पन्न करना होगा। यदि दोनों हस्ताक्षर मेल खाते हैं, तो वेबहुक अनुरोध प्रमाणिक है और उसमें कोई परिवर्तन नहीं किया गया है।",
|
||||
"invalid-widget-found-in-row": "पंक्ति {{row}} में विजेट {{widget}} के लिए अमान्य मान पाया गया।"
|
||||
}
|
||||
@@ -75,7 +75,7 @@
|
||||
"password-length": "La password deve contenere almeno 8 caratteri",
|
||||
"password-case": "La password deve contenere una lettera maiuscola, una minuscola e un numero",
|
||||
"password-special-char": "La password deve contenere un carattere speciale",
|
||||
"agreee": "Accetto i",
|
||||
"agree": "Accetto i",
|
||||
"term": "Termini di servizio",
|
||||
"subscribe-to-opensign": "Iscriviti alla newsletter di OpenSign",
|
||||
"register": "Registrati",
|
||||
@@ -241,7 +241,6 @@
|
||||
"auto-reminder": "Promemoria automatico",
|
||||
"remind-once": "Ricorda una volta ogni (giorni)",
|
||||
"next": "Avanti",
|
||||
"select-folder": "Seleziona cartella",
|
||||
"OpenSign-drive": "{{appName}} Drive",
|
||||
"drive-document-status": {
|
||||
"Completed": "Completato",
|
||||
@@ -296,7 +295,7 @@
|
||||
"edit-team": "Modifica Team",
|
||||
"name-of-team": "Nome del Team",
|
||||
"prev": "Precedente",
|
||||
"no-data-avaliable": "Nessun dato disponibile",
|
||||
"no-data-available": "Nessun dato disponibile",
|
||||
"add-team": "Aggiungi Team",
|
||||
"page-not-found": "Pagina non trovata",
|
||||
"users-from-teams": "utenti dai Team",
|
||||
@@ -307,7 +306,7 @@
|
||||
"delete-user": "Elimina utente",
|
||||
"delete": "Elimina",
|
||||
"add-user": "Aggiungi utente",
|
||||
"password-generateed": "La password verrà generata solo una volta; assicurati di copiarla.",
|
||||
"password-generated": "La password verrà generata solo una volta; assicurati di copiarla.",
|
||||
"Team status": "Stato del Team",
|
||||
"user-deactivated": "Utente disattivato.",
|
||||
"user-activated": "Utente attivato.",
|
||||
@@ -337,7 +336,7 @@
|
||||
"quick-send-alert-1": "Tutti i ruoli in questo documento sono attualmente collegati a contatti. Per inviare rapidamente copie di questo modello a più firmatari, assicurati che almeno un ruolo non sia collegato a nessun contatto.",
|
||||
"quick-send-alert-2": "Assicurati che ci sia almeno un widget firma aggiunto per tutti i destinatari.",
|
||||
"quick-send-alert-3": "Si prega di aggiungere almeno un ruolo a questo template.",
|
||||
"quick-send-alert-4": "Limite raggiunto per {{flow}}.",
|
||||
"quick-send-alert-4": "{{flow}} supporta fino a {{count}} firmatari per batch. Dividi il tuo elenco e riprova.",
|
||||
"copy-link": "Copia link",
|
||||
"copy": "Copia",
|
||||
"revoke-document": "Revoca documento",
|
||||
@@ -438,13 +437,15 @@
|
||||
"reset-password-alert-1": "Il link per reimpostare la password è stato inviato al tuo indirizzo email",
|
||||
"reset-password-alert-2": "Si prega di configurare l'adattatore email",
|
||||
"reset-password-alert-3": "Reimposta la tua password",
|
||||
"faild-animation": "Caricamento animazione non riuscito",
|
||||
"failed-animation": "Caricamento animazione non riuscito",
|
||||
"apply": "Applica",
|
||||
"select-columns": "Seleziona colonne",
|
||||
"copy-type": {
|
||||
"All pages": "Tutte le pagine",
|
||||
"All pages but last": "Tutte le pagine tranne l'ultima",
|
||||
"All pages but first": "Tutte le pagine tranne la prima",
|
||||
"Next to current widget": "Accanto al widget corrente"
|
||||
"Next to current widget": "Accanto al widget corrente",
|
||||
"Page range": "Intervallo di pagine"
|
||||
},
|
||||
"options": "Opzioni",
|
||||
"minimum-check": "Controllo minimo",
|
||||
@@ -626,7 +627,7 @@
|
||||
"date": "Data",
|
||||
"report-not-found": "Report non trovato",
|
||||
"please-wait": "attendere prego",
|
||||
"select-foler": "Seleziona cartella",
|
||||
"select-folder": "Seleziona cartella",
|
||||
"generated-on": "Generato il",
|
||||
"text-field": "Campo di testo",
|
||||
"font-size": "Dimensione carattere",
|
||||
@@ -685,13 +686,13 @@
|
||||
"public-template": "Questo video dimostra come configurare il tuo profilo pubblico personalizzato, come 'https://opensign.me/tuo-username'. Imparerai anche come personalizzare il tuo slogan e rendere i tuoi modelli disponibili per la firma pubblica.",
|
||||
"allowModify-widgets": "È possibile trascinare e rilasciare uno qualsiasi di questi campi nel documento, oltre ai campi già designati dal creatore del documento."
|
||||
},
|
||||
"enter-email-plaholder": "Aggiungi un indirizzo email e premi invio",
|
||||
"enter-email-placeholder": "Aggiungi un indirizzo email e premi invio",
|
||||
"success-email-alert": "Email inviata con successo!",
|
||||
"expired-doc-title": "Documento Scaduto",
|
||||
"expired-on-mssg": "Questo documento è scaduto il {{expiredDate}} e non è più disponibile per la firma.",
|
||||
"signature-validate-alert": "Conferma di aver selezionato almeno {{minRequiredCount}} caselle di controllo.",
|
||||
"signature-validate-alert-2": "Assicurati che questo campo sia compilato correttamente e soddisfi tutti i requisiti.",
|
||||
"remaing-users": "Utenti rimanenti",
|
||||
"remaining-users": "Utenti rimanenti",
|
||||
"Role": "Ruolo",
|
||||
"additional-users": "Acquista utenti aggiuntivi.",
|
||||
"Quantity-of-users": "Quantità di utenti",
|
||||
@@ -713,7 +714,7 @@
|
||||
"rotate-left": "Ruota a sinistra",
|
||||
"rotate-alert-mssg": "Tutti i widget su questa pagina andranno persi. Sei sicuro di voler procedere?",
|
||||
"templateid": "ID Modello",
|
||||
"bulk-send-subcription-alert": "Esegui l'upgrade al piano Professionale o Team per utilizzare l'invio in massa.",
|
||||
"bulk-send-subscription-alert": "Esegui l'upgrade al piano Professionale o Team per utilizzare l'invio in massa.",
|
||||
"generate-test-token": "Genera Token di Test",
|
||||
"regenerate-test-token": "Rigenera Token di Test",
|
||||
"help-test-token": "Questo token può essere utilizzato per testare le API all'endpoint https://sandbox.opensignlabs.com/api/v1, consentendoti di firmare documenti illimitati. Nota che le API sandbox firmeranno i tuoi documenti con certificati autofirmati, che potrebbero non essere riconosciuti come validi da Adobe. Una volta completati i test, puoi eseguire l'upgrade a uno dei nostri piani a pagamento per generare un token di produzione.",
|
||||
@@ -733,8 +734,9 @@
|
||||
"copied-code": "COPIATO",
|
||||
"Installation": "Installazione",
|
||||
"Usage": "Utilizzo",
|
||||
"insufficient-credits": "Crediti di firma insufficienti",
|
||||
"insufficient-credits-mssg": "Il proprietario di questo documento non dispone attualmente dei crediti OpenSign necessari affinché tu possa firmare. Contatta il proprietario per ulteriori dettagli.",
|
||||
"insufficient-credits": "Crediti di firma insufficienti. Si prega di contattare {{ownerEmail}}",
|
||||
"insufficient-credits-title": "Crediti di firma insufficienti",
|
||||
"insufficient-credits-mssg": "Il proprietario di questo documento non dispone attualmente dei crediti {{appName}} necessari affinché tu possa firmare. Contatta il proprietario per ulteriori dettagli.",
|
||||
"angular-npm-mssg-1": "Per integrare OpenSign nel tuo progetto Angular, esegui semplicemente il seguente comando:",
|
||||
"quota-mail-info-head": "Limite mensile di email",
|
||||
"quota-mail-info": "Per mantenere la qualità del servizio e prevenire lo spam, OpenSign consente fino a 15 email al mese con il piano gratuito. Esegui l'upgrade ora per l'invio illimitato di email.",
|
||||
@@ -866,7 +868,6 @@
|
||||
"js-snippet-msg-1": "Puoi ottenere il TemplateId dalla",
|
||||
"js-snippet-msg-2": "pagina di gestione modelli",
|
||||
"js-snippet-msg-3": "pagina.",
|
||||
"agrrement-alert": "Per procedere, devi acconsentire alla divulgazione delle registrazioni elettroniche e delle firme.",
|
||||
"webhook-already-exists": "L'URL esiste già! Prova con uno diverso.",
|
||||
"webhook-must-be-secure": "L'URL del webhook deve essere sicuro e utilizzare https://",
|
||||
"provide-test-token": "Si prega di generare il token di test API",
|
||||
@@ -1030,7 +1031,7 @@
|
||||
},
|
||||
"signer-already-present": "Firmatario già presente",
|
||||
"kiosk-sign": "Firma su chiosco",
|
||||
"dont-have-access-to-template": "Il template è stato eliminato o non hai accesso. Si prega di contattare il mittente.",
|
||||
"do-not-have-access-to-template": "Il template è stato eliminato o non hai accesso. Si prega di contattare il mittente.",
|
||||
"kiosk-info": "La Modalità Kiosk consente di raccogliere firme in presenza in modo rapido ed efficiente. Ideale per fiere, eventi o situazioni con firmatari fisicamente presenti. ",
|
||||
"learn-more": "Scopri di più",
|
||||
"finish-mssg": "Sei sicuro di voler completare il documento?",
|
||||
@@ -1086,8 +1087,6 @@
|
||||
"search-contacts": "Cerca contatti…",
|
||||
"add-role-alert": "Si prega di aggiungere almeno un ruolo",
|
||||
"edit-draft": "Modifica bozza",
|
||||
"invalid-email-found": "Email non valida trovata: {{email}}",
|
||||
"duplicate-email-found": "Email duplicata trovata: {{email}}",
|
||||
"vertical": "Verticale",
|
||||
"horizontal": "Orizzontale",
|
||||
"billing": "Fatturazione",
|
||||
@@ -1101,7 +1100,7 @@
|
||||
"access-denied": "Accesso negato",
|
||||
"upgrade": "Aggiorna",
|
||||
"do-not-access-app": "Non hai accesso a questa applicazione.",
|
||||
"dont-have-access": "Non hai accesso.",
|
||||
"do-not-have-access": "Non hai accesso.",
|
||||
"valid-email-alert": "Inserisci un indirizzo email valido.",
|
||||
"otp-not-validate": "OTP non valido.",
|
||||
"domain-not-allowed": "Questo dominio non è consentito",
|
||||
@@ -1118,10 +1117,12 @@
|
||||
"branding-help": "Il branding consente il white labelling della tua app",
|
||||
"custom-sub-domain": "Sottodominio personalizzato",
|
||||
"app-name": "Nome dell'app",
|
||||
"provide-domain-name": "Fornisci il tuo nome di dominio",
|
||||
"provide-domain-name": "Sono consentiti solo i sottodomini (ad es., app.tuodominio.com). I domini principali/root come tuodominio.com non sono supportati.",
|
||||
"provide-app-name-help": "Fornisci un nome univoco per l'app. Verrà mostrato come nome del tuo brand nell'applicazione e in tutte le e-mail ovunque venga applicato il branding.",
|
||||
"upload-app-logo": "Carica un logo in formato .png, .jpg o .svg. Dimensione consigliata: {{size}} pixel (rapporto 5:2) per la migliore qualità di visualizzazione.",
|
||||
"upload-app-favicon": "Carica un'immagine favicon quadrata (.ico, .png o .svg). La dimensione consigliata è {{size}} pixel per la migliore compatibilità del browser.",
|
||||
"provide-app-name": "Fornisci il nome della tua app",
|
||||
"logo": "Logo",
|
||||
"upload-app-logo": "Carica il logo della tua app",
|
||||
"prefill-unfilled-widget": "I seguenti campi obbligatori non possono essere lasciati vuoti: {{emptyWidget}}. Per favore compilali per procedere.",
|
||||
"Dashboard": "Dashboard",
|
||||
"Analytics": "Analitica",
|
||||
@@ -1345,8 +1346,8 @@
|
||||
"file-selected": "file selezionato",
|
||||
"bulk-send-csv-helper": "Utilizza le intestazioni per i ruoli dei firmatari (Email/Nome/Telefono del ruolo) e i valori di precompilazione (Precompilazione: Nome campo). I campi di precompilazione obbligatori devono essere compilati.",
|
||||
"bulk-send-csv-helper-note": "Nota: Per i campi di precompilazione di tipo data nel CSV, il valore deve essere fornito in questo formato: {{value}} (esempio: {{example}}).",
|
||||
"bulk-send-csv-summary": "{{total}} righe importate. {{invalid}} righe non valide sono state ignorate.",
|
||||
"bulk-send-csv-missing-headers": "Intestazioni CSV obbligatorie mancanti. Ruoli: {{roles}}. Precompilazione: {{prefill}}.",
|
||||
"bulk-send-csv-summary": "{{total}} righe importate dal CSV.",
|
||||
"bulk-send-csv-missing-headers": "Intestazioni CSV obbligatorie mancanti. Ruoli: {{roles}}.",
|
||||
"bulk-send-no-records": "Aggiungi almeno un destinatario prima dell'invio.",
|
||||
"csv-only-support": "Sono supportati solo file CSV.",
|
||||
"csv-no-records": "Nessun record trovato nel CSV.",
|
||||
@@ -1360,5 +1361,40 @@
|
||||
"enter-daily-email-quota": "Inserisci la quota giornaliera di e-mail",
|
||||
"enter-custom-regular-expression": "Inserisci un'espressione regolare personalizzata",
|
||||
"enter-value": "Inserisci {{value}}",
|
||||
"are-you-sure-want-to-send-document": "Sei sicuro di voler inviare il documento?"
|
||||
"are-you-sure-want-to-send-document": "Sei sicuro di voler inviare il documento?",
|
||||
"favicon": "Favicon",
|
||||
"delete-two-factor-authentication": "Sei sicuro di voler eliminare l'autenticazione a due fattori?",
|
||||
"paste-html-here": "Incolla HTML qui",
|
||||
"open-email-builder": "Puoi creare un modello di e-mail utilizzando la piattaforma <1>email builder</1> e copiare il codice HTML.",
|
||||
"contact-saved": "Contatto salvato con successo.",
|
||||
"remove-limit-of-user": "Sei sicuro di voler rimuovere il limite di questo utente? Potrà consumare tutti i crediti premium disponibili della tua organizzazione, condivisi tra tutti gli utenti.",
|
||||
"current-expiry-date": "Data di scadenza attuale",
|
||||
"reminder-error": "“Ricorda una volta ogni (giorni)” non deve superare il “Tempo per completare (giorni)”.",
|
||||
"showing-to-of-records": "Visualizzazione da {{record}} a {{recordPerPage}} di {{totalRows}} record",
|
||||
"records-per-page": "Record per pagina",
|
||||
"email-not-found-in-row": "Email non trovata nella riga {{row}}.",
|
||||
"invalid-email-found-in-row": "Email non valida trovata nella riga {{row}}: {{email}}",
|
||||
"duplicate-email-found-in-row": "Email duplicata trovata nella riga {{row}}: {{email}}",
|
||||
"prefill": "Precompilazione",
|
||||
"recipients-&-data": "Destinatari e dati",
|
||||
"response": "Risposta",
|
||||
"prefill-tab-help": "Questi sono i campi comuni del modello che verranno applicati a tutti i documenti in questo invio massivo.",
|
||||
"recipient-tab-help-p1": "Nel passaggio successivo, dovrai:",
|
||||
"recipient-tab-help-p2": "Caricare un CSV con i dettagli dei destinatari oppure",
|
||||
"recipient-tab-help-p3": "Inserire manualmente le informazioni dei destinatari",
|
||||
"recipient-tab-help-p4": "Ogni destinatario riceverà il proprio documento generato da questo modello.",
|
||||
"response-tab-help": "Rivedi le tue risposte.",
|
||||
"summary": "Riepilogo",
|
||||
"prefill-fields": "Campi di precompilazione",
|
||||
"documents": "Documenti",
|
||||
"message": "Messaggio",
|
||||
"formula-help-p1": "Raccogli input numerici ed esegui calcoli dinamici utilizzando formule.",
|
||||
"example": "Esempio",
|
||||
"formula-help-p2": "Per visualizzare un totale in number-3, usa {{number-1}} + {{number-2}}.",
|
||||
"formula-help-p3": "Per calcolare un valore in number-4, usa ({{number-1}} * {{number-2}}) / {{number-3}}.",
|
||||
"formula-help-p4": "Ogni widget numerico deve avere un nome univoco (es. number-1), poiché le formule si basano su questi identificatori.",
|
||||
"note-consume-premium-credits": "Nota: Questa funzione consuma crediti premium.",
|
||||
"note-consume-premium-credits-on-document-send": "Nota: Questa funzione consuma crediti premium. I crediti vengono consumati ogni volta che un documento viene inviato, indipendentemente dal fatto che venga firmato o meno.",
|
||||
"webhook-authentication-help": "{{appName}} protegge ogni webhook generando una firma HMAC-SHA256 utilizzando la tua chiave di sicurezza webhook e il payload originale della richiesta. Questa firma viene inviata nell'intestazione x-webhook-signature. Il tuo server deve generare la stessa firma utilizzando il payload ricevuto e la tua chiave segreta. Se entrambe le firme coincidono, la richiesta webhook è autentica e non è stata modificata.",
|
||||
"invalid-widget-found-in-row": "Valore non valido rilevato nella riga {{row}} per il widget {{widget}}."
|
||||
}
|
||||
@@ -75,7 +75,7 @@
|
||||
"password-length": "비밀번호는 8자 이상이어야 합니다",
|
||||
"password-case": "비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다",
|
||||
"password-special-char": "비밀번호는 특수 문자를 포함해야 합니다",
|
||||
"agreee": "저는 동의합니다",
|
||||
"agree": "저는 동의합니다",
|
||||
"term": "서비스 약관",
|
||||
"subscribe-to-opensign": "OpenSign 뉴스레터 구독",
|
||||
"register": "등록",
|
||||
@@ -241,7 +241,6 @@
|
||||
"auto-reminder": "자동 알림",
|
||||
"remind-once": " 매 (일)마다 한 번 알림",
|
||||
"next": "다음",
|
||||
"select-folder": "선택 폴더",
|
||||
"OpenSign-drive": "{{appName}} 드라이브",
|
||||
"drive-document-status": {
|
||||
"Completed": "완료됨",
|
||||
@@ -296,7 +295,7 @@
|
||||
"edit-team": "팀 편집",
|
||||
"name-of-team": "팀 이름",
|
||||
"prev": "이전",
|
||||
"no-data-avaliable": "데이터 없음",
|
||||
"no-data-available": "데이터 없음",
|
||||
"add-team": "팀 추가",
|
||||
"page-not-found": "페이지를 찾을 수 없습니다",
|
||||
"users-from-teams": "팀의 사용자",
|
||||
@@ -307,7 +306,7 @@
|
||||
"delete-user": "사용자 삭제",
|
||||
"delete": "삭제",
|
||||
"add-user": "사용자 추가",
|
||||
"password-generateed": "비밀번호는 한 번만 생성되므로 복사해 두세요.",
|
||||
"password-generated": "비밀번호는 한 번만 생성되므로 복사해 두세요.",
|
||||
"Team status": "팀 상태",
|
||||
"user-deactivated": "유저 비활성화됨.",
|
||||
"user-activated": "유저 활성화됨.",
|
||||
@@ -337,7 +336,7 @@
|
||||
"quick-send-alert-1": "이 문서의 모든 역할이 현재 연락처에 연결되어 있습니다. 이 템플릿의 사본을 여러 서명자에게 빠르게 보내려면, 최소한 하나의 역할이 어떤 연락처에도 연결되지 않도록 하십시오.",
|
||||
"quick-send-alert-2": "모든 수신자에게 최소 한 개의 서명 위젯이 추가되었는지 확인하십시오.",
|
||||
"quick-send-alert-3": "이 템플릿에 최소한 하나의 역할을 추가하십시오.",
|
||||
"quick-send-alert-4": "빠른 전송 한도에 도달했습니다.",
|
||||
"quick-send-alert-4": "{{flow}}은(는) 배치당 최대 {{count}}명의 서명자를 지원합니다. 목록을 나누어 다시 시도하세요.",
|
||||
"copy-link": "링크 복사",
|
||||
"copy": "복사",
|
||||
"revoke-document": "문서 취소",
|
||||
@@ -438,14 +437,15 @@
|
||||
"reset-password-alert-1": "비밀번호 재설정 링크가 이메일로 전송되었습니다",
|
||||
"reset-password-alert-2": "이메일 어댑터를 설정하십시오 ",
|
||||
"reset-password-alert-3": "비밀번호 재설정",
|
||||
"faild-animation": "애니메이션 로드 실패",
|
||||
"failed-animation": "애니메이션 로드 실패",
|
||||
"apply": "적용",
|
||||
"select-columns": "열 선택",
|
||||
"copy-type": {
|
||||
"All pages": "모든 페이지",
|
||||
"All pages but last": "마지막 페이지 제외 모든 페이지",
|
||||
"All pages but first": "첫 페이지 제외 모든 페이지",
|
||||
"Next to current widget": "현재 위젯 옆"
|
||||
"Next to current widget": "현재 위젯 옆",
|
||||
"Page range": "페이지 범위"
|
||||
},
|
||||
"options": "옵션",
|
||||
"minimum-check": "최소 선택",
|
||||
@@ -627,7 +627,7 @@
|
||||
"date": "날짜",
|
||||
"report-not-found": "보고서를 찾을 수 없습니다",
|
||||
"please-wait": "잠시만 기다려주세요",
|
||||
"select-foler": "폴더 선택",
|
||||
"select-folder": "폴더 선택",
|
||||
"generated-on": "생성 날짜",
|
||||
"text-field": "텍스트 필드",
|
||||
"font-size": "글꼴 크기",
|
||||
@@ -686,13 +686,13 @@
|
||||
"public-template": "이 비디오는 'https://opensign.me/your-username'과 같은 개인화된 공개 프로필을 설정하는 방법을 보여줍니다. 또한 태그라인을 사용자 지정하고 템플릿을 공개 서명에 사용할 수 있도록 하는 방법도 배웁니다.",
|
||||
"allowModify-widgets": "문서 작성자가 이미 지정한 필드 외에 이러한 필드 중 하나를 문서에 끌어다 놓을 수 있습니다."
|
||||
},
|
||||
"enter-email-plaholder": "이메일 주소를 추가하고 Enter 키를 누르세요",
|
||||
"enter-email-placeholder": "이메일 주소를 추가하고 Enter 키를 누르세요",
|
||||
"success-email-alert": "이메일이 성공적으로 전송되었습니다!",
|
||||
"expired-doc-title": "만료된 문서",
|
||||
"expired-on-mssg": "이 문서는 {{expiredDate}}에 만료되어 더 이상 서명할 수 없습니다.",
|
||||
"signature-validate-alert": "최소 {{minRequiredCount}}개의 확인란을 선택했는지 확인하세요.",
|
||||
"signature-validate-alert-2": "이 필드가 정확하게 채워지고 모든 요구 사항을 충족하는지 확인하세요.",
|
||||
"remaing-users": "남은 사용자",
|
||||
"remaining-users": "남은 사용자",
|
||||
"Role": "역할",
|
||||
"additional-users": "추가 사용자를 구매하세요.",
|
||||
"Quantity-of-users": "사용자 수",
|
||||
@@ -714,7 +714,7 @@
|
||||
"rotate-left": "왼쪽으로 회전",
|
||||
"rotate-alert-mssg": "이 페이지의 모든 위젯이 손실됩니다. 계속하시겠습니까?",
|
||||
"templateid": "템플릿 ID",
|
||||
"bulk-send-subcription-alert": "대량 전송을 사용하려면 프로페셔널 또는 팀 플랜으로 업그레이드하세요.",
|
||||
"bulk-send-subscription-alert": "대량 전송을 사용하려면 프로페셔널 또는 팀 플랜으로 업그레이드하세요.",
|
||||
"generate-test-token": "테스트 토큰 생성",
|
||||
"regenerate-test-token": "테스트 토큰 재성",
|
||||
"help-test-token": "이 토큰은 https://sandbox.opensignlabs.com/api/v1 엔드포인트에서 API를 테스트하는 데 사용할 수 있으며 무제한 문서 서명을 수행할 수 있습니다. 샌드박스 API는 자체 서명된 인증서로 문서에 서명하므로 Adobe에서 유효한 것으로 인식하지 못할 수 있습니다. 테스트를 완료한 후 유료 플랜 중 하나로 업그레이드하여 프로덕션 토큰을 생성할 수 있습니다.",
|
||||
@@ -734,8 +734,9 @@
|
||||
"copied-code": "복사됨",
|
||||
"Installation": "설치",
|
||||
"Usage": "사용법",
|
||||
"insufficient-credits": "서명 크레딧 부족",
|
||||
"insufficient-credits-mssg": "이 문서의 소유자는 현재 서명하는 데 필요한 OpenSign 크레딧이 부족합니다. 자세한 내용이 필요한 경우 소유자에게 문의하세요.",
|
||||
"insufficient-credits": "서명 크레딧이 부족합니다. {{ownerEmail}}에 문의하세요",
|
||||
"insufficient-credits-title": "서명 크레딧 부족",
|
||||
"insufficient-credits-mssg": "이 문서의 소유자는 현재 서명하는 데 필요한 {{appName}} 크레딧이 부족합니다. 자세한 내용이 필요한 경우 소유자에게 문의하세요.",
|
||||
"angular-npm-mssg-1": "Angular 프로젝트에 OpenSign을 통합하려면 다음 명령을 실행하기만 하면 됩니다.",
|
||||
"quota-mail-info-head": "월간 이메일 한도",
|
||||
"quota-mail-info": "서비스 품질을 유지하고 스팸을 방지하기 위해 OpenSign은 무료 플랜에서 월 최대 15개의 이메일을 허용합니다. 지금 업그레이드하여 무제한 이메일 전송을 이용하세요.",
|
||||
@@ -867,7 +868,6 @@
|
||||
"js-snippet-msg-1": "템플릿 ID는 다음에서 얻을 수 있습니다.",
|
||||
"js-snippet-msg-2": "템플릿 관리",
|
||||
"js-snippet-msg-3": "페이지.",
|
||||
"agrrement-alert": "계속하려면 전자 기록 및 서명 공개에 동의해야 합니다.",
|
||||
"webhook-already-exists": "URL이 이미 존재합니다! 다른 URL을 시도하세요.",
|
||||
"webhook-must-be-secure": "웹훅 URL은 안전해야 하며 https://를 사용해야 합니다.",
|
||||
"provide-test-token": "API 테스트 토큰을 생성하세요.",
|
||||
@@ -1031,7 +1031,7 @@
|
||||
},
|
||||
"signer-already-present": "서명자가 이미 존재합니다",
|
||||
"kiosk-sign": "키오스크 서명",
|
||||
"dont-have-access-to-template": "템플릿이 삭제되었거나 접근 권한이 없습니다. 발신자에게 문의하십시오.",
|
||||
"do-not-have-access-to-template": "템플릿이 삭제되었거나 접근 권한이 없습니다. 발신자에게 문의하십시오.",
|
||||
"kiosk-info": "키오스크 모드를 사용하면 직접 서명을 빠르고 효율적으로 수집할 수 있습니다. 모든 서명자가 물리적으로 참석하는 무역 박람회, 이벤트 또는 방문 시나리오에 이상적입니다.",
|
||||
"learn-more": "더 알아보기",
|
||||
"finish-mssg": " 문서를 완료하시겠습니까?",
|
||||
@@ -1087,8 +1087,6 @@
|
||||
"search-contacts": "연락처 검색…",
|
||||
"add-role-alert": "하나 이상의 역할을 추가하십시오",
|
||||
"edit-draft": "초안 편집",
|
||||
"invalid-email-found": "잘못된 이메일 발견: {{email}}",
|
||||
"duplicate-email-found": "중복 이메일 발견: {{email}}",
|
||||
"vertical": "세로",
|
||||
"horizontal": "가로",
|
||||
"billing": "결제",
|
||||
@@ -1102,7 +1100,7 @@
|
||||
"access-denied": "접근 거부",
|
||||
"upgrade": "업그레이드",
|
||||
"do-not-access-app": "이 애플리케이션에 접근할 수 없습니다.",
|
||||
"dont-have-access": "접근 권한이 없습니다.",
|
||||
"do-not-have-access": "접근 권한이 없습니다.",
|
||||
"valid-email-alert": "유효한 이메일 주소를 입력하십시오.",
|
||||
"otp-not-validate": "OTP가 유효하지 않습니다.",
|
||||
"domain-not-allowed": "이 도메인은 허용되지 않습니다",
|
||||
@@ -1119,10 +1117,12 @@
|
||||
"branding-help": "브랜딩은 앱에 화이트 라벨링을 제공합니다",
|
||||
"custom-sub-domain": "사용자 지정 하위 도메인",
|
||||
"app-name": "앱 이름",
|
||||
"provide-domain-name": "도메인 이름을 입력하세요",
|
||||
"provide-domain-name": "서브도메인만 허용됩니다(예: app.yourdomain.com). yourdomain.com과 같은 기본/루트 도메인은 지원되지 않습니다.",
|
||||
"provide-app-name-help": "고유한 앱 이름을 입력하세요. 앱 내에서는 브랜드 이름으로, 브랜딩이 적용되는 모든 이메일에서도 표시됩니다.",
|
||||
"upload-app-logo": ".png, .jpg 또는 .svg 형식의 로고를 업로드하세요. 최상의 표시 품질을 위해 권장 크기: {{size}}픽셀(가로세로 비율 5:2).",
|
||||
"upload-app-favicon": "정사각형 파비콘 이미지(.ico, .png 또는 .svg)를 업로드하세요. 브라우저 호환성을 위해 권장 크기: {{size}}픽셀.",
|
||||
"provide-app-name": "앱 이름을 입력하세요",
|
||||
"logo": "로고",
|
||||
"upload-app-logo": "앱 로고 업로드",
|
||||
"prefill-unfilled-widget": "다음 필수 필드를 비워 둘 수 없습니다: {{emptyWidget}} 계속하려면 채워주세요.",
|
||||
"Dashboard": "대시보드",
|
||||
"Analytics": "분석",
|
||||
@@ -1346,8 +1346,8 @@
|
||||
"files-selected": "파일이 선택되었습니다",
|
||||
"bulk-send-csv-helper": "서명자 역할(역할 이메일/이름/전화번호)과 사전 입력 값(사전 입력: 필드 이름)에 헤더를 사용하세요. 필수 사전 입력 필드는 반드시 입력해야 합니다.",
|
||||
"bulk-send-csv-helper-note": "참고: CSV의 날짜 유형 사전 입력 필드의 값은 다음 형식으로 제공되어야 합니다: {{value}} (예: {{example}}).",
|
||||
"bulk-send-csv-summary": "총 {{total}}개의 행을 가져왔습니다. {{invalid}}개의 잘못된 행은 건너뛰었습니다.",
|
||||
"bulk-send-csv-missing-headers": "필수 CSV 헤더가 누락되었습니다. 역할: {{roles}}. 사전 입력: {{prefill}}.",
|
||||
"bulk-send-csv-summary": "CSV에서 {{total}}개의 행을 가져왔습니다.",
|
||||
"bulk-send-csv-missing-headers": "필수 CSV 헤더가 누락되었습니다. 역할: {{roles}}.",
|
||||
"bulk-send-no-records": "전송하기 전에 최소 한 명의 수신자를 추가하세요.",
|
||||
"csv-only-support": "CSV 파일만 지원됩니다.",
|
||||
"csv-no-records": "CSV에서 레코드를 찾을 수 없습니다.",
|
||||
@@ -1361,5 +1361,40 @@
|
||||
"enter-daily-email-quota": "일일 이메일 할당량을 입력하세요",
|
||||
"enter-custom-regular-expression": "사용자 정의 정규식을 입력하세요",
|
||||
"enter-value": "{{value}}을(를) 입력하세요",
|
||||
"are-you-sure-want-to-send-document": "문서를 보내시겠습니까?"
|
||||
"are-you-sure-want-to-send-document": "문서를 보내시겠습니까?",
|
||||
"favicon": "Favicon",
|
||||
"delete-two-factor-authentication": "이중 인증을 삭제하시겠습니까?",
|
||||
"paste-html-here": "여기에 HTML 붙여넣기",
|
||||
"open-email-builder": "<1>이메일 빌더</1> 플랫폼을 사용하여 이메일 템플릿을 생성하고 HTML 코드를 복사할 수 있습니다.",
|
||||
"contact-saved": "연락처가 성공적으로 저장되었습니다.",
|
||||
"remove-limit-of-user": "이 사용자의 제한을 제거하시겠습니까? 제거하면 이 사용자는 모든 사용자에게 공유된 조직의 사용 가능한 모든 프리미엄 크레딧을 사용할 수 있습니다.",
|
||||
"current-expiry-date": "현재 만료일",
|
||||
"reminder-error": "“(일)마다 한 번 알림”은 “완료까지의 시간(일)”을 초과할 수 없습니다.",
|
||||
"showing-to-of-records": "총 {{totalRows}}개 중 {{record}}부터 {{recordPerPage}}까지 표시 중",
|
||||
"records-per-page": "페이지당 항목 수",
|
||||
"email-not-found-in-row": "{{row}}행에서 이메일을 찾을 수 없습니다.",
|
||||
"invalid-email-found-in-row": "{{row}}행에서 잘못된 이메일이 발견되었습니다: {{email}}",
|
||||
"duplicate-email-found-in-row": "{{row}}행에서 중복된 이메일이 발견되었습니다: {{email}}",
|
||||
"prefill": "미리 채우기",
|
||||
"recipients-&-data": "수신자 및 데이터",
|
||||
"response": "응답",
|
||||
"prefill-tab-help": "이 필드는 이 대량 전송의 모든 문서에 적용되는 공통 템플릿 필드입니다.",
|
||||
"recipient-tab-help-p1": "다음 단계에서는:",
|
||||
"recipient-tab-help-p2": "수신자 정보가 포함된 CSV를 업로드하거나",
|
||||
"recipient-tab-help-p3": "수신자 정보를 수동으로 입력합니다",
|
||||
"recipient-tab-help-p4": "각 수신자는 이 템플릿에서 생성된 개별 문서를 받게 됩니다.",
|
||||
"response-tab-help": "응답을 검토하세요.",
|
||||
"summary": "요약",
|
||||
"prefill-fields": "미리 채우기 필드",
|
||||
"documents": "문서",
|
||||
"message": "메시지",
|
||||
"formula-help-p1": "숫자 입력을 수집하고 수식을 사용하여 동적 계산을 수행하세요.",
|
||||
"example": "예시",
|
||||
"formula-help-p2": "number-3에 합계를 표시하려면 {{number-1}} + {{number-2}}를 사용하세요.",
|
||||
"formula-help-p3": "number-4의 값을 계산하려면 ({{number-1}} * {{number-2}}) / {{number-3}}를 사용하세요.",
|
||||
"formula-help-p4": "각 숫자 위젯은 고유한 이름(예: number-1)을 가져야 하며, 수식은 이러한 식별자를 기반으로 합니다.",
|
||||
"note-consume-premium-credits": "참고: 이 기능은 프리미엄 크레딧을 사용합니다.",
|
||||
"note-consume-premium-credits-on-document-send": "참고: 이 기능은 프리미엄 크레딧을 사용합니다. 문서가 서명되었는지 여부와 관계없이 문서를 보낼 때마다 크레딧이 차감됩니다.",
|
||||
"webhook-authentication-help": "{{appName}}은(는) 웹훅 보안 키와 원본 요청 페이로드를 사용하여 HMAC-SHA256 서명을 생성함으로써 모든 웹훅을 보호합니다. 이 서명은 x-webhook-signature 헤더에 전송됩니다. 귀하의 서버는 수신된 페이로드와 비밀 키를 사용하여 동일한 서명을 생성해야 합니다. 두 서명이 일치하면 해당 웹훅 요청은 인증되었으며 수정되지 않았음을 의미합니다.",
|
||||
"invalid-widget-found-in-row": "{{row}}행에서 위젯 {{widget}}에 대한 유효하지 않은 값이 감지되었습니다."
|
||||
}
|
||||
Binary file not shown.
@@ -35,6 +35,8 @@ const UpdateExistUserAdmin = lazyWithRetry(
|
||||
const Preferences = lazyWithRetry(() => import("./pages/Preferences"));
|
||||
const Login = lazyWithRetry(() => import("./pages/Login"));
|
||||
const VerifyDocument = lazyWithRetry(() => import("./pages/VerifyDocument"));
|
||||
const EmailBuilder = lazyWithRetry(() => import("./pages/EmailBuilder"));
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/legacy/build/pdf.worker.min.mjs`;
|
||||
const AppLoader = () => {
|
||||
return (
|
||||
@@ -137,6 +139,7 @@ function App() {
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/success" element={<DocSuccessPage />} />
|
||||
<Route path="/emailbuilder" element={<EmailBuilder />} />
|
||||
<Route path="*" element={<PageNotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -218,7 +218,7 @@ const AddUser = (props) => {
|
||||
></i>
|
||||
</div>
|
||||
<div className="text-[12px] ml-2 mb-0 text-[red] select-none">
|
||||
{t("password-generateed")}
|
||||
{t("password-generated")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
|
||||
@@ -15,27 +15,31 @@ const DatePicker = ({
|
||||
minDate,
|
||||
maxDate,
|
||||
onChange,
|
||||
handleClear
|
||||
handleClear,
|
||||
label,
|
||||
showLabel = true,
|
||||
showClear = true,
|
||||
dateClassName = ""
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const CustomInput = forwardRef(({ value, onClick }, ref) => (
|
||||
<div
|
||||
className="border-gray-400 rounded-[50px] border-[1px] px-3 text-xs py-2 focus:outline-none hover:border-base-content "
|
||||
className="w-full border-gray-400 rounded-[50px] border-[1px] px-3 text-xs py-2 focus:outline-none hover:border-base-content flex items-center justify-between"
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
>
|
||||
{value}
|
||||
<i
|
||||
className={`${value ? "ml-[15px]" : "w-20"} fa-light fa-calendar `}
|
||||
></i>
|
||||
<span className={`${dateClassName} truncate`}>{value}</span>
|
||||
<i className={`fa-light fa-calendar`}></i>
|
||||
</div>
|
||||
));
|
||||
CustomInput.displayName = "CustomInput";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span>{t("default-date")}: </span>
|
||||
<>
|
||||
{showLabel && (
|
||||
<span className="flex-shrink-0">{label || t("default-date")}: </span>
|
||||
)}
|
||||
<ReactDatePicker
|
||||
renderCustomHeader={({ date, changeYear, changeMonth }) => (
|
||||
<div className="flex justify-start md:ml-2">
|
||||
@@ -65,6 +69,7 @@ const DatePicker = ({
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
wrapperClassName="w-full"
|
||||
closeOnScroll={true}
|
||||
selected={getDefaultDate(selectDate?.date, selectDate?.format)}
|
||||
popperPlacement="top-end"
|
||||
@@ -75,13 +80,15 @@ const DatePicker = ({
|
||||
}
|
||||
portalId="root-portal"
|
||||
/>
|
||||
<span
|
||||
onClick={() => handleClear()}
|
||||
className="underline text-blue-500 cursor-pointer ml-2"
|
||||
>
|
||||
{t("clear")}
|
||||
</span>
|
||||
</div>
|
||||
{showClear && handleClear && (
|
||||
<span
|
||||
onClick={() => handleClear()}
|
||||
className="underline text-blue-500 cursor-pointer ml-2"
|
||||
>
|
||||
{t("clear")}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import juice from "juice";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
const EmailBodyEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
smallscreen = false,
|
||||
bodyName,
|
||||
isReset = false,
|
||||
isTemplateLoaded
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [inputHtml, setInputHtml] = useState("");
|
||||
const [cleanPreview, setCleanPreview] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
initProcessContent();
|
||||
}, [isTemplateLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReset) {
|
||||
initProcessContent(value);
|
||||
}
|
||||
}, [isReset]);
|
||||
|
||||
const initProcessContent = () => {
|
||||
// 1. Sanitize immediately to strip scripts/malicious tags
|
||||
const sanitized = DOMPurify.sanitize(value, {
|
||||
USE_PROFILES: { html: true }, // Ensures basic HTML structure
|
||||
ADD_ATTR: ["target"] // Allow links to open in new tabs
|
||||
});
|
||||
|
||||
// 2. Inline CSS for email compatibility
|
||||
// Note: Juice is fast, but for huge files, you might debounce this
|
||||
try {
|
||||
const inlined = juice(sanitized);
|
||||
setInputHtml(inlined);
|
||||
onChange?.(inlined);
|
||||
setCleanPreview(inlined);
|
||||
} catch (err) {
|
||||
onChange?.(sanitized);
|
||||
setInputHtml(sanitized); // Fallback if juice fails
|
||||
setCleanPreview(sanitized);
|
||||
}
|
||||
};
|
||||
|
||||
const processContent = (value) => {
|
||||
// 1. Sanitize immediately to strip scripts/malicious tags
|
||||
const sanitized = DOMPurify.sanitize(value, {
|
||||
USE_PROFILES: { html: true }, // Ensures basic HTML structure
|
||||
ADD_ATTR: ["target"] // Allow links to open in new tabs
|
||||
});
|
||||
|
||||
// 2. Inline CSS for email compatibility
|
||||
// Note: Juice is fast, but for huge files, you might debounce this
|
||||
try {
|
||||
const inlined = juice(sanitized);
|
||||
setCleanPreview(inlined);
|
||||
onChange?.(inlined);
|
||||
} catch (err) {
|
||||
setCleanPreview(sanitized); // Fallback if juice fails
|
||||
onChange?.(sanitized);
|
||||
}
|
||||
};
|
||||
const handleChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setInputHtml(value);
|
||||
processContent(value);
|
||||
};
|
||||
|
||||
const screen = smallscreen ? "flex-col" : "flex-col md:flex-row ";
|
||||
const template =
|
||||
bodyName === "request"
|
||||
? "#sample/requestemail"
|
||||
: bodyName === "completion"
|
||||
? "#sample/completionemail"
|
||||
: "#";
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm ">
|
||||
<Trans i18nKey={"open-email-builder"}>
|
||||
{"You can create email template using "}
|
||||
<a
|
||||
href={`/emailbuilder${template}`}
|
||||
target="_blank"
|
||||
referrerPolicy="no-referrer"
|
||||
className="op-link op-link-primary font-medium"
|
||||
>
|
||||
email builder
|
||||
</a>
|
||||
{" platform and copy html code."}
|
||||
</Trans>
|
||||
</p>
|
||||
<div className={`flex ${screen} gap-5`}>
|
||||
{/* Editor Pane */}
|
||||
<div className="flex flex-1 flex-col mt-2">
|
||||
<label>{t("paste-html-here")}:</label>
|
||||
<div className="flex-1 mt-1 p-3 text-xs op-textarea op-textarea-bordered min-h-[70vh]">
|
||||
<textarea
|
||||
className="w-full min-h-[70vh] focus:outline-none"
|
||||
value={inputHtml}
|
||||
onChange={(e) => handleChange(e)}
|
||||
placeholder="<html><body><h1>Hello!</h1></body></html>"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Preview Pane */}
|
||||
<div className="flex flex-1 flex-col mt-2">
|
||||
<label>{t("preview")}</label>
|
||||
<div className="flex-1 mt-1 bg-white border-[1px] op-textarea border-[#ccc] min-h-[70vh]">
|
||||
{cleanPreview && (
|
||||
<iframe
|
||||
title="Safe Preview"
|
||||
srcDoc={cleanPreview}
|
||||
// SECURITY: sandbox prevents scripts from running even if they slip through
|
||||
sandbox="allow-popups allow-popups-to-escape-sandbox"
|
||||
className="w-full min-h-[70vh]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailBodyEditor;
|
||||
@@ -4,26 +4,33 @@ import {
|
||||
useMemo
|
||||
} from "react";
|
||||
import axios from "axios";
|
||||
import SuggestionInput from "../shared/fields/SuggestionInput";
|
||||
import Loader from "../../primitives/Loader";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
emailRegex,
|
||||
} from "../../constant/const";
|
||||
import {
|
||||
convertPdfArrayBuffer,
|
||||
getSignedUrl,
|
||||
isBase64,
|
||||
getBase64FromUrl,
|
||||
generateId,
|
||||
} from "../../constant/Utils";
|
||||
import {
|
||||
formatCSVDate,
|
||||
handleEmbedPrefillToDoc,
|
||||
normalizeKey
|
||||
isWidgetResponseCompatible,
|
||||
formatCSVDate,
|
||||
normalizeKey,
|
||||
loadPdfOnce,
|
||||
} from "../../utils";
|
||||
import RenderWidgets from "./components/RenderWidgets";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { setBulkLoader } from "../../redux/reducers/widgetSlice";
|
||||
import {
|
||||
setBulkLoader,
|
||||
} from "../../redux/reducers/widgetSlice";
|
||||
import Table from "./components/Table";
|
||||
import PrefillWidgets from "./components/PrefillWidgets";
|
||||
import WizardHeader from "./components/WizardHeader";
|
||||
import ResponseTab from "./components/ResponseTab";
|
||||
import { steps } from "../../json/BulkSendSteps";
|
||||
|
||||
const EXCLUDED_PREFILL_TYPES = new Set(["image", "draw"]);
|
||||
const EXCLUDED_WIDGET_TYPES = new Set([
|
||||
@@ -37,9 +44,6 @@ const ALL_EXCLUDED_TYPES = new Set([
|
||||
...EXCLUDED_WIDGET_TYPES
|
||||
]);
|
||||
|
||||
const requiredAsteriskCls = (isRequired = false) => {
|
||||
return isRequired ? "after:content-['_*'] after:text-red-500" : "";
|
||||
};
|
||||
const BulkSendUi = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const appName =
|
||||
@@ -54,6 +58,11 @@ const BulkSendUi = (props) => {
|
||||
const [isVacantRoles, setIsVacantRoles] = useState(false);
|
||||
const [fields, setFields] = useState([]);
|
||||
const [headers, setHeaders] = useState([]);
|
||||
const [prefillWidgets, setPrefillWidgets] = useState([]);
|
||||
const [step, setStep] = useState(0);
|
||||
const [stepsList, setStepsList] = useState(steps(t));
|
||||
const [stepKey, setStepKey] = useState(steps(t)?.[0]?.key || "");
|
||||
const [message, setMessage] = useState({ status: "", message: "" });
|
||||
|
||||
const tokenHeader = useMemo(() => {
|
||||
const headers = {
|
||||
@@ -71,9 +80,10 @@ const BulkSendUi = (props) => {
|
||||
if (props?.Placeholders?.length > 0) {
|
||||
const roles = props?.Placeholders?.filter((p) => !p.signerObjId) || [];
|
||||
const emails = [];
|
||||
|
||||
const users = await Promise.all(
|
||||
roles.map(async (role) => {
|
||||
const isPrefill = role?.Role?.toLowerCase() === "prefill";
|
||||
roles?.map(async (role) => {
|
||||
const isPrefill = role.Role?.toLowerCase() === "prefill";
|
||||
const uniqueNames = new Set();
|
||||
const pages = role?.placeHolder ?? [];
|
||||
const widgets = await Promise.all(
|
||||
@@ -85,6 +95,7 @@ const BulkSendUi = (props) => {
|
||||
// exclude certain widget types for non-prefill
|
||||
if (EXCLUDED_WIDGET_TYPES.has(widget.type) && !isPrefill)
|
||||
return false;
|
||||
if (widget?.options?.formula) return false;
|
||||
// unique by widget name
|
||||
if (uniqueNames.has(widgetName)) return false;
|
||||
uniqueNames.add(widgetName);
|
||||
@@ -126,6 +137,7 @@ const BulkSendUi = (props) => {
|
||||
|
||||
return {
|
||||
...widget,
|
||||
options: { ...widget.options, response: response },
|
||||
label: label,
|
||||
response,
|
||||
pageNumber: page.pageNumber
|
||||
@@ -146,7 +158,18 @@ const BulkSendUi = (props) => {
|
||||
};
|
||||
})
|
||||
);
|
||||
setFields(users);
|
||||
const prefills = users.find((r) => r?.label?.toLowerCase() === "prefill");
|
||||
const signers = users.filter(
|
||||
(r) => r?.label?.toLowerCase() !== "prefill"
|
||||
);
|
||||
setFields(signers);
|
||||
if (prefills && prefills?.widgets?.length) {
|
||||
setPrefillWidgets(prefills?.widgets);
|
||||
} else {
|
||||
setStepsList((s) => s.filter((step) => step.key !== "prefill"));
|
||||
setStepKey("recipients");
|
||||
setStep(0);
|
||||
}
|
||||
setEmails(emails);
|
||||
|
||||
// Build headers with required ordering:
|
||||
@@ -154,7 +177,7 @@ const BulkSendUi = (props) => {
|
||||
// 2) then all widgets
|
||||
const emailHeaders = [];
|
||||
const widgetHeaders = [];
|
||||
for (const role of users) {
|
||||
for (const role of signers) {
|
||||
const isPrefill = role?.label?.toLowerCase() === "prefill";
|
||||
|
||||
if (!isPrefill) {
|
||||
@@ -179,7 +202,7 @@ const BulkSendUi = (props) => {
|
||||
}
|
||||
}
|
||||
setHeaders([...emailHeaders, ...widgetHeaders]);
|
||||
setForms((prev) => [...prev, { Id: 1, fields: users }]);
|
||||
setForms((prev) => [...prev, { Id: generateId(8), fields: signers }]);
|
||||
const signer = props.item?.Signers?.filter((x) => x?.objectId);
|
||||
setSigners(signer);
|
||||
}
|
||||
@@ -193,12 +216,15 @@ const BulkSendUi = (props) => {
|
||||
const checkSignatureAndRoles = (placeholders = []) => {
|
||||
const filtered = placeholders.filter((x) => x?.Role !== "prefill");
|
||||
|
||||
const isSignExist = filtered.every((p) =>
|
||||
p?.placeHolder?.some((h) => h?.pos?.some((p) => p?.type === "signature"))
|
||||
);
|
||||
|
||||
const isSignExist =
|
||||
filtered?.length > 0 &&
|
||||
filtered.every((p) =>
|
||||
p?.placeHolder?.some((h) =>
|
||||
h?.pos?.some((x) => x?.type === "signature")
|
||||
)
|
||||
);
|
||||
setIsSignatureExist(isSignExist);
|
||||
setIsVacantRoles(placeholders.some((x) => !x.signerObjId));
|
||||
setIsVacantRoles(filtered?.some((x) => !x.signerObjId));
|
||||
};
|
||||
|
||||
//function to check at least one signature field exist
|
||||
@@ -210,38 +236,77 @@ const BulkSendUi = (props) => {
|
||||
};
|
||||
|
||||
const handleInputChange = (formIndex, signer, role) => {
|
||||
const newForms = [...forms];
|
||||
const email = signer?.Email ? signer?.Email : signer || "";
|
||||
const fieldId = newForms[formIndex].fields.findIndex(
|
||||
(f) => f.label === role
|
||||
);
|
||||
newForms[formIndex].fields[fieldId].email = normalizeKey(email);
|
||||
newForms[formIndex].fields[fieldId].signer = signer?.objectId ? signer : "";
|
||||
setForms(newForms);
|
||||
setForms((prev) => {
|
||||
const next = [...prev];
|
||||
const form = next[formIndex];
|
||||
if (!form) return prev;
|
||||
|
||||
const fields = [...(form.fields ?? [])];
|
||||
const fieldId = fields.findIndex((f) => f.label === role);
|
||||
if (fieldId < 0) return prev;
|
||||
|
||||
const email = signer?.Email ? signer.Email : signer || "";
|
||||
|
||||
fields[fieldId] = {
|
||||
...fields[fieldId],
|
||||
email: normalizeKey(email),
|
||||
signer: signer?.objectId ? signer : ""
|
||||
};
|
||||
|
||||
next[formIndex] = { ...form, fields };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleWidgetDetails = (
|
||||
value = "",
|
||||
formIndex,
|
||||
RoleIndex = 0,
|
||||
roleIndex = 0,
|
||||
widgetIndex
|
||||
) => {
|
||||
const newForms = [...forms];
|
||||
newForms[formIndex].fields[RoleIndex].widgets[widgetIndex].response = value;
|
||||
setForms(newForms);
|
||||
setForms((prev) => {
|
||||
const next = [...prev];
|
||||
const form = next[formIndex];
|
||||
if (!form) return prev;
|
||||
|
||||
const fields = [...(form.fields ?? [])];
|
||||
const field = fields[roleIndex];
|
||||
if (!field) return prev;
|
||||
|
||||
const widgets = [...(field.widgets ?? [])];
|
||||
const w = widgets[widgetIndex];
|
||||
if (!w) return prev;
|
||||
|
||||
widgets[widgetIndex] = {
|
||||
...w,
|
||||
options: { ...w?.options, response: value },
|
||||
response: value
|
||||
};
|
||||
fields[roleIndex] = { ...field, widgets };
|
||||
next[formIndex] = { ...form, fields };
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
function validateEmails(data) {
|
||||
for (const item of data) {
|
||||
for (const [rowIndex, item] of data.entries()) {
|
||||
let email = "";
|
||||
const filterFields = item.fields.filter((p) => p.label !== "prefill");
|
||||
const rowNumber = rowIndex + 1;
|
||||
const filterFields = item?.fields || [];
|
||||
for (const field of filterFields) {
|
||||
if (!emailRegex.test(field.email)) {
|
||||
alert(t("invalid-email-found", { email: field.email }));
|
||||
// skip prefill
|
||||
if (field?.label === "prefill") continue;
|
||||
const params = { email: field.email, row: rowNumber };
|
||||
if (!field.email) {
|
||||
alert(t("email-not-found-in-row", { ...params }));
|
||||
return false;
|
||||
} else if (!emailRegex.test(field.email)) {
|
||||
alert(t("invalid-email-found-in-row", { ...params }));
|
||||
return false;
|
||||
} else if (email === field.email || emails?.includes(field.email)) {
|
||||
alert(t("duplicate-email-found", { email: field.email }));
|
||||
alert(t("duplicate-email-found-in-row", { ...params }));
|
||||
return false;
|
||||
} else {
|
||||
email = field.email;
|
||||
@@ -266,7 +331,13 @@ const BulkSendUi = (props) => {
|
||||
const name = widgetDetails?.options?.name;
|
||||
if (!name || !responseByName.has(name)) return widgetDetails;
|
||||
|
||||
const newResponse = responseByName.get(name);
|
||||
const newResponse = responseByName?.get(name) || "";
|
||||
const isValidResponse = isWidgetResponseCompatible(
|
||||
widgetDetails,
|
||||
newResponse
|
||||
);
|
||||
|
||||
if (!isValidResponse) return widgetDetails;
|
||||
|
||||
return {
|
||||
...widgetDetails,
|
||||
@@ -327,6 +398,49 @@ const BulkSendUi = (props) => {
|
||||
if (validateEmails(forms)) {
|
||||
// Create a copy of Placeholders array from props.item
|
||||
let Placeholders = [...props.Placeholders];
|
||||
|
||||
let pdfUrl = props.item?.URL || props.item?.SignedUrl;
|
||||
const prefillExist = Placeholders?.some(
|
||||
(data) => data.Role === "prefill"
|
||||
);
|
||||
if (prefillExist) {
|
||||
const updatedPrefills = updateWidgetValues(
|
||||
prefillWidgets,
|
||||
Placeholders
|
||||
);
|
||||
const prefillDetails = updatedPrefills?.find(
|
||||
(data) => data.Role === "prefill"
|
||||
);
|
||||
const pdfSignedUrl = await getSignedUrl(
|
||||
pdfUrl,
|
||||
"", //docId
|
||||
props.item.objectId, // templateId
|
||||
);
|
||||
const pdfArrayBuffer = await loadPdfOnce(pdfSignedUrl);
|
||||
if (pdfArrayBuffer === "Error") {
|
||||
const error = t("something-went-wrong-mssg");
|
||||
alert(error);
|
||||
dispatch(setBulkLoader(false));
|
||||
return;
|
||||
}
|
||||
pdfUrl = await handleEmbedPrefillToDoc(
|
||||
prefillDetails,
|
||||
1, // scale
|
||||
pdfArrayBuffer,
|
||||
[], // prefillImg,
|
||||
props.item?.ExtUserPtr?.UserId?.objectId // userId
|
||||
);
|
||||
|
||||
if (pdfUrl?.error) {
|
||||
const error = pdfUrl?.error?.includes("not compatible")
|
||||
? t("pdf-uncompatible", { appName: appName })
|
||||
: pdfUrl?.error;
|
||||
alert(error);
|
||||
dispatch(setBulkLoader(false));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize an empty array to store updated documents
|
||||
let Documents = [];
|
||||
let error = "";
|
||||
@@ -371,39 +485,7 @@ const BulkSendUi = (props) => {
|
||||
widgetValues,
|
||||
updatedPlaceholders
|
||||
);
|
||||
const url = props.item?.URL || props.item?.SignedUrl;
|
||||
let signedUrl = await getSignedUrl(
|
||||
url,
|
||||
"", //docId
|
||||
props.item.objectId, // templateId
|
||||
);
|
||||
const prefillField = form?.fields?.find((f) => f.label === "prefill");
|
||||
const prefills = prefillField?.widgets ?? [];
|
||||
if (prefills?.length > 0) {
|
||||
const prefillDetails = updateWidgetPlaceholder?.find(
|
||||
(x) => x.Role === "prefill"
|
||||
);
|
||||
const pdfArrayBuffer = await convertPdfArrayBuffer(signedUrl);
|
||||
if (pdfArrayBuffer === "Error") {
|
||||
error = t("something-went-wrong-mssg");
|
||||
dispatch(setBulkLoader(false));
|
||||
break;
|
||||
}
|
||||
signedUrl = await handleEmbedPrefillToDoc(
|
||||
prefillDetails,
|
||||
1, // scale
|
||||
pdfArrayBuffer,
|
||||
[], // prefillImg,
|
||||
props.item?.ExtUserPtr?.UserId?.objectId // userId
|
||||
);
|
||||
if (signedUrl?.error) {
|
||||
error = signedUrl.error.includes("not compatible")
|
||||
? t("pdf-uncompatible", { appName: appName })
|
||||
: signedUrl?.error;
|
||||
dispatch(setBulkLoader(false));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let signedUrl = pdfUrl;
|
||||
const placeholders = removeWidgetValues(
|
||||
widgetValues,
|
||||
updateWidgetPlaceholder
|
||||
@@ -450,13 +532,20 @@ const BulkSendUi = (props) => {
|
||||
try {
|
||||
const res = await axios.post(functionsUrl, params, { headers: headers });
|
||||
if (res.data && res.data.result) {
|
||||
props.handleClose("success", Documents?.length);
|
||||
setStep((s) => Math.min(2, s + 1));
|
||||
setStepKey("response");
|
||||
setMessage({
|
||||
status: "success",
|
||||
message: "Documents sent successfully."
|
||||
});
|
||||
// props.handleClose("success", Documents?.length);
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err?.response?.data?.error || err?.message || "something went wrong.";
|
||||
console.error("Error sending documents:", message);
|
||||
props.handleClose("error", 0, message);
|
||||
setMessage({ status: "failed", message });
|
||||
// props.handleClose("error", 0, message);
|
||||
} finally {
|
||||
dispatch(setBulkLoader(false));
|
||||
}
|
||||
@@ -473,115 +562,66 @@ const BulkSendUi = (props) => {
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* {isBulkLoader && (
|
||||
<div className="absolute z-[999] h-full w-full flex justify-center items-center bg-black bg-opacity-30">
|
||||
<Loader />
|
||||
</div>
|
||||
)} */}
|
||||
{props.Placeholders?.length > 0 ? (
|
||||
isSignatureExist ? (
|
||||
isVacantRoles ? (
|
||||
<>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="min-h-max max-h-[250px] overflow-auto">
|
||||
{
|
||||
forms?.length > 0 && (
|
||||
<div className="mx-4">
|
||||
<table className="op-table border-collapse w-full">
|
||||
<thead className="text-[13px] text-center">
|
||||
<tr className="border-y-[1px]">
|
||||
{/* Roles + widgets */}
|
||||
{headers?.map((header) => (
|
||||
<th
|
||||
key={header.label}
|
||||
className={`${requiredAsteriskCls(header?.isRequired)} p-2`}
|
||||
>
|
||||
{header.label}
|
||||
</th>
|
||||
))}
|
||||
{forms?.length > 1 && (
|
||||
<th className="p-2">{t("action")}</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-[12px] text-base-content">
|
||||
{forms.map((form, formIndex) => {
|
||||
const fields = form?.fields ?? [];
|
||||
const emailFields = fields.filter(
|
||||
(f) => f.label !== "prefill"
|
||||
);
|
||||
const widgets = fields.flatMap(
|
||||
(f, fieldIndex) =>
|
||||
(f.widgets ?? []).map(
|
||||
(widget, widgetIndex) => ({
|
||||
widget,
|
||||
fieldIndex, // keep the fieldIndex that produced this widget
|
||||
widgetIndex
|
||||
})
|
||||
)
|
||||
);
|
||||
<div className="border-t mt-3" />
|
||||
{/* Wizard header */}
|
||||
<div className="px-6 py-3">
|
||||
<WizardHeader
|
||||
steps={stepsList}
|
||||
step={step}
|
||||
// onStepClick={(i) => i <= step && setStep(i)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<tr key={form.Id}>
|
||||
{/* Email cell (label + SuggestionInput) */}
|
||||
{emailFields?.map((field, fieldIndex) => (
|
||||
<td key={field.fieldId} className="p-2">
|
||||
<div className="flex flex-col min-w-max">
|
||||
<SuggestionInput
|
||||
required
|
||||
type="email"
|
||||
value={field.email ?? ""}
|
||||
index={fieldIndex}
|
||||
onChange={(signer) =>
|
||||
handleInputChange(
|
||||
formIndex,
|
||||
signer,
|
||||
field.label
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
|
||||
{widgets.map(
|
||||
({ widget, fieldIndex, widgetIndex }) => (
|
||||
<td key={widget.key} className="p-2">
|
||||
<RenderWidgets
|
||||
widget={widget}
|
||||
formIndex={formIndex}
|
||||
prefillIndex={widgetIndex}
|
||||
handleWidgetDetails={(value) =>
|
||||
handleWidgetDetails(
|
||||
value,
|
||||
formIndex,
|
||||
fieldIndex,
|
||||
widgetIndex
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="flex flex-row flex-wrap pb-3 pt-2 px-3 gap-3 justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
className="op-btn op-btn-accent w-[150px] focus:outline-none"
|
||||
>
|
||||
<i className="fa-light fa-paper-plane"></i>
|
||||
<span>{t("send")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{stepKey === "prefill" && (
|
||||
<PrefillWidgets
|
||||
prefills={prefillWidgets}
|
||||
setPrefills={setPrefillWidgets}
|
||||
onNext={() => {
|
||||
setStep((s) => Math.min(2, s + 1));
|
||||
setStepKey("recipients");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{stepKey === "recipients" && (
|
||||
<>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="min-h-max max-h-[250px] overflow-auto">
|
||||
{
|
||||
forms?.length > 0 && (
|
||||
<div className="mx-4">
|
||||
<Table
|
||||
headers={headers}
|
||||
rowData={forms}
|
||||
handleInputChange={handleInputChange}
|
||||
handleWidgetDetails={handleWidgetDetails}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="flex flex-row flex-wrap pb-3 pt-2 px-3 gap-3 justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
className="op-btn op-btn-accent w-[150px] focus:outline-none"
|
||||
>
|
||||
<i className="fa-light fa-paper-plane"></i>
|
||||
<span>{t("send")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
{stepKey === "response" && (
|
||||
<ResponseTab
|
||||
prefillCount={prefillWidgets?.length}
|
||||
documentCount={forms?.length}
|
||||
message={message}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-black p-3 bg-white w-full text-sm md:text-base flex justify-center items-center">
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import RenderWidgets from "./RenderWidgets";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const PrefillWidgets = ({ prefills = [], setPrefills, onNext }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleWidgetDetails = (value, widgetIndex) => {
|
||||
setPrefills((prev) => {
|
||||
const widgets = [...(prev ?? [])];
|
||||
const w = widgets[widgetIndex];
|
||||
if (!w) return prev;
|
||||
widgets[widgetIndex] = {
|
||||
...w,
|
||||
options: { ...w.options, response: value },
|
||||
response: value
|
||||
};
|
||||
return widgets;
|
||||
});
|
||||
};
|
||||
|
||||
const handleNext = (e) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
onNext?.();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{prefills?.length > 0 && (
|
||||
<form
|
||||
onSubmit={handleNext}
|
||||
className="m-3 md:m-6 text-base-content flex flex-col relative"
|
||||
>
|
||||
<div className="py-3 px-[10px] op-card border-[1px] border-gray-400">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-10 gap-y-4 w-full">
|
||||
{prefills.map((widget, index) => (
|
||||
<RenderWidgets
|
||||
key={widget.key}
|
||||
showLabel
|
||||
widget={widget}
|
||||
handleWidgetDetails={(value) =>
|
||||
handleWidgetDetails(value, index)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row flex-wrap mt-3 gap-3 justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
className="op-btn op-btn-primary w-[150px] focus:outline-none"
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrefillWidgets;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { forwardRef, memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import DatePicker from "react-datepicker";
|
||||
import SignatureCanvas from "react-signature-canvas";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -6,6 +6,7 @@ import moment from "moment";
|
||||
import {
|
||||
changeDateToMomentFormat,
|
||||
compressedFileSize,
|
||||
generateId,
|
||||
getMonth,
|
||||
getYear,
|
||||
months,
|
||||
@@ -32,7 +33,12 @@ const inputValidation = (pattern, type) => {
|
||||
return inputTypes[type] || "";
|
||||
};
|
||||
const canavasTheme = `opensigncss:bg-white opensigndark:bg-[#121212] opensigndark:border-[#f6f3f4]/20 opensigncss:border-gray-300 opensigndark:hover:border-white opensigncss:hover:border-black border-[1px]`;
|
||||
const DateWidget = ({ widget, isRequired, onChange }) => {
|
||||
|
||||
const widgetLabelCss = (isRequired = false) => {
|
||||
return `${isRequired ? "after:content-['_*'] after:text-red-500" : ""} block text-xs font-semibold`;
|
||||
};
|
||||
|
||||
const DateWidget = ({ widget, isRequired, onChange, showLabel }) => {
|
||||
const format = widget?.options?.validation?.format || "MM/dd/yyyy";
|
||||
|
||||
const PrefillDateInput = forwardRef(({ value, onClick }, ref) => (
|
||||
@@ -78,6 +84,11 @@ const DateWidget = ({ widget, isRequired, onChange }) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{showLabel && (
|
||||
<div className={`${widgetLabelCss(isRequired)} mb-[0.5rem]`}>
|
||||
{widget?.options?.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-max">
|
||||
<DatePicker
|
||||
portalId="datepicker-portal-root"
|
||||
@@ -110,6 +121,7 @@ const DateWidget = ({ widget, isRequired, onChange }) => {
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
wrapperClassName="w-full"
|
||||
closeOnScroll={true}
|
||||
selected={handleDate(widget)}
|
||||
onChange={(date) => handleOnDateChange(date, widget)}
|
||||
@@ -121,21 +133,24 @@ const DateWidget = ({ widget, isRequired, onChange }) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const TextWidget = ({ widget, isRequired, onChange }) => {
|
||||
const TextWidget = ({
|
||||
widget,
|
||||
isRequired,
|
||||
onChange,
|
||||
showLabel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState([]);
|
||||
const inputType = widget?.options?.validation?.type || "";
|
||||
const serverRegex = widget?.options?.validation?.pattern;
|
||||
const isPredfineType = inputType ? inputOpt?.has(inputType) : "";
|
||||
const regExpression = inputValidation(serverRegex, inputType);
|
||||
const pattern = useMemo(() => toHtmlPattern(regExpression), [regExpression]);
|
||||
|
||||
useEffect(() => {
|
||||
const [value, setValue] = useState(() => {
|
||||
const response =
|
||||
widget?.options?.response ?? widget?.options?.defaultValue ?? "";
|
||||
const isNumber = inputType === "number" && response;
|
||||
setValue(isNumber ? Number(response) : response);
|
||||
}, []);
|
||||
return inputType === "number" && response ? Number(response) : response;
|
||||
});
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const text = e.target.value || "";
|
||||
setValue(text);
|
||||
@@ -143,6 +158,11 @@ const TextWidget = ({ widget, isRequired, onChange }) => {
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{showLabel && (
|
||||
<div className={`${widgetLabelCss(isRequired)} mb-[0.5rem]`}>
|
||||
{widget?.options?.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-max">
|
||||
<input
|
||||
type={isPredfineType ? inputType : "text"}
|
||||
@@ -152,7 +172,9 @@ const TextWidget = ({ widget, isRequired, onChange }) => {
|
||||
widget?.options?.hint ||
|
||||
t("enter-value", { value: widget?.options?.name })
|
||||
}
|
||||
className="op-input op-input-bordered op-input-sm focus:outline-none hover:border-base-content w-full text-xs"
|
||||
className={[
|
||||
"op-input op-input-bordered op-input-sm focus:outline-none hover:border-base-content w-full text-xs",
|
||||
].join(" ")}
|
||||
onChange={(e) => handleInputChange(e)}
|
||||
pattern={pattern || undefined} // if no pattern, browser won't do pattern validation
|
||||
onInvalid={(e) => {
|
||||
@@ -173,17 +195,19 @@ const TextWidget = ({ widget, isRequired, onChange }) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const CheckboxWidget = ({ widget, isRequired, onChange }) => {
|
||||
const CheckboxWidget = ({ widget, isRequired, onChange, showLabel }) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedCheckbox, setSelectedCheckbox] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCheckbox(
|
||||
widget?.options?.response ?? widget?.options?.defaultValue ?? []
|
||||
);
|
||||
}, []);
|
||||
const [selectedCheckbox, setSelectedCheckbox] = useState(
|
||||
() => widget?.options?.response || widget?.options?.defaultValue || []
|
||||
);
|
||||
const groupName = `checkbox-group-${widget.key}`;
|
||||
const noneSelected = (selectedCheckbox?.length ?? 0) === 0;
|
||||
|
||||
const handleCheckboxValue = (isChecked, ind) => {
|
||||
// ✅ clear hidden input error immediately
|
||||
const hidden = document.querySelector(`input[name="${groupName}"]`);
|
||||
hidden?.setCustomValidity("");
|
||||
|
||||
const prevArr = Array.isArray(selectedCheckbox) ? selectedCheckbox : [];
|
||||
|
||||
const updateResponse = isChecked
|
||||
@@ -195,10 +219,15 @@ const CheckboxWidget = ({ widget, isRequired, onChange }) => {
|
||||
setSelectedCheckbox(updateResponse);
|
||||
onChange(updateResponse);
|
||||
};
|
||||
const noneSelected = selectedCheckbox?.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-y-1 min-w-max">
|
||||
{showLabel && (
|
||||
<div className={`${widgetLabelCss(isRequired)} mb-[0.5rem]`}>
|
||||
{widget?.options?.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-y-1 min-w-max relative">
|
||||
{widget.options?.values?.map((value, ind) => (
|
||||
<div
|
||||
key={ind}
|
||||
@@ -209,14 +238,7 @@ const CheckboxWidget = ({ widget, isRequired, onChange }) => {
|
||||
className="mt-[2px] op-checkbox op-checkbox-xs"
|
||||
type="checkbox"
|
||||
checked={selectedCheckbox.includes(ind)}
|
||||
required={isRequired && noneSelected && ind === 0} // ✅ key line
|
||||
onInvalid={(e) =>
|
||||
e.target.setCustomValidity(t("select-at-least-option"))
|
||||
}
|
||||
onChange={(e) => {
|
||||
e.target.setCustomValidity(""); // ✅ clear message as soon as user changes
|
||||
handleCheckboxValue(e.target.checked, ind);
|
||||
}}
|
||||
onChange={(e) => handleCheckboxValue(e.target.checked, ind)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`checkbox-${widget.key + ind}`}
|
||||
@@ -226,27 +248,47 @@ const CheckboxWidget = ({ widget, isRequired, onChange }) => {
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
{/* ✅ the validator */}
|
||||
{isRequired && (
|
||||
<input
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="absolute opacity-0 pointer-events-none w-0.5 h-0.5 left-[0.46rem] top-4"
|
||||
name={groupName}
|
||||
value={noneSelected ? "" : "selected"} // empty => invalid, non-empty => valid
|
||||
required
|
||||
onInvalid={(e) =>
|
||||
e.target.setCustomValidity(t("select-at-least-option"))
|
||||
}
|
||||
onChange={(e) => e.target.setCustomValidity("")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const DropdownWidget = ({ widget, isRequired, onChange }) => {
|
||||
const DropdownWidget = ({ widget, isRequired, onChange, showLabel }) => {
|
||||
const { t } = useTranslation();
|
||||
const [selected, setSelected] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(
|
||||
widget?.options?.response ?? widget?.options?.defaultValue ?? ""
|
||||
);
|
||||
}, []);
|
||||
const [selected, setSelected] = useState(
|
||||
() =>
|
||||
widget?.options?.response?.trim() ||
|
||||
widget?.options?.defaultValue?.trim() ||
|
||||
""
|
||||
);
|
||||
|
||||
const handleDropdownChange = (value) => {
|
||||
setSelected(value);
|
||||
onChange(value);
|
||||
const select = value?.trim();
|
||||
setSelected(select);
|
||||
onChange(select);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{showLabel && (
|
||||
<div className={`${widgetLabelCss(isRequired)} mb-[0.5rem]`}>
|
||||
{widget?.options?.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-max">
|
||||
<select
|
||||
className="op-select op-select-bordered op-select-sm focus:outline-none hover:border-base-content text-base-content w-full"
|
||||
@@ -260,7 +302,7 @@ const DropdownWidget = ({ widget, isRequired, onChange }) => {
|
||||
{t("choose-one")}
|
||||
</option>
|
||||
{widget?.options?.values?.map((option, ind) => (
|
||||
<option key={ind} value={option}>
|
||||
<option key={ind} value={option?.trim()}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
@@ -269,22 +311,28 @@ const DropdownWidget = ({ widget, isRequired, onChange }) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const RadioButtonWidget = ({ widget, isRequired, onChange, formIndex }) => {
|
||||
const [selected, setSelected] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(
|
||||
widget?.options?.response || widget?.options?.defaultValue || ""
|
||||
);
|
||||
}, []);
|
||||
const RadioButtonWidget = ({ widget, isRequired, onChange, showLabel }) => {
|
||||
const [selected, setSelected] = useState(
|
||||
() =>
|
||||
widget?.options?.response?.trim() ||
|
||||
widget?.options?.defaultValue?.trim() ||
|
||||
""
|
||||
);
|
||||
const id = generateId(4);
|
||||
|
||||
const handleRadioChange = (value) => {
|
||||
setSelected(value);
|
||||
onChange(value);
|
||||
const select = value?.trim();
|
||||
setSelected(select);
|
||||
onChange(select);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{showLabel && (
|
||||
<div className={`${widgetLabelCss(isRequired)} mb-[0.5rem]`}>
|
||||
{widget?.options?.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-y-1 min-w-max">
|
||||
{widget.options?.values?.map((data, ind) => (
|
||||
<div
|
||||
@@ -294,10 +342,10 @@ const RadioButtonWidget = ({ widget, isRequired, onChange, formIndex }) => {
|
||||
<input
|
||||
id={`radio-${widget.key + ind}`}
|
||||
className="mt-[2px] op-radio op-radio-xs"
|
||||
name={`radio-group-${widget.key}-${formIndex}`} // ✅ same name => browser treats all radios as ONE group
|
||||
name={`radio-group-${widget.key}-${id}`} // ✅ same name => browser treats all radios as ONE group
|
||||
type="radio"
|
||||
required={isRequired && ind === 0} // ✅ set required ONCE (on first radio) to make the whole group mandatory
|
||||
checked={selected === data}
|
||||
checked={selected === data?.trim()}
|
||||
onChange={() => handleRadioChange(data)}
|
||||
/>
|
||||
<label
|
||||
@@ -312,7 +360,7 @@ const RadioButtonWidget = ({ widget, isRequired, onChange, formIndex }) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const ImageWidget = ({ widget, isRequired, onChange }) => {
|
||||
const ImageWidget = ({ widget, isRequired, onChange, showLabel }) => {
|
||||
const { t } = useTranslation();
|
||||
const imageRef = useRef(null);
|
||||
const [img, setImg] = useState({ src: "", imgType: "" });
|
||||
@@ -341,26 +389,23 @@ const ImageWidget = ({ widget, isRequired, onChange }) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{showLabel && (
|
||||
<div className={`${widgetLabelCss(isRequired)} mb-[0.5rem]`}>
|
||||
{widget?.options?.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col prefillCanvas">
|
||||
{defaultImg?.src || img?.src ? (
|
||||
<>
|
||||
<div
|
||||
className={`cursor-pointer rounded-box ${canavasTheme} flex flex-col w-full h-full justify-center items-center`}
|
||||
>
|
||||
<img
|
||||
alt={`image_${widget?.options?.name}`}
|
||||
src={img.src || defaultImg?.src}
|
||||
draggable="false"
|
||||
className="object-contain h-full w-full aspect-[5/2]"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
onClick={handleClearImage}
|
||||
className="flex justify-start text-blue-500 underline cursor-pointer ml-1"
|
||||
>
|
||||
{t("clear")}
|
||||
</span>
|
||||
</>
|
||||
<div
|
||||
className={`cursor-pointer rounded-box ${canavasTheme} flex flex-col w-full h-full justify-center items-center`}
|
||||
>
|
||||
<img
|
||||
alt={`image_${widget?.options?.name}`}
|
||||
src={img.src || defaultImg?.src}
|
||||
draggable="false"
|
||||
className="object-contain h-full w-full aspect-[5/2]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`cursor-pointer rounded-box ${canavasTheme} overflow-hidden w-full h-full aspect-[5/2] flex flex-col justify-center items-center`}
|
||||
@@ -379,10 +424,18 @@ const ImageWidget = ({ widget, isRequired, onChange }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(defaultImg?.src || img?.src) && (
|
||||
<span
|
||||
onClick={handleClearImage}
|
||||
className="flex justify-start text-blue-500 underline cursor-pointer ml-1"
|
||||
>
|
||||
{t("clear")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const DrawWidget = ({ widget, isRequired, onChange }) => {
|
||||
const DrawWidget = ({ widget, isRequired, onChange, showLabel }) => {
|
||||
const { t } = useTranslation();
|
||||
const canvasRef = useRef(null);
|
||||
const sigRequiredRef = useRef(null);
|
||||
@@ -419,7 +472,12 @@ const DrawWidget = ({ widget, isRequired, onChange }) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="relative">
|
||||
{showLabel && (
|
||||
<div className={`${widgetLabelCss(isRequired)} mb-[0.5rem]`}>
|
||||
{widget?.options?.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-fit">
|
||||
{image ? (
|
||||
<div className={`prefillCanvas ${canavasTheme} rounded-[10px]`}>
|
||||
<img
|
||||
@@ -469,7 +527,12 @@ const DrawWidget = ({ widget, isRequired, onChange }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const CellsWidget = ({ widget, isRequired, onChange }) => {
|
||||
const CellsWidget = ({
|
||||
widget,
|
||||
isRequired,
|
||||
onChange,
|
||||
showLabel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const count = widget?.options?.cellCount || 1;
|
||||
const [word, setWord] = useState("");
|
||||
@@ -493,6 +556,11 @@ const CellsWidget = ({ widget, isRequired, onChange }) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{showLabel && (
|
||||
<div className={`${widgetLabelCss(isRequired)} mb-[0.5rem]`}>
|
||||
{widget?.options?.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<CellsInput
|
||||
hint={
|
||||
@@ -533,25 +601,21 @@ const CellsWidget = ({ widget, isRequired, onChange }) => {
|
||||
|
||||
const RenderWidgets = ({
|
||||
widget,
|
||||
formIndex,
|
||||
prefillIndex,
|
||||
handleWidgetDetails
|
||||
handleWidgetDetails,
|
||||
showLabel = false,
|
||||
}) => {
|
||||
const label = widget?.label?.toLowerCase() ?? "";
|
||||
const isPrefill = label.includes(PREFILL_PREFIX);
|
||||
const commonProps = {
|
||||
widget,
|
||||
isRequired: widget.options?.status === "required" && isPrefill,
|
||||
formIndex
|
||||
};
|
||||
|
||||
const onChange = (value) => {
|
||||
handleWidgetDetails(value, formIndex, prefillIndex);
|
||||
showLabel: showLabel,
|
||||
onChange: handleWidgetDetails,
|
||||
};
|
||||
|
||||
switch (widget.type) {
|
||||
case "date":
|
||||
return <DateWidget {...commonProps} onChange={onChange} />;
|
||||
return <DateWidget {...commonProps} />;
|
||||
case textWidget:
|
||||
case textInputWidget:
|
||||
case "name":
|
||||
@@ -559,24 +623,24 @@ const RenderWidgets = ({
|
||||
case "company":
|
||||
case "job title":
|
||||
case "number":
|
||||
return <TextWidget {...commonProps} onChange={onChange} />;
|
||||
return <TextWidget {...commonProps} />;
|
||||
case "checkbox":
|
||||
return <CheckboxWidget {...commonProps} onChange={onChange} />;
|
||||
return <CheckboxWidget {...commonProps} />;
|
||||
case radioButtonWidget:
|
||||
return <RadioButtonWidget {...commonProps} onChange={onChange} />;
|
||||
return <RadioButtonWidget {...commonProps} />;
|
||||
case "dropdown":
|
||||
return <DropdownWidget {...commonProps} onChange={onChange} />;
|
||||
return <DropdownWidget {...commonProps} />;
|
||||
case "image":
|
||||
case "stamp":
|
||||
return <ImageWidget {...commonProps} onChange={onChange} />;
|
||||
return <ImageWidget {...commonProps} />;
|
||||
case "initials":
|
||||
case "draw":
|
||||
return <DrawWidget {...commonProps} onChange={onChange} />;
|
||||
return <DrawWidget {...commonProps} />;
|
||||
case "cells":
|
||||
return <CellsWidget {...commonProps} onChange={onChange} />;
|
||||
return <CellsWidget {...commonProps} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default RenderWidgets;
|
||||
export default memo(RenderWidgets);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function ResponseTab({
|
||||
prefillCount = 0,
|
||||
documentCount = 0,
|
||||
message = { status: "", message: "" }
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { status, message: msg } = message;
|
||||
return (
|
||||
<div className="rounded-box border p-4 m-3 md:m-6 ">
|
||||
<div className="font-medium">{t("summary")}</div>
|
||||
<ul className="mt-2 list-disc pl-5 text-base-content/80">
|
||||
<li>
|
||||
{t("prefill-fields")}: {prefillCount}
|
||||
</li>
|
||||
<li>
|
||||
{t("documents")}: {documentCount}
|
||||
</li>
|
||||
<li>
|
||||
{t("status")}: {status}
|
||||
</li>
|
||||
{msg && (
|
||||
<li>
|
||||
{t("message")}: {msg}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResponseTab;
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
useState
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SuggestionInput from "../../shared/fields/SuggestionInput";
|
||||
import RenderWidgets from "./RenderWidgets";
|
||||
|
||||
const requiredAsteriskCls = (isRequired = false) => {
|
||||
return isRequired ? "after:content-['_*'] after:text-red-500" : "";
|
||||
};
|
||||
const Table = ({
|
||||
headers = [],
|
||||
rowData = [],
|
||||
handleInputChange,
|
||||
handleWidgetDetails,
|
||||
initialPerPage = 25,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(initialPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const currentData = rowData.slice(startIndex, endIndex);
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
||||
{/* Table */}
|
||||
<div
|
||||
className="overflow-x-auto border border-gray-300 rounded-lg"
|
||||
>
|
||||
<table className="op-table border-collapse w-full">
|
||||
<thead className="text-[13px] text-center">
|
||||
<tr className="border-y-[1px]">
|
||||
{headers?.map((header) => (
|
||||
<th
|
||||
key={header.label}
|
||||
className={`${requiredAsteriskCls(header?.isRequired)} p-2`}
|
||||
>
|
||||
{header.label}
|
||||
</th>
|
||||
))}
|
||||
{rowData.length > 1 && <th className="p-2">{t("action")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentData.length > 0 &&
|
||||
currentData.map((form, formIndex) => {
|
||||
const globalIndex = startIndex + formIndex;
|
||||
const fields = form?.fields ?? [];
|
||||
|
||||
const emailFields = fields.filter((f) => f.label !== "prefill");
|
||||
|
||||
const widgets = fields.flatMap((f, fieldIndex) =>
|
||||
(f.widgets ?? []).map((widget, widgetIndex) => ({
|
||||
widget,
|
||||
fieldIndex, // keep the fieldIndex that produced this widget
|
||||
widgetIndex
|
||||
}))
|
||||
);
|
||||
return (
|
||||
<tr key={form.Id} id={`table-row-${formIndex}`}>
|
||||
{emailFields.map((field, fieldIndex) => (
|
||||
<td key={field.fieldId} className="p-2">
|
||||
<SuggestionInput
|
||||
required
|
||||
type="email"
|
||||
value={field.email ?? ""}
|
||||
index={fieldIndex}
|
||||
onChange={(signer) =>
|
||||
handleInputChange(globalIndex, signer, field.label)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
|
||||
{widgets.map(({ widget, fieldIndex, widgetIndex }) => (
|
||||
<td key={`${form.Id}-${widget.key}`} className="p-2">
|
||||
<RenderWidgets
|
||||
widget={widget}
|
||||
handleWidgetDetails={(value) =>
|
||||
handleWidgetDetails(
|
||||
value,
|
||||
globalIndex, // formIndex,
|
||||
fieldIndex,
|
||||
widgetIndex
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
||||
@@ -0,0 +1,74 @@
|
||||
/* ---------------- Wizard Header ---------------- */
|
||||
|
||||
import React from "react";
|
||||
|
||||
function WizardHeader({ steps, step, onStepClick }) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row flex-wrap items-center justify-center gap-0 md:gap-2">
|
||||
{steps.map((s, i) => {
|
||||
const isActive = i === step;
|
||||
const isDone = i < step;
|
||||
|
||||
return (
|
||||
<React.Fragment key={s.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStepClick?.(i)}
|
||||
className={[
|
||||
"inline-flex items-center gap-2 rounded-md px-3 py-2 text-xs lg:text-sm font-medium border focus:outline-none",
|
||||
isActive
|
||||
? "op-bg-primary text-primary-content border-primary"
|
||||
: isDone
|
||||
? "bg-base-100 text-base-content border-base-300 hover:bg-base-200"
|
||||
: "bg-base-100 text-base-content/70 border-base-300"
|
||||
].join(" ")}
|
||||
title={s.label}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"inline-flex h-5 w-5 items-center justify-center rounded-full text-xs",
|
||||
isActive
|
||||
? "op-bg-primary-content/15 text-primary-content"
|
||||
: isDone
|
||||
? "bg-base-200 text-base-content"
|
||||
: "bg-base-200 text-base-content/70"
|
||||
].join(" ")}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
|
||||
<span className="whitespace-nowrap">{s.label}</span>
|
||||
|
||||
{/* info icon like screenshot */}
|
||||
<a data-tooltip-id={`${s.key}-tooltip`} className="ml-1">
|
||||
<span
|
||||
className={[
|
||||
"inline-flex h-[1.10rem] w-[1.10rem] items-center justify-center border-[1px] rounded-full",
|
||||
!isActive
|
||||
? "border-[#33bbff] text-[#33bbff]"
|
||||
: "border border-base-300 text-base-300"
|
||||
].join(" ")}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i className="fa-light fa-question rounded-full text-[13px]"></i>
|
||||
</span>
|
||||
</a>
|
||||
<span className="text-left">{s.help}</span>
|
||||
</button>
|
||||
|
||||
{i < steps.length - 1 && (
|
||||
<span
|
||||
className="mx-1 select-none text-base-content/40 text-center text-2xl md:mb-2 rotate-90 md:rotate-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
›
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardHeader;
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
import { TEditorBlock } from '../../../documents/editor/core';
|
||||
import { setDocument, useDocument, useSelectedBlockId } from '../../../documents/editor/EditorContext';
|
||||
|
||||
import AvatarSidebarPanel from './input-panels/AvatarSidebarPanel';
|
||||
import ButtonSidebarPanel from './input-panels/ButtonSidebarPanel';
|
||||
import ColumnsContainerSidebarPanel from './input-panels/ColumnsContainerSidebarPanel';
|
||||
import ContainerSidebarPanel from './input-panels/ContainerSidebarPanel';
|
||||
import DividerSidebarPanel from './input-panels/DividerSidebarPanel';
|
||||
import EmailLayoutSidebarPanel from './input-panels/EmailLayoutSidebarPanel';
|
||||
import HeadingSidebarPanel from './input-panels/HeadingSidebarPanel';
|
||||
import HtmlSidebarPanel from './input-panels/HtmlSidebarPanel';
|
||||
import ImageSidebarPanel from './input-panels/ImageSidebarPanel';
|
||||
import SpacerSidebarPanel from './input-panels/SpacerSidebarPanel';
|
||||
import TextSidebarPanel from './input-panels/TextSidebarPanel';
|
||||
|
||||
function renderMessage(val: string) {
|
||||
return (
|
||||
<Box sx={{ m: 3, p: 1, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography color="text.secondary">{val}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConfigurationPanel() {
|
||||
const document = useDocument();
|
||||
const selectedBlockId = useSelectedBlockId();
|
||||
|
||||
if (!selectedBlockId) {
|
||||
return renderMessage('Click on a block to inspect.');
|
||||
}
|
||||
const block = document[selectedBlockId];
|
||||
if (!block) {
|
||||
return renderMessage(`Block with id ${selectedBlockId} was not found. Click on a block to reset.`);
|
||||
}
|
||||
|
||||
const setBlock = (conf: TEditorBlock) => setDocument({ [selectedBlockId]: conf });
|
||||
const { data, type } = block;
|
||||
switch (type) {
|
||||
case 'Avatar':
|
||||
return <AvatarSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Button':
|
||||
return <ButtonSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'ColumnsContainer':
|
||||
return (
|
||||
<ColumnsContainerSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />
|
||||
);
|
||||
case 'Container':
|
||||
return <ContainerSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Divider':
|
||||
return <DividerSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Heading':
|
||||
return <HeadingSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Html':
|
||||
return <HtmlSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Image':
|
||||
return <ImageSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'EmailLayout':
|
||||
return <EmailLayoutSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Spacer':
|
||||
return <SpacerSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Text':
|
||||
return <TextSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
default:
|
||||
return <pre>{JSON.stringify(block, null, ' ')}</pre>;
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from "react";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { AspectRatioOutlined } from "@mui/icons-material";
|
||||
import { ToggleButton } from "@mui/material";
|
||||
import {
|
||||
AvatarProps,
|
||||
AvatarPropsDefaults,
|
||||
AvatarPropsSchema
|
||||
} from "@usewaypoint/block-avatar";
|
||||
|
||||
import BaseSidebarPanel from "./helpers/BaseSidebarPanel";
|
||||
import RadioGroupInput from "./helpers/inputs/RadioGroupInput";
|
||||
import SliderInput from "./helpers/inputs/SliderInput";
|
||||
import TextInput from "./helpers/inputs/TextInput";
|
||||
import MultiStylePropertyPanel from "./helpers/style-inputs/MultiStylePropertyPanel";
|
||||
|
||||
type AvatarSidebarPanelProps = {
|
||||
data: AvatarProps;
|
||||
setData: (v: AvatarProps) => void;
|
||||
};
|
||||
export default function AvatarSidebarPanel({
|
||||
data,
|
||||
setData
|
||||
}: AvatarSidebarPanelProps) {
|
||||
const [, setErrors] = useState<ZodError | null>(null);
|
||||
const updateData = (d: unknown) => {
|
||||
const res = AvatarPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const size = data.props?.size ?? AvatarPropsDefaults.size;
|
||||
const imageUrl = data.props?.imageUrl ?? AvatarPropsDefaults.imageUrl;
|
||||
const alt = data.props?.alt ?? AvatarPropsDefaults.alt;
|
||||
const shape = data.props?.shape ?? AvatarPropsDefaults.shape;
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Avatar block">
|
||||
<SliderInput
|
||||
label="Size"
|
||||
iconLabel={<AspectRatioOutlined sx={{ color: "text.secondary" }} />}
|
||||
units="px"
|
||||
step={3}
|
||||
min={32}
|
||||
max={256}
|
||||
defaultValue={size}
|
||||
onChange={(size) => {
|
||||
updateData({ ...data, props: { ...data.props, size } });
|
||||
}}
|
||||
/>
|
||||
<RadioGroupInput
|
||||
label="Shape"
|
||||
defaultValue={shape}
|
||||
onChange={(shape) => {
|
||||
updateData({ ...data, props: { ...data.props, shape } });
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="circle">Circle</ToggleButton>
|
||||
<ToggleButton value="square">Square</ToggleButton>
|
||||
<ToggleButton value="rounded">Rounded</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<TextInput
|
||||
label="Image URL"
|
||||
defaultValue={imageUrl}
|
||||
onChange={(imageUrl) => {
|
||||
updateData({ ...data, props: { ...data.props, imageUrl } });
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label="Alt text"
|
||||
defaultValue={alt}
|
||||
onChange={(alt) => {
|
||||
updateData({ ...data, props: { ...data.props, alt } });
|
||||
}}
|
||||
/>
|
||||
|
||||
<MultiStylePropertyPanel
|
||||
names={["textAlign", "padding"]}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import React, { useState } from "react";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { ToggleButton } from "@mui/material";
|
||||
import {
|
||||
ButtonProps,
|
||||
ButtonPropsDefaults,
|
||||
ButtonPropsSchema
|
||||
} from "@usewaypoint/block-button";
|
||||
|
||||
import BaseSidebarPanel from "./helpers/BaseSidebarPanel";
|
||||
import ColorInput from "./helpers/inputs/ColorInput";
|
||||
import RadioGroupInput from "./helpers/inputs/RadioGroupInput";
|
||||
import TextInput from "./helpers/inputs/TextInput";
|
||||
import MultiStylePropertyPanel from "./helpers/style-inputs/MultiStylePropertyPanel";
|
||||
|
||||
type ButtonSidebarPanelProps = {
|
||||
data: ButtonProps;
|
||||
setData: (v: ButtonProps) => void;
|
||||
};
|
||||
export default function ButtonSidebarPanel({
|
||||
data,
|
||||
setData
|
||||
}: ButtonSidebarPanelProps) {
|
||||
const [, setErrors] = useState<ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = ButtonPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const text = data.props?.text ?? ButtonPropsDefaults.text;
|
||||
const url = data.props?.url ?? ButtonPropsDefaults.url;
|
||||
const fullWidth = data.props?.fullWidth ?? ButtonPropsDefaults.fullWidth;
|
||||
const size = data.props?.size ?? ButtonPropsDefaults.size;
|
||||
const buttonStyle =
|
||||
data.props?.buttonStyle ?? ButtonPropsDefaults.buttonStyle;
|
||||
const buttonTextColor =
|
||||
data.props?.buttonTextColor ?? ButtonPropsDefaults.buttonTextColor;
|
||||
const buttonBackgroundColor =
|
||||
data.props?.buttonBackgroundColor ??
|
||||
ButtonPropsDefaults.buttonBackgroundColor;
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Button block">
|
||||
<TextInput
|
||||
label="Text"
|
||||
defaultValue={text}
|
||||
onChange={(text) =>
|
||||
updateData({ ...data, props: { ...data.props, text } })
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Url"
|
||||
defaultValue={url}
|
||||
onChange={(url) =>
|
||||
updateData({ ...data, props: { ...data.props, url } })
|
||||
}
|
||||
/>
|
||||
<RadioGroupInput
|
||||
label="Width"
|
||||
defaultValue={fullWidth ? "FULL_WIDTH" : "AUTO"}
|
||||
onChange={(v) =>
|
||||
updateData({
|
||||
...data,
|
||||
props: { ...data.props, fullWidth: v === "FULL_WIDTH" }
|
||||
})
|
||||
}
|
||||
>
|
||||
<ToggleButton value="FULL_WIDTH">Full</ToggleButton>
|
||||
<ToggleButton value="AUTO">Auto</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<RadioGroupInput
|
||||
label="Size"
|
||||
defaultValue={size}
|
||||
onChange={(size) =>
|
||||
updateData({ ...data, props: { ...data.props, size } })
|
||||
}
|
||||
>
|
||||
<ToggleButton value="x-small">Xs</ToggleButton>
|
||||
<ToggleButton value="small">Sm</ToggleButton>
|
||||
<ToggleButton value="medium">Md</ToggleButton>
|
||||
<ToggleButton value="large">Lg</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<RadioGroupInput
|
||||
label="Style"
|
||||
defaultValue={buttonStyle}
|
||||
onChange={(buttonStyle) =>
|
||||
updateData({ ...data, props: { ...data.props, buttonStyle } })
|
||||
}
|
||||
>
|
||||
<ToggleButton value="rectangle">Rectangle</ToggleButton>
|
||||
<ToggleButton value="rounded">Rounded</ToggleButton>
|
||||
<ToggleButton value="pill">Pill</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<ColorInput
|
||||
label="Text color"
|
||||
defaultValue={buttonTextColor}
|
||||
onChange={(buttonTextColor) =>
|
||||
updateData({ ...data, props: { ...data.props, buttonTextColor } })
|
||||
}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Button color"
|
||||
defaultValue={buttonBackgroundColor}
|
||||
onChange={(buttonBackgroundColor) =>
|
||||
updateData({
|
||||
...data,
|
||||
props: { ...data.props, buttonBackgroundColor }
|
||||
})
|
||||
}
|
||||
/>
|
||||
<MultiStylePropertyPanel
|
||||
names={[
|
||||
"backgroundColor",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"textAlign",
|
||||
"padding"
|
||||
]}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import React, { useState } from "react";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import {
|
||||
SpaceBarOutlined,
|
||||
VerticalAlignBottomOutlined,
|
||||
VerticalAlignCenterOutlined,
|
||||
VerticalAlignTopOutlined
|
||||
} from "@mui/icons-material";
|
||||
import { ToggleButton } from "@mui/material";
|
||||
|
||||
import ColumnsContainerPropsSchema, {
|
||||
ColumnsContainerProps
|
||||
} from "../../../../documents/blocks/ColumnsContainer/ColumnsContainerPropsSchema";
|
||||
|
||||
import BaseSidebarPanel from "./helpers/BaseSidebarPanel";
|
||||
import ColumnWidthsInput from "./helpers/inputs/ColumnWidthsInput";
|
||||
import RadioGroupInput from "./helpers/inputs/RadioGroupInput";
|
||||
import SliderInput from "./helpers/inputs/SliderInput";
|
||||
import MultiStylePropertyPanel from "./helpers/style-inputs/MultiStylePropertyPanel";
|
||||
|
||||
type ColumnsContainerPanelProps = {
|
||||
data: ColumnsContainerProps;
|
||||
setData: (v: ColumnsContainerProps) => void;
|
||||
};
|
||||
export default function ColumnsContainerPanel({
|
||||
data,
|
||||
setData
|
||||
}: ColumnsContainerPanelProps) {
|
||||
const [, setErrors] = useState<ZodError | null>(null);
|
||||
const updateData = (d: unknown) => {
|
||||
const res = ColumnsContainerPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Columns block">
|
||||
<RadioGroupInput
|
||||
label="Number of columns"
|
||||
defaultValue={data.props?.columnsCount === 2 ? "2" : "3"}
|
||||
onChange={(v) => {
|
||||
updateData({
|
||||
...data,
|
||||
props: { ...data.props, columnsCount: v === "2" ? 2 : 3 }
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="2">2</ToggleButton>
|
||||
<ToggleButton value="3">3</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<ColumnWidthsInput
|
||||
defaultValue={data.props?.fixedWidths}
|
||||
onChange={(fixedWidths) => {
|
||||
updateData({ ...data, props: { ...data.props, fixedWidths } });
|
||||
}}
|
||||
/>
|
||||
<SliderInput
|
||||
label="Columns gap"
|
||||
iconLabel={<SpaceBarOutlined sx={{ color: "text.secondary" }} />}
|
||||
units="px"
|
||||
step={4}
|
||||
marks
|
||||
min={0}
|
||||
max={80}
|
||||
defaultValue={data.props?.columnsGap ?? 0}
|
||||
onChange={(columnsGap) =>
|
||||
updateData({ ...data, props: { ...data.props, columnsGap } })
|
||||
}
|
||||
/>
|
||||
<RadioGroupInput
|
||||
label="Alignment"
|
||||
defaultValue={data.props?.contentAlignment ?? "middle"}
|
||||
onChange={(contentAlignment) => {
|
||||
updateData({ ...data, props: { ...data.props, contentAlignment } });
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="top">
|
||||
<VerticalAlignTopOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="middle">
|
||||
<VerticalAlignCenterOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="bottom">
|
||||
<VerticalAlignBottomOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
|
||||
<MultiStylePropertyPanel
|
||||
names={["backgroundColor", "padding"]}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import React, { useState } from "react";
|
||||
import type { ZodError } from "zod";
|
||||
|
||||
import ContainerPropsSchema, {
|
||||
ContainerProps
|
||||
} from "../../../../documents/blocks/Container/ContainerPropsSchema";
|
||||
|
||||
import BaseSidebarPanel from "./helpers/BaseSidebarPanel";
|
||||
import MultiStylePropertyPanel from "./helpers/style-inputs/MultiStylePropertyPanel";
|
||||
|
||||
type ContainerSidebarPanelProps = {
|
||||
data: ContainerProps;
|
||||
setData: (v: ContainerProps) => void;
|
||||
};
|
||||
|
||||
export default function ContainerSidebarPanel({
|
||||
data,
|
||||
setData
|
||||
}: ContainerSidebarPanelProps) {
|
||||
const [, setErrors] = useState<ZodError | null>(null);
|
||||
const updateData = (d: unknown) => {
|
||||
const res = ContainerPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<BaseSidebarPanel title="Container block">
|
||||
<MultiStylePropertyPanel
|
||||
names={["backgroundColor", "borderColor", "borderRadius", "padding"]}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import React, { useState } from "react";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { HeightOutlined } from "@mui/icons-material";
|
||||
import {
|
||||
DividerProps,
|
||||
DividerPropsDefaults,
|
||||
DividerPropsSchema
|
||||
} from "@usewaypoint/block-divider";
|
||||
|
||||
import BaseSidebarPanel from "./helpers/BaseSidebarPanel";
|
||||
import ColorInput from "./helpers/inputs/ColorInput";
|
||||
import SliderInput from "./helpers/inputs/SliderInput";
|
||||
import MultiStylePropertyPanel from "./helpers/style-inputs/MultiStylePropertyPanel";
|
||||
|
||||
type DividerSidebarPanelProps = {
|
||||
data: DividerProps;
|
||||
setData: (v: DividerProps) => void;
|
||||
};
|
||||
export default function DividerSidebarPanel({
|
||||
data,
|
||||
setData
|
||||
}: DividerSidebarPanelProps) {
|
||||
const [, setErrors] = useState<ZodError | null>(null);
|
||||
const updateData = (d: unknown) => {
|
||||
const res = DividerPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const lineColor = data.props?.lineColor ?? DividerPropsDefaults.lineColor;
|
||||
const lineHeight = data.props?.lineHeight ?? DividerPropsDefaults.lineHeight;
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Divider block">
|
||||
<ColorInput
|
||||
label="Color"
|
||||
defaultValue={lineColor}
|
||||
onChange={(lineColor) =>
|
||||
updateData({ ...data, props: { ...data.props, lineColor } })
|
||||
}
|
||||
/>
|
||||
<SliderInput
|
||||
label="Height"
|
||||
iconLabel={<HeightOutlined sx={{ color: "text.secondary" }} />}
|
||||
units="px"
|
||||
step={1}
|
||||
min={1}
|
||||
max={24}
|
||||
defaultValue={lineHeight}
|
||||
onChange={(lineHeight) =>
|
||||
updateData({ ...data, props: { ...data.props, lineHeight } })
|
||||
}
|
||||
/>
|
||||
<MultiStylePropertyPanel
|
||||
names={["backgroundColor", "padding"]}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import React, { useState } from "react";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { WidthWide, RoundedCornerOutlined } from "@mui/icons-material";
|
||||
import EmailLayoutPropsSchema, {
|
||||
EmailLayoutProps
|
||||
} from "../../../../documents/blocks/EmailLayout/EmailLayoutPropsSchema";
|
||||
|
||||
import BaseSidebarPanel from "./helpers/BaseSidebarPanel";
|
||||
import ColorInput, { NullableColorInput } from "./helpers/inputs/ColorInput";
|
||||
import { NullableFontFamily } from "./helpers/inputs/FontFamily";
|
||||
import SliderInput from "./helpers/inputs/SliderInput";
|
||||
|
||||
type EmailLayoutSidebarFieldsProps = {
|
||||
data: EmailLayoutProps;
|
||||
setData: (v: EmailLayoutProps) => void;
|
||||
};
|
||||
export default function EmailLayoutSidebarFields({
|
||||
data,
|
||||
setData
|
||||
}: EmailLayoutSidebarFieldsProps) {
|
||||
const [, setErrors] = useState<ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = EmailLayoutPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Global">
|
||||
<ColorInput
|
||||
label="Backdrop color"
|
||||
defaultValue={data.backdropColor ?? "#F5F5F5"}
|
||||
onChange={(backdropColor) => updateData({ ...data, backdropColor })}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Canvas color"
|
||||
defaultValue={data.canvasColor ?? "#FFFFFF"}
|
||||
onChange={(canvasColor) => updateData({ ...data, canvasColor })}
|
||||
/>
|
||||
<NullableColorInput
|
||||
label="Canvas border color"
|
||||
defaultValue={data.borderColor ?? null}
|
||||
onChange={(borderColor) => updateData({ ...data, borderColor })}
|
||||
/>
|
||||
<SliderInput
|
||||
iconLabel={<WidthWide />}
|
||||
units="px"
|
||||
step={10}
|
||||
min={320}
|
||||
max={1200}
|
||||
label="Canvas width"
|
||||
defaultValue={data.canvasWidth ?? 600}
|
||||
onChange={(canvasWidth) => updateData({ ...data, canvasWidth })}
|
||||
/>
|
||||
<SliderInput
|
||||
iconLabel={<RoundedCornerOutlined />}
|
||||
units="px"
|
||||
step={4}
|
||||
marks
|
||||
min={0}
|
||||
max={48}
|
||||
label="Canvas border radius"
|
||||
defaultValue={data.borderRadius ?? 0}
|
||||
onChange={(borderRadius) => updateData({ ...data, borderRadius })}
|
||||
/>
|
||||
<NullableFontFamily
|
||||
label="Font family"
|
||||
defaultValue="MODERN_SANS"
|
||||
onChange={(fontFamily) => updateData({ ...data, fontFamily })}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Text color"
|
||||
defaultValue={data.textColor ?? "#262626"}
|
||||
onChange={(textColor) => updateData({ ...data, textColor })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import React, { useState } from "react";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { ToggleButton } from "@mui/material";
|
||||
import {
|
||||
HeadingProps,
|
||||
HeadingPropsDefaults,
|
||||
HeadingPropsSchema
|
||||
} from "@usewaypoint/block-heading";
|
||||
|
||||
import BaseSidebarPanel from "./helpers/BaseSidebarPanel";
|
||||
import RadioGroupInput from "./helpers/inputs/RadioGroupInput";
|
||||
import TextInput from "./helpers/inputs/TextInput";
|
||||
import MultiStylePropertyPanel from "./helpers/style-inputs/MultiStylePropertyPanel";
|
||||
|
||||
type HeadingSidebarPanelProps = {
|
||||
data: HeadingProps;
|
||||
setData: (v: HeadingProps) => void;
|
||||
};
|
||||
export default function HeadingSidebarPanel({
|
||||
data,
|
||||
setData
|
||||
}: HeadingSidebarPanelProps) {
|
||||
const [, setErrors] = useState<ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = HeadingPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Heading block">
|
||||
<TextInput
|
||||
label="Content"
|
||||
rows={3}
|
||||
defaultValue={data.props?.text ?? HeadingPropsDefaults.text}
|
||||
onChange={(text) => {
|
||||
updateData({ ...data, props: { ...data.props, text } });
|
||||
}}
|
||||
/>
|
||||
<RadioGroupInput
|
||||
label="Level"
|
||||
defaultValue={data.props?.level ?? HeadingPropsDefaults.level}
|
||||
onChange={(level) => {
|
||||
updateData({ ...data, props: { ...data.props, level } });
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="h1">H1</ToggleButton>
|
||||
<ToggleButton value="h2">H2</ToggleButton>
|
||||
<ToggleButton value="h3">H3</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<MultiStylePropertyPanel
|
||||
names={[
|
||||
"color",
|
||||
"backgroundColor",
|
||||
"fontFamily",
|
||||
"fontWeight",
|
||||
"textAlign",
|
||||
"padding"
|
||||
]}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import React, { useState } from "react";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { HtmlProps, HtmlPropsSchema } from "@usewaypoint/block-html";
|
||||
|
||||
import BaseSidebarPanel from "./helpers/BaseSidebarPanel";
|
||||
import TextInput from "./helpers/inputs/TextInput";
|
||||
import MultiStylePropertyPanel from "./helpers/style-inputs/MultiStylePropertyPanel";
|
||||
|
||||
type HtmlSidebarPanelProps = {
|
||||
data: HtmlProps;
|
||||
setData: (v: HtmlProps) => void;
|
||||
};
|
||||
export default function HtmlSidebarPanel({
|
||||
data,
|
||||
setData
|
||||
}: HtmlSidebarPanelProps) {
|
||||
const [, setErrors] = useState<ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = HtmlPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Html block">
|
||||
<TextInput
|
||||
label="Content"
|
||||
rows={5}
|
||||
defaultValue={data.props?.contents ?? ""}
|
||||
onChange={(contents) =>
|
||||
updateData({ ...data, props: { ...data.props, contents } })
|
||||
}
|
||||
/>
|
||||
<MultiStylePropertyPanel
|
||||
names={[
|
||||
"color",
|
||||
"backgroundColor",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"textAlign",
|
||||
"padding"
|
||||
]}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import React, { useState } from "react";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import {
|
||||
VerticalAlignBottomOutlined,
|
||||
VerticalAlignCenterOutlined,
|
||||
VerticalAlignTopOutlined
|
||||
} from "@mui/icons-material";
|
||||
import { Stack, ToggleButton } from "@mui/material";
|
||||
import { ImageProps, ImagePropsSchema } from "@usewaypoint/block-image";
|
||||
|
||||
import BaseSidebarPanel from "./helpers/BaseSidebarPanel";
|
||||
import RadioGroupInput from "./helpers/inputs/RadioGroupInput";
|
||||
import TextDimensionInput from "./helpers/inputs/TextDimensionInput";
|
||||
import TextInput from "./helpers/inputs/TextInput";
|
||||
import MultiStylePropertyPanel from "./helpers/style-inputs/MultiStylePropertyPanel";
|
||||
|
||||
type ImageSidebarPanelProps = {
|
||||
data: ImageProps;
|
||||
setData: (v: ImageProps) => void;
|
||||
};
|
||||
export default function ImageSidebarPanel({
|
||||
data,
|
||||
setData
|
||||
}: ImageSidebarPanelProps) {
|
||||
const [, setErrors] = useState<ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = ImagePropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Image block">
|
||||
<TextInput
|
||||
label="Source URL"
|
||||
defaultValue={data.props?.url ?? ""}
|
||||
onChange={(v) => {
|
||||
const url = v.trim().length === 0 ? null : v.trim();
|
||||
updateData({ ...data, props: { ...data.props, url } });
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Alt text"
|
||||
defaultValue={data.props?.alt ?? ""}
|
||||
onChange={(alt) =>
|
||||
updateData({ ...data, props: { ...data.props, alt } })
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Click through URL"
|
||||
defaultValue={data.props?.linkHref ?? ""}
|
||||
onChange={(v) => {
|
||||
const linkHref = v.trim().length === 0 ? null : v.trim();
|
||||
updateData({ ...data, props: { ...data.props, linkHref } });
|
||||
}}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextDimensionInput
|
||||
label="Width"
|
||||
defaultValue={data.props?.width}
|
||||
onChange={(width) =>
|
||||
updateData({ ...data, props: { ...data.props, width } })
|
||||
}
|
||||
/>
|
||||
<TextDimensionInput
|
||||
label="Height"
|
||||
defaultValue={data.props?.height}
|
||||
onChange={(height) =>
|
||||
updateData({ ...data, props: { ...data.props, height } })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<RadioGroupInput
|
||||
label="Alignment"
|
||||
defaultValue={data.props?.contentAlignment ?? "middle"}
|
||||
onChange={(contentAlignment) =>
|
||||
updateData({ ...data, props: { ...data.props, contentAlignment } })
|
||||
}
|
||||
>
|
||||
<ToggleButton value="top">
|
||||
<VerticalAlignTopOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="middle">
|
||||
<VerticalAlignCenterOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="bottom">
|
||||
<VerticalAlignBottomOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
|
||||
<MultiStylePropertyPanel
|
||||
names={["backgroundColor", "textAlign", "padding"]}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import React, { useState } from "react";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { HeightOutlined } from "@mui/icons-material";
|
||||
import {
|
||||
SpacerProps,
|
||||
SpacerPropsDefaults,
|
||||
SpacerPropsSchema
|
||||
} from "@usewaypoint/block-spacer";
|
||||
|
||||
import BaseSidebarPanel from "./helpers/BaseSidebarPanel";
|
||||
import SliderInput from "./helpers/inputs/SliderInput";
|
||||
|
||||
type SpacerSidebarPanelProps = {
|
||||
data: SpacerProps;
|
||||
setData: (v: SpacerProps) => void;
|
||||
};
|
||||
export default function SpacerSidebarPanel({
|
||||
data,
|
||||
setData
|
||||
}: SpacerSidebarPanelProps) {
|
||||
const [, setErrors] = useState<ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = SpacerPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Spacer block">
|
||||
<SliderInput
|
||||
label="Height"
|
||||
iconLabel={<HeightOutlined sx={{ color: "text.secondary" }} />}
|
||||
units="px"
|
||||
step={4}
|
||||
min={4}
|
||||
max={128}
|
||||
defaultValue={data.props?.height ?? SpacerPropsDefaults.height}
|
||||
onChange={(height) =>
|
||||
updateData({ ...data, props: { ...data.props, height } })
|
||||
}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import React, { useState } from "react";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { TextProps, TextPropsSchema } from "@usewaypoint/block-text";
|
||||
|
||||
import BaseSidebarPanel from "./helpers/BaseSidebarPanel";
|
||||
import BooleanInput from "./helpers/inputs/BooleanInput";
|
||||
import TextInput from "./helpers/inputs/TextInput";
|
||||
import MultiStylePropertyPanel from "./helpers/style-inputs/MultiStylePropertyPanel";
|
||||
|
||||
type TextSidebarPanelProps = {
|
||||
data: TextProps;
|
||||
setData: (v: TextProps) => void;
|
||||
};
|
||||
export default function TextSidebarPanel({
|
||||
data,
|
||||
setData
|
||||
}: TextSidebarPanelProps) {
|
||||
const [, setErrors] = useState<ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = TextPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Text block">
|
||||
<TextInput
|
||||
label="Content"
|
||||
rows={5}
|
||||
defaultValue={data.props?.text ?? ""}
|
||||
onChange={(text) =>
|
||||
updateData({ ...data, props: { ...data.props, text } })
|
||||
}
|
||||
/>
|
||||
<BooleanInput
|
||||
label="Markdown (GitHub flavored)"
|
||||
defaultValue={data.props?.markdown ?? false}
|
||||
onChange={(markdown) =>
|
||||
updateData({ ...data, props: { ...data.props, markdown } })
|
||||
}
|
||||
/>
|
||||
|
||||
<MultiStylePropertyPanel
|
||||
names={[
|
||||
"color",
|
||||
"backgroundColor",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"textAlign",
|
||||
"padding"
|
||||
]}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
|
||||
type SidebarPanelProps = {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
export default function BaseSidebarPanel({
|
||||
title,
|
||||
children
|
||||
}: SidebarPanelProps) {
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
color="text.secondary"
|
||||
sx={{ display: "block", mb: 2 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack spacing={5} mb={3}>
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { FormControlLabel, Switch } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export default function BooleanInput({ label, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
return (
|
||||
<FormControlLabel
|
||||
label={label}
|
||||
control={
|
||||
<Switch
|
||||
checked={value}
|
||||
onChange={(_, checked: boolean) => {
|
||||
setValue(checked);
|
||||
onChange(checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { AddOutlined, CloseOutlined } from "@mui/icons-material";
|
||||
import { ButtonBase, InputLabel, Menu, Stack } from "@mui/material";
|
||||
|
||||
import Picker from "./Picker";
|
||||
|
||||
const BUTTON_SX = {
|
||||
border: "1px solid",
|
||||
borderColor: "cadet.400",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "4px",
|
||||
bgcolor: "#FFFFFF"
|
||||
};
|
||||
|
||||
type Props =
|
||||
| {
|
||||
nullable: true;
|
||||
label: string;
|
||||
onChange: (value: string | null) => void;
|
||||
defaultValue: string | null;
|
||||
}
|
||||
| {
|
||||
nullable: false;
|
||||
label: string;
|
||||
onChange: (value: string) => void;
|
||||
defaultValue: string;
|
||||
};
|
||||
export default function ColorInput({
|
||||
label,
|
||||
defaultValue,
|
||||
onChange,
|
||||
nullable
|
||||
}: Props) {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const handleClickOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const renderResetButton = () => {
|
||||
if (!nullable) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={() => {
|
||||
setValue(null);
|
||||
onChange(null);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined fontSize="small" sx={{ color: "grey.600" }} />
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
|
||||
const renderOpenButton = () => {
|
||||
if (value) {
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={handleClickOpen}
|
||||
sx={{ ...BUTTON_SX, bgcolor: value }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ButtonBase onClick={handleClickOpen} sx={{ ...BUTTON_SX }}>
|
||||
<AddOutlined fontSize="small" />
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack alignItems="flex-start">
|
||||
<InputLabel sx={{ mb: 0.5 }}>{label}</InputLabel>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{renderOpenButton()}
|
||||
{renderResetButton()}
|
||||
</Stack>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
MenuListProps={{
|
||||
sx: { height: "auto", padding: 0 }
|
||||
}}
|
||||
>
|
||||
<Picker
|
||||
value={value || ""}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
onChange(v);
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import React, { useState } from "react";
|
||||
import { HexColorInput, HexColorPicker } from "react-colorful";
|
||||
|
||||
import { Box, Stack, SxProps } from "@mui/material";
|
||||
|
||||
import Swatch from "./Swatch";
|
||||
|
||||
const DEFAULT_PRESET_COLORS = [
|
||||
"#E11D48",
|
||||
"#DB2777",
|
||||
"#C026D3",
|
||||
"#9333EA",
|
||||
"#7C3AED",
|
||||
"#4F46E5",
|
||||
"#2563EB",
|
||||
"#0284C7",
|
||||
"#0891B2",
|
||||
"#0D9488",
|
||||
"#059669",
|
||||
"#16A34A",
|
||||
"#65A30D",
|
||||
"#CA8A04",
|
||||
"#D97706",
|
||||
"#EA580C",
|
||||
"#DC2626",
|
||||
"#FFFFFF",
|
||||
"#FAFAFA",
|
||||
"#F5F5F5",
|
||||
"#E5E5E5",
|
||||
"#D4D4D4",
|
||||
"#A3A3A3",
|
||||
"#737373",
|
||||
"#525252",
|
||||
"#404040",
|
||||
"#262626",
|
||||
"#171717",
|
||||
"#0A0A0A",
|
||||
"#000000"
|
||||
];
|
||||
|
||||
const SX: SxProps = {
|
||||
p: 1,
|
||||
".react-colorful__pointer ": {
|
||||
width: 16,
|
||||
height: 16
|
||||
},
|
||||
".react-colorful__saturation": {
|
||||
mb: 1,
|
||||
borderRadius: "4px"
|
||||
},
|
||||
".react-colorful__last-control": {
|
||||
borderRadius: "4px"
|
||||
},
|
||||
".react-colorful__hue-pointer": {
|
||||
width: "4px",
|
||||
borderRadius: "4px",
|
||||
height: 24,
|
||||
cursor: "col-resize"
|
||||
},
|
||||
".react-colorful__saturation-pointer": {
|
||||
cursor: "all-scroll"
|
||||
},
|
||||
input: {
|
||||
padding: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "grey.300",
|
||||
borderRadius: "4px",
|
||||
width: "100%"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
};
|
||||
export default function Picker({ value, onChange }: Props) {
|
||||
const [internalValue, setInternalValue] = useState(value);
|
||||
const handleChange = (v: string) => {
|
||||
setInternalValue(v);
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(v)) {
|
||||
onChange(v);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={1} sx={SX}>
|
||||
<HexColorPicker color={value} onChange={handleChange} />
|
||||
<Swatch
|
||||
paletteColors={DEFAULT_PRESET_COLORS}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Box pt={1}>
|
||||
<HexColorInput prefixed color={internalValue} onChange={handleChange} />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
|
||||
import { Box, Button, SxProps } from "@mui/material";
|
||||
|
||||
type Props = {
|
||||
paletteColors: string[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const TILE_BUTTON: SxProps = {
|
||||
width: 24,
|
||||
height: 24
|
||||
};
|
||||
export default function Swatch({ paletteColors, value, onChange }: Props) {
|
||||
const renderButton = (colorValue: string) => {
|
||||
return (
|
||||
<Button
|
||||
key={colorValue}
|
||||
onClick={() => onChange(colorValue)}
|
||||
sx={{
|
||||
...TILE_BUTTON,
|
||||
backgroundColor: colorValue,
|
||||
border: "1px solid",
|
||||
borderColor: value === colorValue ? "black" : "grey.200",
|
||||
minWidth: 24,
|
||||
display: "inline-flex",
|
||||
"&:hover": {
|
||||
backgroundColor: colorValue,
|
||||
borderColor: "grey.500"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Box
|
||||
width="100%"
|
||||
sx={{
|
||||
display: "grid",
|
||||
gap: 1,
|
||||
gridTemplateColumns: "1fr 1fr 1fr 1fr 1fr 1fr"
|
||||
}}
|
||||
>
|
||||
{paletteColors.map((c) => renderButton(c))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
import BaseColorInput from "./BaseColorInput";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
onChange: (value: string) => void;
|
||||
defaultValue: string;
|
||||
};
|
||||
export default function ColorInput(props: Props) {
|
||||
return <BaseColorInput {...props} nullable={false} />;
|
||||
}
|
||||
|
||||
type NullableProps = {
|
||||
label: string;
|
||||
onChange: (value: null | string) => void;
|
||||
defaultValue: null | string;
|
||||
};
|
||||
export function NullableColorInput(props: NullableProps) {
|
||||
return <BaseColorInput {...props} nullable />;
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Stack } from '@mui/material';
|
||||
|
||||
import TextDimensionInput from './TextDimensionInput';
|
||||
|
||||
export const DEFAULT_2_COLUMNS = [6] as [number];
|
||||
export const DEFAULT_3_COLUMNS = [4, 8] as [number, number];
|
||||
|
||||
type TWidthValue = number | null | undefined;
|
||||
type FixedWidths = [
|
||||
//
|
||||
number | null | undefined,
|
||||
number | null | undefined,
|
||||
number | null | undefined,
|
||||
];
|
||||
type ColumnsLayoutInputProps = {
|
||||
defaultValue: FixedWidths | null | undefined;
|
||||
onChange: (v: FixedWidths | null | undefined) => void;
|
||||
};
|
||||
export default function ColumnWidthsInput({ defaultValue, onChange }: ColumnsLayoutInputProps) {
|
||||
const [currentValue, setCurrentValue] = useState<[TWidthValue, TWidthValue, TWidthValue]>(() => {
|
||||
if (defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
return [null, null, null];
|
||||
});
|
||||
|
||||
const setIndexValue = (index: 0 | 1 | 2, value: number | null | undefined) => {
|
||||
const nValue: FixedWidths = [...currentValue];
|
||||
nValue[index] = value;
|
||||
setCurrentValue(nValue);
|
||||
onChange(nValue);
|
||||
};
|
||||
|
||||
const columnsCountValue = 3;
|
||||
let column3 = null;
|
||||
if (columnsCountValue === 3) {
|
||||
column3 = (
|
||||
<TextDimensionInput
|
||||
label="Column 3"
|
||||
defaultValue={currentValue?.[2]}
|
||||
onChange={(v) => {
|
||||
setIndexValue(2, v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextDimensionInput
|
||||
label="Column 1"
|
||||
defaultValue={currentValue?.[0]}
|
||||
onChange={(v) => {
|
||||
setIndexValue(0, v);
|
||||
}}
|
||||
/>
|
||||
<TextDimensionInput
|
||||
label="Column 2"
|
||||
defaultValue={currentValue?.[1]}
|
||||
onChange={(v) => {
|
||||
setIndexValue(1, v);
|
||||
}}
|
||||
/>
|
||||
{column3}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { MenuItem, TextField } from '@mui/material';
|
||||
|
||||
import { FONT_FAMILIES } from '../../../../../../documents/blocks/helpers/fontFamily';
|
||||
|
||||
const OPTIONS = FONT_FAMILIES.map((option) => (
|
||||
<MenuItem key={option.key} value={option.key} sx={{ fontFamily: option.value }}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
type NullableProps = {
|
||||
label: string;
|
||||
onChange: (value: null | string) => void;
|
||||
defaultValue: null | string;
|
||||
};
|
||||
export function NullableFontFamily({ label, onChange, defaultValue }: NullableProps) {
|
||||
const [value, setValue] = useState(defaultValue ?? 'inherit');
|
||||
return (
|
||||
<TextField
|
||||
select
|
||||
variant="standard"
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={(ev) => {
|
||||
const v = ev.target.value;
|
||||
setValue(v);
|
||||
onChange(v === null ? null : v);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="inherit">Match email settings</MenuItem>
|
||||
{OPTIONS}
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { TextFieldsOutlined } from '@mui/icons-material';
|
||||
import { InputLabel, Stack } from '@mui/material';
|
||||
|
||||
import RawSliderInput from './raw/RawSliderInput';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: number;
|
||||
onChange: (v: number) => void;
|
||||
};
|
||||
export default function FontSizeInput({ label, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const handleChange = (value: number) => {
|
||||
setValue(value);
|
||||
onChange(value);
|
||||
};
|
||||
return (
|
||||
<Stack spacing={1} alignItems="flex-start">
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
<RawSliderInput
|
||||
iconLabel={<TextFieldsOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value}
|
||||
setValue={handleChange}
|
||||
units="px"
|
||||
step={1}
|
||||
min={10}
|
||||
max={48}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ToggleButton } from '@mui/material';
|
||||
|
||||
import RadioGroupInput from './RadioGroupInput';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
export default function FontWeightInput({ label, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
return (
|
||||
<RadioGroupInput
|
||||
label={label}
|
||||
defaultValue={value}
|
||||
onChange={(fontWeight) => {
|
||||
setValue(fontWeight);
|
||||
onChange(fontWeight);
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="normal">Regular</ToggleButton>
|
||||
<ToggleButton value="bold">Bold</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
);
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
AlignHorizontalLeftOutlined,
|
||||
AlignHorizontalRightOutlined,
|
||||
AlignVerticalBottomOutlined,
|
||||
AlignVerticalTopOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import { InputLabel, Stack } from '@mui/material';
|
||||
|
||||
import RawSliderInput from './raw/RawSliderInput';
|
||||
|
||||
type TPaddingValue = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
right: number;
|
||||
left: number;
|
||||
};
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: TPaddingValue | null;
|
||||
onChange: (value: TPaddingValue) => void;
|
||||
};
|
||||
export default function PaddingInput({ label, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(() => {
|
||||
if (defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
return {
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
};
|
||||
});
|
||||
|
||||
function handleChange(internalName: keyof TPaddingValue, nValue: number) {
|
||||
const v = {
|
||||
...value,
|
||||
[internalName]: nValue,
|
||||
};
|
||||
setValue(v);
|
||||
onChange(v);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} alignItems="flex-start" pb={1}>
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
|
||||
<RawSliderInput
|
||||
iconLabel={<AlignVerticalTopOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value.top}
|
||||
setValue={(num) => handleChange('top', num)}
|
||||
units="px"
|
||||
step={4}
|
||||
min={0}
|
||||
max={80}
|
||||
marks
|
||||
/>
|
||||
|
||||
<RawSliderInput
|
||||
iconLabel={<AlignVerticalBottomOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value.bottom}
|
||||
setValue={(num) => handleChange('bottom', num)}
|
||||
units="px"
|
||||
step={4}
|
||||
min={0}
|
||||
max={80}
|
||||
marks
|
||||
/>
|
||||
|
||||
<RawSliderInput
|
||||
iconLabel={<AlignHorizontalLeftOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value.left}
|
||||
setValue={(num) => handleChange('left', num)}
|
||||
units="px"
|
||||
step={4}
|
||||
min={0}
|
||||
max={80}
|
||||
marks
|
||||
/>
|
||||
|
||||
<RawSliderInput
|
||||
iconLabel={<AlignHorizontalRightOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value.right}
|
||||
setValue={(num) => handleChange('right', num)}
|
||||
units="px"
|
||||
step={4}
|
||||
min={0}
|
||||
max={80}
|
||||
marks
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { InputLabel, Stack, ToggleButtonGroup } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
label: string | JSX.Element;
|
||||
children: JSX.Element | JSX.Element[];
|
||||
defaultValue: string;
|
||||
onChange: (v: string) => void;
|
||||
};
|
||||
export default function RadioGroupInput({ label, children, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
return (
|
||||
<Stack alignItems="flex-start">
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
fullWidth
|
||||
value={value}
|
||||
size="small"
|
||||
onChange={(_, v: unknown) => {
|
||||
if (typeof v !== 'string') {
|
||||
throw new Error('RadioGroupInput can only receive string values');
|
||||
}
|
||||
setValue(v);
|
||||
onChange(v);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ToggleButtonGroup>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { InputLabel, Stack } from '@mui/material';
|
||||
|
||||
import RawSliderInput from './raw/RawSliderInput';
|
||||
|
||||
type SliderInputProps = {
|
||||
label: string;
|
||||
iconLabel: JSX.Element;
|
||||
|
||||
step?: number;
|
||||
marks?: boolean;
|
||||
units: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
|
||||
defaultValue: number;
|
||||
onChange: (v: number) => void;
|
||||
};
|
||||
|
||||
export default function SliderInput({ label, defaultValue, onChange, ...props }: SliderInputProps) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
return (
|
||||
<Stack spacing={1} alignItems="flex-start">
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
<RawSliderInput
|
||||
value={value}
|
||||
setValue={(value: number) => {
|
||||
setValue(value);
|
||||
onChange(value);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { FormatAlignCenterOutlined, FormatAlignLeftOutlined, FormatAlignRightOutlined } from '@mui/icons-material';
|
||||
import { ToggleButton } from '@mui/material';
|
||||
|
||||
import RadioGroupInput from './RadioGroupInput';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
};
|
||||
export default function TextAlignInput({ label, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue ?? 'left');
|
||||
|
||||
return (
|
||||
<RadioGroupInput
|
||||
label={label}
|
||||
defaultValue={value}
|
||||
onChange={(value) => {
|
||||
setValue(value);
|
||||
onChange(value);
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="left">
|
||||
<FormatAlignLeftOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="center">
|
||||
<FormatAlignCenterOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="right">
|
||||
<FormatAlignRightOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
import { TextField, Typography } from '@mui/material';
|
||||
|
||||
type TextDimensionInputProps = {
|
||||
label: string;
|
||||
defaultValue: number | null | undefined;
|
||||
onChange: (v: number | null) => void;
|
||||
};
|
||||
export default function TextDimensionInput({ label, defaultValue, onChange }: TextDimensionInputProps) {
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (ev) => {
|
||||
const value = parseInt(ev.target.value);
|
||||
onChange(isNaN(value) ? null : value);
|
||||
};
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
onChange={handleChange}
|
||||
defaultValue={defaultValue}
|
||||
label={label}
|
||||
variant="standard"
|
||||
placeholder="auto"
|
||||
size="small"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
px
|
||||
</Typography>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { InputProps, TextField } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
helperText?: string | JSX.Element;
|
||||
InputProps?: InputProps;
|
||||
defaultValue: string;
|
||||
onChange: (v: string) => void;
|
||||
};
|
||||
export default function TextInput({ helperText, label, placeholder, rows, InputProps, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const isMultiline = typeof rows === 'number' && rows > 1;
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline={isMultiline}
|
||||
minRows={rows}
|
||||
variant={isMultiline ? 'outlined' : 'standard'}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
helperText={helperText}
|
||||
InputProps={InputProps}
|
||||
value={value}
|
||||
onChange={(ev) => {
|
||||
const v = ev.target.value;
|
||||
setValue(v);
|
||||
onChange(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
|
||||
import { Box, Slider, Stack, Typography } from "@mui/material";
|
||||
|
||||
type SliderInputProps = {
|
||||
iconLabel: JSX.Element;
|
||||
|
||||
step?: number;
|
||||
marks?: boolean;
|
||||
units: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
|
||||
value: number;
|
||||
setValue: (v: number) => void;
|
||||
};
|
||||
|
||||
export default function RawSliderInput({
|
||||
iconLabel,
|
||||
value,
|
||||
setValue,
|
||||
units,
|
||||
...props
|
||||
}: SliderInputProps) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={2}
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
>
|
||||
<Box sx={{ minWidth: 24, lineHeight: 1, flexShrink: 0 }}>{iconLabel}</Box>
|
||||
<Slider
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={(_, value: unknown) => {
|
||||
if (typeof value !== "number") {
|
||||
throw new Error(
|
||||
"RawSliderInput values can only receive numeric values"
|
||||
);
|
||||
}
|
||||
setValue(value);
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ minWidth: 32, textAlign: "right", flexShrink: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ lineHeight: 1 }}
|
||||
>
|
||||
{value}
|
||||
{units}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
|
||||
import { TStyle } from "../../../../../../documents/blocks/helpers/TStyle";
|
||||
|
||||
import SingleStylePropertyPanel from "./SingleStylePropertyPanel";
|
||||
|
||||
type MultiStylePropertyPanelProps = {
|
||||
names: (keyof TStyle)[];
|
||||
value: TStyle | undefined | null;
|
||||
onChange: (style: TStyle) => void;
|
||||
};
|
||||
export default function MultiStylePropertyPanel({
|
||||
names,
|
||||
value,
|
||||
onChange
|
||||
}: MultiStylePropertyPanelProps) {
|
||||
return (
|
||||
<>
|
||||
{names.map((name) => (
|
||||
<SingleStylePropertyPanel
|
||||
key={name}
|
||||
name={name}
|
||||
value={value || {}}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import React from "react";
|
||||
|
||||
import { RoundedCornerOutlined } from "@mui/icons-material";
|
||||
|
||||
import { TStyle } from "../../../../../../documents/blocks/helpers/TStyle";
|
||||
import { NullableColorInput } from "../inputs/ColorInput";
|
||||
import { NullableFontFamily } from "../inputs/FontFamily";
|
||||
import FontSizeInput from "../inputs/FontSizeInput";
|
||||
import FontWeightInput from "../inputs/FontWeightInput";
|
||||
import PaddingInput from "../inputs/PaddingInput";
|
||||
import SliderInput from "../inputs/SliderInput";
|
||||
import TextAlignInput from "../inputs/TextAlignInput";
|
||||
|
||||
type StylePropertyPanelProps = {
|
||||
name: keyof TStyle;
|
||||
value: TStyle;
|
||||
onChange: (style: TStyle) => void;
|
||||
};
|
||||
export default function SingleStylePropertyPanel({
|
||||
name,
|
||||
value,
|
||||
onChange
|
||||
}: StylePropertyPanelProps) {
|
||||
const defaultValue = value[name] ?? null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleChange = (v: any) => {
|
||||
onChange({ ...value, [name]: v });
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case "backgroundColor":
|
||||
return (
|
||||
<NullableColorInput
|
||||
label="Background color"
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
case "borderColor":
|
||||
return (
|
||||
<NullableColorInput
|
||||
label="Border color"
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
case "borderRadius":
|
||||
return (
|
||||
<SliderInput
|
||||
iconLabel={<RoundedCornerOutlined />}
|
||||
units="px"
|
||||
step={4}
|
||||
marks
|
||||
min={0}
|
||||
max={48}
|
||||
label="Border radius"
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
case "color":
|
||||
return (
|
||||
<NullableColorInput
|
||||
label="Text color"
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
case "fontFamily":
|
||||
return (
|
||||
<NullableFontFamily
|
||||
label="Font family"
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
case "fontSize":
|
||||
return (
|
||||
<FontSizeInput
|
||||
label="Font size"
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
case "fontWeight":
|
||||
return (
|
||||
<FontWeightInput
|
||||
label="Font weight"
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
case "textAlign":
|
||||
return (
|
||||
<TextAlignInput
|
||||
label="Alignment"
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
case "padding":
|
||||
return (
|
||||
<PaddingInput
|
||||
label="Padding"
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
|
||||
import { setDocument, useDocument } from "../../documents/editor/EditorContext";
|
||||
|
||||
import EmailLayoutSidebarPanel from "./ConfigurationPanel/input-panels/EmailLayoutSidebarPanel";
|
||||
|
||||
export default function StylesPanel() {
|
||||
const block = useDocument().root;
|
||||
if (!block) {
|
||||
return <p>Block not found</p>;
|
||||
}
|
||||
|
||||
const { data, type } = block;
|
||||
if (type !== "EmailLayout") {
|
||||
throw new Error('Expected "root" element to be of type EmailLayout');
|
||||
}
|
||||
|
||||
return (
|
||||
<EmailLayoutSidebarPanel
|
||||
key="root"
|
||||
data={data}
|
||||
setData={(data) => setDocument({ root: { type, data } })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
|
||||
import { AppRegistrationOutlined, LastPageOutlined } from "@mui/icons-material";
|
||||
import { IconButton } from "@mui/material";
|
||||
|
||||
import {
|
||||
toggleInspectorDrawerOpen,
|
||||
useInspectorDrawerOpen
|
||||
} from "../../documents/editor/EditorContext";
|
||||
|
||||
export default function ToggleInspectorPanelButton() {
|
||||
const inspectorDrawerOpen = useInspectorDrawerOpen();
|
||||
|
||||
const handleClick = () => {
|
||||
toggleInspectorDrawerOpen();
|
||||
};
|
||||
if (inspectorDrawerOpen) {
|
||||
return (
|
||||
<IconButton onClick={handleClick}>
|
||||
<LastPageOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<IconButton onClick={handleClick}>
|
||||
<AppRegistrationOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
|
||||
import { Box, Drawer, Tab, Tabs } from "@mui/material";
|
||||
|
||||
import {
|
||||
setSidebarTab,
|
||||
useInspectorDrawerOpen,
|
||||
useSelectedSidebarTab
|
||||
} from "../../documents/editor/EditorContext";
|
||||
|
||||
import ConfigurationPanel from "./ConfigurationPanel";
|
||||
import StylesPanel from "./StylesPanel";
|
||||
|
||||
export const INSPECTOR_DRAWER_WIDTH = 320;
|
||||
|
||||
export default function InspectorDrawer() {
|
||||
const selectedSidebarTab = useSelectedSidebarTab();
|
||||
const inspectorDrawerOpen = useInspectorDrawerOpen();
|
||||
|
||||
const renderCurrentSidebarPanel = () => {
|
||||
switch (selectedSidebarTab) {
|
||||
case "block-configuration":
|
||||
return <ConfigurationPanel />;
|
||||
case "styles":
|
||||
return <StylesPanel />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
variant="persistent"
|
||||
anchor="right"
|
||||
open={inspectorDrawerOpen}
|
||||
sx={{
|
||||
width: inspectorDrawerOpen ? INSPECTOR_DRAWER_WIDTH : 0
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: INSPECTOR_DRAWER_WIDTH,
|
||||
height: 49,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider"
|
||||
}}
|
||||
>
|
||||
<Box px={2}>
|
||||
<Tabs
|
||||
value={selectedSidebarTab}
|
||||
onChange={(_, v) => setSidebarTab(v)}
|
||||
>
|
||||
<Tab value="styles" label="Styles" />
|
||||
<Tab value="block-configuration" label="Inspect" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: INSPECTOR_DRAWER_WIDTH,
|
||||
height: "calc(100% - 49px)",
|
||||
overflow: "auto"
|
||||
}}
|
||||
>
|
||||
{renderCurrentSidebarPanel()}
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
import { Button } from "@mui/material";
|
||||
|
||||
import { resetDocument } from "../../documents/editor/EditorContext";
|
||||
import getConfiguration from "../../getConfiguration";
|
||||
|
||||
export default function SidebarButton({
|
||||
href,
|
||||
children
|
||||
}: {
|
||||
href: string;
|
||||
children: JSX.Element | string;
|
||||
}) {
|
||||
const handleClick = () => {
|
||||
resetDocument(getConfiguration(href));
|
||||
};
|
||||
return (
|
||||
<Button size="small" href={href} onClick={handleClick}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
import { FirstPageOutlined, MenuOutlined } from "@mui/icons-material";
|
||||
import { IconButton } from "@mui/material";
|
||||
|
||||
import {
|
||||
toggleSamplesDrawerOpen,
|
||||
useSamplesDrawerOpen
|
||||
} from "../../documents/editor/EditorContext";
|
||||
|
||||
function useIcon() {
|
||||
const samplesDrawerOpen = useSamplesDrawerOpen();
|
||||
if (samplesDrawerOpen) {
|
||||
return <FirstPageOutlined fontSize="small" />;
|
||||
}
|
||||
return <MenuOutlined fontSize="small" />;
|
||||
}
|
||||
|
||||
export default function ToggleSamplesPanelButton() {
|
||||
const icon = useIcon();
|
||||
return <IconButton onClick={toggleSamplesDrawerOpen}>{icon}</IconButton>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
import { Divider, Drawer, Stack, Typography } from "@mui/material";
|
||||
import { useSamplesDrawerOpen } from "../../documents/editor/EditorContext";
|
||||
import SidebarButton from "./SidebarButton";
|
||||
|
||||
export const SAMPLES_DRAWER_WIDTH = 240;
|
||||
|
||||
export default function SamplesDrawer() {
|
||||
const samplesDrawerOpen = useSamplesDrawerOpen();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
variant="persistent"
|
||||
anchor="left"
|
||||
open={samplesDrawerOpen}
|
||||
sx={{ width: samplesDrawerOpen ? SAMPLES_DRAWER_WIDTH : 0 }}
|
||||
>
|
||||
<Stack
|
||||
spacing={3}
|
||||
py={1}
|
||||
px={2}
|
||||
width={SAMPLES_DRAWER_WIDTH}
|
||||
justifyContent="space-between"
|
||||
height="100%"
|
||||
>
|
||||
<Stack
|
||||
spacing={2}
|
||||
sx={{
|
||||
"& .MuiButtonBase-root": {
|
||||
width: "100%",
|
||||
justifyContent: "flex-start"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="h1" sx={{ p: 0.75 }}>
|
||||
EmailBuilder
|
||||
</Typography>
|
||||
|
||||
<Stack alignItems="flex-start">
|
||||
<SidebarButton href="#">Empty</SidebarButton>
|
||||
<SidebarButton href="#sample/requestemail">
|
||||
Request Email
|
||||
</SidebarButton>
|
||||
<SidebarButton href="#sample/completionemail">
|
||||
Completion Email
|
||||
</SidebarButton>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import { FileDownloadOutlined } from "@mui/icons-material";
|
||||
import { IconButton, Tooltip } from "@mui/material";
|
||||
|
||||
import { useDocument } from "../../../documents/editor/EditorContext";
|
||||
// import { renderToStaticMarkup } from "@usewaypoint/email-builder";
|
||||
import renderEmailHtml from "../helper/renderEmailHtml";
|
||||
|
||||
export default function DownloadJson() {
|
||||
const doc = useDocument();
|
||||
// const code = useMemo(
|
||||
// () => renderToStaticMarkup(doc, { rootBlockId: "root" }),
|
||||
// [doc]
|
||||
// );
|
||||
|
||||
const code = useMemo(() => renderEmailHtml(doc, "root"), [doc]);
|
||||
|
||||
const href = useMemo(() => {
|
||||
const blob = new Blob([code], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
return url;
|
||||
}, [doc]);
|
||||
return (
|
||||
<Tooltip title="Download html of template in txt file">
|
||||
<IconButton href={href} download="emailTemplate.txt">
|
||||
<FileDownloadOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
// import { renderToStaticMarkup } from "@usewaypoint/email-builder";
|
||||
|
||||
import { useDocument } from "../../documents/editor/EditorContext";
|
||||
|
||||
import HighlightedCodePanel from "./helper/HighlightedCodePanel";
|
||||
import renderEmailHtml from "./helper/renderEmailHtml";
|
||||
|
||||
export default function HtmlPanel() {
|
||||
const document = useDocument();
|
||||
// const code = useMemo(
|
||||
// () => renderToStaticMarkup(document, { rootBlockId: "root" }),
|
||||
// [document]
|
||||
// );
|
||||
const code = useMemo(() => renderEmailHtml(document, "root"), [document]);
|
||||
return <HighlightedCodePanel type="html" value={code} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import { useDocument } from "../../documents/editor/EditorContext";
|
||||
|
||||
import HighlightedCodePanel from "./helper/HighlightedCodePanel";
|
||||
|
||||
export default function JsonPanel() {
|
||||
const document = useDocument();
|
||||
const code = useMemo(() => JSON.stringify(document, null, " "), [document]);
|
||||
return <HighlightedCodePanel type="json" value={code} />;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
// DataObjectOutlined,
|
||||
import {
|
||||
CodeOutlined,
|
||||
EditOutlined,
|
||||
PreviewOutlined
|
||||
} from "@mui/icons-material";
|
||||
import { Tab, Tabs, Tooltip } from "@mui/material";
|
||||
|
||||
import {
|
||||
setSelectedMainTab,
|
||||
useSelectedMainTab
|
||||
} from "../../documents/editor/EditorContext";
|
||||
|
||||
export default function MainTabsGroup() {
|
||||
const selectedMainTab = useSelectedMainTab();
|
||||
const handleChange = (_: unknown, v: unknown) => {
|
||||
switch (v) {
|
||||
case "json":
|
||||
case "preview":
|
||||
case "editor":
|
||||
case "html":
|
||||
setSelectedMainTab(v);
|
||||
return;
|
||||
default:
|
||||
setSelectedMainTab("editor");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs value={selectedMainTab} onChange={handleChange}>
|
||||
<Tab
|
||||
value="editor"
|
||||
label={
|
||||
<Tooltip title="Edit">
|
||||
<EditOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
value="preview"
|
||||
label={
|
||||
<Tooltip title="Preview">
|
||||
<PreviewOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
value="html"
|
||||
label={
|
||||
<Tooltip title="HTML output">
|
||||
<CodeOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
{/* <Tab
|
||||
value="json"
|
||||
label={
|
||||
<Tooltip title="JSON output">
|
||||
<DataObjectOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
}
|
||||
/> */}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { ContentCopyOutlined } from "@mui/icons-material";
|
||||
import { IconButton, Snackbar, Tooltip } from "@mui/material";
|
||||
|
||||
import { useDocument } from "../../documents/editor/EditorContext";
|
||||
// import { renderToStaticMarkup } from "@usewaypoint/email-builder";
|
||||
import renderEmailHtml from "./helper/renderEmailHtml";
|
||||
|
||||
export default function ShareButton() {
|
||||
const document = useDocument();
|
||||
// const code = useMemo(
|
||||
// () => renderToStaticMarkup(document, { rootBlockId: "root" }),
|
||||
// [document]
|
||||
// );
|
||||
const code = useMemo(() => renderEmailHtml(document, "root"), [document]);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const onClick = async () => {
|
||||
await copyToClipboard(code);
|
||||
setMessage("Your current template is copied");
|
||||
setTimeout(() => {
|
||||
setMessage(null);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// 1) Try modern Clipboard API
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to legacy approach
|
||||
}
|
||||
|
||||
// 2) Fallback: execCommand('copy')
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
|
||||
// Prevent scrolling to bottom on iOS
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "0";
|
||||
textarea.style.opacity = "0";
|
||||
textarea.style.pointerEvents = "none";
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
setMessage(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* <IconButton onClick={onClick}>
|
||||
<Tooltip title="Share current template">
|
||||
<IosShareOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
</IconButton> */}
|
||||
<IconButton onClick={onClick} sx={{ color: "text.primary" }}>
|
||||
<Tooltip title="copy" placement="left-start">
|
||||
<ContentCopyOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: "top", horizontal: "center" }}
|
||||
open={message !== null}
|
||||
onClose={onClose}
|
||||
message={message}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { html, json } from "./highlighters";
|
||||
|
||||
type TextEditorPanelProps = {
|
||||
type: "json" | "html" | "javascript";
|
||||
value: string;
|
||||
};
|
||||
export default function HighlightedCodePanel({
|
||||
type,
|
||||
value
|
||||
}: TextEditorPanelProps) {
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
switch (type) {
|
||||
case "html":
|
||||
html(value).then(setCode);
|
||||
return;
|
||||
case "json":
|
||||
json(value).then(setCode);
|
||||
return;
|
||||
}
|
||||
}, [setCode, value, type]);
|
||||
|
||||
if (code === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
style={{ margin: 0, padding: 16 }}
|
||||
dangerouslySetInnerHTML={{ __html: code }}
|
||||
onClick={(ev) => {
|
||||
const s = window.getSelection();
|
||||
if (s === null) {
|
||||
return;
|
||||
}
|
||||
s.selectAllChildren(ev.currentTarget);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import hljs from "highlight.js";
|
||||
import jsonHighlighter from "highlight.js/lib/languages/json";
|
||||
import xmlHighlighter from "highlight.js/lib/languages/xml";
|
||||
import prettierPluginBabel from "prettier/plugins/babel";
|
||||
import prettierPluginEstree from "prettier/plugins/estree";
|
||||
import prettierPluginHtml from "prettier/plugins/html";
|
||||
import { format } from "prettier/standalone";
|
||||
|
||||
hljs.registerLanguage("json", jsonHighlighter);
|
||||
hljs.registerLanguage("html", xmlHighlighter);
|
||||
|
||||
export async function html(value: string): Promise<string> {
|
||||
const prettyValue = await format(value, {
|
||||
parser: "html",
|
||||
plugins: [prettierPluginHtml]
|
||||
});
|
||||
return hljs.highlight(prettyValue, { language: "html" }).value;
|
||||
}
|
||||
|
||||
export async function json(value: string): Promise<string> {
|
||||
const prettyValue = await format(value, {
|
||||
parser: "json",
|
||||
printWidth: 0,
|
||||
trailingComma: "all",
|
||||
plugins: [prettierPluginBabel, prettierPluginEstree]
|
||||
});
|
||||
return hljs.highlight(prettyValue, { language: "javascript" }).value;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
renderToStaticMarkup
|
||||
} from "@usewaypoint/email-builder";
|
||||
import { TEditorConfiguration } from "../../../documents/editor/core";
|
||||
|
||||
function overrideCanvasSize(
|
||||
html: string,
|
||||
document: TEditorConfiguration,
|
||||
rootBlockId: string
|
||||
) {
|
||||
const rootBlock = document[rootBlockId];
|
||||
if (!rootBlock || rootBlock.type !== "EmailLayout") {
|
||||
return html;
|
||||
}
|
||||
|
||||
const data = rootBlock.data as { canvasWidth?: number | null };
|
||||
|
||||
const canvasWidth =
|
||||
typeof data?.canvasWidth === "number" && data.canvasWidth > 0
|
||||
? data.canvasWidth
|
||||
: 600;
|
||||
|
||||
let updatedHtml = html.replace(
|
||||
/max-width:\s*600px/gi,
|
||||
`max-width:${canvasWidth}px`
|
||||
);
|
||||
|
||||
return updatedHtml;
|
||||
}
|
||||
|
||||
export default function renderEmailHtml(
|
||||
document: TEditorConfiguration,
|
||||
rootBlockId = "root"
|
||||
) {
|
||||
const html = renderToStaticMarkup(document, { rootBlockId });
|
||||
return overrideCanvasSize(html, document, rootBlockId);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import { MonitorOutlined, PhoneIphoneOutlined } from "@mui/icons-material";
|
||||
import {
|
||||
Box,
|
||||
Stack,
|
||||
SxProps,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Tooltip
|
||||
} from "@mui/material";
|
||||
import { Reader } from "@usewaypoint/email-builder";
|
||||
|
||||
import EditorBlock from "../../documents/editor/EditorBlock";
|
||||
import {
|
||||
setSelectedScreenSize,
|
||||
useDocument,
|
||||
useSelectedMainTab,
|
||||
useSelectedScreenSize
|
||||
} from "../../documents/editor/EditorContext";
|
||||
import ToggleInspectorPanelButton from "../InspectorDrawer/ToggleInspectorPanelButton";
|
||||
import ToggleSamplesPanelButton from "../SamplesDrawer/ToggleSamplesPanelButton";
|
||||
|
||||
import DownloadJson from "./DownloadJson";
|
||||
import HtmlPanel from "./HtmlPanel";
|
||||
import JsonPanel from "./JsonPanel";
|
||||
import MainTabsGroup from "./MainTabsGroup";
|
||||
import ShareButton from "./ShareButton";
|
||||
import renderEmailHtml from "./helper/renderEmailHtml";
|
||||
|
||||
export default function TemplatePanel() {
|
||||
const document = useDocument();
|
||||
const selectedMainTab = useSelectedMainTab();
|
||||
const selectedScreenSize = useSelectedScreenSize();
|
||||
const previewHtml = useMemo(
|
||||
() => renderEmailHtml(document, "root"),
|
||||
[document]
|
||||
);
|
||||
|
||||
let mainBoxSx: SxProps = { height: "100%" };
|
||||
if (selectedScreenSize === "mobile") {
|
||||
mainBoxSx = {
|
||||
...mainBoxSx,
|
||||
margin: "32px auto",
|
||||
width: 370,
|
||||
height: 800,
|
||||
boxShadow:
|
||||
"rgba(33, 36, 67, 0.04) 0px 10px 20px, rgba(33, 36, 67, 0.04) 0px 2px 6px, rgba(33, 36, 67, 0.04) 0px 0px 1px"
|
||||
};
|
||||
}
|
||||
|
||||
const handleScreenSizeChange = (_: unknown, value: unknown) => {
|
||||
switch (value) {
|
||||
case "mobile":
|
||||
case "desktop":
|
||||
setSelectedScreenSize(value);
|
||||
return;
|
||||
default:
|
||||
setSelectedScreenSize("desktop");
|
||||
}
|
||||
};
|
||||
|
||||
const renderMainPanel = () => {
|
||||
switch (selectedMainTab) {
|
||||
case "editor":
|
||||
return (
|
||||
<Box sx={mainBoxSx}>
|
||||
<EditorBlock id="root" />
|
||||
</Box>
|
||||
);
|
||||
case "preview":
|
||||
return (
|
||||
<Box sx={mainBoxSx}>
|
||||
<iframe
|
||||
title="Email preview"
|
||||
srcDoc={previewHtml}
|
||||
sandbox="allow-popups allow-popups-to-escape-sandbox"
|
||||
style={{ width: "100%", height: "100%", border: "none" }}
|
||||
/>
|
||||
{/* <Reader document={document} rootBlockId="root" /> */}
|
||||
</Box>
|
||||
);
|
||||
case "html":
|
||||
return <HtmlPanel />;
|
||||
case "json":
|
||||
return <JsonPanel />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
sx={{
|
||||
height: 49,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
backgroundColor: "white",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: "appBar",
|
||||
px: 1
|
||||
}}
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<ToggleSamplesPanelButton />
|
||||
<Stack
|
||||
px={2}
|
||||
direction="row"
|
||||
gap={2}
|
||||
width="100%"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<MainTabsGroup />
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<DownloadJson />
|
||||
<ToggleButtonGroup
|
||||
value={selectedScreenSize}
|
||||
exclusive
|
||||
size="small"
|
||||
onChange={handleScreenSizeChange}
|
||||
>
|
||||
<ToggleButton value="desktop">
|
||||
<Tooltip title="Desktop view">
|
||||
<MonitorOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="mobile">
|
||||
<Tooltip title="Mobile view">
|
||||
<PhoneIphoneOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<ShareButton />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<ToggleInspectorPanelButton />
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{ height: "calc(100vh - 49px)", overflow: "auto", minWidth: 370 }}
|
||||
>
|
||||
{renderMainPanel()}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
useState
|
||||
} from "react";
|
||||
import { Stack, useTheme } from "@mui/material";
|
||||
import {
|
||||
useInspectorDrawerOpen,
|
||||
useSamplesDrawerOpen
|
||||
} from "../documents/editor/EditorContext";
|
||||
import InspectorDrawer, { INSPECTOR_DRAWER_WIDTH } from "./InspectorDrawer";
|
||||
import SamplesDrawer, { SAMPLES_DRAWER_WIDTH } from "./SamplesDrawer";
|
||||
import TemplatePanel from "./TemplatePanel";
|
||||
|
||||
function useDrawerTransition(
|
||||
cssProperty: "margin-left" | "margin-right",
|
||||
open: boolean
|
||||
) {
|
||||
const { transitions } = useTheme();
|
||||
return transitions.create(cssProperty, {
|
||||
easing: !open ? transitions.easing.sharp : transitions.easing.easeOut,
|
||||
duration: !open
|
||||
? transitions.duration.leavingScreen
|
||||
: transitions.duration.enteringScreen
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export default function EmailBuilderApp() {
|
||||
const inspectorDrawerOpen = useInspectorDrawerOpen();
|
||||
const samplesDrawerOpen = useSamplesDrawerOpen();
|
||||
|
||||
const marginLeftTransition = useDrawerTransition(
|
||||
"margin-left",
|
||||
samplesDrawerOpen
|
||||
);
|
||||
const marginRightTransition = useDrawerTransition(
|
||||
"margin-right",
|
||||
inspectorDrawerOpen
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InspectorDrawer />
|
||||
<SamplesDrawer />
|
||||
<Stack
|
||||
sx={{
|
||||
marginRight: inspectorDrawerOpen ? `${INSPECTOR_DRAWER_WIDTH}px` : 0,
|
||||
marginLeft: samplesDrawerOpen ? `${SAMPLES_DRAWER_WIDTH}px` : 0,
|
||||
transition: [marginLeftTransition, marginRightTransition].join(", ")
|
||||
}}
|
||||
>
|
||||
<TemplatePanel />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { CssBaseline, ThemeProvider } from "@mui/material";
|
||||
|
||||
import App from "./App";
|
||||
import theme from "./EmailBuildertheme";
|
||||
|
||||
const EmailBuilder = () => {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailBuilder;
|
||||
@@ -0,0 +1,520 @@
|
||||
import { alpha, createTheme, darken, lighten } from "@mui/material/styles";
|
||||
|
||||
const BRAND_NAVY = "#212443";
|
||||
const BRAND_BLUE = "#0079CC";
|
||||
const BRAND_GREEN = "#1F8466";
|
||||
const BRAND_RED = "#E81212";
|
||||
const BRAND_YELLOW = "#F6DC9F";
|
||||
const BRAND_PURPLE = "#6C0E7C";
|
||||
const BRAND_BROWN = "#CC996C";
|
||||
const STANDARD_FONT_FAMILY =
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"';
|
||||
const MONOSPACE_FONT_FAMILY =
|
||||
'ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace';
|
||||
|
||||
const BASE_THEME = createTheme({
|
||||
palette: {
|
||||
background: {
|
||||
default: "#f2f5f7"
|
||||
},
|
||||
text: {
|
||||
primary: "#1F1F21",
|
||||
secondary: "#4F4F4F"
|
||||
}
|
||||
},
|
||||
typography: {
|
||||
fontFamily: STANDARD_FONT_FAMILY
|
||||
}
|
||||
});
|
||||
|
||||
const THEME = createTheme(BASE_THEME, {
|
||||
palette: {
|
||||
brand: {
|
||||
navy: BRAND_NAVY,
|
||||
blue: BRAND_BLUE,
|
||||
red: BRAND_RED,
|
||||
green: BRAND_GREEN,
|
||||
yellow: BRAND_YELLOW,
|
||||
purple: BRAND_PURPLE,
|
||||
brown: BRAND_BROWN
|
||||
},
|
||||
success: {
|
||||
main: BRAND_GREEN,
|
||||
light: lighten(BRAND_GREEN, 0.15),
|
||||
dark: darken(BRAND_GREEN, 0.15)
|
||||
},
|
||||
error: {
|
||||
main: BRAND_RED,
|
||||
light: lighten(BRAND_RED, 0.15),
|
||||
dark: darken(BRAND_RED, 0.15)
|
||||
},
|
||||
cadet: {
|
||||
100: "#F9FAFB",
|
||||
200: "#F2F5F7",
|
||||
300: "#DCE4EA",
|
||||
400: "#A8BBCA",
|
||||
500: "#6A8BA4"
|
||||
},
|
||||
highlight: {
|
||||
100: lighten(BRAND_YELLOW, 0.8),
|
||||
200: lighten(BRAND_YELLOW, 0.6),
|
||||
300: lighten(BRAND_YELLOW, 0.4),
|
||||
400: lighten(BRAND_YELLOW, 0.2),
|
||||
500: BRAND_YELLOW
|
||||
},
|
||||
info: {
|
||||
main: BRAND_BLUE
|
||||
},
|
||||
primary: {
|
||||
main: BRAND_BLUE
|
||||
}
|
||||
},
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: `
|
||||
address {
|
||||
font-style: normal;
|
||||
}
|
||||
fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
pre {
|
||||
font-family: ${MONOSPACE_FONT_FAMILY}
|
||||
white-space: pre-wrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
`
|
||||
},
|
||||
MuiAlert: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontSize: BASE_THEME.typography.pxToRem(14)
|
||||
},
|
||||
action: {
|
||||
paddingTop: 0,
|
||||
marginRight: 0
|
||||
},
|
||||
filledSuccess: {
|
||||
backgroundColor: BRAND_GREEN
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiStepLabel: {
|
||||
styleOverrides: {
|
||||
label: {
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiDialog: {
|
||||
defaultProps: {
|
||||
fullWidth: true
|
||||
}
|
||||
},
|
||||
MuiDialogContent: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
paddingTop: BASE_THEME.spacing(1),
|
||||
paddingBottom: BASE_THEME.spacing(2)
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiDialogTitle: {
|
||||
defaultProps: {
|
||||
variant: "h4"
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
paddingTop: BASE_THEME.spacing(3),
|
||||
paddingBottom: BASE_THEME.spacing(1)
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiDialogActions: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderTop: "1px solid",
|
||||
borderTopColor: BASE_THEME.palette.divider,
|
||||
marginTop: BASE_THEME.spacing(2.5),
|
||||
padding: `${BASE_THEME.spacing(1.5)} ${BASE_THEME.spacing(3)}`
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTableCell: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
...BASE_THEME.typography.body2,
|
||||
borderColor: BASE_THEME.palette.grey[200]
|
||||
},
|
||||
head: {
|
||||
...BASE_THEME.typography.overline,
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
letterSpacing: "0.075em",
|
||||
color: BASE_THEME.palette.text.secondary
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTableRow: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
"&:last-child td": {
|
||||
borderBottom: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiAvatar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: "uppercase",
|
||||
fontSize: BASE_THEME.typography.pxToRem(14)
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
"&.MuiChip-filledError, &.MuiChip-filledSuccess": {
|
||||
fill: BASE_THEME.palette.primary.contrastText
|
||||
}
|
||||
},
|
||||
sizeSmall: {
|
||||
borderRadius: BASE_THEME.spacing(0.5),
|
||||
fontSize: 12
|
||||
},
|
||||
iconSmall: {
|
||||
fontSize: 14,
|
||||
marginLeft: BASE_THEME.spacing(1)
|
||||
},
|
||||
colorSecondary: {
|
||||
borderColor: BASE_THEME.palette.grey[400],
|
||||
color: BASE_THEME.palette.text.secondary
|
||||
},
|
||||
label: {
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiDrawer: {
|
||||
defaultProps: {
|
||||
PaperProps: {
|
||||
elevation: 2
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
fontSize: BASE_THEME.typography.pxToRem(12),
|
||||
backgroundColor: alpha(BASE_THEME.palette.text.primary, 0.9)
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiSlider: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
height: 1
|
||||
},
|
||||
track: {
|
||||
height: 1,
|
||||
border: "none"
|
||||
},
|
||||
rail: {
|
||||
height: 1,
|
||||
backgroundColor: BASE_THEME.palette.grey[500]
|
||||
},
|
||||
mark: {
|
||||
backgroundColor: BASE_THEME.palette.grey[500]
|
||||
},
|
||||
markActive: {
|
||||
height: 0
|
||||
},
|
||||
thumb: {
|
||||
height: 16,
|
||||
width: 16,
|
||||
cursor: "col-resize",
|
||||
"&:hover, &.Mui-active, &.Mui-focusVisible": {
|
||||
boxShadow: `0 0 0 4px ${alpha(BRAND_BLUE, 0.2)}`
|
||||
},
|
||||
"&:before": {
|
||||
display: "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiPaper: {
|
||||
defaultProps: {
|
||||
elevation: 2,
|
||||
square: true
|
||||
}
|
||||
},
|
||||
MuiButtonBase: {
|
||||
defaultProps: {
|
||||
disableTouchRipple: true,
|
||||
focusRipple: true
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
"&.MuiButton-containedSecondary.Mui-disabled": {
|
||||
backgroundColor: BASE_THEME.palette.grey[100]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiButtonGroup: {
|
||||
defaultProps: {
|
||||
disableElevation: true
|
||||
}
|
||||
},
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
edgeStart: {
|
||||
marginLeft: BASE_THEME.spacing(-1)
|
||||
},
|
||||
colorSecondary: {
|
||||
color: BASE_THEME.palette.grey[500]
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiButton: {
|
||||
defaultProps: {
|
||||
disableElevation: true
|
||||
},
|
||||
styleOverrides: {
|
||||
textPrimary: {
|
||||
color: BASE_THEME.palette.text.primary
|
||||
},
|
||||
textSecondary: {
|
||||
color: BASE_THEME.palette.text.secondary
|
||||
},
|
||||
outlinedPrimary: {
|
||||
borderColor: BASE_THEME.palette.grey[300],
|
||||
color: BASE_THEME.palette.text.primary,
|
||||
"&:hover, &:active, &:focus": {
|
||||
borderColor: BASE_THEME.palette.grey[500],
|
||||
color: BASE_THEME.palette.text.primary
|
||||
}
|
||||
},
|
||||
containedSecondary: {
|
||||
backgroundColor: BASE_THEME.palette.common.white,
|
||||
border: `1px solid ${BASE_THEME.palette.grey[300]}`,
|
||||
color: BASE_THEME.palette.text.primary,
|
||||
"&:hover, &:active, &:focus": {
|
||||
backgroundColor: BASE_THEME.palette.common.white,
|
||||
borderColor: BASE_THEME.palette.grey[500],
|
||||
color: BASE_THEME.palette.text.primary
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiToggleButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
paddingLeft: BASE_THEME.spacing(1.5),
|
||||
paddingRight: BASE_THEME.spacing(1.5)
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiInputBase: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
"&:not(.Mui-disabled, .Mui-error):before": {
|
||||
borderBottom: `1px solid ${BASE_THEME.palette.grey[400]}`
|
||||
},
|
||||
"&:hover:not(.Mui-disabled, .Mui-error):before": {
|
||||
borderBottom: `1px solid ${BASE_THEME.palette.grey[500]} !important`
|
||||
},
|
||||
"&:after": {
|
||||
borderBottom: `1px solid ${BASE_THEME.palette.text.primary} !important`
|
||||
},
|
||||
"&.MuiOutlinedInput-root:not(.Mui-error)": {
|
||||
"& fieldset": {
|
||||
borderColor: BASE_THEME.palette.grey[300],
|
||||
transition: "border-color 0.2s"
|
||||
}
|
||||
},
|
||||
"&.MuiOutlinedInput-root:not(.Mui-disabled, .Mui-error)": {
|
||||
"&:hover fieldset": {
|
||||
borderColor: BASE_THEME.palette.grey[400]
|
||||
},
|
||||
"&.Mui-focused fieldset": {
|
||||
borderColor: BASE_THEME.palette.text.secondary,
|
||||
borderWidth: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
input: {
|
||||
fontSize: BASE_THEME.typography.pxToRem(14),
|
||||
"&.Mui-disabled": {
|
||||
WebkitTextFillColor: "inherit",
|
||||
color: BASE_THEME.palette.text.secondary
|
||||
}
|
||||
},
|
||||
inputSizeSmall: {}
|
||||
}
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
notchedOutline: {
|
||||
"& legend": {
|
||||
fontSize: "0.85em",
|
||||
maxWidth: "100%"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiInputAdornment: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
"& .MuiTypography-root": {
|
||||
fontSize: BASE_THEME.typography.pxToRem(14),
|
||||
color: BASE_THEME.palette.text.secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiInputLabel: {
|
||||
defaultProps: {
|
||||
shrink: true
|
||||
},
|
||||
styleOverrides: {
|
||||
shrink: {
|
||||
transform: "scale(0.85)",
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
"&.Mui-focused": {
|
||||
color: BASE_THEME.palette.text.primary
|
||||
},
|
||||
"&.MuiInputLabel-standard": {
|
||||
transform: "translate(0, -4px) scale(0.85)",
|
||||
color: "#4F4F4F"
|
||||
},
|
||||
"&.MuiInputLabel-outlined": {
|
||||
transform: "translate(15px, -8px) scale(0.85)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTabs: {
|
||||
defaultProps: {
|
||||
variant: "scrollable"
|
||||
},
|
||||
styleOverrides: {
|
||||
indicator: {
|
||||
height: 1,
|
||||
backgroundColor: BASE_THEME.palette.text.primary
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: "none",
|
||||
minWidth: BASE_THEME.spacing(2),
|
||||
paddingLeft: BASE_THEME.spacing(1.5),
|
||||
paddingRight: BASE_THEME.spacing(1.5),
|
||||
fontSize: BASE_THEME.typography.pxToRem(14),
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
lineHeight: 1.5,
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
transition: "color 0.2s",
|
||||
"&.Mui-selected": {
|
||||
color: BASE_THEME.palette.text.primary
|
||||
},
|
||||
"&:hover": {
|
||||
color: BASE_THEME.palette.text.primary
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiCardHeader: {
|
||||
styleOverrides: {
|
||||
title: {
|
||||
fontSize: BASE_THEME.typography.pxToRem(18),
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
typography: {
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
h1: {
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
fontSize: BASE_THEME.typography.pxToRem(40),
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: "-0.02em",
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium
|
||||
},
|
||||
h2: {
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
fontSize: BASE_THEME.typography.pxToRem(32),
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: "-0.02em",
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium
|
||||
},
|
||||
h3: {
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
fontSize: BASE_THEME.typography.pxToRem(24),
|
||||
lineHeight: 1.5,
|
||||
letterSpacing: "-0.01em",
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium
|
||||
},
|
||||
h4: {
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
fontSize: BASE_THEME.typography.pxToRem(20),
|
||||
lineHeight: 1.5,
|
||||
letterSpacing: "-0.01em",
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium
|
||||
},
|
||||
h5: {
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
fontSize: BASE_THEME.typography.pxToRem(18),
|
||||
lineHeight: 1.5,
|
||||
letterSpacing: "-0.01em",
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium
|
||||
},
|
||||
h6: {
|
||||
fontFamily: BASE_THEME.typography.fontFamily,
|
||||
fontSize: BASE_THEME.typography.pxToRem(16),
|
||||
lineHeight: 1.5,
|
||||
letterSpacing: "-0.005em",
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium
|
||||
},
|
||||
body1: {
|
||||
fontSize: BASE_THEME.typography.pxToRem(14)
|
||||
},
|
||||
body2: {
|
||||
fontSize: BASE_THEME.typography.pxToRem(12)
|
||||
},
|
||||
overline: {
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
letterSpacing: "0.05em"
|
||||
},
|
||||
button: {
|
||||
textTransform: "none",
|
||||
fontWeight: BASE_THEME.typography.fontWeightMedium,
|
||||
lineHeight: 1.5
|
||||
},
|
||||
caption: {
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.5
|
||||
}
|
||||
},
|
||||
shadows: [
|
||||
"none",
|
||||
"0px 4px 15px rgba(33, 36, 67, 0.04), 0px 0px 2px rgba(33, 36, 67, 0.04), 0px 0px 1px rgba(33, 36, 67, 0.04)",
|
||||
"0px 10px 20px rgba(33, 36, 67, 0.04), 0px 2px 6px rgba(33, 36, 67, 0.04), 0px 0px 1px rgba(33, 36, 67, 0.04)",
|
||||
"0px 16px 24px rgba(33, 36, 67, 0.05), 0px 2px 6px rgba(33, 36, 67, 0.05), 0px 0px 1px rgba(33, 36, 67, 0.05)",
|
||||
"0px 24px 32px rgba(33, 36, 67, 0.06), 0px 16px 24px rgba(33, 36, 67, 0.06), 0px 4px 8px rgba(33, 36, 67, 0.06)",
|
||||
...Array(20).fill("none")
|
||||
]
|
||||
});
|
||||
|
||||
export default THEME;
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
|
||||
import { ColumnsContainer as BaseColumnsContainer } from "@usewaypoint/block-columns-container";
|
||||
|
||||
import { useCurrentBlockId } from "../../editor/EditorBlock";
|
||||
import { setDocument, setSelectedBlockId } from "../../editor/EditorContext";
|
||||
import EditorChildrenIds, {
|
||||
EditorChildrenChange
|
||||
} from "../helpers/EditorChildrenIds";
|
||||
|
||||
import ColumnsContainerPropsSchema, {
|
||||
ColumnsContainerProps
|
||||
} from "./ColumnsContainerPropsSchema";
|
||||
|
||||
const EMPTY_COLUMNS = [
|
||||
{ childrenIds: [] },
|
||||
{ childrenIds: [] },
|
||||
{ childrenIds: [] }
|
||||
];
|
||||
|
||||
export default function ColumnsContainerEditor({
|
||||
style,
|
||||
props
|
||||
}: ColumnsContainerProps) {
|
||||
const currentBlockId = useCurrentBlockId();
|
||||
|
||||
const { columns, ...restProps } = props ?? {};
|
||||
const columnsValue = columns ?? EMPTY_COLUMNS;
|
||||
|
||||
const updateColumn = (
|
||||
columnIndex: 0 | 1 | 2,
|
||||
{ block, blockId, childrenIds }: EditorChildrenChange
|
||||
) => {
|
||||
const nColumns = [...columnsValue];
|
||||
nColumns[columnIndex] = { childrenIds };
|
||||
setDocument({
|
||||
[blockId]: block,
|
||||
[currentBlockId]: {
|
||||
type: "ColumnsContainer",
|
||||
data: ColumnsContainerPropsSchema.parse({
|
||||
style,
|
||||
props: {
|
||||
...restProps,
|
||||
columns: nColumns
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
setSelectedBlockId(blockId);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseColumnsContainer
|
||||
props={restProps}
|
||||
style={style}
|
||||
columns={[
|
||||
<EditorChildrenIds
|
||||
childrenIds={columns?.[0]?.childrenIds}
|
||||
onChange={(change) => updateColumn(0, change)}
|
||||
/>,
|
||||
<EditorChildrenIds
|
||||
childrenIds={columns?.[1]?.childrenIds}
|
||||
onChange={(change) => updateColumn(1, change)}
|
||||
/>,
|
||||
<EditorChildrenIds
|
||||
childrenIds={columns?.[2]?.childrenIds}
|
||||
onChange={(change) => updateColumn(2, change)}
|
||||
/>
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ColumnsContainerPropsSchema as BaseColumnsContainerPropsSchema } from "@usewaypoint/block-columns-container";
|
||||
|
||||
const BasePropsShape = BaseColumnsContainerPropsSchema.shape.props
|
||||
.unwrap()
|
||||
.unwrap().shape;
|
||||
|
||||
const ColumnsContainerPropsSchema = z.object({
|
||||
style: BaseColumnsContainerPropsSchema.shape.style,
|
||||
props: z
|
||||
.object({
|
||||
...BasePropsShape,
|
||||
columns: z.tuple([
|
||||
z.object({ childrenIds: z.array(z.string()) }),
|
||||
z.object({ childrenIds: z.array(z.string()) }),
|
||||
z.object({ childrenIds: z.array(z.string()) })
|
||||
])
|
||||
})
|
||||
.optional()
|
||||
.nullable()
|
||||
});
|
||||
|
||||
export type ColumnsContainerProps = z.infer<typeof ColumnsContainerPropsSchema>;
|
||||
export default ColumnsContainerPropsSchema;
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
|
||||
import { Container as BaseContainer } from "@usewaypoint/block-container";
|
||||
|
||||
import { useCurrentBlockId } from "../../editor/EditorBlock";
|
||||
import {
|
||||
setDocument,
|
||||
setSelectedBlockId,
|
||||
useDocument
|
||||
} from "../../editor/EditorContext";
|
||||
import EditorChildrenIds from "../helpers/EditorChildrenIds";
|
||||
|
||||
import { ContainerProps } from "./ContainerPropsSchema";
|
||||
|
||||
export default function ContainerEditor({ style, props }: ContainerProps) {
|
||||
const childrenIds = props?.childrenIds ?? [];
|
||||
|
||||
const document = useDocument();
|
||||
const currentBlockId = useCurrentBlockId();
|
||||
|
||||
return (
|
||||
<BaseContainer style={style}>
|
||||
<EditorChildrenIds
|
||||
childrenIds={childrenIds}
|
||||
onChange={({ block, blockId, childrenIds }) => {
|
||||
setDocument({
|
||||
[blockId]: block,
|
||||
[currentBlockId]: {
|
||||
type: "Container",
|
||||
data: {
|
||||
...document[currentBlockId].data,
|
||||
props: { childrenIds: childrenIds }
|
||||
}
|
||||
}
|
||||
});
|
||||
setSelectedBlockId(blockId);
|
||||
}}
|
||||
/>
|
||||
</BaseContainer>
|
||||
);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ContainerPropsSchema as BaseContainerPropsSchema } from "@usewaypoint/block-container";
|
||||
|
||||
const ContainerPropsSchema = z.object({
|
||||
style: BaseContainerPropsSchema.shape.style,
|
||||
props: z
|
||||
.object({
|
||||
childrenIds: z.array(z.string()).optional().nullable()
|
||||
})
|
||||
.optional()
|
||||
.nullable()
|
||||
});
|
||||
|
||||
export default ContainerPropsSchema;
|
||||
|
||||
export type ContainerProps = z.infer<typeof ContainerPropsSchema>;
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import React from "react";
|
||||
|
||||
import { useCurrentBlockId } from "../../editor/EditorBlock";
|
||||
import {
|
||||
setDocument,
|
||||
setSelectedBlockId,
|
||||
useDocument
|
||||
} from "../../editor/EditorContext";
|
||||
import EditorChildrenIds from "../helpers/EditorChildrenIds";
|
||||
|
||||
import { EmailLayoutProps } from "./EmailLayoutPropsSchema";
|
||||
|
||||
function getFontFamily(fontFamily: EmailLayoutProps["fontFamily"]) {
|
||||
const f = fontFamily ?? "MODERN_SANS";
|
||||
switch (f) {
|
||||
case "MODERN_SANS":
|
||||
return '"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif';
|
||||
case "BOOK_SANS":
|
||||
return 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif';
|
||||
case "ORGANIC_SANS":
|
||||
return 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif';
|
||||
case "GEOMETRIC_SANS":
|
||||
return 'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif';
|
||||
case "HEAVY_SANS":
|
||||
return 'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif';
|
||||
case "ROUNDED_SANS":
|
||||
return 'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif';
|
||||
case "MODERN_SERIF":
|
||||
return 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif';
|
||||
case "BOOK_SERIF":
|
||||
return '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif';
|
||||
case "MONOSPACE":
|
||||
return '"Nimbus Mono PS", "Courier New", "Cutive Mono", monospace';
|
||||
}
|
||||
}
|
||||
|
||||
export default function EmailLayoutEditor(props: EmailLayoutProps) {
|
||||
const childrenIds = props.childrenIds ?? [];
|
||||
const canvasWidth = props.canvasWidth ?? 600;
|
||||
const document = useDocument();
|
||||
const currentBlockId = useCurrentBlockId();
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
setSelectedBlockId(null);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: props.backdropColor ?? "#F5F5F5",
|
||||
color: props.textColor ?? "#262626",
|
||||
fontFamily: getFontFamily(props.fontFamily),
|
||||
fontSize: "16px",
|
||||
fontWeight: "400",
|
||||
letterSpacing: "0.15008px",
|
||||
lineHeight: "1.5",
|
||||
margin: "0",
|
||||
padding: "32px 0",
|
||||
width: "100%",
|
||||
minHeight: "100%"
|
||||
}}
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
style={{
|
||||
margin: "0 auto",
|
||||
// maxWidth: "600px",
|
||||
maxWidth: `${canvasWidth}px`,
|
||||
backgroundColor: props.canvasColor ?? "#FFFFFF",
|
||||
borderRadius: props.borderRadius ?? undefined,
|
||||
border: (() => {
|
||||
const v = props.borderColor;
|
||||
if (!v) {
|
||||
return undefined;
|
||||
}
|
||||
return `1px solid ${v}`;
|
||||
})()
|
||||
}}
|
||||
role="presentation"
|
||||
cellSpacing="0"
|
||||
cellPadding="0"
|
||||
border={0}
|
||||
>
|
||||
<tbody>
|
||||
<tr style={{ width: "100%" }}>
|
||||
<td>
|
||||
<EditorChildrenIds
|
||||
childrenIds={childrenIds}
|
||||
onChange={({ block, blockId, childrenIds }) => {
|
||||
setDocument({
|
||||
[blockId]: block,
|
||||
[currentBlockId]: {
|
||||
type: "EmailLayout",
|
||||
data: {
|
||||
...document[currentBlockId].data,
|
||||
childrenIds: childrenIds
|
||||
}
|
||||
}
|
||||
});
|
||||
setSelectedBlockId(blockId);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const COLOR_SCHEMA = z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
const FONT_FAMILY_SCHEMA = z
|
||||
.enum([
|
||||
"MODERN_SANS",
|
||||
"BOOK_SANS",
|
||||
"ORGANIC_SANS",
|
||||
"GEOMETRIC_SANS",
|
||||
"HEAVY_SANS",
|
||||
"ROUNDED_SANS",
|
||||
"MODERN_SERIF",
|
||||
"BOOK_SERIF",
|
||||
"MONOSPACE"
|
||||
])
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
const EmailLayoutPropsSchema = z.object({
|
||||
backdropColor: COLOR_SCHEMA,
|
||||
borderColor: COLOR_SCHEMA,
|
||||
borderRadius: z.number().optional().nullable(),
|
||||
canvasColor: COLOR_SCHEMA,
|
||||
canvasWidth: z.number().optional().nullable(),
|
||||
textColor: COLOR_SCHEMA,
|
||||
fontFamily: FONT_FAMILY_SCHEMA,
|
||||
childrenIds: z.array(z.string()).optional().nullable()
|
||||
});
|
||||
|
||||
export default EmailLayoutPropsSchema;
|
||||
|
||||
export type EmailLayoutProps = z.infer<typeof EmailLayoutPropsSchema>;
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
|
||||
import { Box, Button, SxProps, Typography } from "@mui/material";
|
||||
|
||||
type BlockMenuButtonProps = {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const BUTTON_SX: SxProps = { p: 1.5, display: "flex", flexDirection: "column" };
|
||||
const ICON_SX: SxProps = {
|
||||
mb: 0.75,
|
||||
width: "100%",
|
||||
bgcolor: "cadet.200",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
p: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "cadet.300"
|
||||
};
|
||||
|
||||
export default function BlockTypeButton({
|
||||
label,
|
||||
icon,
|
||||
onClick
|
||||
}: BlockMenuButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
sx={BUTTON_SX}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<Box sx={ICON_SX}>{icon}</Box>
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
|
||||
import { Box, Menu } from "@mui/material";
|
||||
|
||||
import { TEditorBlock } from "../../../../editor/core";
|
||||
|
||||
import BlockButton from "./BlockButton";
|
||||
import { BUTTONS } from "./buttons";
|
||||
|
||||
type BlocksMenuProps = {
|
||||
anchorEl: HTMLElement | null;
|
||||
setAnchorEl: (v: HTMLElement | null) => void;
|
||||
onSelect: (block: TEditorBlock) => void;
|
||||
};
|
||||
export default function BlocksMenu({
|
||||
anchorEl,
|
||||
setAnchorEl,
|
||||
onSelect
|
||||
}: BlocksMenuProps) {
|
||||
const onClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const onClick = (block: TEditorBlock) => {
|
||||
onSelect(block);
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
if (anchorEl === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open
|
||||
anchorEl={anchorEl}
|
||||
onClose={onClose}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "center" }}
|
||||
>
|
||||
<Box
|
||||
sx={{ p: 1, display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr" }}
|
||||
>
|
||||
{BUTTONS.map((k, i) => (
|
||||
<BlockButton
|
||||
key={i}
|
||||
label={k.label}
|
||||
icon={k.icon}
|
||||
onClick={() => onClick(k.block())}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { AddOutlined } from "@mui/icons-material";
|
||||
import { Fade, IconButton } from "@mui/material";
|
||||
|
||||
type Props = {
|
||||
buttonElement: HTMLElement | null;
|
||||
onClick: () => void;
|
||||
};
|
||||
export default function DividerButton({ buttonElement, onClick }: Props) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function listener({ clientX, clientY }: MouseEvent) {
|
||||
if (!buttonElement) {
|
||||
return;
|
||||
}
|
||||
const rect = buttonElement.getBoundingClientRect();
|
||||
const rectY = rect.y;
|
||||
const bottomX = rect.x;
|
||||
const topX = bottomX + rect.width;
|
||||
|
||||
if (Math.abs(clientY - rectY) < 20) {
|
||||
if (bottomX < clientX && clientX < topX) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setVisible(false);
|
||||
}
|
||||
window.addEventListener("mousemove", listener);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", listener);
|
||||
};
|
||||
}, [buttonElement, setVisible]);
|
||||
|
||||
return (
|
||||
<Fade in={visible}>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
p: 0.12,
|
||||
position: "absolute",
|
||||
top: "-12px",
|
||||
left: "50%",
|
||||
transform: "translateX(-10px)",
|
||||
bgcolor: "brand.blue",
|
||||
color: "primary.contrastText",
|
||||
zIndex: "fab",
|
||||
"&:hover, &:active, &:focus": {
|
||||
bgcolor: "brand.blue",
|
||||
color: "primary.contrastText"
|
||||
}
|
||||
}}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<AddOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
|
||||
import { AddOutlined } from "@mui/icons-material";
|
||||
import { ButtonBase } from "@mui/material";
|
||||
|
||||
type Props = {
|
||||
onClick: () => void;
|
||||
};
|
||||
export default function PlaceholderButton({ onClick }: Props) {
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignContent: "center",
|
||||
justifyContent: "center",
|
||||
height: 48,
|
||||
width: "100%",
|
||||
bgcolor: "rgba(0,0,0, 0.05)"
|
||||
}}
|
||||
>
|
||||
<AddOutlined
|
||||
sx={{
|
||||
p: 0.12,
|
||||
bgcolor: "brand.blue",
|
||||
borderRadius: 24,
|
||||
color: "primary.contrastText"
|
||||
}}
|
||||
fontSize="small"
|
||||
/>
|
||||
</ButtonBase>
|
||||
);
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
AccountCircleOutlined,
|
||||
Crop32Outlined,
|
||||
HMobiledataOutlined,
|
||||
HorizontalRuleOutlined,
|
||||
HtmlOutlined,
|
||||
ImageOutlined,
|
||||
LibraryAddOutlined,
|
||||
NotesOutlined,
|
||||
SmartButtonOutlined,
|
||||
ViewColumnOutlined
|
||||
} from "@mui/icons-material";
|
||||
|
||||
import { TEditorBlock } from "../../../../editor/core";
|
||||
|
||||
type TButtonProps = {
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
block: () => TEditorBlock;
|
||||
};
|
||||
export const BUTTONS: TButtonProps[] = [
|
||||
{
|
||||
label: "Heading",
|
||||
icon: <HMobiledataOutlined />,
|
||||
block: () => ({
|
||||
type: "Heading",
|
||||
data: {
|
||||
props: { text: "Hello friend" },
|
||||
style: {
|
||||
padding: { top: 16, bottom: 16, left: 24, right: 24 }
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Text",
|
||||
icon: <NotesOutlined />,
|
||||
block: () => ({
|
||||
type: "Text",
|
||||
data: {
|
||||
props: { text: "My new text block" },
|
||||
style: {
|
||||
padding: { top: 16, bottom: 16, left: 24, right: 24 },
|
||||
fontWeight: "normal"
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
{
|
||||
label: "Button",
|
||||
icon: <SmartButtonOutlined />,
|
||||
block: () => ({
|
||||
type: "Button",
|
||||
data: {
|
||||
props: { text: "Button", url: window.location.origin },
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } }
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Image",
|
||||
icon: <ImageOutlined />,
|
||||
block: () => ({
|
||||
type: "Image",
|
||||
data: {
|
||||
props: {
|
||||
url: "https://ui-avatars.com/api/?size=128",
|
||||
alt: "Sample product",
|
||||
contentAlignment: "middle",
|
||||
linkHref: null
|
||||
},
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } }
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Avatar",
|
||||
icon: <AccountCircleOutlined />,
|
||||
block: () => ({
|
||||
type: "Avatar",
|
||||
data: {
|
||||
props: {
|
||||
imageUrl: "https://ui-avatars.com/api/?size=128",
|
||||
shape: "circle"
|
||||
},
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } }
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Divider",
|
||||
icon: <HorizontalRuleOutlined />,
|
||||
block: () => ({
|
||||
type: "Divider",
|
||||
data: {
|
||||
style: { padding: { top: 16, right: 0, bottom: 16, left: 0 } },
|
||||
props: {
|
||||
lineColor: "#CCCCCC"
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Spacer",
|
||||
icon: <Crop32Outlined />,
|
||||
block: () => ({
|
||||
type: "Spacer",
|
||||
data: {}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Html",
|
||||
icon: <HtmlOutlined />,
|
||||
block: () => ({
|
||||
type: "Html",
|
||||
data: {
|
||||
props: { contents: "<strong>Hello world</strong>" },
|
||||
style: {
|
||||
fontSize: 16,
|
||||
textAlign: null,
|
||||
padding: { top: 16, bottom: 16, left: 24, right: 24 }
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Columns",
|
||||
icon: <ViewColumnOutlined />,
|
||||
block: () => ({
|
||||
type: "ColumnsContainer",
|
||||
data: {
|
||||
props: {
|
||||
columnsGap: 16,
|
||||
columnsCount: 3,
|
||||
columns: [
|
||||
{ childrenIds: [] },
|
||||
{ childrenIds: [] },
|
||||
{ childrenIds: [] }
|
||||
]
|
||||
},
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } }
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Container",
|
||||
icon: <LibraryAddOutlined />,
|
||||
block: () => ({
|
||||
type: "Container",
|
||||
data: {
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// { label: 'ProgressBar', icon: <ProgressBarOutlined />, block: () => ({}) },
|
||||
// { label: 'LoopContainer', icon: <ViewListOutlined />, block: () => ({}) },
|
||||
];
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { TEditorBlock } from "../../../../editor/core";
|
||||
|
||||
import BlocksMenu from "./BlocksMenu";
|
||||
import DividerButton from "./DividerButton";
|
||||
import PlaceholderButton from "./PlaceholderButton";
|
||||
|
||||
type Props = {
|
||||
placeholder?: boolean;
|
||||
onSelect: (block: TEditorBlock) => void;
|
||||
};
|
||||
export default function AddBlockButton({ onSelect, placeholder }: Props) {
|
||||
const [menuAnchorEl, setMenuAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [buttonElement, setButtonElement] = useState<HTMLElement | null>(null);
|
||||
|
||||
const handleButtonClick = () => {
|
||||
setMenuAnchorEl(buttonElement);
|
||||
};
|
||||
|
||||
const renderButton = () => {
|
||||
if (placeholder) {
|
||||
return <PlaceholderButton onClick={handleButtonClick} />;
|
||||
} else {
|
||||
return (
|
||||
<DividerButton
|
||||
buttonElement={buttonElement}
|
||||
onClick={handleButtonClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={setButtonElement} style={{ position: "relative" }}>
|
||||
{renderButton()}
|
||||
</div>
|
||||
<BlocksMenu
|
||||
anchorEl={menuAnchorEl}
|
||||
setAnchorEl={setMenuAnchorEl}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
import { TEditorBlock } from "../../../editor/core";
|
||||
import EditorBlock from "../../../editor/EditorBlock";
|
||||
|
||||
import AddBlockButton from "./AddBlockMenu";
|
||||
|
||||
export type EditorChildrenChange = {
|
||||
blockId: string;
|
||||
block: TEditorBlock;
|
||||
childrenIds: string[];
|
||||
};
|
||||
|
||||
function generateId() {
|
||||
return `block-${Date.now()}`;
|
||||
}
|
||||
|
||||
export type EditorChildrenIdsProps = {
|
||||
childrenIds: string[] | null | undefined;
|
||||
onChange: (val: EditorChildrenChange) => void;
|
||||
};
|
||||
export default function EditorChildrenIds({
|
||||
childrenIds,
|
||||
onChange
|
||||
}: EditorChildrenIdsProps) {
|
||||
const appendBlock = (block: TEditorBlock) => {
|
||||
const blockId = generateId();
|
||||
return onChange({
|
||||
blockId,
|
||||
block,
|
||||
childrenIds: [...(childrenIds || []), blockId]
|
||||
});
|
||||
};
|
||||
|
||||
const insertBlock = (block: TEditorBlock, index: number) => {
|
||||
const blockId = generateId();
|
||||
const newChildrenIds = [...(childrenIds || [])];
|
||||
newChildrenIds.splice(index, 0, blockId);
|
||||
return onChange({
|
||||
blockId,
|
||||
block,
|
||||
childrenIds: newChildrenIds
|
||||
});
|
||||
};
|
||||
|
||||
if (!childrenIds || childrenIds.length === 0) {
|
||||
return <AddBlockButton placeholder onSelect={appendBlock} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{childrenIds.map((childId, i) => (
|
||||
<Fragment key={childId}>
|
||||
<AddBlockButton onSelect={(block) => insertBlock(block, i)} />
|
||||
<EditorBlock id={childId} />
|
||||
</Fragment>
|
||||
))}
|
||||
<AddBlockButton onSelect={appendBlock} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type TStyle = {
|
||||
backgroundColor?: any;
|
||||
borderColor?: any;
|
||||
borderRadius?: any;
|
||||
color?: any;
|
||||
fontFamily?: any;
|
||||
fontSize?: any;
|
||||
fontWeight?: any;
|
||||
padding?: any;
|
||||
textAlign?: any;
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import React, { CSSProperties, useState } from "react";
|
||||
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
import { useCurrentBlockId } from "../../../editor/EditorBlock";
|
||||
import {
|
||||
setSelectedBlockId,
|
||||
useSelectedBlockId
|
||||
} from "../../../editor/EditorContext";
|
||||
|
||||
import TuneMenu from "./TuneMenu";
|
||||
|
||||
type TEditorBlockWrapperProps = {
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
export default function EditorBlockWrapper({
|
||||
children
|
||||
}: TEditorBlockWrapperProps) {
|
||||
const selectedBlockId = useSelectedBlockId();
|
||||
const [mouseInside, setMouseInside] = useState(false);
|
||||
const blockId = useCurrentBlockId();
|
||||
|
||||
let outline: CSSProperties["outline"];
|
||||
if (selectedBlockId === blockId) {
|
||||
outline = "2px solid rgba(0,121,204, 1)";
|
||||
} else if (mouseInside) {
|
||||
outline = "2px solid rgba(0,121,204, 0.3)";
|
||||
}
|
||||
|
||||
const renderMenu = () => {
|
||||
if (selectedBlockId !== blockId) {
|
||||
return null;
|
||||
}
|
||||
return <TuneMenu blockId={blockId} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
maxWidth: "100%",
|
||||
outlineOffset: "-1px",
|
||||
outline
|
||||
}}
|
||||
onMouseEnter={(ev) => {
|
||||
setMouseInside(true);
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setMouseInside(false);
|
||||
}}
|
||||
onClick={(ev) => {
|
||||
setSelectedBlockId(blockId);
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}}
|
||||
>
|
||||
{renderMenu()}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import React, { CSSProperties } from "react";
|
||||
|
||||
import { TStyle } from "../TStyle";
|
||||
|
||||
type TReaderBlockWrapperProps = {
|
||||
style: TStyle;
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
export default function ReaderBlockWrapper({
|
||||
style,
|
||||
children
|
||||
}: TReaderBlockWrapperProps) {
|
||||
const { padding, borderColor, ...restStyle } = style;
|
||||
const cssStyle: CSSProperties = {
|
||||
...restStyle
|
||||
};
|
||||
|
||||
if (padding) {
|
||||
const { top, bottom, left, right } = padding;
|
||||
cssStyle.padding = `${top}px ${right}px ${bottom}px ${left}px`;
|
||||
}
|
||||
|
||||
if (borderColor) {
|
||||
cssStyle.border = `1px solid ${borderColor}`;
|
||||
}
|
||||
|
||||
return <div style={{ maxWidth: "100%", ...cssStyle }}>{children}</div>;
|
||||
}
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
ArrowDownwardOutlined,
|
||||
ArrowUpwardOutlined,
|
||||
ContentCopyOutlined,
|
||||
DeleteOutlined
|
||||
} from "@mui/icons-material";
|
||||
import { IconButton, Paper, Stack, SxProps, Tooltip } from "@mui/material";
|
||||
|
||||
import { TEditorBlock, TEditorConfiguration } from "../../../editor/core";
|
||||
import {
|
||||
resetDocument,
|
||||
setSelectedBlockId,
|
||||
useDocument
|
||||
} from "../../../editor/EditorContext";
|
||||
import { ColumnsContainerProps } from "../../ColumnsContainer/ColumnsContainerPropsSchema";
|
||||
import cloneDocumentBlock from "../cloneDocumentBlock";
|
||||
|
||||
const sx: SxProps = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: -56,
|
||||
borderRadius: 64,
|
||||
paddingX: 0.5,
|
||||
paddingY: 1,
|
||||
zIndex: "fab"
|
||||
};
|
||||
|
||||
function findParentBlockId(blockId: string, document: TEditorConfiguration) {
|
||||
for (const [id, b] of Object.entries(document)) {
|
||||
if (id === blockId) {
|
||||
continue;
|
||||
}
|
||||
const block = b as TEditorBlock;
|
||||
switch (block.type) {
|
||||
case "EmailLayout":
|
||||
if (block.data.childrenIds?.includes(blockId)) {
|
||||
return id;
|
||||
}
|
||||
break;
|
||||
case "Container":
|
||||
if (block.data.props?.childrenIds?.includes(blockId)) {
|
||||
return id;
|
||||
}
|
||||
break;
|
||||
case "ColumnsContainer":
|
||||
if (
|
||||
block.data.props?.columns?.some((col) =>
|
||||
col.childrenIds?.includes(blockId)
|
||||
)
|
||||
) {
|
||||
return id;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
blockId: string;
|
||||
};
|
||||
export default function TuneMenu({ blockId }: Props) {
|
||||
const document = useDocument();
|
||||
|
||||
const handleDuplicateClick = () => {
|
||||
const parentBlockId = findParentBlockId(blockId, document);
|
||||
|
||||
const { document: newDocument, blockId: newBlockId } = cloneDocumentBlock(
|
||||
document,
|
||||
blockId
|
||||
);
|
||||
|
||||
if (parentBlockId) {
|
||||
const parentBlock = newDocument[parentBlockId];
|
||||
switch (parentBlock.type) {
|
||||
case "EmailLayout": {
|
||||
if (!parentBlock.data.childrenIds) {
|
||||
parentBlock.data.childrenIds = [];
|
||||
}
|
||||
const index = parentBlock.data.childrenIds.indexOf(blockId);
|
||||
parentBlock.data.childrenIds.splice(index + 1, 0, newBlockId);
|
||||
break;
|
||||
}
|
||||
case "Container": {
|
||||
if (!parentBlock.data.props) {
|
||||
parentBlock.data.props = {};
|
||||
}
|
||||
if (!parentBlock.data.props.childrenIds) {
|
||||
parentBlock.data.props.childrenIds = [];
|
||||
}
|
||||
const index = parentBlock.data.props.childrenIds.indexOf(blockId);
|
||||
parentBlock.data.props.childrenIds.splice(index + 1, 0, newBlockId);
|
||||
break;
|
||||
}
|
||||
case "ColumnsContainer":
|
||||
if (!parentBlock.data.props) {
|
||||
parentBlock.data.props = {
|
||||
columns: [
|
||||
{ childrenIds: [] },
|
||||
{ childrenIds: [] },
|
||||
{ childrenIds: [] }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
for (const column of parentBlock.data.props.columns) {
|
||||
if (column.childrenIds.includes(blockId)) {
|
||||
const index = column.childrenIds.indexOf(blockId);
|
||||
column.childrenIds.splice(index + 1, 0, newBlockId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
resetDocument(newDocument);
|
||||
setSelectedBlockId(newBlockId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
const filterChildrenIds = (childrenIds: string[] | null | undefined) => {
|
||||
if (!childrenIds) {
|
||||
return childrenIds;
|
||||
}
|
||||
return childrenIds.filter((f) => f !== blockId);
|
||||
};
|
||||
const nDocument: typeof document = { ...document };
|
||||
for (const [id, b] of Object.entries(nDocument)) {
|
||||
const block = b as TEditorBlock;
|
||||
if (id === blockId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (block.type) {
|
||||
case "EmailLayout":
|
||||
nDocument[id] = {
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
childrenIds: filterChildrenIds(block.data.childrenIds)
|
||||
}
|
||||
};
|
||||
break;
|
||||
case "Container":
|
||||
nDocument[id] = {
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
props: {
|
||||
...block.data.props,
|
||||
childrenIds: filterChildrenIds(block.data.props?.childrenIds)
|
||||
}
|
||||
}
|
||||
};
|
||||
break;
|
||||
case "ColumnsContainer":
|
||||
nDocument[id] = {
|
||||
type: "ColumnsContainer",
|
||||
data: {
|
||||
style: block.data.style,
|
||||
props: {
|
||||
...block.data.props,
|
||||
columns: block.data.props?.columns?.map((c) => ({
|
||||
childrenIds: filterChildrenIds(c.childrenIds)
|
||||
}))
|
||||
}
|
||||
} as ColumnsContainerProps
|
||||
};
|
||||
break;
|
||||
default:
|
||||
nDocument[id] = block;
|
||||
}
|
||||
}
|
||||
delete nDocument[blockId];
|
||||
resetDocument(nDocument);
|
||||
};
|
||||
|
||||
const handleMoveClick = (direction: "up" | "down") => {
|
||||
const moveChildrenIds = (ids: string[] | null | undefined) => {
|
||||
if (!ids) {
|
||||
return ids;
|
||||
}
|
||||
const index = ids.indexOf(blockId);
|
||||
if (index < 0) {
|
||||
return ids;
|
||||
}
|
||||
|
||||
const childrenIds = [...ids];
|
||||
if (direction === "up" && index > 0) {
|
||||
[childrenIds[index], childrenIds[index - 1]] = [
|
||||
childrenIds[index - 1],
|
||||
childrenIds[index]
|
||||
];
|
||||
} else if (direction === "down" && index < childrenIds.length - 1) {
|
||||
[childrenIds[index], childrenIds[index + 1]] = [
|
||||
childrenIds[index + 1],
|
||||
childrenIds[index]
|
||||
];
|
||||
}
|
||||
return childrenIds;
|
||||
};
|
||||
|
||||
const nDocument: typeof document = { ...document };
|
||||
for (const [id, b] of Object.entries(nDocument)) {
|
||||
const block = b as TEditorBlock;
|
||||
if (id === blockId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (block.type) {
|
||||
case "EmailLayout":
|
||||
nDocument[id] = {
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
childrenIds: moveChildrenIds(block.data.childrenIds)
|
||||
}
|
||||
};
|
||||
break;
|
||||
case "Container":
|
||||
nDocument[id] = {
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
props: {
|
||||
...block.data.props,
|
||||
childrenIds: moveChildrenIds(block.data.props?.childrenIds)
|
||||
}
|
||||
}
|
||||
};
|
||||
break;
|
||||
case "ColumnsContainer":
|
||||
nDocument[id] = {
|
||||
type: "ColumnsContainer",
|
||||
data: {
|
||||
style: block.data.style,
|
||||
props: {
|
||||
...block.data.props,
|
||||
columns: block.data.props?.columns?.map((c) => ({
|
||||
childrenIds: moveChildrenIds(c.childrenIds)
|
||||
}))
|
||||
}
|
||||
} as ColumnsContainerProps
|
||||
};
|
||||
break;
|
||||
default:
|
||||
nDocument[id] = block;
|
||||
}
|
||||
}
|
||||
|
||||
resetDocument(nDocument);
|
||||
setSelectedBlockId(blockId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={sx} onClick={(ev) => ev.stopPropagation()}>
|
||||
<Stack>
|
||||
<Tooltip title="Move up" placement="left-start">
|
||||
<IconButton
|
||||
onClick={() => handleMoveClick("up")}
|
||||
sx={{ color: "text.primary" }}
|
||||
>
|
||||
<ArrowUpwardOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Move down" placement="left-start">
|
||||
<IconButton
|
||||
onClick={() => handleMoveClick("down")}
|
||||
sx={{ color: "text.primary" }}
|
||||
>
|
||||
<ArrowDownwardOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Duplicate" placement="left-start">
|
||||
<IconButton
|
||||
onClick={handleDuplicateClick}
|
||||
sx={{ color: "text.primary" }}
|
||||
>
|
||||
<ContentCopyOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete" placement="left-start">
|
||||
<IconButton
|
||||
onClick={handleDeleteClick}
|
||||
sx={{ color: "text.primary" }}
|
||||
>
|
||||
<DeleteOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { TEditorBlock, TEditorConfiguration } from "../../editor/core";
|
||||
|
||||
type TResult = {
|
||||
document: TEditorConfiguration;
|
||||
blockId: string;
|
||||
};
|
||||
|
||||
function cloneChildrenIds(
|
||||
document: TEditorConfiguration,
|
||||
blockIds: string[]
|
||||
): string[] {
|
||||
return blockIds.map((blockId) => {
|
||||
const newBlock = cloneBlock(document, blockId);
|
||||
const newBlockId = `block-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
||||
document[newBlockId] = newBlock;
|
||||
return newBlockId;
|
||||
});
|
||||
}
|
||||
|
||||
function cloneBlock(
|
||||
document: TEditorConfiguration,
|
||||
blockId: string
|
||||
): TEditorBlock {
|
||||
const clone = structuredClone(document[blockId]);
|
||||
switch (clone.type) {
|
||||
case "EmailLayout":
|
||||
throw new Error("Cloning EmailLayout blocks is not supported.");
|
||||
case "Avatar":
|
||||
case "Button":
|
||||
case "Divider":
|
||||
case "Heading":
|
||||
case "Html":
|
||||
case "Image":
|
||||
case "Spacer":
|
||||
case "Text":
|
||||
return clone;
|
||||
case "Container":
|
||||
if (clone.data?.props?.childrenIds) {
|
||||
clone.data.props.childrenIds = cloneChildrenIds(
|
||||
document,
|
||||
clone.data.props.childrenIds
|
||||
);
|
||||
}
|
||||
return clone;
|
||||
case "ColumnsContainer":
|
||||
if (clone.data?.props?.columns) {
|
||||
clone.data.props.columns[0].childrenIds = cloneChildrenIds(
|
||||
document,
|
||||
clone.data.props.columns[0].childrenIds
|
||||
);
|
||||
clone.data.props.columns[1].childrenIds = cloneChildrenIds(
|
||||
document,
|
||||
clone.data.props.columns[1].childrenIds
|
||||
);
|
||||
clone.data.props.columns[2].childrenIds = cloneChildrenIds(
|
||||
document,
|
||||
clone.data.props.columns[2].childrenIds
|
||||
);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
export default function cloneDocumentBlock(
|
||||
originalDocument: TEditorConfiguration,
|
||||
originalBlockId: string
|
||||
): TResult {
|
||||
const document = { ...originalDocument };
|
||||
const blockId = `block-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
||||
document[blockId] = cloneBlock(document, originalBlockId);
|
||||
return {
|
||||
document,
|
||||
blockId
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export const FONT_FAMILIES = [
|
||||
{
|
||||
key: "MODERN_SANS",
|
||||
label: "Modern sans",
|
||||
value: '"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif'
|
||||
},
|
||||
{
|
||||
key: "BOOK_SANS",
|
||||
label: "Book sans",
|
||||
value: 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif'
|
||||
},
|
||||
{
|
||||
key: "ORGANIC_SANS",
|
||||
label: "Organic sans",
|
||||
value:
|
||||
'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif'
|
||||
},
|
||||
{
|
||||
key: "GEOMETRIC_SANS",
|
||||
label: "Geometric sans",
|
||||
value:
|
||||
'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif'
|
||||
},
|
||||
{
|
||||
key: "HEAVY_SANS",
|
||||
label: "Heavy sans",
|
||||
value:
|
||||
'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif'
|
||||
},
|
||||
{
|
||||
key: "ROUNDED_SANS",
|
||||
label: "Rounded sans",
|
||||
value:
|
||||
'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif'
|
||||
},
|
||||
{
|
||||
key: "MODERN_SERIF",
|
||||
label: "Modern serif",
|
||||
value: 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif'
|
||||
},
|
||||
{
|
||||
key: "BOOK_SERIF",
|
||||
label: "Book serif",
|
||||
value:
|
||||
'"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif'
|
||||
},
|
||||
{
|
||||
key: "MONOSPACE",
|
||||
label: "Monospace",
|
||||
value: '"Nimbus Mono PS", "Courier New", "Cutive Mono", monospace'
|
||||
}
|
||||
];
|
||||
|
||||
export const FONT_FAMILY_NAMES = [
|
||||
"MODERN_SANS",
|
||||
"BOOK_SANS",
|
||||
"ORGANIC_SANS",
|
||||
"GEOMETRIC_SANS",
|
||||
"HEAVY_SANS",
|
||||
"ROUNDED_SANS",
|
||||
"MODERN_SERIF",
|
||||
"BOOK_SERIF",
|
||||
"MONOSPACE"
|
||||
] as const;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { FONT_FAMILY_NAMES } from "./fontFamily";
|
||||
|
||||
export function zColor() {
|
||||
return z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
||||
}
|
||||
|
||||
export function zFontFamily() {
|
||||
return z.enum(FONT_FAMILY_NAMES);
|
||||
}
|
||||
|
||||
export function zFontWeight() {
|
||||
return z.enum(["bold", "normal"]);
|
||||
}
|
||||
|
||||
export function zTextAlign() {
|
||||
return z.enum(["left", "center", "right"]);
|
||||
}
|
||||
|
||||
export function zPadding() {
|
||||
return z.object({
|
||||
top: z.number(),
|
||||
bottom: z.number(),
|
||||
right: z.number(),
|
||||
left: z.number()
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React, { createContext, useContext } from "react";
|
||||
|
||||
import { EditorBlock as CoreEditorBlock } from "./core";
|
||||
import { useDocument } from "./EditorContext";
|
||||
|
||||
const EditorBlockContext = createContext<string | null>(null);
|
||||
export const useCurrentBlockId = () => useContext(EditorBlockContext)!;
|
||||
|
||||
type EditorBlockProps = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id - Block id
|
||||
* @returns EditorBlock component that loads data from the EditorDocumentContext
|
||||
*/
|
||||
export default function EditorBlock({ id }: EditorBlockProps) {
|
||||
const document = useDocument();
|
||||
const block = document[id];
|
||||
if (!block) {
|
||||
throw new Error("Could not find block");
|
||||
}
|
||||
return (
|
||||
<EditorBlockContext.Provider value={id}>
|
||||
<CoreEditorBlock {...block} />
|
||||
</EditorBlockContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import getConfiguration from "../../getConfiguration";
|
||||
|
||||
import { TEditorConfiguration } from "./core";
|
||||
|
||||
type TValue = {
|
||||
document: TEditorConfiguration;
|
||||
|
||||
selectedBlockId: string | null;
|
||||
selectedSidebarTab: "block-configuration" | "styles";
|
||||
selectedMainTab: "editor" | "preview" | "json" | "html";
|
||||
selectedScreenSize: "desktop" | "mobile";
|
||||
|
||||
inspectorDrawerOpen: boolean;
|
||||
samplesDrawerOpen: boolean;
|
||||
};
|
||||
|
||||
const editorStateStore = create<TValue>(() => ({
|
||||
document: getConfiguration(window.location.hash),
|
||||
selectedBlockId: null,
|
||||
selectedSidebarTab: "styles",
|
||||
selectedMainTab: "editor",
|
||||
selectedScreenSize: "desktop",
|
||||
|
||||
inspectorDrawerOpen: true,
|
||||
samplesDrawerOpen: true
|
||||
}));
|
||||
|
||||
export function useDocument() {
|
||||
return editorStateStore((s) => s.document);
|
||||
}
|
||||
|
||||
export function useSelectedBlockId() {
|
||||
return editorStateStore((s) => s.selectedBlockId);
|
||||
}
|
||||
|
||||
export function useSelectedScreenSize() {
|
||||
return editorStateStore((s) => s.selectedScreenSize);
|
||||
}
|
||||
|
||||
export function useSelectedMainTab() {
|
||||
return editorStateStore((s) => s.selectedMainTab);
|
||||
}
|
||||
|
||||
export function setSelectedMainTab(selectedMainTab: TValue["selectedMainTab"]) {
|
||||
return editorStateStore.setState({ selectedMainTab });
|
||||
}
|
||||
|
||||
export function useSelectedSidebarTab() {
|
||||
return editorStateStore((s) => s.selectedSidebarTab);
|
||||
}
|
||||
|
||||
export function useInspectorDrawerOpen() {
|
||||
return editorStateStore((s) => s.inspectorDrawerOpen);
|
||||
}
|
||||
|
||||
export function useSamplesDrawerOpen() {
|
||||
return editorStateStore((s) => s.samplesDrawerOpen);
|
||||
}
|
||||
|
||||
export function setSelectedBlockId(selectedBlockId: TValue["selectedBlockId"]) {
|
||||
const selectedSidebarTab =
|
||||
selectedBlockId === null ? "styles" : "block-configuration";
|
||||
const options: Partial<TValue> = {};
|
||||
if (selectedBlockId !== null) {
|
||||
options.inspectorDrawerOpen = true;
|
||||
}
|
||||
return editorStateStore.setState({
|
||||
selectedBlockId,
|
||||
selectedSidebarTab,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
export function setSidebarTab(
|
||||
selectedSidebarTab: TValue["selectedSidebarTab"]
|
||||
) {
|
||||
return editorStateStore.setState({ selectedSidebarTab });
|
||||
}
|
||||
|
||||
export function resetDocument(document: TValue["document"]) {
|
||||
return editorStateStore.setState({
|
||||
document,
|
||||
selectedSidebarTab: "styles",
|
||||
selectedBlockId: null
|
||||
});
|
||||
}
|
||||
|
||||
export function setDocument(document: TValue["document"]) {
|
||||
const originalDocument = editorStateStore.getState().document;
|
||||
return editorStateStore.setState({
|
||||
document: {
|
||||
...originalDocument,
|
||||
...document
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleInspectorDrawerOpen() {
|
||||
const inspectorDrawerOpen = !editorStateStore.getState().inspectorDrawerOpen;
|
||||
return editorStateStore.setState({ inspectorDrawerOpen });
|
||||
}
|
||||
|
||||
export function toggleSamplesDrawerOpen() {
|
||||
const samplesDrawerOpen = !editorStateStore.getState().samplesDrawerOpen;
|
||||
return editorStateStore.setState({ samplesDrawerOpen });
|
||||
}
|
||||
|
||||
export function setSelectedScreenSize(
|
||||
selectedScreenSize: TValue["selectedScreenSize"]
|
||||
) {
|
||||
return editorStateStore.setState({ selectedScreenSize });
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import React from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Avatar, AvatarPropsSchema } from "@usewaypoint/block-avatar";
|
||||
import { Button, ButtonPropsSchema } from "@usewaypoint/block-button";
|
||||
import { Divider, DividerPropsSchema } from "@usewaypoint/block-divider";
|
||||
import { Heading, HeadingPropsSchema } from "@usewaypoint/block-heading";
|
||||
import { Html, HtmlPropsSchema } from "@usewaypoint/block-html";
|
||||
import { Image, ImagePropsSchema } from "@usewaypoint/block-image";
|
||||
import { Spacer, SpacerPropsSchema } from "@usewaypoint/block-spacer";
|
||||
import { Text, TextPropsSchema } from "@usewaypoint/block-text";
|
||||
import {
|
||||
buildBlockComponent,
|
||||
buildBlockConfigurationDictionary,
|
||||
buildBlockConfigurationSchema
|
||||
} from "@usewaypoint/document-core";
|
||||
|
||||
import ColumnsContainerEditor from "../blocks/ColumnsContainer/ColumnsContainerEditor";
|
||||
import ColumnsContainerPropsSchema from "../blocks/ColumnsContainer/ColumnsContainerPropsSchema";
|
||||
import ContainerEditor from "../blocks/Container/ContainerEditor";
|
||||
import ContainerPropsSchema from "../blocks/Container/ContainerPropsSchema";
|
||||
import EmailLayoutEditor from "../blocks/EmailLayout/EmailLayoutEditor";
|
||||
import EmailLayoutPropsSchema from "../blocks/EmailLayout/EmailLayoutPropsSchema";
|
||||
import EditorBlockWrapper from "../blocks/helpers/block-wrappers/EditorBlockWrapper";
|
||||
|
||||
const EDITOR_DICTIONARY = buildBlockConfigurationDictionary({
|
||||
Avatar: {
|
||||
schema: AvatarPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Avatar {...props} />
|
||||
</EditorBlockWrapper>
|
||||
)
|
||||
},
|
||||
Button: {
|
||||
schema: ButtonPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Button {...props} />
|
||||
</EditorBlockWrapper>
|
||||
)
|
||||
},
|
||||
Container: {
|
||||
schema: ContainerPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<ContainerEditor {...props} />
|
||||
</EditorBlockWrapper>
|
||||
)
|
||||
},
|
||||
ColumnsContainer: {
|
||||
schema: ColumnsContainerPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<ColumnsContainerEditor {...props} />
|
||||
</EditorBlockWrapper>
|
||||
)
|
||||
},
|
||||
Heading: {
|
||||
schema: HeadingPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Heading {...props} />
|
||||
</EditorBlockWrapper>
|
||||
)
|
||||
},
|
||||
Html: {
|
||||
schema: HtmlPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Html {...props} />
|
||||
</EditorBlockWrapper>
|
||||
)
|
||||
},
|
||||
Image: {
|
||||
schema: ImagePropsSchema,
|
||||
Component: (data) => {
|
||||
const props = {
|
||||
...data,
|
||||
props: {
|
||||
...data.props,
|
||||
url:
|
||||
data.props?.url ??
|
||||
"https://placehold.co/600x400@2x/F8F8F8/CCC?text=Your%20image"
|
||||
}
|
||||
};
|
||||
return (
|
||||
<EditorBlockWrapper>
|
||||
<Image {...props} />
|
||||
</EditorBlockWrapper>
|
||||
);
|
||||
}
|
||||
},
|
||||
Text: {
|
||||
schema: TextPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Text {...props} />
|
||||
</EditorBlockWrapper>
|
||||
)
|
||||
},
|
||||
EmailLayout: {
|
||||
schema: EmailLayoutPropsSchema,
|
||||
Component: (p) => <EmailLayoutEditor {...p} />
|
||||
},
|
||||
Spacer: {
|
||||
schema: SpacerPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Spacer {...props} />
|
||||
</EditorBlockWrapper>
|
||||
)
|
||||
},
|
||||
Divider: {
|
||||
schema: DividerPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Divider {...props} />
|
||||
</EditorBlockWrapper>
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
export const EditorBlock = buildBlockComponent(EDITOR_DICTIONARY);
|
||||
export const EditorBlockSchema =
|
||||
buildBlockConfigurationSchema(EDITOR_DICTIONARY);
|
||||
export const EditorConfigurationSchema = z.record(
|
||||
z.string(),
|
||||
EditorBlockSchema
|
||||
);
|
||||
|
||||
export type TEditorBlock = z.infer<typeof EditorBlockSchema>;
|
||||
export type TEditorConfiguration = Record<string, TEditorBlock>;
|
||||
@@ -0,0 +1,34 @@
|
||||
import EMPTY_EMAIL_MESSAGE from "./sample/empty-email-message";
|
||||
import getRequestEmail from "./sample/request-email";
|
||||
import getCompletionEmail from "./sample/completion-email";
|
||||
|
||||
export default function getConfiguration(
|
||||
template: string,
|
||||
) {
|
||||
|
||||
if (template.startsWith("#sample/")) {
|
||||
const sampleName = template.replace("#sample/", "");
|
||||
switch (sampleName) {
|
||||
case "requestemail":
|
||||
return getRequestEmail(
|
||||
);
|
||||
case "completionemail":
|
||||
return getCompletionEmail(
|
||||
);
|
||||
default:
|
||||
return "requestemail";
|
||||
}
|
||||
}
|
||||
|
||||
if (template.startsWith("#code/")) {
|
||||
const encodedString = template.replace("#code/", "");
|
||||
const configurationString = decodeURIComponent(atob(encodedString));
|
||||
try {
|
||||
return JSON.parse(configurationString);
|
||||
} catch {
|
||||
console.error(`Couldn't load configuration from hash.`);
|
||||
}
|
||||
}
|
||||
|
||||
return EMPTY_EMAIL_MESSAGE;
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
import { TEditorConfiguration } from "../../documents/editor/core";
|
||||
|
||||
const getCompletionEmail = (
|
||||
): TEditorConfiguration => {
|
||||
const appName =
|
||||
"OpenSign™";
|
||||
|
||||
const logoBlock =
|
||||
{
|
||||
"block-1709571212684": {
|
||||
type: "Image",
|
||||
data: {
|
||||
style: {
|
||||
padding: { top: 24, bottom: 24, right: 24, left: 24 }
|
||||
},
|
||||
props: {
|
||||
width: null,
|
||||
height: 50,
|
||||
url: "https://qikinnovation.ams3.digitaloceanspaces.com/logo.png",
|
||||
alt: "logo",
|
||||
linkHref: null,
|
||||
contentAlignment: "middle"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const logoBlockId =
|
||||
["block-1709571212684"];
|
||||
|
||||
return {
|
||||
root: {
|
||||
type: "EmailLayout",
|
||||
data: {
|
||||
backdropColor: "#f5f5f5",
|
||||
canvasColor: "#FFFFFF",
|
||||
canvasWidth: 600,
|
||||
textColor: "#242424",
|
||||
fontFamily: "MODERN_SANS",
|
||||
childrenIds: [
|
||||
...logoBlockId,
|
||||
"block-1770633502472",
|
||||
"block-1770795667636",
|
||||
"block-1770795483071"
|
||||
]
|
||||
}
|
||||
},
|
||||
...logoBlock,
|
||||
"block-1770633502472": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
color: "#FFFFFF",
|
||||
backgroundColor: "#47A3AD",
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
right: 24,
|
||||
left: 24
|
||||
}
|
||||
},
|
||||
props: {
|
||||
markdown: false,
|
||||
text: "Document signed successfully\n\n"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633797211": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 24
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "Sender"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633813576": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
color: "#626363",
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 0
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "{{receiver_email}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633912944": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 24
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "Organization"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633915601": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
color: "#626363",
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 0
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "{{company_name}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633918679": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 24
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "Expires on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633921948": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
color: "#626363",
|
||||
backgroundColor: null,
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 0
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "{{expiry_date}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633961786": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 24
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "Note"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633964531": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
color: "#626363",
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 0
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "{{note}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770795483071": {
|
||||
type: "Html",
|
||||
data: {
|
||||
style: {
|
||||
backgroundColor: "#f5f5f5",
|
||||
fontSize: 14,
|
||||
textAlign: null,
|
||||
padding: {
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
right: 24,
|
||||
left: 24
|
||||
}
|
||||
},
|
||||
props: {
|
||||
contents: `This is an automated email from ${appName}. For any queries regarding this email, please contact the sender <a href="mailto:{{sender_mail}}" target="_blank">{{sender_mail}}</a> directly.`
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770795667636": {
|
||||
type: "Html",
|
||||
data: {
|
||||
style: {
|
||||
fontSize: 14,
|
||||
textAlign: null,
|
||||
padding: {
|
||||
top: 32,
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
left: 20
|
||||
}
|
||||
},
|
||||
props: {
|
||||
contents:
|
||||
'All parties have successfully signed the document "<b>{{document_title}}</b>". Kindly download the document from the attachment.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default getCompletionEmail;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { TEditorConfiguration } from "../../documents/editor/core";
|
||||
|
||||
const EMPTY_EMAIL_MESSAGE: TEditorConfiguration = {
|
||||
root: {
|
||||
type: "EmailLayout",
|
||||
data: {
|
||||
backdropColor: "#F5F5F5",
|
||||
canvasColor: "#FFFFFF",
|
||||
canvasWidth: 600,
|
||||
textColor: "#262626",
|
||||
fontFamily: "MODERN_SANS",
|
||||
childrenIds: []
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default EMPTY_EMAIL_MESSAGE;
|
||||
@@ -0,0 +1,323 @@
|
||||
import { TEditorConfiguration } from "../../documents/editor/core";
|
||||
|
||||
const getRequestEmail = (
|
||||
): TEditorConfiguration => {
|
||||
const appName =
|
||||
"OpenSign™";
|
||||
|
||||
const logoBlock =
|
||||
{
|
||||
"block-1709571212684": {
|
||||
type: "Image",
|
||||
data: {
|
||||
style: {
|
||||
padding: { top: 24, bottom: 24, right: 24, left: 24 }
|
||||
},
|
||||
props: {
|
||||
width: null,
|
||||
height: 50,
|
||||
url: "https://qikinnovation.ams3.digitaloceanspaces.com/logo.png",
|
||||
alt: "logo",
|
||||
linkHref: null,
|
||||
contentAlignment: "middle"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const logoBlockId =
|
||||
["block-1709571212684"];
|
||||
|
||||
return {
|
||||
root: {
|
||||
type: "EmailLayout",
|
||||
data: {
|
||||
backdropColor: "#f5f5f5",
|
||||
canvasColor: "#FFFFFF",
|
||||
canvasWidth: 600,
|
||||
textColor: "#242424",
|
||||
fontFamily: "MODERN_SANS",
|
||||
childrenIds: [
|
||||
...logoBlockId,
|
||||
"block-1770633502472",
|
||||
"block-1770633643816",
|
||||
"block-1770633750385",
|
||||
"block-1770633994542",
|
||||
"block-1770795931867"
|
||||
]
|
||||
}
|
||||
},
|
||||
...logoBlock,
|
||||
"block-1770633502472": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
color: "#FFFFFF",
|
||||
backgroundColor: "#47A3AD",
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
right: 24,
|
||||
left: 24
|
||||
}
|
||||
},
|
||||
props: {
|
||||
markdown: false,
|
||||
text: "Digital Signature Request"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633643816": {
|
||||
type: "Html",
|
||||
data: {
|
||||
style: {
|
||||
fontSize: 14,
|
||||
textAlign: null,
|
||||
padding: {
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
left: 20
|
||||
}
|
||||
},
|
||||
props: {
|
||||
contents:
|
||||
"{{sender_name}} has requested you to review and sign <b>{{document_title}}</b>."
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633750385": {
|
||||
type: "ColumnsContainer",
|
||||
data: {
|
||||
style: {
|
||||
padding: {
|
||||
top: 20,
|
||||
bottom: 32,
|
||||
right: 20,
|
||||
left: 4
|
||||
}
|
||||
},
|
||||
props: {
|
||||
fixedWidths: [128, null, null],
|
||||
columnsCount: 2,
|
||||
columnsGap: 0,
|
||||
contentAlignment: "top",
|
||||
columns: [
|
||||
{
|
||||
childrenIds: [
|
||||
"block-1770633797211",
|
||||
"block-1770633912944",
|
||||
"block-1770633918679",
|
||||
"block-1770633961786"
|
||||
]
|
||||
},
|
||||
{
|
||||
childrenIds: [
|
||||
"block-1770633813576",
|
||||
"block-1770633915601",
|
||||
"block-1770633921948",
|
||||
"block-1770633964531"
|
||||
]
|
||||
},
|
||||
{
|
||||
childrenIds: []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633797211": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 24
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "Sender"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633813576": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
color: "#626363",
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 0
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "{{sender_mail}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633912944": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 24
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "Organization"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633915601": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
color: "#626363",
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 0
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "{{company_name}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633918679": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 24
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "Expires on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633921948": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
color: "#626363",
|
||||
backgroundColor: null,
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 0
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "{{expiry_date}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633961786": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 24
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "Note"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633964531": {
|
||||
type: "Text",
|
||||
data: {
|
||||
style: {
|
||||
color: "#626363",
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
padding: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 24,
|
||||
left: 0
|
||||
}
|
||||
},
|
||||
props: {
|
||||
text: "{{note}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770633994542": {
|
||||
type: "Button",
|
||||
data: {
|
||||
style: {
|
||||
fontSize: 14,
|
||||
textAlign: "left",
|
||||
padding: {
|
||||
top: 12,
|
||||
bottom: 20,
|
||||
right: 12,
|
||||
left: 52
|
||||
}
|
||||
},
|
||||
props: {
|
||||
buttonBackgroundColor: "#D46B0f",
|
||||
buttonStyle: "rectangle",
|
||||
fullWidth: false,
|
||||
size: "medium",
|
||||
text: "Sign Here",
|
||||
url: "{{signing_url}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"block-1770795931867": {
|
||||
type: "Html",
|
||||
data: {
|
||||
style: {
|
||||
backgroundColor: "#f5f5f5",
|
||||
fontSize: 14,
|
||||
textAlign: null,
|
||||
padding: {
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
right: 24,
|
||||
left: 24
|
||||
}
|
||||
},
|
||||
props: {
|
||||
contents: `This is an automated email from ${appName}. For any queries regarding this email, please contact the sender <a href="mailto:{{sender_mail}}" target="_blank">{{sender_mail}}</a> directly.`
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
export default getRequestEmail;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import ModalUi from "../../primitives/ModalUi";
|
||||
import { EmailBody } from "./EmailBody";
|
||||
import {
|
||||
@@ -11,11 +11,15 @@ import { useTranslation } from "react-i18next";
|
||||
import Loader from "../../primitives/Loader";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
const statusMap = {
|
||||
success: "success",
|
||||
"quota-reached": "quotareached",
|
||||
};
|
||||
function CustomizeMail(props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const copyUrlRef = useRef(null);
|
||||
const [isCustomize, setIsCustomize] = useState(false);
|
||||
const [isReset, setIsReset] = useState(false);
|
||||
const [isLoader, setIsLoader] = useState(false);
|
||||
|
||||
const handleCloseSendmailModal = () => {
|
||||
@@ -26,12 +30,7 @@ function CustomizeMail(props) {
|
||||
props?.setIsMailModal(false);
|
||||
navigate("/report/1MwEuxLEkF");
|
||||
};
|
||||
const handleOnchangeRequest = (value) => {
|
||||
props?.setCustomizeMail((prev) => ({
|
||||
...prev,
|
||||
body: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEmailSendToSigners = async () => {
|
||||
setIsLoader(true);
|
||||
const documentData = await contractDocument(props?.documentId);
|
||||
@@ -58,20 +57,27 @@ function CustomizeMail(props) {
|
||||
props?.setIsMailModal(false);
|
||||
props?.setIsSend(true);
|
||||
setIsLoader(false);
|
||||
if (mailRes?.status === "success") {
|
||||
props?.setMailStatus("success");
|
||||
} else if (mailRes?.status === "quota-reached") {
|
||||
props?.setMailStatus("quotareached");
|
||||
} else if (mailRes?.status === "daily-quota-reached") {
|
||||
props?.setMailStatus("dailyquotareached");
|
||||
} else {
|
||||
props?.setMailStatus("failed");
|
||||
}
|
||||
// setMailStatus(mail_status);
|
||||
props?.setMailStatus(statusMap[mailRes?.status] ?? "failed");
|
||||
} else {
|
||||
alert("something-went-wrong-mssg");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setIsReset(true);
|
||||
props?.setCustomizeMail({
|
||||
subject: defaultMailSubject,
|
||||
body: defaultMailBody
|
||||
});
|
||||
};
|
||||
const onChangeSubject = (value) => {
|
||||
setIsReset(false);
|
||||
props?.setCustomizeMail((prev) => ({ ...prev, subject: value }));
|
||||
};
|
||||
const onChangeBody = (value) => {
|
||||
setIsReset(false);
|
||||
props?.setCustomizeMail((prev) => ({ ...prev, body: value }));
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{isLoader ? (
|
||||
@@ -92,17 +98,13 @@ function CustomizeMail(props) {
|
||||
<EmailBody
|
||||
requestBody={props?.customizeMail?.body}
|
||||
requestSubject={props?.customizeMail?.subject}
|
||||
handleOnchangeRequest={handleOnchangeRequest}
|
||||
setCustomizeMail={props?.setCustomizeMail}
|
||||
onChangeBody={onChangeBody}
|
||||
onChangeSubject={onChangeSubject}
|
||||
isReset={isReset}
|
||||
/>
|
||||
<div
|
||||
className="flex justify-end items-center gap-1 mt-2 op-link op-link-primary"
|
||||
onClick={() => {
|
||||
props?.setCustomizeMail({
|
||||
subject: defaultMailSubject,
|
||||
body: defaultMailBody
|
||||
});
|
||||
}}
|
||||
onClick={() => handleReset()}
|
||||
>
|
||||
<span>{t("reset-to-default")}</span>
|
||||
</div>
|
||||
@@ -144,7 +146,7 @@ function CustomizeMail(props) {
|
||||
<span className="h-[1px] w-[20%] bg-[#ccc]"></span>
|
||||
</div>
|
||||
<div className="my-3">{props?.handleShareList()}</div>
|
||||
<p id="copyUrl" ref={copyUrlRef} className="hidden"></p>
|
||||
<p id="copyUrl" ref={props?.copyUrlRef} className="hidden"></p>
|
||||
</div>
|
||||
</ModalUi>
|
||||
)}
|
||||
|
||||
@@ -263,6 +263,14 @@ const EditTemplate = ({
|
||||
: [...prev, color]; // add
|
||||
});
|
||||
};
|
||||
|
||||
const reminderCustomWarning = (e) => {
|
||||
if (!formData.RemindOnceInEvery || formData.RemindOnceInEvery === 0) {
|
||||
return e.target.setCustomValidity(t("input-required"));
|
||||
} else {
|
||||
return e.target.setCustomValidity(t("reminder-error"));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ModalUi
|
||||
isOpen
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { Quill } from "react-quill-new";
|
||||
// Use a third-party plugin so editors can toggle raw HTML view.
|
||||
import htmlEditButton from "quill-html-edit-button";
|
||||
|
||||
// Register the module which adds a "<>" button for HTML editing.
|
||||
Quill.register("modules/htmlEditButton", htmlEditButton);
|
||||
|
||||
// Custom Undo button icon component for Quill editor. You can import it directly
|
||||
// from 'quill/assets/icons/undo.svg' but I found that a number of loaders do not
|
||||
@@ -60,9 +55,7 @@ export const module1 = {
|
||||
container: "#toolbar1",
|
||||
handlers: { undo: undoChange, redo: redoChange }
|
||||
},
|
||||
history: { delay: 500, maxStack: 100, userOnly: true },
|
||||
// Enable the "<>" button registered above
|
||||
htmlEditButton: {}
|
||||
history: { delay: 500, maxStack: 100, userOnly: true }
|
||||
};
|
||||
|
||||
// Modules object for setting up the Quill editor
|
||||
@@ -71,9 +64,7 @@ export const module2 = {
|
||||
container: "#toolbar2",
|
||||
handlers: { undo: undoChange, redo: redoChange }
|
||||
},
|
||||
history: { delay: 500, maxStack: 100, userOnly: true },
|
||||
// Enable the "<>" button registered above
|
||||
htmlEditButton: {}
|
||||
history: { delay: 500, maxStack: 100, userOnly: true }
|
||||
};
|
||||
|
||||
// Formats objects for setting up the Quill editor
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Tooltip from "../../primitives/Tooltip";
|
||||
import ReactQuill from "react-quill-new";
|
||||
import "../../styles/quill.css";
|
||||
import EditorToolbar, { module1, formats } from "./EditorToolbar";
|
||||
// import ReactQuill from "react-quill-new";
|
||||
// import "../../styles/quill.css";
|
||||
// import EditorToolbar, { module1, formats } from "./EditorToolbar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmailBodyEditor from "../EmailBodyEditor";
|
||||
|
||||
export function EmailBody(props) {
|
||||
const { t } = useTranslation();
|
||||
@@ -17,12 +18,7 @@ export function EmailBody(props) {
|
||||
onInvalid={(e) => e.target.setCustomValidity(t("input-required"))}
|
||||
onInput={(e) => e.target.setCustomValidity("")}
|
||||
value={props.requestSubject}
|
||||
onChange={(e) => {
|
||||
props?.setCustomizeMail((prev) => ({
|
||||
...prev,
|
||||
subject: e.target.value
|
||||
}));
|
||||
}}
|
||||
onChange={(e) => props?.onChangeSubject?.(e.target.value)}
|
||||
placeholder='${senderName} has requested you to sign "${documentName}"'
|
||||
className="op-input op-input-bordered op-input-sm focus:outline-none hover:border-base-content w-full text-xs"
|
||||
/>
|
||||
@@ -30,7 +26,7 @@ export function EmailBody(props) {
|
||||
{t("body")} <Tooltip message={t("email-body")} />
|
||||
</label>
|
||||
<div className="px-1 py-2 w-full focus:outline-none text-xs">
|
||||
<EditorToolbar containerId="toolbar1" />
|
||||
{/* <EditorToolbar containerId="toolbar1" />
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
value={props.requestBody}
|
||||
@@ -38,6 +34,12 @@ export function EmailBody(props) {
|
||||
modules={module1}
|
||||
formats={formats}
|
||||
onChange={props.handleOnchangeRequest}
|
||||
/> */}
|
||||
<EmailBodyEditor
|
||||
value={props.requestBody}
|
||||
onChange={props?.onChangeBody}
|
||||
isReset={props?.isReset}
|
||||
smallscreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user