diff --git a/cypress/e2e/content/actions.spec.js b/cypress/e2e/content/actions.spec.js
index e6bf44494a..49e151c509 100644
--- a/cypress/e2e/content/actions.spec.js
+++ b/cypress/e2e/content/actions.spec.js
@@ -213,6 +213,13 @@ describe("Actions in content editor", () => {
});
cy.get("input[name=title]", { timeout: 5000 }).click().type(timestamp);
+
+ cy.getBySelector("ManualMetaFlow").click();
+
+ cy.getBySelector("metaDescription")
+ .find("textarea")
+ .first()
+ .type(timestamp);
cy.getBySelector("CreateItemSaveButton").click();
cy.contains("Created Item", { timeout: 5000 }).should("exist");
@@ -267,4 +274,64 @@ describe("Actions in content editor", () => {
// }).should("exist");
// // cy.contains("The item has been purged from the CDN cache", { timeout: 5000 }).should("exist");
// });
+
+ it("Creates a new content item using AI-generated data", () => {
+ cy.waitOn("/v1/content/models*", () => {
+ cy.waitOn("/v1/content/models/*/fields?showDeleted=true", () => {
+ cy.visit("/content/6-a1a600-k0b6f0/new");
+ });
+ });
+
+ cy.intercept("/ai").as("ai");
+ cy.wait(5000);
+
+ // Generate AI content for single line text
+ cy.get("#12-0c3934-8dz720").find("[data-cy='AIOpen']").click();
+ cy.getBySelector("AITopicField").type("biking");
+ cy.getBySelector("AIAudienceField").type("young adults");
+ cy.getBySelector("AIGenerate").click();
+
+ cy.wait("@ai");
+
+ cy.getBySelector("AIApprove").click();
+
+ // Generate AI content for wysiwyg
+ cy.get("#12-717920-6z46t7").find("[data-cy='AIOpen']").click();
+ cy.getBySelector("AITopicField").type("biking");
+ cy.getBySelector("AIAudienceField").type("young adults");
+ cy.getBySelector("AIGenerate").click();
+
+ cy.wait("@ai");
+
+ cy.getBySelector("AIApprove").click();
+
+ // Select AI-assisted metadata generation flow
+ cy.getBySelector("ManualMetaFlow").click();
+
+ // Generate AI content for meta title
+ cy.getBySelector("metaTitle").find("input").clear();
+ cy.getBySelector("metaTitle").find("[data-cy='AIOpen']").click();
+ cy.getBySelector("AIGenerate").click();
+
+ cy.wait("@ai");
+
+ cy.getBySelector("AISuggestion1").click();
+ cy.getBySelector("AIApprove").click();
+
+ // Generate AI content for meta description
+ cy.getBySelector("metaDescription")
+ .find("textarea[name='metaDescription']")
+ .clear({ force: true });
+ cy.getBySelector("metaDescription").find("[data-cy='AIOpen']").click();
+ cy.getBySelector("AIGenerate").click();
+
+ cy.wait("@ai");
+
+ cy.getBySelector("AISuggestion1").click();
+ cy.getBySelector("AIApprove").click();
+
+ cy.getBySelector("CreateItemSaveButton").click();
+
+ cy.contains("Created Item", { timeout: 5000 }).should("exist");
+ });
});
diff --git a/cypress/e2e/content/analyticsDashboard.spec.js b/cypress/e2e/content/analyticsDashboard.spec.js
index 89d96647d8..2dae38aead 100644
--- a/cypress/e2e/content/analyticsDashboard.spec.js
+++ b/cypress/e2e/content/analyticsDashboard.spec.js
@@ -1,6 +1,6 @@
describe("Analytics dashboard", () => {
before(() => {
- cy.waitOn("*getPropertyList*", () => {
+ cy.waitOn("*properties*", () => {
cy.visit("/content");
});
});
diff --git a/cypress/e2e/content/content.spec.js b/cypress/e2e/content/content.spec.js
index 0d629acca7..5762d0c1f6 100644
--- a/cypress/e2e/content/content.spec.js
+++ b/cypress/e2e/content/content.spec.js
@@ -184,7 +184,7 @@ describe("Content Specs", () => {
});
it("Currency Field", () => {
- cy.get("#12-b35c68-jd1s8s input[type=number]")
+ cy.get("#12-b35c68-jd1s8s input")
.focus()
.clear()
.type("100.00")
diff --git a/cypress/e2e/content/singlePageAnalytics.spec.js b/cypress/e2e/content/singlePageAnalytics.spec.js
index 59af69ded1..8303e8c104 100644
--- a/cypress/e2e/content/singlePageAnalytics.spec.js
+++ b/cypress/e2e/content/singlePageAnalytics.spec.js
@@ -1,6 +1,6 @@
describe("Single Page Analytics", () => {
before(() => {
- cy.waitOn("*getPropertyList*", () => {
+ cy.waitOn("*properties*", () => {
cy.visit("/content/6-a1a600-k0b6f0/7-a1be38-1b42ht/analytics");
});
});
diff --git a/cypress/e2e/schema/field.spec.js b/cypress/e2e/schema/field.spec.js
index 4ec52339ce..92625bf5b0 100644
--- a/cypress/e2e/schema/field.spec.js
+++ b/cypress/e2e/schema/field.spec.js
@@ -17,12 +17,14 @@ const SELECTORS = {
FIELD_SELECT_MEDIA: "FieldItem_images",
FIELD_SELECT_BOOLEAN: "FieldItem_yes_no",
FIELD_SELECT_ONE_TO_ONE: "FieldItem_one_to_one",
+ FIELD_SELECT_CURRENCY: "FieldItem_currency",
MEDIA_CHECKBOX_LIMIT: "MediaCheckbox_limit",
MEDIA_CHECKBOX_LOCK: "MediaCheckbox_group_id",
DROPDOWN_ADD_OPTION: "DropdownAddOption",
DROPDOWN_DELETE_OPTION: "DeleteOption",
AUTOCOMPLETE_MODEL_ZUID: "Autocomplete_relatedModelZUID",
AUTOCOMPLETE_FIELED_ZUID: "Autocomplete_relatedFieldZUID",
+ AUTOCOMPLETE_FIELD_CURRENCY: "Autocomplete_currency",
INPUT_LABEL: "FieldFormInput_label",
INPUT_NAME: "FieldFormInput_name",
INPUT_OPTION_LABEL: "OptionLabel",
@@ -357,6 +359,44 @@ describe("Schema: Fields", () => {
cy.getBySelector(`Field_${fieldName}`).should("exist");
});
+ it("Creates a currency field", () => {
+ cy.intercept("**/fields?showDeleted=true").as("getFields");
+
+ const fieldLabel = `Currency ${timestamp}`;
+ const fieldName = `currency_${timestamp}`;
+
+ // Open the add field modal
+ cy.getBySelector(SELECTORS.ADD_FIELD_BTN).should("exist").click();
+ cy.getBySelector(SELECTORS.ADD_FIELD_MODAL).should("exist");
+
+ // Select one-to-one relationship field
+ cy.getBySelector(SELECTORS.FIELD_SELECT_CURRENCY).should("exist").click();
+
+ // Select default currency
+ cy.getBySelector(SELECTORS.AUTOCOMPLETE_FIELD_CURRENCY).type("phil");
+ cy.get("[role=listbox] [role=option]").first().click();
+
+ // Fill up fields
+ cy.getBySelector(SELECTORS.INPUT_LABEL).should("exist").type(fieldLabel);
+
+ // Navigate to rules tab and add default value
+ cy.getBySelector(SELECTORS.RULES_TAB_BTN).click();
+ // click on the default value checkbox
+ cy.getBySelector(SELECTORS.DEFAULT_VALUE_CHECKBOX).click();
+ // enter a default value
+ cy.getBySelector(SELECTORS.DEFAULT_VALUE_INPUT).type("1000.50");
+ // Verify default currency
+ cy.getBySelector(SELECTORS.DEFAULT_VALUE_INPUT).contains("PHP");
+ // Click done
+ cy.getBySelector(SELECTORS.SAVE_FIELD_BUTTON).should("exist").click();
+ cy.getBySelector(SELECTORS.ADD_FIELD_MODAL).should("not.exist");
+
+ cy.wait("@getFields");
+
+ // Check if field exists
+ cy.getBySelector(`Field_${fieldName}`).should("exist");
+ });
+
it("Creates a field via add another field button", () => {
cy.intercept("**/fields?showDeleted=true").as("getFields");
diff --git a/package-lock.json b/package-lock.json
index 73d29b28d9..7e2341f4d3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,7 +5,6 @@
"requires": true,
"packages": {
"": {
- "name": "manager-ui",
"version": "1.0.1",
"license": "Commons Clause License Condition v1.0",
"dependencies": {
@@ -31,7 +30,7 @@
"@tinymce/tinymce-react": "^4.3.0",
"@welldone-software/why-did-you-render": "^6.1.1",
"@zesty-io/core": "1.10.0",
- "@zesty-io/material": "^0.15.2",
+ "@zesty-io/material": "^0.15.5",
"chart.js": "^3.8.0",
"chartjs-adapter-moment": "^1.0.1",
"chartjs-plugin-datalabels": "^2.0.0",
@@ -3912,9 +3911,9 @@
}
},
"node_modules/@zesty-io/material": {
- "version": "0.15.2",
- "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.2.tgz",
- "integrity": "sha512-m5dLNBpZtPtXUlo57In+k2ldo8OtAUPLvjLATV7S4qC6GKVSZHpq4Sqvab3YZ8C2dskcHGTos4aOJicgI3/UKA==",
+ "version": "0.15.5",
+ "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.5.tgz",
+ "integrity": "sha512-/xSfR3FjmAW9wKSJthXaxyekkysl6i7naF19PP5XIycxEQWMLN8BT7Cvy/ihi99m7anLl8sNzswax4daHTRHkA==",
"dependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
@@ -18397,9 +18396,9 @@
}
},
"@zesty-io/material": {
- "version": "0.15.2",
- "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.2.tgz",
- "integrity": "sha512-m5dLNBpZtPtXUlo57In+k2ldo8OtAUPLvjLATV7S4qC6GKVSZHpq4Sqvab3YZ8C2dskcHGTos4aOJicgI3/UKA==",
+ "version": "0.15.5",
+ "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.5.tgz",
+ "integrity": "sha512-/xSfR3FjmAW9wKSJthXaxyekkysl6i7naF19PP5XIycxEQWMLN8BT7Cvy/ihi99m7anLl8sNzswax4daHTRHkA==",
"requires": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
diff --git a/package.json b/package.json
index 8ec0d7b68e..a7c32655a8 100644
--- a/package.json
+++ b/package.json
@@ -59,7 +59,7 @@
"@tinymce/tinymce-react": "^4.3.0",
"@welldone-software/why-did-you-render": "^6.1.1",
"@zesty-io/core": "1.10.0",
- "@zesty-io/material": "^0.15.2",
+ "@zesty-io/material": "^0.15.5",
"chart.js": "^3.8.0",
"chartjs-adapter-moment": "^1.0.1",
"chartjs-plugin-datalabels": "^2.0.0",
diff --git a/public/images/flags/ad.svg b/public/images/flags/ad.svg
new file mode 100644
index 0000000000..067ab772f6
--- /dev/null
+++ b/public/images/flags/ad.svg
@@ -0,0 +1,150 @@
+
diff --git a/public/images/flags/ae.svg b/public/images/flags/ae.svg
new file mode 100644
index 0000000000..651ac8523d
--- /dev/null
+++ b/public/images/flags/ae.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/af.svg b/public/images/flags/af.svg
new file mode 100644
index 0000000000..521ac4cfd8
--- /dev/null
+++ b/public/images/flags/af.svg
@@ -0,0 +1,81 @@
+
diff --git a/public/images/flags/ag.svg b/public/images/flags/ag.svg
new file mode 100644
index 0000000000..243c3d8f9e
--- /dev/null
+++ b/public/images/flags/ag.svg
@@ -0,0 +1,14 @@
+
diff --git a/public/images/flags/ai.svg b/public/images/flags/ai.svg
new file mode 100644
index 0000000000..628ad9be93
--- /dev/null
+++ b/public/images/flags/ai.svg
@@ -0,0 +1,29 @@
+
diff --git a/public/images/flags/al.svg b/public/images/flags/al.svg
new file mode 100644
index 0000000000..1135b4b80a
--- /dev/null
+++ b/public/images/flags/al.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/am.svg b/public/images/flags/am.svg
new file mode 100644
index 0000000000..99fa4dc597
--- /dev/null
+++ b/public/images/flags/am.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/ao.svg b/public/images/flags/ao.svg
new file mode 100644
index 0000000000..b1863bd0f6
--- /dev/null
+++ b/public/images/flags/ao.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/aq.svg b/public/images/flags/aq.svg
new file mode 100644
index 0000000000..53840cccb0
--- /dev/null
+++ b/public/images/flags/aq.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/ar.svg b/public/images/flags/ar.svg
new file mode 100644
index 0000000000..d20cbbdcdc
--- /dev/null
+++ b/public/images/flags/ar.svg
@@ -0,0 +1,32 @@
+
diff --git a/public/images/flags/arab.svg b/public/images/flags/arab.svg
new file mode 100644
index 0000000000..96d27157e9
--- /dev/null
+++ b/public/images/flags/arab.svg
@@ -0,0 +1,109 @@
+
diff --git a/public/images/flags/as.svg b/public/images/flags/as.svg
new file mode 100644
index 0000000000..3543556725
--- /dev/null
+++ b/public/images/flags/as.svg
@@ -0,0 +1,72 @@
+
diff --git a/public/images/flags/at.svg b/public/images/flags/at.svg
new file mode 100644
index 0000000000..9d2775c083
--- /dev/null
+++ b/public/images/flags/at.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/images/flags/au.svg b/public/images/flags/au.svg
new file mode 100644
index 0000000000..96e80768bb
--- /dev/null
+++ b/public/images/flags/au.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/images/flags/aw.svg b/public/images/flags/aw.svg
new file mode 100644
index 0000000000..413b7c45b6
--- /dev/null
+++ b/public/images/flags/aw.svg
@@ -0,0 +1,186 @@
+
diff --git a/public/images/flags/ax.svg b/public/images/flags/ax.svg
new file mode 100644
index 0000000000..0584d713b5
--- /dev/null
+++ b/public/images/flags/ax.svg
@@ -0,0 +1,18 @@
+
diff --git a/public/images/flags/az.svg b/public/images/flags/az.svg
new file mode 100644
index 0000000000..3557522110
--- /dev/null
+++ b/public/images/flags/az.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/images/flags/ba.svg b/public/images/flags/ba.svg
new file mode 100644
index 0000000000..93bd9cf937
--- /dev/null
+++ b/public/images/flags/ba.svg
@@ -0,0 +1,12 @@
+
diff --git a/public/images/flags/bb.svg b/public/images/flags/bb.svg
new file mode 100644
index 0000000000..cecd5cc334
--- /dev/null
+++ b/public/images/flags/bb.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/bd.svg b/public/images/flags/bd.svg
new file mode 100644
index 0000000000..16b794debd
--- /dev/null
+++ b/public/images/flags/bd.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/images/flags/be.svg b/public/images/flags/be.svg
new file mode 100644
index 0000000000..ac706a0b5a
--- /dev/null
+++ b/public/images/flags/be.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/bf.svg b/public/images/flags/bf.svg
new file mode 100644
index 0000000000..4713822584
--- /dev/null
+++ b/public/images/flags/bf.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/bg.svg b/public/images/flags/bg.svg
new file mode 100644
index 0000000000..af2d0d07c3
--- /dev/null
+++ b/public/images/flags/bg.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/bh.svg b/public/images/flags/bh.svg
new file mode 100644
index 0000000000..7a2ea549b6
--- /dev/null
+++ b/public/images/flags/bh.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/images/flags/bi.svg b/public/images/flags/bi.svg
new file mode 100644
index 0000000000..a4434a955f
--- /dev/null
+++ b/public/images/flags/bi.svg
@@ -0,0 +1,15 @@
+
diff --git a/public/images/flags/bj.svg b/public/images/flags/bj.svg
new file mode 100644
index 0000000000..0846724d17
--- /dev/null
+++ b/public/images/flags/bj.svg
@@ -0,0 +1,14 @@
+
diff --git a/public/images/flags/bl.svg b/public/images/flags/bl.svg
new file mode 100644
index 0000000000..f84cbbaeb1
--- /dev/null
+++ b/public/images/flags/bl.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/bm.svg b/public/images/flags/bm.svg
new file mode 100644
index 0000000000..bab3e0abe0
--- /dev/null
+++ b/public/images/flags/bm.svg
@@ -0,0 +1,97 @@
+
diff --git a/public/images/flags/bn.svg b/public/images/flags/bn.svg
new file mode 100644
index 0000000000..4b416ebb73
--- /dev/null
+++ b/public/images/flags/bn.svg
@@ -0,0 +1,36 @@
+
diff --git a/public/images/flags/bo.svg b/public/images/flags/bo.svg
new file mode 100644
index 0000000000..46dc76735e
--- /dev/null
+++ b/public/images/flags/bo.svg
@@ -0,0 +1,674 @@
+
diff --git a/public/images/flags/bq.svg b/public/images/flags/bq.svg
new file mode 100644
index 0000000000..0e6bc76e62
--- /dev/null
+++ b/public/images/flags/bq.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/br.svg b/public/images/flags/br.svg
new file mode 100644
index 0000000000..22c908e7e3
--- /dev/null
+++ b/public/images/flags/br.svg
@@ -0,0 +1,45 @@
+
diff --git a/public/images/flags/bs.svg b/public/images/flags/bs.svg
new file mode 100644
index 0000000000..5cc918e5ad
--- /dev/null
+++ b/public/images/flags/bs.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/bt.svg b/public/images/flags/bt.svg
new file mode 100644
index 0000000000..798c79b381
--- /dev/null
+++ b/public/images/flags/bt.svg
@@ -0,0 +1,89 @@
+
diff --git a/public/images/flags/bv.svg b/public/images/flags/bv.svg
new file mode 100644
index 0000000000..40e16d9482
--- /dev/null
+++ b/public/images/flags/bv.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/bw.svg b/public/images/flags/bw.svg
new file mode 100644
index 0000000000..3435608d6c
--- /dev/null
+++ b/public/images/flags/bw.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/by.svg b/public/images/flags/by.svg
new file mode 100644
index 0000000000..7e90ff255c
--- /dev/null
+++ b/public/images/flags/by.svg
@@ -0,0 +1,18 @@
+
diff --git a/public/images/flags/bz.svg b/public/images/flags/bz.svg
new file mode 100644
index 0000000000..25386a51a4
--- /dev/null
+++ b/public/images/flags/bz.svg
@@ -0,0 +1,145 @@
+
diff --git a/public/images/flags/ca.svg b/public/images/flags/ca.svg
new file mode 100644
index 0000000000..89da5b7b55
--- /dev/null
+++ b/public/images/flags/ca.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/images/flags/cc.svg b/public/images/flags/cc.svg
new file mode 100644
index 0000000000..ddfd180382
--- /dev/null
+++ b/public/images/flags/cc.svg
@@ -0,0 +1,19 @@
+
diff --git a/public/images/flags/cd.svg b/public/images/flags/cd.svg
new file mode 100644
index 0000000000..b9cf528941
--- /dev/null
+++ b/public/images/flags/cd.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/cefta.svg b/public/images/flags/cefta.svg
new file mode 100644
index 0000000000..f748d08a12
--- /dev/null
+++ b/public/images/flags/cefta.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/cf.svg b/public/images/flags/cf.svg
new file mode 100644
index 0000000000..a6cd3670f2
--- /dev/null
+++ b/public/images/flags/cf.svg
@@ -0,0 +1,15 @@
+
diff --git a/public/images/flags/cg.svg b/public/images/flags/cg.svg
new file mode 100644
index 0000000000..f5a0e42d45
--- /dev/null
+++ b/public/images/flags/cg.svg
@@ -0,0 +1,12 @@
+
diff --git a/public/images/flags/ch.svg b/public/images/flags/ch.svg
new file mode 100644
index 0000000000..b42d6709cf
--- /dev/null
+++ b/public/images/flags/ch.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/images/flags/ci.svg b/public/images/flags/ci.svg
new file mode 100644
index 0000000000..e400f0c1cd
--- /dev/null
+++ b/public/images/flags/ci.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/ck.svg b/public/images/flags/ck.svg
new file mode 100644
index 0000000000..18e547b17d
--- /dev/null
+++ b/public/images/flags/ck.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/images/flags/cl.svg b/public/images/flags/cl.svg
new file mode 100644
index 0000000000..5b3c72fa7c
--- /dev/null
+++ b/public/images/flags/cl.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/cm.svg b/public/images/flags/cm.svg
new file mode 100644
index 0000000000..70adc8b681
--- /dev/null
+++ b/public/images/flags/cm.svg
@@ -0,0 +1,15 @@
+
diff --git a/public/images/flags/cn.svg b/public/images/flags/cn.svg
new file mode 100644
index 0000000000..10d3489a0e
--- /dev/null
+++ b/public/images/flags/cn.svg
@@ -0,0 +1,11 @@
+
diff --git a/public/images/flags/co.svg b/public/images/flags/co.svg
new file mode 100644
index 0000000000..ebd0a0fb2d
--- /dev/null
+++ b/public/images/flags/co.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/cp.svg b/public/images/flags/cp.svg
new file mode 100644
index 0000000000..b8aa9cfd69
--- /dev/null
+++ b/public/images/flags/cp.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/cr.svg b/public/images/flags/cr.svg
new file mode 100644
index 0000000000..5a409eebb2
--- /dev/null
+++ b/public/images/flags/cr.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/cu.svg b/public/images/flags/cu.svg
new file mode 100644
index 0000000000..053c9ee3a0
--- /dev/null
+++ b/public/images/flags/cu.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/cv.svg b/public/images/flags/cv.svg
new file mode 100644
index 0000000000..aec8994902
--- /dev/null
+++ b/public/images/flags/cv.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/cw.svg b/public/images/flags/cw.svg
new file mode 100644
index 0000000000..bb0ece22e4
--- /dev/null
+++ b/public/images/flags/cw.svg
@@ -0,0 +1,14 @@
+
diff --git a/public/images/flags/cx.svg b/public/images/flags/cx.svg
new file mode 100644
index 0000000000..374ff2dab5
--- /dev/null
+++ b/public/images/flags/cx.svg
@@ -0,0 +1,15 @@
+
diff --git a/public/images/flags/cy.svg b/public/images/flags/cy.svg
new file mode 100644
index 0000000000..7e3d883da8
--- /dev/null
+++ b/public/images/flags/cy.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/cz.svg b/public/images/flags/cz.svg
new file mode 100644
index 0000000000..7913de3895
--- /dev/null
+++ b/public/images/flags/cz.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/de.svg b/public/images/flags/de.svg
new file mode 100644
index 0000000000..71aa2d2c30
--- /dev/null
+++ b/public/images/flags/de.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/dg.svg b/public/images/flags/dg.svg
new file mode 100644
index 0000000000..f163caf947
--- /dev/null
+++ b/public/images/flags/dg.svg
@@ -0,0 +1,130 @@
+
diff --git a/public/images/flags/dj.svg b/public/images/flags/dj.svg
new file mode 100644
index 0000000000..9b00a82056
--- /dev/null
+++ b/public/images/flags/dj.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/dk.svg b/public/images/flags/dk.svg
new file mode 100644
index 0000000000..563277f81d
--- /dev/null
+++ b/public/images/flags/dk.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/dm.svg b/public/images/flags/dm.svg
new file mode 100644
index 0000000000..f692094ddb
--- /dev/null
+++ b/public/images/flags/dm.svg
@@ -0,0 +1,152 @@
+
diff --git a/public/images/flags/do.svg b/public/images/flags/do.svg
new file mode 100644
index 0000000000..b1be393ed1
--- /dev/null
+++ b/public/images/flags/do.svg
@@ -0,0 +1,121 @@
+
diff --git a/public/images/flags/dz.svg b/public/images/flags/dz.svg
new file mode 100644
index 0000000000..5ff29a74a0
--- /dev/null
+++ b/public/images/flags/dz.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/eac.svg b/public/images/flags/eac.svg
new file mode 100644
index 0000000000..aaf8133f35
--- /dev/null
+++ b/public/images/flags/eac.svg
@@ -0,0 +1,48 @@
+
diff --git a/public/images/flags/ec.svg b/public/images/flags/ec.svg
new file mode 100644
index 0000000000..397bfd9822
--- /dev/null
+++ b/public/images/flags/ec.svg
@@ -0,0 +1,138 @@
+
diff --git a/public/images/flags/ee.svg b/public/images/flags/ee.svg
new file mode 100644
index 0000000000..8b98c2c429
--- /dev/null
+++ b/public/images/flags/ee.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/eg.svg b/public/images/flags/eg.svg
new file mode 100644
index 0000000000..00d1fa59ee
--- /dev/null
+++ b/public/images/flags/eg.svg
@@ -0,0 +1,38 @@
+
diff --git a/public/images/flags/eh.svg b/public/images/flags/eh.svg
new file mode 100644
index 0000000000..6aec72883c
--- /dev/null
+++ b/public/images/flags/eh.svg
@@ -0,0 +1,16 @@
+
diff --git a/public/images/flags/er.svg b/public/images/flags/er.svg
new file mode 100644
index 0000000000..3f4f3f2921
--- /dev/null
+++ b/public/images/flags/er.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/images/flags/es-ct.svg b/public/images/flags/es-ct.svg
new file mode 100644
index 0000000000..4d85911402
--- /dev/null
+++ b/public/images/flags/es-ct.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/images/flags/es-ga.svg b/public/images/flags/es-ga.svg
new file mode 100644
index 0000000000..31657813ea
--- /dev/null
+++ b/public/images/flags/es-ga.svg
@@ -0,0 +1,187 @@
+
diff --git a/public/images/flags/es-pv.svg b/public/images/flags/es-pv.svg
new file mode 100644
index 0000000000..21c8759ec0
--- /dev/null
+++ b/public/images/flags/es-pv.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/es.svg b/public/images/flags/es.svg
new file mode 100644
index 0000000000..acdf927f23
--- /dev/null
+++ b/public/images/flags/es.svg
@@ -0,0 +1,544 @@
+
diff --git a/public/images/flags/et.svg b/public/images/flags/et.svg
new file mode 100644
index 0000000000..3f99be4860
--- /dev/null
+++ b/public/images/flags/et.svg
@@ -0,0 +1,14 @@
+
diff --git a/public/images/flags/eu.svg b/public/images/flags/eu.svg
new file mode 100644
index 0000000000..b0874c1ed4
--- /dev/null
+++ b/public/images/flags/eu.svg
@@ -0,0 +1,28 @@
+
diff --git a/public/images/flags/fi.svg b/public/images/flags/fi.svg
new file mode 100644
index 0000000000..470be2d07c
--- /dev/null
+++ b/public/images/flags/fi.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/fj.svg b/public/images/flags/fj.svg
new file mode 100644
index 0000000000..23fbe57a8d
--- /dev/null
+++ b/public/images/flags/fj.svg
@@ -0,0 +1,120 @@
+
diff --git a/public/images/flags/fk.svg b/public/images/flags/fk.svg
new file mode 100644
index 0000000000..c65bf96de9
--- /dev/null
+++ b/public/images/flags/fk.svg
@@ -0,0 +1,90 @@
+
diff --git a/public/images/flags/fm.svg b/public/images/flags/fm.svg
new file mode 100644
index 0000000000..c1b7c97784
--- /dev/null
+++ b/public/images/flags/fm.svg
@@ -0,0 +1,11 @@
+
diff --git a/public/images/flags/fo.svg b/public/images/flags/fo.svg
new file mode 100644
index 0000000000..f802d285ac
--- /dev/null
+++ b/public/images/flags/fo.svg
@@ -0,0 +1,12 @@
+
diff --git a/public/images/flags/fr.svg b/public/images/flags/fr.svg
new file mode 100644
index 0000000000..4110e59e4c
--- /dev/null
+++ b/public/images/flags/fr.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/ga.svg b/public/images/flags/ga.svg
new file mode 100644
index 0000000000..76edab429c
--- /dev/null
+++ b/public/images/flags/ga.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/gb-eng.svg b/public/images/flags/gb-eng.svg
new file mode 100644
index 0000000000..12e3b67d56
--- /dev/null
+++ b/public/images/flags/gb-eng.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/gb-nir.svg b/public/images/flags/gb-nir.svg
new file mode 100644
index 0000000000..e6be8dbc2d
--- /dev/null
+++ b/public/images/flags/gb-nir.svg
@@ -0,0 +1,132 @@
+
diff --git a/public/images/flags/gb-sct.svg b/public/images/flags/gb-sct.svg
new file mode 100644
index 0000000000..f50cd322ac
--- /dev/null
+++ b/public/images/flags/gb-sct.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/images/flags/gb-wls.svg b/public/images/flags/gb-wls.svg
new file mode 100644
index 0000000000..6e15fd0158
--- /dev/null
+++ b/public/images/flags/gb-wls.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/images/flags/gb.svg b/public/images/flags/gb.svg
new file mode 100644
index 0000000000..799138319d
--- /dev/null
+++ b/public/images/flags/gb.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/gd.svg b/public/images/flags/gd.svg
new file mode 100644
index 0000000000..cb51e9618e
--- /dev/null
+++ b/public/images/flags/gd.svg
@@ -0,0 +1,27 @@
+
diff --git a/public/images/flags/ge.svg b/public/images/flags/ge.svg
new file mode 100644
index 0000000000..d8126ec8d8
--- /dev/null
+++ b/public/images/flags/ge.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/gf.svg b/public/images/flags/gf.svg
new file mode 100644
index 0000000000..f8fe94c659
--- /dev/null
+++ b/public/images/flags/gf.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/gg.svg b/public/images/flags/gg.svg
new file mode 100644
index 0000000000..f8216c8bc1
--- /dev/null
+++ b/public/images/flags/gg.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/images/flags/gh.svg b/public/images/flags/gh.svg
new file mode 100644
index 0000000000..5c3e3e69ab
--- /dev/null
+++ b/public/images/flags/gh.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/gi.svg b/public/images/flags/gi.svg
new file mode 100644
index 0000000000..e2b590afef
--- /dev/null
+++ b/public/images/flags/gi.svg
@@ -0,0 +1,32 @@
+
diff --git a/public/images/flags/gl.svg b/public/images/flags/gl.svg
new file mode 100644
index 0000000000..eb5a52e9e4
--- /dev/null
+++ b/public/images/flags/gl.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/images/flags/gm.svg b/public/images/flags/gm.svg
new file mode 100644
index 0000000000..8fe9d66920
--- /dev/null
+++ b/public/images/flags/gm.svg
@@ -0,0 +1,14 @@
+
diff --git a/public/images/flags/gn.svg b/public/images/flags/gn.svg
new file mode 100644
index 0000000000..40d6ad4f03
--- /dev/null
+++ b/public/images/flags/gn.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/gp.svg b/public/images/flags/gp.svg
new file mode 100644
index 0000000000..ee55c4bcd3
--- /dev/null
+++ b/public/images/flags/gp.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/gq.svg b/public/images/flags/gq.svg
new file mode 100644
index 0000000000..134e442173
--- /dev/null
+++ b/public/images/flags/gq.svg
@@ -0,0 +1,23 @@
+
diff --git a/public/images/flags/gr.svg b/public/images/flags/gr.svg
new file mode 100644
index 0000000000..599741eec8
--- /dev/null
+++ b/public/images/flags/gr.svg
@@ -0,0 +1,16 @@
+
diff --git a/public/images/flags/gs.svg b/public/images/flags/gs.svg
new file mode 100644
index 0000000000..1536e073ec
--- /dev/null
+++ b/public/images/flags/gs.svg
@@ -0,0 +1,133 @@
+
diff --git a/public/images/flags/gt.svg b/public/images/flags/gt.svg
new file mode 100644
index 0000000000..f7cffbdc7a
--- /dev/null
+++ b/public/images/flags/gt.svg
@@ -0,0 +1,204 @@
+
diff --git a/public/images/flags/gu.svg b/public/images/flags/gu.svg
new file mode 100644
index 0000000000..0d66e1bfa8
--- /dev/null
+++ b/public/images/flags/gu.svg
@@ -0,0 +1,23 @@
+
diff --git a/public/images/flags/gw.svg b/public/images/flags/gw.svg
new file mode 100644
index 0000000000..d470bac9f7
--- /dev/null
+++ b/public/images/flags/gw.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/gy.svg b/public/images/flags/gy.svg
new file mode 100644
index 0000000000..569fb56275
--- /dev/null
+++ b/public/images/flags/gy.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/images/flags/hk.svg b/public/images/flags/hk.svg
new file mode 100644
index 0000000000..4fd55bc14b
--- /dev/null
+++ b/public/images/flags/hk.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/images/flags/hm.svg b/public/images/flags/hm.svg
new file mode 100644
index 0000000000..815c482085
--- /dev/null
+++ b/public/images/flags/hm.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/images/flags/hn.svg b/public/images/flags/hn.svg
new file mode 100644
index 0000000000..11fde67db9
--- /dev/null
+++ b/public/images/flags/hn.svg
@@ -0,0 +1,18 @@
+
diff --git a/public/images/flags/hr.svg b/public/images/flags/hr.svg
new file mode 100644
index 0000000000..44fed27d54
--- /dev/null
+++ b/public/images/flags/hr.svg
@@ -0,0 +1,58 @@
+
diff --git a/public/images/flags/ht.svg b/public/images/flags/ht.svg
new file mode 100644
index 0000000000..5d48eb93b2
--- /dev/null
+++ b/public/images/flags/ht.svg
@@ -0,0 +1,116 @@
+
diff --git a/public/images/flags/hu.svg b/public/images/flags/hu.svg
new file mode 100644
index 0000000000..baddf7f5ea
--- /dev/null
+++ b/public/images/flags/hu.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/ic.svg b/public/images/flags/ic.svg
new file mode 100644
index 0000000000..81e6ee2e13
--- /dev/null
+++ b/public/images/flags/ic.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/id.svg b/public/images/flags/id.svg
new file mode 100644
index 0000000000..3b7c8fcfd9
--- /dev/null
+++ b/public/images/flags/id.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/images/flags/ie.svg b/public/images/flags/ie.svg
new file mode 100644
index 0000000000..049be14de1
--- /dev/null
+++ b/public/images/flags/ie.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/il.svg b/public/images/flags/il.svg
new file mode 100644
index 0000000000..f43be7e8ed
--- /dev/null
+++ b/public/images/flags/il.svg
@@ -0,0 +1,14 @@
+
diff --git a/public/images/flags/im.svg b/public/images/flags/im.svg
new file mode 100644
index 0000000000..f06f3d6fe1
--- /dev/null
+++ b/public/images/flags/im.svg
@@ -0,0 +1,36 @@
+
diff --git a/public/images/flags/in.svg b/public/images/flags/in.svg
new file mode 100644
index 0000000000..bc47d74911
--- /dev/null
+++ b/public/images/flags/in.svg
@@ -0,0 +1,25 @@
+
diff --git a/public/images/flags/io.svg b/public/images/flags/io.svg
new file mode 100644
index 0000000000..77016679ef
--- /dev/null
+++ b/public/images/flags/io.svg
@@ -0,0 +1,130 @@
+
diff --git a/public/images/flags/iq.svg b/public/images/flags/iq.svg
new file mode 100644
index 0000000000..259da9adc5
--- /dev/null
+++ b/public/images/flags/iq.svg
@@ -0,0 +1,10 @@
+
diff --git a/public/images/flags/ir.svg b/public/images/flags/ir.svg
new file mode 100644
index 0000000000..8c6d516216
--- /dev/null
+++ b/public/images/flags/ir.svg
@@ -0,0 +1,219 @@
+
diff --git a/public/images/flags/is.svg b/public/images/flags/is.svg
new file mode 100644
index 0000000000..a6588afaef
--- /dev/null
+++ b/public/images/flags/is.svg
@@ -0,0 +1,12 @@
+
diff --git a/public/images/flags/it.svg b/public/images/flags/it.svg
new file mode 100644
index 0000000000..20a8bfdcc8
--- /dev/null
+++ b/public/images/flags/it.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/je.svg b/public/images/flags/je.svg
new file mode 100644
index 0000000000..611180d42a
--- /dev/null
+++ b/public/images/flags/je.svg
@@ -0,0 +1,62 @@
+
diff --git a/public/images/flags/jm.svg b/public/images/flags/jm.svg
new file mode 100644
index 0000000000..269df03836
--- /dev/null
+++ b/public/images/flags/jm.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/images/flags/jo.svg b/public/images/flags/jo.svg
new file mode 100644
index 0000000000..d6f927d44f
--- /dev/null
+++ b/public/images/flags/jo.svg
@@ -0,0 +1,16 @@
+
diff --git a/public/images/flags/jp.svg b/public/images/flags/jp.svg
new file mode 100644
index 0000000000..cc1c181ce9
--- /dev/null
+++ b/public/images/flags/jp.svg
@@ -0,0 +1,11 @@
+
diff --git a/public/images/flags/ke.svg b/public/images/flags/ke.svg
new file mode 100644
index 0000000000..3a67ca3ccd
--- /dev/null
+++ b/public/images/flags/ke.svg
@@ -0,0 +1,23 @@
+
diff --git a/public/images/flags/kg.svg b/public/images/flags/kg.svg
new file mode 100644
index 0000000000..68c210b1cf
--- /dev/null
+++ b/public/images/flags/kg.svg
@@ -0,0 +1,15 @@
+
diff --git a/public/images/flags/kh.svg b/public/images/flags/kh.svg
new file mode 100644
index 0000000000..c658838f4e
--- /dev/null
+++ b/public/images/flags/kh.svg
@@ -0,0 +1,61 @@
+
diff --git a/public/images/flags/ki.svg b/public/images/flags/ki.svg
new file mode 100644
index 0000000000..0c80328071
--- /dev/null
+++ b/public/images/flags/ki.svg
@@ -0,0 +1,36 @@
+
diff --git a/public/images/flags/km.svg b/public/images/flags/km.svg
new file mode 100644
index 0000000000..414d65e47f
--- /dev/null
+++ b/public/images/flags/km.svg
@@ -0,0 +1,16 @@
+
diff --git a/public/images/flags/kn.svg b/public/images/flags/kn.svg
new file mode 100644
index 0000000000..47fe64d617
--- /dev/null
+++ b/public/images/flags/kn.svg
@@ -0,0 +1,14 @@
+
diff --git a/public/images/flags/kp.svg b/public/images/flags/kp.svg
new file mode 100644
index 0000000000..4d1dbab246
--- /dev/null
+++ b/public/images/flags/kp.svg
@@ -0,0 +1,15 @@
+
diff --git a/public/images/flags/kr.svg b/public/images/flags/kr.svg
new file mode 100644
index 0000000000..6947eab2b3
--- /dev/null
+++ b/public/images/flags/kr.svg
@@ -0,0 +1,24 @@
+
diff --git a/public/images/flags/kw.svg b/public/images/flags/kw.svg
new file mode 100644
index 0000000000..3dd89e9962
--- /dev/null
+++ b/public/images/flags/kw.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/ky.svg b/public/images/flags/ky.svg
new file mode 100644
index 0000000000..74a2fea2a1
--- /dev/null
+++ b/public/images/flags/ky.svg
@@ -0,0 +1,103 @@
+
diff --git a/public/images/flags/kz.svg b/public/images/flags/kz.svg
new file mode 100644
index 0000000000..04a47f53e8
--- /dev/null
+++ b/public/images/flags/kz.svg
@@ -0,0 +1,36 @@
+
diff --git a/public/images/flags/la.svg b/public/images/flags/la.svg
new file mode 100644
index 0000000000..6aea6b72b4
--- /dev/null
+++ b/public/images/flags/la.svg
@@ -0,0 +1,12 @@
+
diff --git a/public/images/flags/lb.svg b/public/images/flags/lb.svg
new file mode 100644
index 0000000000..8619f2410e
--- /dev/null
+++ b/public/images/flags/lb.svg
@@ -0,0 +1,15 @@
+
diff --git a/public/images/flags/lc.svg b/public/images/flags/lc.svg
new file mode 100644
index 0000000000..bb256541c6
--- /dev/null
+++ b/public/images/flags/lc.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/images/flags/li.svg b/public/images/flags/li.svg
new file mode 100644
index 0000000000..68ea26fa30
--- /dev/null
+++ b/public/images/flags/li.svg
@@ -0,0 +1,43 @@
+
diff --git a/public/images/flags/lk.svg b/public/images/flags/lk.svg
new file mode 100644
index 0000000000..2c5cdbe09d
--- /dev/null
+++ b/public/images/flags/lk.svg
@@ -0,0 +1,22 @@
+
diff --git a/public/images/flags/lr.svg b/public/images/flags/lr.svg
new file mode 100644
index 0000000000..e482ab9d74
--- /dev/null
+++ b/public/images/flags/lr.svg
@@ -0,0 +1,14 @@
+
diff --git a/public/images/flags/ls.svg b/public/images/flags/ls.svg
new file mode 100644
index 0000000000..a7c01a98ff
--- /dev/null
+++ b/public/images/flags/ls.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/images/flags/lt.svg b/public/images/flags/lt.svg
new file mode 100644
index 0000000000..90ec5d240e
--- /dev/null
+++ b/public/images/flags/lt.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/lu.svg b/public/images/flags/lu.svg
new file mode 100644
index 0000000000..cc12206812
--- /dev/null
+++ b/public/images/flags/lu.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/lv.svg b/public/images/flags/lv.svg
new file mode 100644
index 0000000000..6a9e75ec97
--- /dev/null
+++ b/public/images/flags/lv.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/ly.svg b/public/images/flags/ly.svg
new file mode 100644
index 0000000000..1eaa51e468
--- /dev/null
+++ b/public/images/flags/ly.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/ma.svg b/public/images/flags/ma.svg
new file mode 100644
index 0000000000..7ce56eff70
--- /dev/null
+++ b/public/images/flags/ma.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/images/flags/mc.svg b/public/images/flags/mc.svg
new file mode 100644
index 0000000000..9cb6c9e8a0
--- /dev/null
+++ b/public/images/flags/mc.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/md.svg b/public/images/flags/md.svg
new file mode 100644
index 0000000000..6dc441e177
--- /dev/null
+++ b/public/images/flags/md.svg
@@ -0,0 +1,70 @@
+
diff --git a/public/images/flags/me.svg b/public/images/flags/me.svg
new file mode 100644
index 0000000000..d891890746
--- /dev/null
+++ b/public/images/flags/me.svg
@@ -0,0 +1,116 @@
+
diff --git a/public/images/flags/mf.svg b/public/images/flags/mf.svg
new file mode 100644
index 0000000000..6305edc1c2
--- /dev/null
+++ b/public/images/flags/mf.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/mg.svg b/public/images/flags/mg.svg
new file mode 100644
index 0000000000..5fa2d2440d
--- /dev/null
+++ b/public/images/flags/mg.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/mh.svg b/public/images/flags/mh.svg
new file mode 100644
index 0000000000..7b9f490755
--- /dev/null
+++ b/public/images/flags/mh.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/mk.svg b/public/images/flags/mk.svg
new file mode 100644
index 0000000000..4f5cae77ed
--- /dev/null
+++ b/public/images/flags/mk.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/ml.svg b/public/images/flags/ml.svg
new file mode 100644
index 0000000000..6f6b71695c
--- /dev/null
+++ b/public/images/flags/ml.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/mm.svg b/public/images/flags/mm.svg
new file mode 100644
index 0000000000..42b4dee2b8
--- /dev/null
+++ b/public/images/flags/mm.svg
@@ -0,0 +1,12 @@
+
diff --git a/public/images/flags/mn.svg b/public/images/flags/mn.svg
new file mode 100644
index 0000000000..152c2fcb0f
--- /dev/null
+++ b/public/images/flags/mn.svg
@@ -0,0 +1,14 @@
+
diff --git a/public/images/flags/mo.svg b/public/images/flags/mo.svg
new file mode 100644
index 0000000000..d39985d05f
--- /dev/null
+++ b/public/images/flags/mo.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/images/flags/mp.svg b/public/images/flags/mp.svg
new file mode 100644
index 0000000000..ff59ebf87b
--- /dev/null
+++ b/public/images/flags/mp.svg
@@ -0,0 +1,86 @@
+
diff --git a/public/images/flags/mq.svg b/public/images/flags/mq.svg
new file mode 100644
index 0000000000..b221951e36
--- /dev/null
+++ b/public/images/flags/mq.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/mr.svg b/public/images/flags/mr.svg
new file mode 100644
index 0000000000..7558234cbf
--- /dev/null
+++ b/public/images/flags/mr.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/ms.svg b/public/images/flags/ms.svg
new file mode 100644
index 0000000000..faf07b07fd
--- /dev/null
+++ b/public/images/flags/ms.svg
@@ -0,0 +1,29 @@
+
diff --git a/public/images/flags/mt.svg b/public/images/flags/mt.svg
new file mode 100644
index 0000000000..c597266c36
--- /dev/null
+++ b/public/images/flags/mt.svg
@@ -0,0 +1,58 @@
+
diff --git a/public/images/flags/mu.svg b/public/images/flags/mu.svg
new file mode 100644
index 0000000000..82d7a3bec5
--- /dev/null
+++ b/public/images/flags/mu.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/images/flags/mv.svg b/public/images/flags/mv.svg
new file mode 100644
index 0000000000..10450f9845
--- /dev/null
+++ b/public/images/flags/mv.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/mw.svg b/public/images/flags/mw.svg
new file mode 100644
index 0000000000..d83ddb2178
--- /dev/null
+++ b/public/images/flags/mw.svg
@@ -0,0 +1,10 @@
+
diff --git a/public/images/flags/mx.svg b/public/images/flags/mx.svg
new file mode 100644
index 0000000000..f98a89e173
--- /dev/null
+++ b/public/images/flags/mx.svg
@@ -0,0 +1,382 @@
+
diff --git a/public/images/flags/my.svg b/public/images/flags/my.svg
new file mode 100644
index 0000000000..89576f69ea
--- /dev/null
+++ b/public/images/flags/my.svg
@@ -0,0 +1,26 @@
+
diff --git a/public/images/flags/mz.svg b/public/images/flags/mz.svg
new file mode 100644
index 0000000000..2ee6ec14b4
--- /dev/null
+++ b/public/images/flags/mz.svg
@@ -0,0 +1,21 @@
+
diff --git a/public/images/flags/na.svg b/public/images/flags/na.svg
new file mode 100644
index 0000000000..35b9f783e1
--- /dev/null
+++ b/public/images/flags/na.svg
@@ -0,0 +1,16 @@
+
diff --git a/public/images/flags/nc.svg b/public/images/flags/nc.svg
new file mode 100644
index 0000000000..068f0c69aa
--- /dev/null
+++ b/public/images/flags/nc.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/ne.svg b/public/images/flags/ne.svg
new file mode 100644
index 0000000000..39a82b8277
--- /dev/null
+++ b/public/images/flags/ne.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/nf.svg b/public/images/flags/nf.svg
new file mode 100644
index 0000000000..c8b30938d7
--- /dev/null
+++ b/public/images/flags/nf.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/images/flags/ng.svg b/public/images/flags/ng.svg
new file mode 100644
index 0000000000..81eb35f78e
--- /dev/null
+++ b/public/images/flags/ng.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/ni.svg b/public/images/flags/ni.svg
new file mode 100644
index 0000000000..6dcdc9a806
--- /dev/null
+++ b/public/images/flags/ni.svg
@@ -0,0 +1,129 @@
+
diff --git a/public/images/flags/nl.svg b/public/images/flags/nl.svg
new file mode 100644
index 0000000000..e90f5b0351
--- /dev/null
+++ b/public/images/flags/nl.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/no.svg b/public/images/flags/no.svg
new file mode 100644
index 0000000000..a5f2a152a9
--- /dev/null
+++ b/public/images/flags/no.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/np.svg b/public/images/flags/np.svg
new file mode 100644
index 0000000000..8d71d106bb
--- /dev/null
+++ b/public/images/flags/np.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/nr.svg b/public/images/flags/nr.svg
new file mode 100644
index 0000000000..ff394c4112
--- /dev/null
+++ b/public/images/flags/nr.svg
@@ -0,0 +1,12 @@
+
diff --git a/public/images/flags/nu.svg b/public/images/flags/nu.svg
new file mode 100644
index 0000000000..4067bafff0
--- /dev/null
+++ b/public/images/flags/nu.svg
@@ -0,0 +1,10 @@
+
diff --git a/public/images/flags/nz.svg b/public/images/flags/nz.svg
new file mode 100644
index 0000000000..935d8a749d
--- /dev/null
+++ b/public/images/flags/nz.svg
@@ -0,0 +1,36 @@
+
diff --git a/public/images/flags/om.svg b/public/images/flags/om.svg
new file mode 100644
index 0000000000..c003f86e46
--- /dev/null
+++ b/public/images/flags/om.svg
@@ -0,0 +1,115 @@
+
diff --git a/public/images/flags/pa.svg b/public/images/flags/pa.svg
new file mode 100644
index 0000000000..8dc03bc61b
--- /dev/null
+++ b/public/images/flags/pa.svg
@@ -0,0 +1,14 @@
+
diff --git a/public/images/flags/pc.svg b/public/images/flags/pc.svg
new file mode 100644
index 0000000000..882197da67
--- /dev/null
+++ b/public/images/flags/pc.svg
@@ -0,0 +1,33 @@
+
diff --git a/public/images/flags/pe.svg b/public/images/flags/pe.svg
new file mode 100644
index 0000000000..33e6cfd417
--- /dev/null
+++ b/public/images/flags/pe.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/images/flags/pf.svg b/public/images/flags/pf.svg
new file mode 100644
index 0000000000..e06b236e82
--- /dev/null
+++ b/public/images/flags/pf.svg
@@ -0,0 +1,19 @@
+
diff --git a/public/images/flags/pg.svg b/public/images/flags/pg.svg
new file mode 100644
index 0000000000..237cb6eeed
--- /dev/null
+++ b/public/images/flags/pg.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/images/flags/ph.svg b/public/images/flags/ph.svg
new file mode 100644
index 0000000000..65489e1cb2
--- /dev/null
+++ b/public/images/flags/ph.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/pk.svg b/public/images/flags/pk.svg
new file mode 100644
index 0000000000..491e58ab16
--- /dev/null
+++ b/public/images/flags/pk.svg
@@ -0,0 +1,15 @@
+
diff --git a/public/images/flags/pl.svg b/public/images/flags/pl.svg
new file mode 100644
index 0000000000..0fa5145241
--- /dev/null
+++ b/public/images/flags/pl.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/pm.svg b/public/images/flags/pm.svg
new file mode 100644
index 0000000000..19a9330a31
--- /dev/null
+++ b/public/images/flags/pm.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/pn.svg b/public/images/flags/pn.svg
new file mode 100644
index 0000000000..07958aca12
--- /dev/null
+++ b/public/images/flags/pn.svg
@@ -0,0 +1,53 @@
+
diff --git a/public/images/flags/pr.svg b/public/images/flags/pr.svg
new file mode 100644
index 0000000000..ec51831dcd
--- /dev/null
+++ b/public/images/flags/pr.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/ps.svg b/public/images/flags/ps.svg
new file mode 100644
index 0000000000..b33824a5dd
--- /dev/null
+++ b/public/images/flags/ps.svg
@@ -0,0 +1,15 @@
+
diff --git a/public/images/flags/pt.svg b/public/images/flags/pt.svg
new file mode 100644
index 0000000000..445cf7f536
--- /dev/null
+++ b/public/images/flags/pt.svg
@@ -0,0 +1,57 @@
+
diff --git a/public/images/flags/pw.svg b/public/images/flags/pw.svg
new file mode 100644
index 0000000000..9f89c5f148
--- /dev/null
+++ b/public/images/flags/pw.svg
@@ -0,0 +1,11 @@
+
diff --git a/public/images/flags/py.svg b/public/images/flags/py.svg
new file mode 100644
index 0000000000..38e2051eb2
--- /dev/null
+++ b/public/images/flags/py.svg
@@ -0,0 +1,157 @@
+
diff --git a/public/images/flags/qa.svg b/public/images/flags/qa.svg
new file mode 100644
index 0000000000..901f3fa761
--- /dev/null
+++ b/public/images/flags/qa.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/images/flags/re.svg b/public/images/flags/re.svg
new file mode 100644
index 0000000000..64e788e011
--- /dev/null
+++ b/public/images/flags/re.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/ro.svg b/public/images/flags/ro.svg
new file mode 100644
index 0000000000..fda0f7bec9
--- /dev/null
+++ b/public/images/flags/ro.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/rs.svg b/public/images/flags/rs.svg
new file mode 100644
index 0000000000..2f971025b8
--- /dev/null
+++ b/public/images/flags/rs.svg
@@ -0,0 +1,292 @@
+
diff --git a/public/images/flags/ru.svg b/public/images/flags/ru.svg
new file mode 100644
index 0000000000..cf243011ae
--- /dev/null
+++ b/public/images/flags/ru.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/rw.svg b/public/images/flags/rw.svg
new file mode 100644
index 0000000000..06e26ae44e
--- /dev/null
+++ b/public/images/flags/rw.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/sa.svg b/public/images/flags/sa.svg
new file mode 100644
index 0000000000..c0a148663b
--- /dev/null
+++ b/public/images/flags/sa.svg
@@ -0,0 +1,25 @@
+
diff --git a/public/images/flags/sb.svg b/public/images/flags/sb.svg
new file mode 100644
index 0000000000..6066f94cd1
--- /dev/null
+++ b/public/images/flags/sb.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/sc.svg b/public/images/flags/sc.svg
new file mode 100644
index 0000000000..9a46b369b3
--- /dev/null
+++ b/public/images/flags/sc.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/sd.svg b/public/images/flags/sd.svg
new file mode 100644
index 0000000000..12818b4110
--- /dev/null
+++ b/public/images/flags/sd.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/se.svg b/public/images/flags/se.svg
new file mode 100644
index 0000000000..8ba745acaf
--- /dev/null
+++ b/public/images/flags/se.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/images/flags/sg.svg b/public/images/flags/sg.svg
new file mode 100644
index 0000000000..c4dd4ac9eb
--- /dev/null
+++ b/public/images/flags/sg.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/sh-ac.svg b/public/images/flags/sh-ac.svg
new file mode 100644
index 0000000000..22b365832e
--- /dev/null
+++ b/public/images/flags/sh-ac.svg
@@ -0,0 +1,689 @@
+
diff --git a/public/images/flags/sh-hl.svg b/public/images/flags/sh-hl.svg
new file mode 100644
index 0000000000..b92e703f27
--- /dev/null
+++ b/public/images/flags/sh-hl.svg
@@ -0,0 +1,164 @@
+
diff --git a/public/images/flags/sh-ta.svg b/public/images/flags/sh-ta.svg
new file mode 100644
index 0000000000..a103aac05f
--- /dev/null
+++ b/public/images/flags/sh-ta.svg
@@ -0,0 +1,76 @@
+
diff --git a/public/images/flags/sh.svg b/public/images/flags/sh.svg
new file mode 100644
index 0000000000..7aba0aec8a
--- /dev/null
+++ b/public/images/flags/sh.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/si.svg b/public/images/flags/si.svg
new file mode 100644
index 0000000000..66a390dcd2
--- /dev/null
+++ b/public/images/flags/si.svg
@@ -0,0 +1,18 @@
+
diff --git a/public/images/flags/sj.svg b/public/images/flags/sj.svg
new file mode 100644
index 0000000000..bb2799ce73
--- /dev/null
+++ b/public/images/flags/sj.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/sk.svg b/public/images/flags/sk.svg
new file mode 100644
index 0000000000..81476940eb
--- /dev/null
+++ b/public/images/flags/sk.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/images/flags/sl.svg b/public/images/flags/sl.svg
new file mode 100644
index 0000000000..a07baf75b4
--- /dev/null
+++ b/public/images/flags/sl.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/sm.svg b/public/images/flags/sm.svg
new file mode 100644
index 0000000000..00e9286c44
--- /dev/null
+++ b/public/images/flags/sm.svg
@@ -0,0 +1,75 @@
+
diff --git a/public/images/flags/sn.svg b/public/images/flags/sn.svg
new file mode 100644
index 0000000000..7c0673d6d6
--- /dev/null
+++ b/public/images/flags/sn.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/images/flags/so.svg b/public/images/flags/so.svg
new file mode 100644
index 0000000000..a581ac63cf
--- /dev/null
+++ b/public/images/flags/so.svg
@@ -0,0 +1,11 @@
+
diff --git a/public/images/flags/sr.svg b/public/images/flags/sr.svg
new file mode 100644
index 0000000000..5e71c40026
--- /dev/null
+++ b/public/images/flags/sr.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/ss.svg b/public/images/flags/ss.svg
new file mode 100644
index 0000000000..b257aa0b3e
--- /dev/null
+++ b/public/images/flags/ss.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/images/flags/st.svg b/public/images/flags/st.svg
new file mode 100644
index 0000000000..1294bcb70e
--- /dev/null
+++ b/public/images/flags/st.svg
@@ -0,0 +1,16 @@
+
diff --git a/public/images/flags/sv.svg b/public/images/flags/sv.svg
new file mode 100644
index 0000000000..c811e912f0
--- /dev/null
+++ b/public/images/flags/sv.svg
@@ -0,0 +1,594 @@
+
diff --git a/public/images/flags/sx.svg b/public/images/flags/sx.svg
new file mode 100644
index 0000000000..18f7a1397b
--- /dev/null
+++ b/public/images/flags/sx.svg
@@ -0,0 +1,56 @@
+
diff --git a/public/images/flags/sy.svg b/public/images/flags/sy.svg
new file mode 100644
index 0000000000..5225550525
--- /dev/null
+++ b/public/images/flags/sy.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/sz.svg b/public/images/flags/sz.svg
new file mode 100644
index 0000000000..294a2cc1a8
--- /dev/null
+++ b/public/images/flags/sz.svg
@@ -0,0 +1,34 @@
+
diff --git a/public/images/flags/tc.svg b/public/images/flags/tc.svg
new file mode 100644
index 0000000000..63f13c359b
--- /dev/null
+++ b/public/images/flags/tc.svg
@@ -0,0 +1,50 @@
+
diff --git a/public/images/flags/td.svg b/public/images/flags/td.svg
new file mode 100644
index 0000000000..fa3bd927c1
--- /dev/null
+++ b/public/images/flags/td.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/tf.svg b/public/images/flags/tf.svg
new file mode 100644
index 0000000000..fba233563f
--- /dev/null
+++ b/public/images/flags/tf.svg
@@ -0,0 +1,15 @@
+
diff --git a/public/images/flags/tg.svg b/public/images/flags/tg.svg
new file mode 100644
index 0000000000..c63a6d1a94
--- /dev/null
+++ b/public/images/flags/tg.svg
@@ -0,0 +1,14 @@
+
diff --git a/public/images/flags/th.svg b/public/images/flags/th.svg
new file mode 100644
index 0000000000..1e93a61e95
--- /dev/null
+++ b/public/images/flags/th.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/tj.svg b/public/images/flags/tj.svg
new file mode 100644
index 0000000000..9fba246cde
--- /dev/null
+++ b/public/images/flags/tj.svg
@@ -0,0 +1,22 @@
+
diff --git a/public/images/flags/tk.svg b/public/images/flags/tk.svg
new file mode 100644
index 0000000000..05d3e86ce6
--- /dev/null
+++ b/public/images/flags/tk.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/tl.svg b/public/images/flags/tl.svg
new file mode 100644
index 0000000000..3d0701a2c8
--- /dev/null
+++ b/public/images/flags/tl.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/tm.svg b/public/images/flags/tm.svg
new file mode 100644
index 0000000000..8b656cc2b8
--- /dev/null
+++ b/public/images/flags/tm.svg
@@ -0,0 +1,204 @@
+
diff --git a/public/images/flags/tn.svg b/public/images/flags/tn.svg
new file mode 100644
index 0000000000..5735c1984d
--- /dev/null
+++ b/public/images/flags/tn.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/images/flags/to.svg b/public/images/flags/to.svg
new file mode 100644
index 0000000000..d072337066
--- /dev/null
+++ b/public/images/flags/to.svg
@@ -0,0 +1,10 @@
+
diff --git a/public/images/flags/tr.svg b/public/images/flags/tr.svg
new file mode 100644
index 0000000000..b96da21f0e
--- /dev/null
+++ b/public/images/flags/tr.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/images/flags/tt.svg b/public/images/flags/tt.svg
new file mode 100644
index 0000000000..bc24938cf8
--- /dev/null
+++ b/public/images/flags/tt.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/tv.svg b/public/images/flags/tv.svg
new file mode 100644
index 0000000000..675210ec55
--- /dev/null
+++ b/public/images/flags/tv.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/images/flags/tw.svg b/public/images/flags/tw.svg
new file mode 100644
index 0000000000..57fd98b433
--- /dev/null
+++ b/public/images/flags/tw.svg
@@ -0,0 +1,34 @@
+
diff --git a/public/images/flags/tz.svg b/public/images/flags/tz.svg
new file mode 100644
index 0000000000..a2cfbca42a
--- /dev/null
+++ b/public/images/flags/tz.svg
@@ -0,0 +1,13 @@
+
diff --git a/public/images/flags/ua.svg b/public/images/flags/ua.svg
new file mode 100644
index 0000000000..a339eb1b9c
--- /dev/null
+++ b/public/images/flags/ua.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/images/flags/ug.svg b/public/images/flags/ug.svg
new file mode 100644
index 0000000000..737eb2ce1a
--- /dev/null
+++ b/public/images/flags/ug.svg
@@ -0,0 +1,30 @@
+
diff --git a/public/images/flags/um.svg b/public/images/flags/um.svg
new file mode 100644
index 0000000000..9e9eddaa4a
--- /dev/null
+++ b/public/images/flags/um.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/images/flags/un.svg b/public/images/flags/un.svg
new file mode 100644
index 0000000000..e57793bc79
--- /dev/null
+++ b/public/images/flags/un.svg
@@ -0,0 +1,16 @@
+
diff --git a/public/images/flags/us.svg b/public/images/flags/us.svg
new file mode 100644
index 0000000000..9cfd0c927f
--- /dev/null
+++ b/public/images/flags/us.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/images/flags/uy.svg b/public/images/flags/uy.svg
new file mode 100644
index 0000000000..62c36f8e5e
--- /dev/null
+++ b/public/images/flags/uy.svg
@@ -0,0 +1,28 @@
+
diff --git a/public/images/flags/uz.svg b/public/images/flags/uz.svg
new file mode 100644
index 0000000000..0ccca1b1b4
--- /dev/null
+++ b/public/images/flags/uz.svg
@@ -0,0 +1,30 @@
+
diff --git a/public/images/flags/va.svg b/public/images/flags/va.svg
new file mode 100644
index 0000000000..87e0fbbdcc
--- /dev/null
+++ b/public/images/flags/va.svg
@@ -0,0 +1,190 @@
+
diff --git a/public/images/flags/vc.svg b/public/images/flags/vc.svg
new file mode 100644
index 0000000000..f26c2d8da9
--- /dev/null
+++ b/public/images/flags/vc.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/images/flags/ve.svg b/public/images/flags/ve.svg
new file mode 100644
index 0000000000..314e7f5f7f
--- /dev/null
+++ b/public/images/flags/ve.svg
@@ -0,0 +1,26 @@
+
diff --git a/public/images/flags/vg.svg b/public/images/flags/vg.svg
new file mode 100644
index 0000000000..0ee90fb28c
--- /dev/null
+++ b/public/images/flags/vg.svg
@@ -0,0 +1,59 @@
+
diff --git a/public/images/flags/vi.svg b/public/images/flags/vi.svg
new file mode 100644
index 0000000000..4270257799
--- /dev/null
+++ b/public/images/flags/vi.svg
@@ -0,0 +1,28 @@
+
diff --git a/public/images/flags/vn.svg b/public/images/flags/vn.svg
new file mode 100644
index 0000000000..7e4bac8f4a
--- /dev/null
+++ b/public/images/flags/vn.svg
@@ -0,0 +1,11 @@
+
diff --git a/public/images/flags/vu.svg b/public/images/flags/vu.svg
new file mode 100644
index 0000000000..91e1236a0a
--- /dev/null
+++ b/public/images/flags/vu.svg
@@ -0,0 +1,21 @@
+
diff --git a/public/images/flags/wf.svg b/public/images/flags/wf.svg
new file mode 100644
index 0000000000..054c57df99
--- /dev/null
+++ b/public/images/flags/wf.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/ws.svg b/public/images/flags/ws.svg
new file mode 100644
index 0000000000..0e758a7a95
--- /dev/null
+++ b/public/images/flags/ws.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/xk.svg b/public/images/flags/xk.svg
new file mode 100644
index 0000000000..551e7a4145
--- /dev/null
+++ b/public/images/flags/xk.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/xx.svg b/public/images/flags/xx.svg
new file mode 100644
index 0000000000..9333be3635
--- /dev/null
+++ b/public/images/flags/xx.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/images/flags/ye.svg b/public/images/flags/ye.svg
new file mode 100644
index 0000000000..1c9e6d6392
--- /dev/null
+++ b/public/images/flags/ye.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/images/flags/yt.svg b/public/images/flags/yt.svg
new file mode 100644
index 0000000000..e7776b3078
--- /dev/null
+++ b/public/images/flags/yt.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/images/flags/za.svg b/public/images/flags/za.svg
new file mode 100644
index 0000000000..d563adb90c
--- /dev/null
+++ b/public/images/flags/za.svg
@@ -0,0 +1,17 @@
+
diff --git a/public/images/flags/zm.svg b/public/images/flags/zm.svg
new file mode 100644
index 0000000000..13239f5e23
--- /dev/null
+++ b/public/images/flags/zm.svg
@@ -0,0 +1,27 @@
+
diff --git a/public/images/flags/zw.svg b/public/images/flags/zw.svg
new file mode 100644
index 0000000000..6399ab4ab3
--- /dev/null
+++ b/public/images/flags/zw.svg
@@ -0,0 +1,21 @@
+
diff --git a/public/images/openai-badge.svg b/public/images/openai-badge.svg
new file mode 100644
index 0000000000..cc72d9c62e
--- /dev/null
+++ b/public/images/openai-badge.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/apps/code-editor/src/app/components/FileActions/components/EditorActions/EditorActions.js b/src/apps/code-editor/src/app/components/FileActions/components/EditorActions/EditorActions.js
index d2f6d33212..ec1caf87bb 100644
--- a/src/apps/code-editor/src/app/components/FileActions/components/EditorActions/EditorActions.js
+++ b/src/apps/code-editor/src/app/components/FileActions/components/EditorActions/EditorActions.js
@@ -4,7 +4,9 @@ import { Save } from "./Save";
import { Publish } from "./Publish";
import styles from "./EditorActions.less";
+import { usePermission } from "../../../../../../../../shell/hooks/use-permissions";
export const EditorActions = memo(function EditorActions(props) {
+ const canPublish = usePermission("PUBLISH");
return (
-
+ {canPublish && (
+
+ )}
);
});
diff --git a/src/apps/code-editor/src/app/components/FileList/FileList.js b/src/apps/code-editor/src/app/components/FileList/FileList.js
index f7c85c180c..823999426a 100644
--- a/src/apps/code-editor/src/app/components/FileList/FileList.js
+++ b/src/apps/code-editor/src/app/components/FileList/FileList.js
@@ -17,9 +17,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCloudUploadAlt } from "@fortawesome/free-solid-svg-icons";
import { resolvePathPart, publishFile } from "../../../store/files";
import { collapseNavItem } from "../../../store/navCode";
-
+import { usePermission } from "../../../../../../shell/hooks/use-permissions";
import styles from "./FileList.less";
export const FileList = memo(function FileList(props) {
+ const canPublish = usePermission("PUBLISH");
// const [branch, setBranch] = useState(props.branch);
const [shownFiles, setShownFiles] = useState(
props.navCode.tree.sort(byLabel)
@@ -47,14 +48,20 @@ export const FileList = memo(function FileList(props) {
};
const actions = [
- !file.isLive}
- onClick={(file) => props.dispatch(publishFile(file.ZUID, file.status))}
- />,
+ ...(canPublish
+ ? [
+ !file.isLive}
+ onClick={(file) =>
+ props.dispatch(publishFile(file.ZUID, file.status))
+ }
+ />,
+ ]
+ : []),
];
return (
diff --git a/src/apps/content-editor/src/app/ContentEditor.js b/src/apps/content-editor/src/app/ContentEditor.js
index 24a5e88ca3..3d22f11e63 100644
--- a/src/apps/content-editor/src/app/ContentEditor.js
+++ b/src/apps/content-editor/src/app/ContentEditor.js
@@ -31,6 +31,7 @@ import Analytics from "./views/Analytics";
import { ResizableContainer } from "../../../../shell/components/ResizeableContainer";
import { StagedChangesProvider } from "./views/ItemList/StagedChangesContext";
import { SelectedItemsProvider } from "./views/ItemList/SelectedItemsContext";
+import { TableSortProvider } from "./views/ItemList/TableSortProvider";
// Makes sure that other apps using legacy theme does not get affected with the palette
let customTheme = createTheme(legacyTheme, {
@@ -174,7 +175,9 @@ export default function ContentEditor() {
render={() => (
-
+
+
+
)}
diff --git a/src/apps/content-editor/src/app/components/APIEndpoints.tsx b/src/apps/content-editor/src/app/components/APIEndpoints.tsx
new file mode 100644
index 0000000000..52b43eff1d
--- /dev/null
+++ b/src/apps/content-editor/src/app/components/APIEndpoints.tsx
@@ -0,0 +1,89 @@
+import { useSelector } from "react-redux";
+import { useParams } from "react-router";
+import {
+ MenuList,
+ MenuItem,
+ ListItemIcon,
+ Typography,
+ Chip,
+} from "@mui/material";
+import { DesignServicesRounded, VisibilityRounded } from "@mui/icons-material";
+
+import { AppState } from "../../../../../shell/store/types";
+import { ContentItem } from "../../../../../shell/services/types";
+import { useGetDomainsQuery } from "../../../../../shell/services/accounts";
+import { ApiType } from "../../../../schema/src/app/components/ModelApi";
+
+type APIEndpointsProps = {
+ type: Extract;
+};
+export const APIEndpoints = ({ type }: APIEndpointsProps) => {
+ const { itemZUID } = useParams<{
+ itemZUID: string;
+ }>();
+ const item = useSelector(
+ (state: AppState) => state.content[itemZUID] as ContentItem
+ );
+ const instance = useSelector((state: AppState) => state.instance);
+ const { data: domains } = useGetDomainsQuery();
+
+ const apiTypeEndpointMap: Partial> = {
+ "quick-access": `/-/instant/${itemZUID}.json`,
+ "site-generators": item ? `/${item?.web?.path}/?toJSON` : "/?toJSON",
+ };
+
+ const liveDomain = domains?.find((domain) => domain.branch == "live");
+
+ return (
+
+
+ {liveDomain && (
+
+ )}
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/components/ContentBreadcrumbs.tsx b/src/apps/content-editor/src/app/components/ContentBreadcrumbs.tsx
index 3d4dcbd0ab..dfedaa2610 100644
--- a/src/apps/content-editor/src/app/components/ContentBreadcrumbs.tsx
+++ b/src/apps/content-editor/src/app/components/ContentBreadcrumbs.tsx
@@ -4,7 +4,7 @@ import {
useGetContentNavItemsQuery,
} from "../../../../../shell/services/instance";
import { Home } from "@zesty-io/material";
-import { useHistory, useParams } from "react-router";
+import { useHistory, useParams, useLocation } from "react-router";
import { useMemo } from "react";
import { ContentNavItem } from "../../../../../shell/services/types";
import { MODEL_ICON } from "../../../../../shell/constants";
@@ -18,8 +18,12 @@ export const ContentBreadcrumbs = () => {
itemZUID: string;
}>();
const history = useHistory();
+ const location = useLocation();
const breadcrumbData = useMemo(() => {
+ const isInMultipageTableView = !["new", "import"].includes(
+ location?.pathname?.split("/")?.pop()
+ );
let activeItem: ContentNavItem;
const crumbs = [];
@@ -52,6 +56,12 @@ export const ContentBreadcrumbs = () => {
parent = null;
}
}
+
+ if (!itemZUID && isInMultipageTableView) {
+ // Remove the model as a breadcrumb item when viewing in multipage table view
+ crumbs?.pop();
+ }
+
return crumbs.map((item) => ({
node: ,
onClick: () => {
@@ -62,7 +72,7 @@ export const ContentBreadcrumbs = () => {
}
},
}));
- }, [nav, itemZUID]);
+ }, [nav, itemZUID, modelZUID, location]);
return (
{
+ if (fields?.length) {
+ return fields.reduce((accu, curr) => {
+ if (
+ !curr.deletedAt &&
+ DYNAMIC_META_FIELD_NAMES.includes(curr.name.toLowerCase())
+ ) {
+ accu[curr.name] = curr;
+ }
+
+ return accu;
+ }, {});
+ }
+
+ return {};
+ }, [fields]);
+
const activeFields = useMemo(() => {
if (fields?.length) {
- return fields.filter((field) => !field.deletedAt);
+ return fields.filter(
+ (field) =>
+ !field.deletedAt &&
+ ![
+ "og_image",
+ "og_title",
+ "og_description",
+ "tc_title",
+ "tc_description",
+ ].includes(field.name)
+ );
}
return [];
@@ -201,6 +227,24 @@ export default memo(function Editor({
value: value,
});
+ if ("og_title" in metaFields) {
+ dispatch({
+ type: "SET_ITEM_DATA",
+ itemZUID,
+ key: "og_title",
+ value: value,
+ });
+ }
+
+ if ("tc_title" in metaFields) {
+ dispatch({
+ type: "SET_ITEM_DATA",
+ itemZUID,
+ key: "tc_title",
+ value: value,
+ });
+ }
+
// Datasets do not get path parts
if (model?.type !== "dataset") {
dispatch({
@@ -231,16 +275,44 @@ export default memo(function Editor({
}
if (firstContentField && firstContentField.name === name) {
+ // Remove tags and replace MS smart quotes with regular quotes
+ const cleanedValue = value
+ ?.replace(/<[^>]*>/g, "")
+ ?.replaceAll(/[\u2018\u2019\u201A]/gm, "'")
+ ?.replaceAll("’", "'")
+ ?.replaceAll(/[\u201C\u201D\u201E]/gm, '"')
+ ?.replaceAll("“", '"')
+ ?.replaceAll("”", '"')
+ ?.slice(0, 160);
+
dispatch({
type: "SET_ITEM_WEB",
itemZUID,
key: "metaDescription",
- value: value.replace(/<[^>]*>/g, "").slice(0, 160),
+ value: cleanedValue,
});
+
+ if ("og_description" in metaFields) {
+ dispatch({
+ type: "SET_ITEM_DATA",
+ itemZUID,
+ key: "og_description",
+ value: cleanedValue,
+ });
+ }
+
+ if ("tc_description" in metaFields) {
+ dispatch({
+ type: "SET_ITEM_DATA",
+ itemZUID,
+ key: "tc_description",
+ value: cleanedValue,
+ });
+ }
}
}
},
- [fieldErrors]
+ [fieldErrors, metaFields]
);
const applyDefaultValuesToItemData = useCallback(() => {
@@ -283,10 +355,6 @@ export default memo(function Editor({
return (
- {saveClicked && hasErrors && (
-
- )}
-
{activeFields.length ? (
activeFields.map((field) => {
return (
diff --git a/src/apps/content-editor/src/app/components/Editor/Editor.less b/src/apps/content-editor/src/app/components/Editor/Editor.less
index 60565bf620..e60308712b 100644
--- a/src/apps/content-editor/src/app/components/Editor/Editor.less
+++ b/src/apps/content-editor/src/app/components/Editor/Editor.less
@@ -3,7 +3,6 @@
.Fields {
display: flex;
flex-direction: column;
- height: 100%;
width: 100%;
scrollbar-width: none; /* FireFox */
diff --git a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx
index 886b96dc93..59ddaff608 100644
--- a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx
+++ b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx
@@ -282,6 +282,7 @@ export const Field = ({
case "text":
return (
setEditorType(value)}
+ value={value}
>
!!error)}
/>
diff --git a/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx b/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx
index 9be5237114..7e1032e3e4 100644
--- a/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx
+++ b/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx
@@ -282,7 +282,7 @@ const FieldLabel = memo(
{(!!customTooltip || settings?.settings?.tooltip) && (
diff --git a/src/apps/content-editor/src/app/components/Editor/FieldError.tsx b/src/apps/content-editor/src/app/components/Editor/FieldError.tsx
index f0746e7e59..dfb8a3b700 100644
--- a/src/apps/content-editor/src/app/components/Editor/FieldError.tsx
+++ b/src/apps/content-editor/src/app/components/Editor/FieldError.tsx
@@ -1,4 +1,10 @@
-import { useMemo, useRef, useEffect } from "react";
+import {
+ useMemo,
+ useRef,
+ useEffect,
+ forwardRef,
+ useImperativeHandle,
+} from "react";
import { Stack, Typography, Box, ThemeProvider } from "@mui/material";
import DangerousRoundedIcon from "@mui/icons-material/DangerousRounded";
import { theme } from "@zesty-io/material";
@@ -6,6 +12,15 @@ import { Error } from "./Field/FieldShell";
import { ContentModelField } from "../../../../../../shell/services/types";
import pluralizeWord from "../../../../../../utility/pluralizeWord";
+const SEO_FIELD_LABELS = {
+ metaDescription: "Meta Description",
+ metaTitle: "Meta Title",
+ metaKeywords: "Meta Keywords",
+ metaLinkText: "Navigation Title",
+ parentZUID: "Page Parent",
+ pathPart: "URL Path Part",
+};
+
type FieldErrorProps = {
errors: Record;
fields: ContentModelField[];
@@ -55,95 +70,115 @@ const getErrorMessage = (errors: Error) => {
return errorMessages;
};
-export const FieldError = ({ errors, fields }: FieldErrorProps) => {
- const errorContainerEl = useRef(null);
-
- // Scroll to the errors on mount
- useEffect(() => {
- errorContainerEl?.current?.scrollIntoView({
- behavior: "smooth",
- block: "center",
- inline: "center",
- });
- }, []);
-
- const fieldErrors = useMemo(() => {
- const errorMap = Object.entries(errors)?.map(([name, errorDetails]) => {
- const errorMessages = getErrorMessage(errorDetails);
-
- const fieldData = fields?.find((field) => field.name === name);
-
- return {
- label: fieldData?.label,
- errorMessages,
- sort: fieldData?.sort,
- ZUID: fieldData?.ZUID,
- };
- });
-
- return errorMap.sort((a, b) => a.sort - b.sort);
- }, [errors, fields]);
-
- const fieldsWithErrors = fieldErrors?.filter(
- (error) => error.errorMessages.length > 0
- );
-
- const handleErrorClick = (fieldZUID: string) => {
- const fieldElement = document.getElementById(fieldZUID);
- fieldElement?.scrollIntoView({ behavior: "smooth" });
- };
-
- return (
-
-
-
-
- Item cannot be saved due to invalid field values.
-
-
- Please correct the following {fieldsWithErrors?.length} field
- {fieldsWithErrors?.length > 1 && "s"} before saving:
-
-
- {fieldErrors?.map((error, index) => {
- if (error.errorMessages.length > 0) {
- return (
-
- handleErrorClick(error.ZUID)}
- >
- {error.label}
-
- {error.errorMessages.length === 1 ? (
- - {error.errorMessages[0]}
- ) : (
-
- {error.errorMessages.map((msg, idx) => (
- {msg}
- ))}
+export const FieldError = forwardRef(
+ ({ errors, fields }: FieldErrorProps, ref) => {
+ const errorContainerEl = useRef(null);
+
+ useImperativeHandle(
+ ref,
+ () => {
+ return {
+ scrollToErrors() {
+ errorContainerEl?.current?.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ inline: "center",
+ });
+ },
+ };
+ },
+ [errorContainerEl]
+ );
+
+ // Scroll to the errors on mount
+ useEffect(() => {
+ errorContainerEl?.current?.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ inline: "center",
+ });
+ }, []);
+
+ const fieldErrors = useMemo(() => {
+ const errorMap = Object.entries(errors)?.map(([name, errorDetails]) => {
+ const errorMessages = getErrorMessage(errorDetails);
+
+ const fieldData = fields?.find((field) => field.name === name);
+
+ return {
+ label:
+ fieldData?.label ||
+ SEO_FIELD_LABELS[name as keyof typeof SEO_FIELD_LABELS],
+ errorMessages,
+ sort: fieldData?.sort,
+ ZUID: fieldData?.ZUID || name,
+ };
+ });
+
+ return errorMap.sort((a, b) => a.sort - b.sort);
+ }, [errors, fields]);
+
+ const fieldsWithErrors = fieldErrors?.filter(
+ (error) => error.errorMessages.length > 0
+ );
+
+ const handleErrorClick = (fieldZUID: string) => {
+ const fieldElement = document.getElementById(fieldZUID);
+ fieldElement?.scrollIntoView({ behavior: "smooth" });
+ };
+
+ return (
+
+
+
+
+ Item cannot be saved due to invalid field values.
+
+
+ Please correct the following {fieldsWithErrors?.length} field
+ {fieldsWithErrors?.length > 1 && "s"} before saving:
+
+
+ {fieldErrors?.map((error, index) => {
+ if (error.errorMessages.length > 0) {
+ return (
+
+ handleErrorClick(error.ZUID)}
+ >
+ {error.label}
- )}
-
- );
- }
- })}
-
-
-
- );
-};
+ {error.errorMessages.length === 1 ? (
+ - {error.errorMessages[0]}
+ ) : (
+
+ {error.errorMessages.map((msg, idx) => (
+ {msg}
+ ))}
+
+ )}
+
+ );
+ }
+ })}
+
+
+
+ );
+ }
+);
diff --git a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx
index 6ea4f8af00..56ab43a4ec 100644
--- a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx
+++ b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx
@@ -1,4 +1,11 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ useImperativeHandle,
+ forwardRef,
+} from "react";
import { useDispatch, useSelector } from "react-redux";
import {
Box,
@@ -26,7 +33,7 @@ import {
} from "@mui/icons-material";
import { alpha } from "@mui/material/styles";
import { CompactView, Modal, Login } from "@bynder/compact-view";
-import { Bynder } from "@zesty-io/material";
+import { Bynder, FileReplace } from "@zesty-io/material";
import {
useGetBinsQuery,
@@ -43,6 +50,8 @@ import styles from "../../../../media/src/app/components/Thumbnail/Loading.less"
import cx from "classnames";
import { FileTypePreview } from "../../../../media/src/app/components/FileModal/FileTypePreview";
import { useGetInstanceSettingsQuery } from "../../../../../shell/services/instance";
+import { ReplaceFileModal } from "../../../../media/src/app/components/FileModal/ReplaceFileModal";
+import { showReportDialog } from "@sentry/react";
type FieldTypeMediaProps = {
images: string[];
@@ -53,283 +62,520 @@ type FieldTypeMediaProps = {
hasError?: boolean;
hideDrag?: boolean;
lockedToGroupId: string | null;
+ settings?: any;
};
-export const FieldTypeMedia = ({
- images,
- limit,
- openMediaBrowser,
- onChange,
- name,
- hasError,
- hideDrag,
- lockedToGroupId,
-}: FieldTypeMediaProps) => {
- const [draggedIndex, setDraggedIndex] = useState(null);
- const [hoveredIndex, setHoveredIndex] = useState(null);
- const [localImageZUIDs, setLocalImageZUIDs] = useState(images);
- const instanceId = useSelector((state: any) => state.instance.ID);
- const ecoId = useSelector((state: any) => state.instance.ecoID);
- const { data: bins } = useGetBinsQuery({ instanceId, ecoId });
- const defaultBin = bins?.find((bin) => bin.default);
- const dispatch = useDispatch();
- const [showFileModal, setShowFileModal] = useState("");
- const [imageToReplace, setImageToReplace] = useState("");
- const [isBynderOpen, setIsBynderOpen] = useState(false);
- const { data: rawInstanceSettings } = useGetInstanceSettingsQuery();
-
- const bynderPortalUrlSetting = rawInstanceSettings?.find(
- (setting) => setting.key === "bynder_portal_url"
- );
- const bynderTokenSetting = rawInstanceSettings?.find(
- (setting) => setting.key === "bynder_token"
- );
- // Checks if the bynder portal and token are set
- const isBynderSessionValid =
- localStorage.getItem("cvrt") && localStorage.getItem("cvad");
+export const FieldTypeMedia = forwardRef(
+ (
+ {
+ images,
+ limit,
+ openMediaBrowser,
+ onChange,
+ name,
+ hasError,
+ hideDrag,
+ lockedToGroupId,
+ settings,
+ }: FieldTypeMediaProps,
+ ref
+ ) => {
+ const [draggedIndex, setDraggedIndex] = useState(null);
+ const [hoveredIndex, setHoveredIndex] = useState(null);
+ const [localImageZUIDs, setLocalImageZUIDs] = useState(images);
+ const instanceId = useSelector((state: any) => state.instance.ID);
+ const ecoId = useSelector((state: any) => state.instance.ecoID);
+ const { data: bins } = useGetBinsQuery({ instanceId, ecoId });
+ const defaultBin = bins?.find((bin) => bin.default);
+ const dispatch = useDispatch();
+ const [showFileModal, setShowFileModal] = useState("");
+ const [imageToReplace, setImageToReplace] = useState("");
+ const [isBynderOpen, setIsBynderOpen] = useState(false);
+ const { data: rawInstanceSettings } = useGetInstanceSettingsQuery();
+ const [selectionError, setSelectionError] = useState("");
+
+ const bynderPortalUrlSetting = rawInstanceSettings?.find(
+ (setting) => setting.key === "bynder_portal_url"
+ );
+ const bynderTokenSetting = rawInstanceSettings?.find(
+ (setting) => setting.key === "bynder_token"
+ );
+ // Checks if the bynder portal and token are set
+ const isBynderSessionValid =
+ localStorage.getItem("cvrt") && localStorage.getItem("cvad");
+
+ useEffect(() => {
+ setLocalImageZUIDs(images);
+ }, [images]);
+
+ useEffect(() => {
+ if (bynderPortalUrlSetting?.value) {
+ localStorage.setItem("cvad", bynderPortalUrlSetting.value);
+ } else {
+ localStorage.removeItem("cvad");
+ }
+ }, [bynderPortalUrlSetting]);
- useEffect(() => {
- setLocalImageZUIDs(images);
- }, [images]);
+ useEffect(() => {
+ if (bynderTokenSetting?.value) {
+ localStorage.setItem("cvrt", bynderTokenSetting.value);
+ } else {
+ localStorage.removeItem("cvrt");
+ }
+ }, [bynderTokenSetting]);
+
+ useImperativeHandle(ref, () => ({
+ triggerOpenMediaBrowser() {
+ openMediaBrowser({
+ limit,
+ callback: addZestyImage,
+ });
+ },
+ }));
+
+ const addZestyImage = (selectedImages: any[]) => {
+ const removedImages: any[] = [];
+ const filteredSelectedImages = selectedImages?.filter((selectedImage) => {
+ //remove any images that do not match the file extension
+ if (settings?.fileExtensions) {
+ if (
+ settings?.fileExtensions?.includes(
+ `.${fileExtension(selectedImage.filename)}`
+ )
+ ) {
+ return true;
+ } else {
+ removedImages.push(selectedImage);
+ return false;
+ }
+ } else {
+ return true;
+ }
+ });
- useEffect(() => {
- if (bynderPortalUrlSetting?.value) {
- localStorage.setItem("cvad", bynderPortalUrlSetting.value);
- } else {
- localStorage.removeItem("cvad");
- }
- }, [bynderPortalUrlSetting]);
+ if (removedImages.length) {
+ const filenames = removedImages.map((image) => image.filename);
+ const formattedFilenames =
+ filenames.length > 1
+ ? filenames.slice(0, -1).join(", ") + " and " + filenames.slice(-1)
+ : filenames[0];
+
+ setSelectionError(
+ `Could not add ${formattedFilenames}. ${settings?.fileExtensionsErrorMessage}`
+ );
+ } else {
+ setSelectionError("");
+ }
- useEffect(() => {
- if (bynderTokenSetting?.value) {
- localStorage.setItem("cvrt", bynderTokenSetting.value);
- } else {
- localStorage.removeItem("cvrt");
- }
- }, [bynderTokenSetting]);
+ const newImageZUIDs = filteredSelectedImages?.map((image) => image.id);
- const addZestyImage = (selectedImages: any[]) => {
- const newImageZUIDs = selectedImages.map((image) => image.id);
- // remove any duplicates
- const filteredImageZUIDs = newImageZUIDs.filter(
- (zuid) => !images.includes(zuid)
- );
+ // remove any duplicates
+ const filteredImageZUIDs = newImageZUIDs.filter(
+ (zuid) => !images.includes(zuid)
+ );
- onChange([...images, ...filteredImageZUIDs].join(","), name);
- };
+ // Do not trigger onChange if no images are added
+ if (![...images, ...filteredImageZUIDs]?.length) return;
- const addBynderAsset = (selectedAsset: any[]) => {
- if (images.length > limit) return;
+ onChange([...images, ...filteredImageZUIDs].join(","), name);
+ };
- const newBynderAssets = selectedAsset
- .slice(0, limit - images.length)
- .map((asset) => asset.originalUrl);
- const filteredBynderAssets = newBynderAssets.filter(
- (asset) => !images.includes(asset)
- );
+ const addBynderAsset = (selectedAsset: any[]) => {
+ if (images.length > limit) return;
- onChange([...images, ...filteredBynderAssets].join(","), name);
- };
+ const removedAssets: any[] = [];
+ const filteredBynderAssets = selectedAsset?.filter((asset) => {
+ if (settings?.fileExtensions) {
+ const assetExtension = `.${asset.extensions[0]}`;
+ if (settings?.fileExtensions?.includes(assetExtension)) {
+ return true;
+ } else {
+ removedAssets.push(asset);
+ return false;
+ }
+ } else {
+ return true;
+ }
+ });
- const removeImage = (imageId: string) => {
- const newImageZUIDs = images.filter((image) => image !== imageId);
+ if (removedAssets.length) {
+ const filenames = removedAssets.map((asset) => asset.name);
+ const formattedFilenames =
+ filenames.length > 1
+ ? filenames.slice(0, -1).join(", ") + " and " + filenames.slice(-1)
+ : filenames[0];
+
+ setSelectionError(
+ `Could not add ${formattedFilenames}. ${settings?.fileExtensionsErrorMessage}`
+ );
+ } else {
+ setSelectionError("");
+ }
- onChange(newImageZUIDs.join(","), name);
- };
+ const newBynderAssets = filteredBynderAssets
+ .slice(0, limit - images.length)
+ .map((asset) => asset.originalUrl);
+ const filteredBynderAssetsUrls = newBynderAssets.filter(
+ (asset) => !images.includes(asset)
+ );
- const replaceImage = (images: any[]) => {
- const imageZUID = images.map((image) => image.id)?.[0];
- let imageToReplace: string;
- setImageToReplace((value: string) => {
- imageToReplace = value;
- return "";
- });
- // if selected replacement image is already in the list of images, do nothing
- if (localImageZUIDs.includes(imageZUID)) return;
- const newImageZUIDs = localImageZUIDs.map((zuid) => {
- if (zuid === imageToReplace) {
- return imageZUID;
- }
+ onChange([...images, ...filteredBynderAssetsUrls].join(","), name);
+ };
- return zuid;
- });
- onChange(newImageZUIDs.join(","), name);
- };
+ const removeImage = (imageId: string) => {
+ const newImageZUIDs = images.filter((image) => image !== imageId);
- const replaceBynderAsset = (selectedAsset: any) => {
- // Prevent adding bynder asset that has already been added
- if (localImageZUIDs.includes(selectedAsset.originalUrl)) return;
+ onChange(newImageZUIDs.join(","), name);
+ };
- const newImages = localImageZUIDs.map((image) => {
- if (image === imageToReplace) {
- return selectedAsset.originalUrl;
+ const replaceImage = (images: any[]) => {
+ const imageZUID = images.map((image) => image.id)?.[0];
+ let imageToReplace: string;
+ setImageToReplace((value: string) => {
+ imageToReplace = value;
+ return "";
+ });
+ // if selected replacement image is already in the list of images, do nothing
+ if (localImageZUIDs.includes(imageZUID)) return;
+ // if extension is not allowed set error message
+ if (settings?.fileExtensions) {
+ if (
+ !settings?.fileExtensions?.includes(
+ `.${fileExtension(images[0].filename)}`
+ )
+ ) {
+ setSelectionError(
+ `Could not replace. ${settings?.fileExtensionsErrorMessage}`
+ );
+ return;
+ } else {
+ setSelectionError("");
+ }
}
+ const newImageZUIDs = localImageZUIDs.map((zuid) => {
+ if (zuid === imageToReplace) {
+ return imageZUID;
+ }
- return image;
- });
+ return zuid;
+ });
- setImageToReplace("");
- onChange(newImages.join(","), name);
- };
+ onChange(newImageZUIDs.join(","), name);
+ };
+
+ const replaceBynderAsset = (selectedAsset: any) => {
+ // Prevent adding bynder asset that has already been added
+ if (localImageZUIDs.includes(selectedAsset.originalUrl)) return;
+
+ const assetExtension = `.${selectedAsset.extensions[0]}`;
+ if (
+ settings?.fileExtensions &&
+ !settings?.fileExtensions?.includes(assetExtension)
+ ) {
+ setSelectionError(
+ `Could not replace. ${settings?.fileExtensionsErrorMessage}`
+ );
+ return;
+ } else {
+ setSelectionError("");
+ }
- const onDrop = useCallback(
- (acceptedFiles: File[]) => {
- if (!defaultBin) return;
+ const newImages = localImageZUIDs.map((image) => {
+ if (image === imageToReplace) {
+ return selectedAsset.originalUrl;
+ }
- openMediaBrowser({
- limit,
- callback: addZestyImage,
+ return image;
});
- dispatch(
- fileUploadStage(
- acceptedFiles.map((file) => {
- return {
- file,
- bin_id: defaultBin.id,
- group_id: lockedToGroupId ? lockedToGroupId : defaultBin.id,
- };
- })
- )
- );
- },
- [defaultBin, dispatch, addZestyImage]
- );
-
- const handleReorder = () => {
- const newLocalImages = [...localImageZUIDs];
- const draggedField = newLocalImages[draggedIndex];
- newLocalImages.splice(draggedIndex, 1);
- newLocalImages.splice(hoveredIndex, 0, draggedField);
-
- setDraggedIndex(null);
- setHoveredIndex(null);
- setLocalImageZUIDs(newLocalImages);
- onChange(newLocalImages.join(","), name);
- };
+ setImageToReplace("");
+ onChange(newImages.join(","), name);
+ };
+
+ const onDrop = useCallback(
+ (acceptedFiles: File[]) => {
+ if (!defaultBin) return;
+
+ openMediaBrowser({
+ limit,
+ callback: addZestyImage,
+ });
+
+ dispatch(
+ fileUploadStage(
+ acceptedFiles.map((file) => {
+ return {
+ file,
+ bin_id: defaultBin.id,
+ group_id: lockedToGroupId ? lockedToGroupId : defaultBin.id,
+ };
+ })
+ )
+ );
+ },
+ [defaultBin, dispatch, addZestyImage]
+ );
- const sortedImages = useMemo(() => {
- if (draggedIndex === null || hoveredIndex === null) {
- return localImageZUIDs;
- } else {
- const newImages = [...localImageZUIDs];
- const draggedImage = newImages[draggedIndex];
- newImages.splice(draggedIndex, 1);
- newImages.splice(hoveredIndex, 0, draggedImage);
- return newImages;
- }
- }, [draggedIndex, hoveredIndex, localImageZUIDs]);
+ const handleReorder = () => {
+ const newLocalImages = [...localImageZUIDs];
+ const draggedField = newLocalImages[draggedIndex];
+ newLocalImages.splice(draggedIndex, 1);
+ newLocalImages.splice(hoveredIndex, 0, draggedField);
+
+ setDraggedIndex(null);
+ setHoveredIndex(null);
+ setLocalImageZUIDs(newLocalImages);
+ onChange(newLocalImages.join(","), name);
+ };
+
+ const sortedImages = useMemo(() => {
+ if (draggedIndex === null || hoveredIndex === null) {
+ return localImageZUIDs;
+ } else {
+ const newImages = [...localImageZUIDs];
+ const draggedImage = newImages[draggedIndex];
+ newImages.splice(draggedIndex, 1);
+ newImages.splice(hoveredIndex, 0, draggedImage);
+ return newImages;
+ }
+ }, [draggedIndex, hoveredIndex, localImageZUIDs]);
- const { getRootProps, getInputProps, open, isDragActive } = useDropzone({
- onDrop,
- });
+ const { getRootProps, getInputProps, open, isDragActive } = useDropzone({
+ onDrop,
+ });
- if (!images?.length)
- return (
- <>
- evt.stopPropagation(),
- onKeyDown: (evt) => evt.stopPropagation(),
- })}
- >
-
-
`1px dashed ${theme.palette.primary.main}`,
- borderRadius: "8px",
- backgroundColor: (theme) =>
- alpha(theme.palette.primary.main, 0.04),
- borderColor: hasError ? "error.main" : "primary.main",
- }}
+ if (!images?.length)
+ return (
+ <>
+ evt.stopPropagation(),
+ onKeyDown: (evt) => evt.stopPropagation(),
+ })}
>
-
- {isDragActive ? (
-
- ) : (
-
- )}
-
+
+ `1px dashed ${theme.palette.primary.main}`,
+ borderRadius: "8px",
+ backgroundColor: (theme) =>
+ alpha(theme.palette.primary.main, 0.04),
+ borderColor: hasError ? "error.main" : "primary.main",
+ }}
+ >
+
{isDragActive ? (
- "Drop your files here to Upload"
+
) : (
- <>
- Drag and drop your files here
or
- >
+
)}
-
- {!isDragActive && (
-
- }
- fullWidth
- sx={{
- maxWidth: "196px",
- flexShrink: 0,
- }}
- >
- Upload
-
- }
- variant="outlined"
- onClick={() => {
- openMediaBrowser({
- limit,
- callback: addZestyImage,
- });
- }}
- sx={{
- maxWidth: "196px",
- flexShrink: 0,
- }}
+ {isDragActive ? (
+ "Drop your files here to Upload"
+ ) : (
+ <>
+ Drag and drop your files here
or
+ >
+ )}
+
+ {!isDragActive && (
+
- Add from Media
-
- {isBynderSessionValid && (
- )}
-
+ }
+ variant="outlined"
+ onClick={() => {
+ openMediaBrowser({
+ limit,
+ callback: addZestyImage,
+ });
+ }}
+ sx={{
+ maxWidth: "196px",
+ flexShrink: 0,
+ }}
+ >
+ Add from Media
+
+ {isBynderSessionValid && (
+
+ )}
+
+ )}
+
+
+ {selectionError && (
+
+ {selectionError}
+
+ )}
+
+ setIsBynderOpen(false)}>
+
+ {
+ if (assets?.length) {
+ addBynderAsset(assets);
+ setIsBynderOpen(false);
+ }
+ }}
+ />
+
+
+ >
+ );
+
+ return (
+ <>
+
+ hasError ? `1px solid ${theme.palette.error.main}` : "none",
+ }}
+ >
+ {sortedImages.map((image, index) => {
+ const isBynderAsset = image.includes("bynder.com");
+
+ return (
+ setShowFileModal(imageZUID)}
+ onRemove={removeImage}
+ onReplace={(imageZUID) => {
+ setImageToReplace(imageZUID);
+
+ if (isBynderAsset) {
+ setIsBynderOpen(true);
+ } else {
+ openMediaBrowser({
+ callback: replaceImage,
+ isReplace: true,
+ });
+ }
+ }}
+ hideDrag={hideDrag || limit === 1}
+ isBynderAsset={isBynderAsset}
+ isBynderSessionValid={!!isBynderSessionValid}
+ />
+ );
+ })}
+ {limit > images.length && (
+
+ {!isBynderSessionValid && (
+ }
+ fullWidth
+ >
+ Upload
+
)}
-
-
-
+
+ {isBynderSessionValid && (
+
+ )}
+
+ )}
+
+ {selectionError && (
+
+ {selectionError}
+
+ )}
+ {showFileModal && (
+ setShowFileModal("")}
+ currentFiles={
+ sortedImages?.filter(
+ (image) => typeof image === "string"
+ ) as string[]
+ }
+ onFileChange={(fileId) => {
+ setShowFileModal(fileId);
+ }}
+ />
+ )}
setIsBynderOpen(false)}>
{
if (assets?.length) {
- addBynderAsset(assets);
+ if (imageToReplace) {
+ replaceBynderAsset(assets[0]);
+ } else {
+ addBynderAsset(assets);
+ }
+
setIsBynderOpen(false);
}
}}
@@ -338,123 +584,8 @@ export const FieldTypeMedia = ({
>
);
-
- return (
- <>
-
- hasError ? `1px solid ${theme.palette.error.main}` : "none",
- }}
- >
- {sortedImages.map((image, index) => {
- const isBynderAsset = image.includes("bynder.com");
-
- return (
- setShowFileModal(imageZUID)}
- onRemove={removeImage}
- onReplace={(imageZUID) => {
- setImageToReplace(imageZUID);
-
- if (isBynderAsset) {
- setIsBynderOpen(true);
- } else {
- openMediaBrowser({
- callback: replaceImage,
- isReplace: true,
- });
- }
- }}
- hideDrag={hideDrag || limit === 1}
- isBynderAsset={isBynderAsset}
- isBynderSessionValid={!!isBynderSessionValid}
- />
- );
- })}
- {limit > images.length && (
-
- {!isBynderSessionValid && (
- }
- fullWidth
- >
- Upload
-
- )}
-
- {isBynderSessionValid && (
-
- )}
-
- )}
-
- {showFileModal && (
- setShowFileModal("")}
- currentFiles={
- sortedImages?.filter(
- (image) => typeof image === "string"
- ) as string[]
- }
- onFileChange={(fileId) => {
- setShowFileModal(fileId);
- }}
- />
- )}
- setIsBynderOpen(false)}>
-
- {
- if (assets?.length) {
- if (imageToReplace) {
- replaceBynderAsset(assets[0]);
- } else {
- addBynderAsset(assets);
- }
-
- setIsBynderOpen(false);
- }
- }}
- />
-
-
- >
- );
-};
+ }
+);
type MediaItemProps = {
imageZUID: string;
@@ -462,14 +593,15 @@ type MediaItemProps = {
setDraggedIndex?: (index: number) => void;
setHoveredIndex?: (index: number) => void;
index: number;
- onPreview: (imageZUID: string) => void;
- onRemove: (imageZUID: string) => void;
- onReplace: (imageZUID: string) => void;
+ onPreview?: (imageZUID: string) => void;
+ onRemove?: (imageZUID: string) => void;
+ onReplace?: (imageZUID: string) => void;
hideDrag?: boolean;
isBynderAsset: boolean;
isBynderSessionValid: boolean;
+ hideActionButtons?: boolean;
};
-const MediaItem = ({
+export const MediaItem = ({
imageZUID,
onReorder,
setDraggedIndex,
@@ -481,6 +613,7 @@ const MediaItem = ({
hideDrag,
isBynderAsset,
isBynderSessionValid,
+ hideActionButtons,
}: MediaItemProps) => {
const [isDragging, setIsDragging] = useState(false);
const [isDraggable, setIsDraggable] = useState(false);
@@ -489,6 +622,7 @@ const MediaItem = ({
skip: imageZUID?.substr(0, 4) === "http",
});
const [showRenameFileModal, setShowRenameFileModal] = useState(false);
+ const [isReplaceFileModalOpen, setIsReplaceFileModalOpen] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const [isCopiedZuid, setIsCopiedZuid] = useState(false);
const [newFilename, setNewFilename] = useState("");
@@ -587,7 +721,7 @@ const MediaItem = ({
onClick={() => {
if (isURL) return;
- onPreview(imageZUID);
+ onPreview && onPreview(imageZUID);
}}
alignItems="center"
sx={{
@@ -649,6 +783,7 @@ const MediaItem = ({
)}
@@ -656,7 +791,9 @@ const MediaItem = ({
)}
-
- {!isBynderAsset || (isBynderAsset && isBynderSessionValid) ? (
-
+ {!hideActionButtons && (
+
+ {!isBynderAsset || (isBynderAsset && isBynderSessionValid) ? (
+
+ {
+ event.stopPropagation();
+ onReplace && onReplace(imageZUID);
+ }}
+ >
+
+
+
+ ) : (
+ <>>
+ )}
+ {!isURL && (
+
+
+
+
+
+ )}
+
{
event.stopPropagation();
- onReplace(imageZUID);
+ setAnchorEl(event.currentTarget);
}}
>
-
+
- ) : (
- <>>
- )}
- {!isURL && (
-
-
-
-
-
- )}
-
- {
+
-
-
-
+
+
+ )}
{showRenameFileModal && (
@@ -806,6 +957,13 @@ const MediaItem = ({
extension={fileExtension(data.filename)}
/>
)}
+ {isReplaceFileModalOpen && (
+ setIsReplaceFileModalOpen(false)}
+ onCancel={() => setIsReplaceFileModalOpen(false)}
+ />
+ )}
>
);
};
diff --git a/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx b/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx
index 50a6889c6a..f55257c2c4 100644
--- a/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx
+++ b/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx
@@ -44,12 +44,21 @@ export default memo(function PendingEditsModal(props: PendingEditsModalProps) {
switch (action) {
case "save":
setLoading(true);
- props.onSave().then(() => {
- setLoading(false);
- setOpen(false);
- // @ts-ignore
- answer(true);
- });
+ props
+ .onSave()
+ .then((i) => {
+ // @ts-ignore
+ answer(true);
+ })
+ .catch((err) => {
+ console.error(err);
+ // @ts-ignore
+ answer(false);
+ })
+ .finally(() => {
+ setLoading(false);
+ setOpen(false);
+ });
break;
case "delete":
setLoading(true);
diff --git a/src/apps/content-editor/src/app/views/Analytics/components/AnalyticsPropertySelector.tsx b/src/apps/content-editor/src/app/views/Analytics/components/AnalyticsPropertySelector.tsx
index 8a87b9525b..be65cded50 100644
--- a/src/apps/content-editor/src/app/views/Analytics/components/AnalyticsPropertySelector.tsx
+++ b/src/apps/content-editor/src/app/views/Analytics/components/AnalyticsPropertySelector.tsx
@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { Box, Button, Link, Skeleton } from "@mui/material";
import SettingsIcon from "@mui/icons-material/Settings";
-import { useGetAnalyticsPropertiesQuery } from "../../../../../../../shell/services/cloudFunctions";
+import { useGetAnalyticsPropertiesQuery } from "../../../../../../../shell/services/analytics";
import { useGetInstanceSettingsQuery } from "../../../../../../../shell/services/instance";
import { PropertiesDialog } from "./PropertiesDialog";
diff --git a/src/apps/content-editor/src/app/views/Analytics/components/AuthView.tsx b/src/apps/content-editor/src/app/views/Analytics/components/AuthView.tsx
index b7520248c3..10f35d322d 100644
--- a/src/apps/content-editor/src/app/views/Analytics/components/AuthView.tsx
+++ b/src/apps/content-editor/src/app/views/Analytics/components/AuthView.tsx
@@ -29,7 +29,7 @@ export const AuthView = ({ validateAuth, isDashboard }: Props) => {
const receiveMessage = (event: MessageEvent) => {
if (
// @ts-ignore
- event.origin === CONFIG.CLOUD_FUNCTIONS_DOMAIN &&
+ event.origin === CONFIG.API_ANALYTICS &&
event.data.source === "zesty"
) {
if (event.data.status === 200) {
@@ -45,7 +45,7 @@ export const AuthView = ({ validateAuth, isDashboard }: Props) => {
tabWindow?.close();
tabWindow = window.open(
// @ts-ignore
- `${CONFIG.CLOUD_FUNCTIONS_DOMAIN}/authenticateGoogleAnalytics?user_id=${user.ID}&account_id=${instance.ID}`
+ `${CONFIG.API_ANALYTICS}/ga4/auth/connect?user_id=${user.ID}&account_id=${instance.ID}`
);
};
diff --git a/src/apps/content-editor/src/app/views/Analytics/components/PropertiesDialog.tsx b/src/apps/content-editor/src/app/views/Analytics/components/PropertiesDialog.tsx
index 823f27aea4..ec1dc8b07f 100644
--- a/src/apps/content-editor/src/app/views/Analytics/components/PropertiesDialog.tsx
+++ b/src/apps/content-editor/src/app/views/Analytics/components/PropertiesDialog.tsx
@@ -32,7 +32,7 @@ import googleIcon from "../../../../../../../../public/images/googleIcon.svg";
import {
useDisconnectGoogleAnalyticsMutation,
useGetAnalyticsPropertiesQuery,
-} from "../../../../../../../shell/services/cloudFunctions";
+} from "../../../../../../../shell/services/analytics";
import {
useCreateInstanceSettingsMutation,
useGetInstanceSettingsQuery,
@@ -98,7 +98,7 @@ export const PropertiesDialog = ({ onClose }: Props) => {
const receiveMessage = (event: MessageEvent) => {
if (
// @ts-ignore
- event.origin === CONFIG.CLOUD_FUNCTIONS_DOMAIN &&
+ event.origin === CONFIG.API_ANALYTICS &&
event.data.source === "zesty"
) {
if (event.data.status === 200) {
@@ -114,7 +114,7 @@ export const PropertiesDialog = ({ onClose }: Props) => {
tabWindow?.close();
tabWindow = window.open(
// @ts-ignore
- `${CONFIG.CLOUD_FUNCTIONS_DOMAIN}/authenticateGoogleAnalytics?user_id=${user.ID}&account_id=${instance.ID}`
+ `${CONFIG.API_ANALYTICS}/ga4/auth/connect?user_id=${user.ID}&account_id=${instance.ID}`
);
};
diff --git a/src/apps/content-editor/src/app/views/Analytics/index.tsx b/src/apps/content-editor/src/app/views/Analytics/index.tsx
index 067a6e7165..d0b426b157 100644
--- a/src/apps/content-editor/src/app/views/Analytics/index.tsx
+++ b/src/apps/content-editor/src/app/views/Analytics/index.tsx
@@ -3,7 +3,7 @@ import { Box } from "@mui/material";
import { ThemeProvider } from "@mui/material/styles";
import { theme } from "@zesty-io/material";
import { AuthView } from "./components/AuthView";
-import { useGetAnalyticsPropertiesQuery } from "../../../../../../shell/services/cloudFunctions";
+import { useGetAnalyticsPropertiesQuery } from "../../../../../../shell/services/analytics";
import SinglePageAnalytics from "./views/SinglePageAnalytics";
import AnalyticsDashboard from "./views/AnalyticsDashboard";
import { ContentItem } from "../../../../../../shell/services/types";
diff --git a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/GainersLosersWrapper.tsx b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/GainersLosersWrapper.tsx
index ca2d238e1f..581da2ca73 100644
--- a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/GainersLosersWrapper.tsx
+++ b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/GainersLosersWrapper.tsx
@@ -1,5 +1,5 @@
import { Moment } from "moment-timezone";
-import { useGetAnalyticsPagePathsByFilterQuery } from "../../../../../../../../../shell/services/cloudFunctions";
+import { useGetAnalyticsPagePathsByFilterQuery } from "../../../../../../../../../shell/services/analytics";
import { ItemsTableContent } from "./ItemsTable";
type Props = {
diff --git a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/ItemsTable.tsx b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/ItemsTable.tsx
index 4870b05c9e..c52858bbaf 100644
--- a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/ItemsTable.tsx
+++ b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/ItemsTable.tsx
@@ -11,7 +11,7 @@ import { DataGridPro, GridRenderCellParams } from "@mui/x-data-grid-pro";
import {
useGetAnalyticsPropertiesQuery,
useGetAnalyticsPropertyDataByQueryQuery,
-} from "../../../../../../../../../shell/services/cloudFunctions";
+} from "../../../../../../../../../shell/services/analytics";
import { Moment } from "moment-timezone";
import {
findTopDimensions,
diff --git a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/MostPopularWrapper.tsx b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/MostPopularWrapper.tsx
index b06373bd10..6210d16738 100644
--- a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/MostPopularWrapper.tsx
+++ b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/MostPopularWrapper.tsx
@@ -1,5 +1,5 @@
import { Moment } from "moment-timezone";
-import { useGetAnalyticsPropertyDataByQueryQuery } from "../../../../../../../../../shell/services/cloudFunctions";
+import { useGetAnalyticsPropertyDataByQueryQuery } from "../../../../../../../../../shell/services/analytics";
import { findTopDimensions, generateDateRangesForReport } from "../../../utils";
import { ItemsTableContent } from "./ItemsTable";
diff --git a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/index.tsx b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/index.tsx
index bf49192693..d256b7c794 100644
--- a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/index.tsx
+++ b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/index.tsx
@@ -22,7 +22,7 @@ import { AnalyticsDateFilter } from "../../components/AnalyticsDateFilter";
import { useSelector } from "react-redux";
import { AppState } from "../../../../../../../../shell/store/types";
import { Metric } from "../../components/Metric";
-import { useGetAnalyticsPropertyDataByQueryQuery } from "../../../../../../../../shell/services/cloudFunctions";
+import { useGetAnalyticsPropertyDataByQueryQuery } from "../../../../../../../../shell/services/analytics";
import {
convertSecondsToMinutesAndSeconds,
findValuesForDimensions,
diff --git a/src/apps/content-editor/src/app/views/Analytics/views/SinglePageAnalytics/index.tsx b/src/apps/content-editor/src/app/views/Analytics/views/SinglePageAnalytics/index.tsx
index 44ed372d35..44dc954c37 100644
--- a/src/apps/content-editor/src/app/views/Analytics/views/SinglePageAnalytics/index.tsx
+++ b/src/apps/content-editor/src/app/views/Analytics/views/SinglePageAnalytics/index.tsx
@@ -20,7 +20,7 @@ import {
import { UsersBarChart } from "./UsersBarChart";
import { useParams as useQueryParams } from "../../../../../../../../shell/hooks/useParams";
import { useHistory, useParams } from "react-router-dom";
-import { useGetAnalyticsPropertyDataByQueryQuery } from "../../../../../../../../shell/services/cloudFunctions";
+import { useGetAnalyticsPropertyDataByQueryQuery } from "../../../../../../../../shell/services/analytics";
import {
convertSecondsToMinutesAndSeconds,
findValuesForDimensions,
@@ -28,7 +28,7 @@ import {
getDateRangeAndLabelsFromParams,
} from "../../utils";
import { Metric } from "../../components/Metric";
-import { useGetAnalyticsPropertiesQuery } from "../../../../../../../../shell/services/cloudFunctions";
+import { useGetAnalyticsPropertiesQuery } from "../../../../../../../../shell/services/analytics";
import instanceZUID from "../../../../../../../../utility/instanceZUID";
import { NotFound } from "../../../../../../../../shell/components/NotFound";
import WarningRoundedIcon from "@mui/icons-material/WarningRounded";
diff --git a/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx b/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx
index 6259b29f40..04a9b6dca6 100644
--- a/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx
+++ b/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useState, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import useIsMounted from "ismounted";
import { useHistory, useParams } from "react-router-dom";
@@ -6,14 +6,13 @@ import isEmpty from "lodash/isEmpty";
import { createSelector } from "@reduxjs/toolkit";
import { cloneDeep } from "lodash";
-import { Divider, Box, Stack } from "@mui/material";
+import { Box, Stack, ThemeProvider, Button } from "@mui/material";
+import { theme, Brain } from "@zesty-io/material";
import { WithLoader } from "@zesty-io/core/WithLoader";
import { NotFound } from "../../../../../../shell/components/NotFound";
import { Header } from "./Header";
import { Editor } from "../../components/Editor";
-import { ItemSettings } from "../ItemEdit/Meta/ItemSettings";
-import { DataSettings } from "../ItemEdit/Meta/ItemSettings/DataSettings";
import { fetchFields } from "../../../../../../shell/store/fields";
import {
createItem,
@@ -35,6 +34,10 @@ import {
ContentModelField,
} from "../../../../../../shell/services/types";
import { SchedulePublish } from "../../../../../../shell/components/SchedulePublish";
+import { Meta } from "../ItemEdit/Meta";
+import { SocialMediaPreview } from "../ItemEdit/Meta/SocialMediaPreview";
+import { FieldError } from "../../components/Editor/FieldError";
+import { AIGeneratorProvider } from "../../../../../../shell/components/withAi/AIGeneratorProvider";
export type ActionAfterSave =
| ""
@@ -54,7 +57,7 @@ const selectSortedModelFields = createSelector(
.sort((a, b) => a.sort - b.sort)
);
-type FieldError = {
+type FieldErrors = {
[key: string]: Error;
};
@@ -77,8 +80,12 @@ export const ItemCreate = () => {
const [newItemZUID, setNewItemZUID] = useState();
const [isScheduleDialogOpen, setIsScheduleDialogOpen] = useState(false);
const [willRedirect, setWillRedirect] = useState(true);
- const [fieldErrors, setFieldErrors] = useState({});
+ const [fieldErrors, setFieldErrors] = useState({});
const [saveClicked, setSaveClicked] = useState(false);
+ // const [hasSEOErrors, setHasSEOErrors] = useState(false);
+ const [SEOErrors, setSEOErrors] = useState({});
+ const metaRef = useRef(null);
+ const fieldErrorRef = useRef(null);
const [
createPublishing,
@@ -91,7 +98,7 @@ export const ItemCreate = () => {
const {
isSuccess: isSuccessNewModelFields,
- isFetching: isFetchingNewModelFields,
+ isLoading: isFetchingNewModelFields,
} = useGetContentModelFieldsQuery(modelZUID);
// on mount and update modelZUID, load item fields
@@ -102,7 +109,14 @@ export const ItemCreate = () => {
// if item doesn't exist, generate a new one
useEffect(() => {
if (isEmpty(item) && !saving) {
- dispatch(generateItem(modelZUID));
+ const initialData = fields?.reduce((accu, curr) => {
+ if (!curr.deletedAt) {
+ accu[curr.name] = null;
+ }
+ return accu;
+ }, {});
+
+ dispatch(generateItem(modelZUID, initialData));
}
}, [modelZUID, item, saving]);
@@ -131,6 +145,27 @@ export const ItemCreate = () => {
return hasErrors;
}, [fieldErrors]);
+ const hasSEOErrors = useMemo(() => {
+ const hasErrors = Object.values(SEOErrors)
+ ?.map((error) => {
+ return Object.values(error) ?? [];
+ })
+ ?.flat()
+ .some((error) => !!error);
+
+ return hasErrors;
+ }, [SEOErrors]);
+
+ const activeFields = useMemo(() => {
+ if (fields?.length) {
+ return fields.filter(
+ (field) => !field.deletedAt && !["og_image"].includes(field.name)
+ );
+ }
+
+ return [];
+ }, [fields]);
+
const loadItemFields = async (modelZUID: string) => {
setLoading(true);
try {
@@ -148,12 +183,22 @@ export const ItemCreate = () => {
const save = async (action: ActionAfterSave) => {
setSaveClicked(true);
- if (hasErrors) return;
+ metaRef.current?.validateMetaFields?.();
+ if (hasErrors || hasSEOErrors) {
+ fieldErrorRef.current?.scrollToErrors?.();
+ return;
+ }
setSaving(true);
try {
- const res: any = await dispatch(createItem(modelZUID, itemZUID));
+ const res: any = await dispatch(
+ createItem({
+ modelZUID,
+ itemZUID,
+ skipPathPartValidation: model?.type === "dataset",
+ })
+ );
if (res.err || res.error) {
if (res.missingRequired || res.lackingCharLength) {
const missingRequiredFieldNames: string[] =
@@ -226,6 +271,7 @@ export const ItemCreate = () => {
setFieldErrors(errors);
// scroll to required field
+ fieldErrorRef.current?.scrollToErrors?.();
}
if (res.error) {
@@ -327,66 +373,108 @@ export const ItemCreate = () => {
}
message="Creating New Item"
>
-
+
-
-
- {
- setFieldErrors(errors);
- }}
- />
-
-
-
- Meta Settings
- {model && model?.type === "dataset" ? (
-
- ) : (
-
+ {saveClicked && (hasErrors || hasSEOErrors) && (
+
+
+
+ )}
+
+ {
+ setFieldErrors(errors);
+ }}
/>
- )}
+ {
+ setSEOErrors(errors);
+ }}
+ isSaving={saving}
+ ref={metaRef}
+ errors={SEOErrors}
+ />
+
-
-
+
+
+ {model?.type !== "dataset" && (
+ <>
+
+
+ >
+ )}
+
+
+
+
{isScheduleDialogOpen && !isLoadingNewItem && (
;
}
- const canPublish = usePermission("PUBLISH");
- const canDelete = usePermission("DELETE");
- const canUpdate = usePermission("UPDATE");
+ const canPublish = usePermission("PUBLISH", props.itemZUID);
+ const canDelete = usePermission("DELETE", props.itemZUID);
+ const canUpdate = usePermission("UPDATE", props.itemZUID);
const domain = useDomain();
const { publishing } = props.item;
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Content/Content.js b/src/apps/content-editor/src/app/views/ItemEdit/Content/Content.js
index b6c524c769..7af50192b2 100644
--- a/src/apps/content-editor/src/app/views/ItemEdit/Content/Content.js
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Content/Content.js
@@ -14,6 +14,7 @@ import { Actions } from "./Actions";
import { useLocalStorage } from "react-use";
import { useContext } from "react";
import { DuoModeContext } from "../../../../../../../shell/contexts/duoModeContext";
+import { FieldError } from "../../../components/Editor/FieldError";
export default function Content(props) {
const [showSidebar, setShowSidebar] = useLocalStorage(
@@ -71,6 +72,15 @@ export default function Content(props) {
flex="0 1 auto"
>
+ {props.saveClicked && props.hasErrors && (
+
+
+
+ )}
this.setState({ makeActive: "" })}
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/FreestyleWrapper.tsx b/src/apps/content-editor/src/app/views/ItemEdit/FreestyleWrapper.tsx
index e58728ade1..4bdd5ec16f 100644
--- a/src/apps/content-editor/src/app/views/ItemEdit/FreestyleWrapper.tsx
+++ b/src/apps/content-editor/src/app/views/ItemEdit/FreestyleWrapper.tsx
@@ -2,8 +2,9 @@ import Cookies from "js-cookie";
import { MutableRefObject, forwardRef, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { AppState } from "../../../../../../shell/store/types";
-import { useParams } from "react-router";
+import { useHistory, useParams } from "react-router";
import { withDAM } from "../../../../../../shell/components/withDAM";
+import { Button, Dialog } from "@mui/material";
const IframeComponent = forwardRef(
(props: any, ref: MutableRefObject) => {
@@ -19,6 +20,7 @@ export const FreestyleWrapper = () => {
itemZUID: string;
}>();
const iframeRef = useRef(null);
+ const history = useHistory();
const instance = useSelector((state: AppState) => state.instance);
const [sessionToken] = useState(Cookies.get(CONFIG.COOKIE_NAME));
@@ -39,14 +41,30 @@ export const FreestyleWrapper = () => {
);
};
+ const handleFreestyleExit = () => {
+ history.push(`/content/${modelZUID}/${itemZUID}`);
+ };
+
return (
-
+
);
};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js b/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js
index ace3b56c03..c64bfe5285 100644
--- a/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js
+++ b/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js
@@ -1,10 +1,18 @@
-import { Fragment, useEffect, useState, useMemo, createContext } from "react";
+import {
+ Fragment,
+ useEffect,
+ useState,
+ useMemo,
+ createContext,
+ useRef,
+} from "react";
import {
Switch,
Route,
Redirect,
useParams,
useHistory,
+ useLocation,
} from "react-router-dom";
import useIsMounted from "ismounted";
import { useDispatch, useSelector } from "react-redux";
@@ -27,7 +35,6 @@ import { WithLoader } from "@zesty-io/core/WithLoader";
import { PendingEditsModal } from "../../components/PendingEditsModal";
import { LockedItem } from "../../components/LockedItem";
import { Content } from "./Content";
-import { Meta } from "./Meta";
import { ItemHead } from "./ItemHead";
import { NotFound } from "../NotFound";
@@ -47,6 +54,9 @@ import {
import { DuoModeContext } from "../../../../../../shell/contexts/duoModeContext";
import { useLocalStorage } from "react-use";
import { FreestyleWrapper } from "./FreestyleWrapper";
+import { Meta } from "./Meta";
+import { FieldError } from "../../components/Editor/FieldError";
+import { AIGeneratorProvider } from "../../../../../../shell/components/withAi/AIGeneratorProvider";
const selectItemHeadTags = createSelector(
(state) => state.headTags,
@@ -70,7 +80,10 @@ export default function ItemEdit() {
const dispatch = useDispatch();
const history = useHistory();
const isMounted = useIsMounted();
+ const location = useLocation();
const { modelZUID, itemZUID } = useParams();
+ const metaRef = useRef(null);
+ const fieldErrorRef = useRef(null);
const item = useSelector((state) => state.content[itemZUID]);
const items = useSelector((state) => state.content);
const model = useSelector((state) => state.models[modelZUID]);
@@ -86,6 +99,9 @@ export default function ItemEdit() {
const [notFound, setNotFound] = useState("");
const [saveClicked, setSaveClicked] = useState(false);
const [fieldErrors, setFieldErrors] = useState({});
+ const [SEOErrors, setSEOErrors] = useState({});
+ // const [hasSEOErrors, setHasSEOErrors] = useState(false);
+ const [headerTitle, setHeaderTitle] = useState("");
const { data: fields, isLoading: isLoadingFields } =
useGetContentModelFieldsQuery(modelZUID);
const [showDuoModeLS, setShowDuoModeLS] = useLocalStorage(
@@ -96,12 +112,30 @@ export default function ItemEdit() {
const duoModeDisabled =
isFetching ||
instanceSettings?.find((setting) => {
+ // Makes sure that the CSP value is either empty or contains
+ // frame-ancestors 'self' zesty.io *.zesty.io anywhere in the value
+ const invalidCSPSettings =
+ setting.key === "content_security_policy" && !!setting.value
+ ? !setting.value.includes("frame-ancestors") ||
+ !setting.value.includes("'self'") ||
+ !(
+ setting.value.includes("zesty.io") ||
+ setting.value.includes("*.zesty.io")
+ )
+ : false;
+
// if any of these settings are present then DuoMode is unavailable
return (
(setting.key === "basic_content_api_key" && setting.value) ||
(setting.key === "headless_authorization_key" && setting.value) ||
(setting.key === "authorization_key" && setting.value) ||
- (setting.key === "x_frame_options" && setting.value)
+ (setting.key === "x_frame_options" &&
+ !!setting.value &&
+ setting.value !== "sameorigin") ||
+ (setting.key === "referrer_policy" &&
+ !!setting.value &&
+ setting.value !== "strict-origin-when-cross-origin") ||
+ invalidCSPSettings
);
}) ||
model?.type === "dataset";
@@ -127,6 +161,18 @@ export default function ItemEdit() {
};
}, [modelZUID, itemZUID]);
+ useEffect(() => {
+ if (!loading) {
+ setHeaderTitle(item?.web?.metaTitle || item?.web?.metaLinkText || "");
+ }
+ }, [loading]);
+
+ useEffect(() => {
+ setSaveClicked(false);
+ setFieldErrors({});
+ setSEOErrors({});
+ }, [location.pathname]);
+
const hasErrors = useMemo(() => {
const hasErrors = Object.values(fieldErrors)
?.map((error) => {
@@ -142,6 +188,27 @@ export default function ItemEdit() {
return hasErrors;
}, [fieldErrors]);
+ const hasSEOErrors = useMemo(() => {
+ const hasErrors = Object.values(SEOErrors)
+ ?.map((error) => {
+ return Object.values(error) ?? [];
+ })
+ ?.flat()
+ .some((error) => !!error);
+
+ return hasErrors;
+ }, [SEOErrors]);
+
+ const activeFields = useMemo(() => {
+ if (fields?.length) {
+ return fields.filter(
+ (field) => !field.deletedAt && !["og_image"].includes(field.name)
+ );
+ }
+
+ return [];
+ }, [fields]);
+
async function lockItem() {
setCheckingLock(true);
try {
@@ -227,11 +294,26 @@ export default function ItemEdit() {
async function save() {
setSaveClicked(true);
- if (hasErrors) return;
-
- setSaving(true);
try {
- const res = await dispatch(saveItem(itemZUID));
+ if (
+ hasErrors ||
+ hasSEOErrors ||
+ metaRef.current?.validateMetaFields?.()
+ ) {
+ throw new Error(`Cannot Save: ${item.web.metaTitle}`);
+ }
+
+ setSaving(true);
+
+ // Skip content item fields validation when in the meta tab since this
+ // means that the user only wants to update the meta fields
+ const res = await dispatch(
+ saveItem({
+ itemZUID,
+ skipContentItemValidation:
+ location?.pathname?.split("/")?.pop() === "meta",
+ })
+ );
if (res.err === "VALIDATION_ERROR") {
const missingRequiredFieldNames = res.missingRequired?.reduce(
(acc, curr) => {
@@ -299,7 +381,7 @@ export default function ItemEdit() {
}
setFieldErrors(errors);
- return;
+ throw new Error(errors);
}
if (res.status === 400) {
dispatch(
@@ -308,9 +390,10 @@ export default function ItemEdit() {
kind: "error",
})
);
- return;
+ throw new Error(`Cannot Save: ${item.web.metaTitle}`);
}
+ setHeaderTitle(item?.web?.metaTitle || item?.web?.metaLinkText || "");
dispatch(
notify({
message: `Item Saved: ${
@@ -322,12 +405,13 @@ export default function ItemEdit() {
// fetch new draft history
dispatch(fetchAuditTrailDrafting(itemZUID));
} catch (err) {
- console.error(err);
// we need to set the item to dirty again because the save failed
dispatch({
type: "MARK_ITEM_DIRTY",
itemZUID,
});
+ fieldErrorRef.current?.scrollToErrors?.();
+ throw new Error(err);
} finally {
if (isMounted.current) {
setSaving(false);
@@ -394,12 +478,18 @@ export default function ItemEdit() {
>
save().catch((err) => console.error(err))}
saving={saving}
hasError={Object.keys(fieldErrors)?.length}
+ headerTitle={headerTitle}
/>
(
-
+
+ {
+ setSEOErrors(errors);
+ }}
+ isSaving={saving}
+ errors={SEOErrors}
+ errorComponent={
+ saveClicked &&
+ hasSEOErrors && (
+
+ )
+ }
+ />
+
)}
/>
(
- {
- setFieldErrors(errors);
- }}
- fieldErrors={fieldErrors}
- hasErrors={hasErrors}
- />
+
+
+ save().catch((err) => console.error(err))
+ }
+ dispatch={dispatch}
+ loading={loading}
+ saving={saving}
+ saveClicked={saveClicked}
+ onUpdateFieldErrors={(errors) => {
+ setFieldErrors(errors);
+ }}
+ fieldErrors={fieldErrors}
+ hasErrors={hasErrors}
+ activeFields={activeFields}
+ fieldErrorRef={fieldErrorRef}
+ />
+
)}
/>
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/ItemHead/ItemHead.less b/src/apps/content-editor/src/app/views/ItemEdit/ItemHead/ItemHead.less
index 56a1012a2f..b9501ddb2c 100644
--- a/src/apps/content-editor/src/app/views/ItemEdit/ItemHead/ItemHead.less
+++ b/src/apps/content-editor/src/app/views/ItemEdit/ItemHead/ItemHead.less
@@ -5,6 +5,7 @@
flex: 1;
overflow: scroll;
background-color: #f9fafb;
+ height: 100%;
// .Head {
// display: flex;
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MatchedWords.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MatchedWords.tsx
new file mode 100644
index 0000000000..11065918a3
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MatchedWords.tsx
@@ -0,0 +1,105 @@
+import { Box, Stack, Typography, Chip } from "@mui/material";
+import { Check, AddRounded, RemoveRounded } from "@mui/icons-material";
+import { useMemo, useState } from "react";
+import { useSelector } from "react-redux";
+import { useParams } from "react-router";
+
+import { AppState } from "../../../../../../../../shell/store/types";
+import { cleanContent } from "./index";
+import { DYNAMIC_META_FIELD_NAMES } from "../index";
+
+type MatchedWordsProps = {
+ uniqueNonCommonWordsArray: string[];
+};
+export const MatchedWords = ({
+ uniqueNonCommonWordsArray,
+}: MatchedWordsProps) => {
+ const { itemZUID } = useParams<{
+ itemZUID: string;
+ }>();
+ const item = useSelector((state: AppState) => state.content[itemZUID]);
+ const [showAll, setShowAll] = useState(false);
+
+ const contentAndMetaWordMatches = useMemo(() => {
+ const textMetaFieldNames = [
+ "metaDescription",
+ "metaTitle",
+ "metaKeywords",
+ "pathPart",
+ ...DYNAMIC_META_FIELD_NAMES,
+ ];
+
+ if (
+ item?.web &&
+ Object.values(item.web)?.length &&
+ item?.data &&
+ Object.values(item.data)?.length &&
+ uniqueNonCommonWordsArray?.length
+ ) {
+ const metaWords = Object.entries({ ...item.web, ...item.data })?.reduce(
+ (accu: string[], [fieldName, value]) => {
+ if (textMetaFieldNames.includes(fieldName) && !!value) {
+ // Replace all new line characters with a space and remove all special characters
+ const cleanedValue = cleanContent(value);
+
+ accu = [...accu, ...cleanedValue?.split(" ")];
+
+ return accu;
+ }
+
+ return accu;
+ },
+ []
+ );
+
+ const uniqueMetaWords = Array.from(new Set(metaWords));
+
+ return uniqueMetaWords.filter((metaWord) =>
+ uniqueNonCommonWordsArray.includes(metaWord)
+ );
+ }
+
+ return [];
+ }, [uniqueNonCommonWordsArray, item?.web, item?.data]);
+
+ return (
+
+
+ Content and Meta Matched Words
+
+
+ {!contentAndMetaWordMatches?.length && (
+
+ No Matching Words
+
+ )}
+ {contentAndMetaWordMatches
+ ?.slice(0, showAll ? undefined : 9)
+ ?.map((word) => (
+ }
+ variant="outlined"
+ />
+ ))}
+ {contentAndMetaWordMatches?.length > 10 && (
+
+ ) : (
+
+ )
+ }
+ onClick={() => setShowAll(!showAll)}
+ />
+ )}
+
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MostMentionedWords.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MostMentionedWords.tsx
new file mode 100644
index 0000000000..0104ae3f4a
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MostMentionedWords.tsx
@@ -0,0 +1,124 @@
+import {
+ Box,
+ Stack,
+ Typography,
+ Chip,
+ TextField,
+ InputAdornment,
+} from "@mui/material";
+import { Search, AddRounded, RemoveRounded } from "@mui/icons-material";
+import { useMemo, useState } from "react";
+import { COMMON_WORDS } from ".";
+
+type MostMentionedWordsProps = {
+ wordsArray: string[];
+};
+export const MostMentionedWords = ({ wordsArray }: MostMentionedWordsProps) => {
+ const [filterKeyword, setFilterKeyword] = useState("");
+ const [showAll, setShowAll] = useState(false);
+
+ const wordCount = useMemo(() => {
+ if (!!wordsArray?.length) {
+ const wordsWithCount = wordsArray?.reduce(
+ (accu: Record, word) => {
+ if (!COMMON_WORDS.includes(word)) {
+ if (word in accu) {
+ accu[word] += 1;
+ } else {
+ accu[word] = 1;
+ }
+ }
+
+ return accu;
+ },
+ {}
+ );
+
+ return Object.entries(wordsWithCount ?? {})
+ ?.filter(([, count]) => count > 1)
+ ?.sort(([, a], [, b]) => b - a);
+ }
+
+ return [];
+ }, [wordsArray]);
+
+ const filteredWords = useMemo(() => {
+ if (!!filterKeyword) {
+ return wordCount?.filter(([word]) =>
+ word.includes(filterKeyword.toLowerCase().trim())
+ );
+ }
+
+ return wordCount;
+ }, [filterKeyword, wordCount]);
+
+ return (
+
+
+
+ Most Mentioned Words in Content Item
+
+
+ Check that your focus keywords are occurring a minimum 2 times.
+
+
+ setFilterKeyword(evt.target.value)}
+ size="small"
+ placeholder="Filter words"
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ />
+
+ {!filteredWords?.length && !filterKeyword && (
+
+ No words in your content item occur 2 or more times
+
+ )}
+ {filteredWords
+ ?.slice(0, showAll ? undefined : 9)
+ ?.map(([word, count]) => (
+
+ {word}
+
+ {count}
+
+ >
+ }
+ size="small"
+ variant="outlined"
+ />
+ ))}
+ {filteredWords?.length > 10 && (
+
+ ) : (
+
+ )
+ }
+ onClick={() => setShowAll(!showAll)}
+ />
+ )}
+
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx
new file mode 100644
index 0000000000..61619ab16f
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx
@@ -0,0 +1,62 @@
+import { Stack, Box, Typography, Divider } from "@mui/material";
+
+type WordCountProps = {
+ totalWords: number;
+ totalUniqueWords: number;
+ totalUniqueNonCommonWords: number;
+};
+export const WordCount = ({
+ totalWords,
+ totalUniqueWords,
+ totalUniqueNonCommonWords,
+}: WordCountProps) => {
+ return (
+
+
+
+ Words
+
+
+ {totalWords}
+
+
+
+
+
+ Unique Words
+
+
+ {totalUniqueWords}
+
+
+
+
+
+ Non Filler Words
+
+
+ {totalUniqueNonCommonWords}
+
+
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/index.tsx
new file mode 100644
index 0000000000..218b70f963
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/index.tsx
@@ -0,0 +1,247 @@
+import { useMemo, useState } from "react";
+import { useSelector } from "react-redux";
+import { useParams } from "react-router";
+
+import { AppState } from "../../../../../../../../shell/store/types";
+import { useGetContentModelFieldsQuery } from "../../../../../../../../shell/services/instance";
+import { WordCount } from "./WordCount";
+import { MatchedWords } from "./MatchedWords";
+import { MostMentionedWords } from "./MostMentionedWords";
+import { DYNAMIC_META_FIELD_NAMES } from "../index";
+
+export const COMMON_WORDS: Readonly = [
+ "null",
+ "1",
+ "2",
+ "3",
+ "4",
+ "5",
+ "6",
+ "7",
+ "8",
+ "9",
+ "0",
+ "our",
+ "one",
+ "two",
+ "three",
+ "four",
+ "five",
+ "I'm",
+ "that's",
+ "it's",
+ "aren't",
+ "we've",
+ "i've",
+ "didn't",
+ "don't",
+ "you'll",
+ "you're",
+ "we're",
+ "Here's",
+ "about",
+ "actually",
+ "always",
+ "even",
+ "given",
+ "into",
+ "just",
+ "not",
+ "Im",
+ "thats",
+ "its",
+ "arent",
+ "weve",
+ "ive",
+ "didnt",
+ "dont",
+ "the",
+ "of",
+ "to",
+ "and",
+ "a",
+ "in",
+ "is",
+ "it",
+ "you",
+ "that",
+ "he",
+ "was",
+ "for",
+ "on",
+ "are",
+ "with",
+ "as",
+ "I",
+ "his",
+ "they",
+ "be",
+ "at",
+ "one",
+ "have",
+ "this",
+ "from",
+ "or",
+ "had",
+ "by",
+ "but",
+ "some",
+ "what",
+ "there",
+ "we",
+ "can",
+ "out",
+ "were",
+ "all",
+ "your",
+ "when",
+ "up",
+ "use",
+ "how",
+ "said",
+ "an",
+ "each",
+ "she",
+ "which",
+ "do",
+ "their",
+ "if",
+ "will",
+ "way",
+ "many",
+ "then",
+ "them",
+ "would",
+ "like",
+ "so",
+ "these",
+ "her",
+ "see",
+ "him",
+ "has",
+ "more",
+ "could",
+ "go",
+ "come",
+ "did",
+ "my",
+ "no",
+ "get",
+ "me",
+ "say",
+ "too",
+ "here",
+ "must",
+ "such",
+ "try",
+ "us",
+ "own",
+ "oh",
+ "any",
+ "youll",
+ "youre",
+ "also",
+ "than",
+ "those",
+ "though",
+ "thing",
+ "things",
+] as const;
+
+const findMatch = (needle: string, haystack: string[]) => {
+ let truth = false;
+ haystack.forEach((word) => {
+ if (word.toLowerCase() == needle.toLowerCase()) truth = true;
+ });
+ return truth;
+};
+
+export const cleanContent = (string: string) => {
+ return string
+ ?.replaceAll(/(\d-(.*?)-(.*?))(,| )/gi, "") // Remove zuids
+ ?.replaceAll(/(<([^>]+)>)/gi, "") // Remove html tags
+ ?.replaceAll(/(&(.*?);)/gi, " ") // Remove encoded characters
+ ?.replaceAll(/[^a-zA-Z0-9\s']|(? {
+ const { itemZUID, modelZUID } = useParams<{
+ itemZUID: string;
+ modelZUID: string;
+ }>();
+ const item = useSelector((state: AppState) => state.content[itemZUID]);
+ const { data: modelFields } = useGetContentModelFieldsQuery(modelZUID, {
+ skip: !modelZUID,
+ });
+
+ const textFieldNames = useMemo(() => {
+ if (modelFields?.length) {
+ const textFieldTypes = [
+ "text",
+ "wysiwyg_basic",
+ "wysiwyg_advanced",
+ "article_writer",
+ "markdown",
+ "textarea",
+ ];
+
+ return modelFields.reduce((accu: string[], curr) => {
+ if (
+ textFieldTypes.includes(curr.datatype) &&
+ !curr.deletedAt &&
+ !DYNAMIC_META_FIELD_NAMES.includes(curr.name)
+ ) {
+ accu = [...accu, curr.name];
+ return accu;
+ }
+
+ return accu;
+ }, []);
+ }
+ }, [modelFields]);
+
+ const contentItemWordsArray = useMemo(() => {
+ if (
+ item?.data &&
+ Object.values(item.data)?.length &&
+ textFieldNames?.length
+ ) {
+ let words: string[] = [];
+
+ textFieldNames.forEach((fieldName) => {
+ let value = item?.data[fieldName];
+
+ if (!!value) {
+ value = cleanContent(String(value));
+
+ words = [...words, ...value.split(" ")];
+ }
+ });
+
+ return words;
+ }
+
+ return [];
+ }, [textFieldNames, item?.data]);
+
+ const uniqueWordsArray = Array.from(new Set(contentItemWordsArray));
+ const uniqueNonCommonWordsArray = Array.from(
+ uniqueWordsArray?.filter((word) => !COMMON_WORDS.includes(word))
+ );
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.js
deleted file mode 100644
index 4fcaee4823..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.js
+++ /dev/null
@@ -1,417 +0,0 @@
-import React, { useState } from "react";
-
-import Divider from "@mui/material/Divider";
-import Button from "@mui/material/Button";
-
-import Card from "@mui/material/Card";
-import CardHeader from "@mui/material/CardHeader";
-import CardContent from "@mui/material/CardContent";
-import SavedSearchIcon from "@mui/icons-material/SavedSearch";
-
-import { faCheck, faSearchDollar } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import cx from "classnames";
-
-import styles from "./ContentInsights.less";
-export function ContentInsights(props) {
- const [showAllWords, setShowAllWords] = useState(false);
-
- // clean up functions
- const stripTags = (string) => {
- return string.replace(/(<([^>]+)>)/gi, "");
- };
- const stripEncoded = (string) => {
- return string.replace(/(&(.*?);)/gi, " ");
- };
- const stripHidden = (string) => {
- return string.replace(/(\r|\n|\t)/gi, " ");
- };
- const stripZUIDs = (string) => {
- return string.replace(/(\d-(.*?)-(.*?))(,| )/gi, " ");
- };
- const stripPunctuation = (string) => {
- return string.replace(/("|,|:|;|\. |!)/gi, " ");
- };
- const stripDoubleSpace = (string) => {
- return string.replace(/\s\s+/g, " ");
- };
- const stripDashesAndSlashes = (string) => {
- return string.replace(/-|\//g, " ");
- };
- const commonwords = [
- "null",
- "1",
- "2",
- "3",
- "4",
- "5",
- "6",
- "7",
- "8",
- "9",
- "0",
- "our",
- "one",
- "two",
- "three",
- "four",
- "five",
- "I'm",
- "that's",
- "it's",
- "aren't",
- "we've",
- "i've",
- "didn't",
- "don't",
- "you'll",
- "you're",
- "we're",
- "Here's",
- "about",
- "actually",
- "always",
- "even",
- "given",
- "into",
- "just",
- "not",
- "Im",
- "thats",
- "its",
- "arent",
- "weve",
- "ive",
- "didnt",
- "dont",
- "the",
- "of",
- "to",
- "and",
- "a",
- "in",
- "is",
- "it",
- "you",
- "that",
- "he",
- "was",
- "for",
- "on",
- "are",
- "with",
- "as",
- "I",
- "his",
- "they",
- "be",
- "at",
- "one",
- "have",
- "this",
- "from",
- "or",
- "had",
- "by",
- "but",
- "some",
- "what",
- "there",
- "we",
- "can",
- "out",
- "were",
- "all",
- "your",
- "when",
- "up",
- "use",
- "how",
- "said",
- "an",
- "each",
- "she",
- "which",
- "do",
- "their",
- "if",
- "will",
- "way",
- "many",
- "then",
- "them",
- "would",
- "like",
- "so",
- "these",
- "her",
- "see",
- "him",
- "has",
- "more",
- "could",
- "go",
- "come",
- "did",
- "my",
- "no",
- "get",
- "me",
- "say",
- "too",
- "here",
- "must",
- "such",
- "try",
- "us",
- "own",
- "oh",
- "any",
- "youll",
- "youre",
- "also",
- "than",
- "those",
- "though",
- "thing",
- "things",
- ];
-
- const findMatch = (needle, haystack) => {
- let truth = false;
- haystack.forEach((word) => {
- if (word.toLowerCase() == needle.toLowerCase()) truth = true;
- });
- return truth;
- };
-
- let combinedString = "";
- let wordCount = {};
- let metaWordCount = {};
-
- // Working with Content
- // Content: combine all the text content we find from the item
- for (const [key, value] of Object.entries(props.content)) {
- combinedString += " " + value;
- }
-
- // Content: clean the string up by remove values that are not considered word content
- combinedString = stripDoubleSpace(
- stripPunctuation(
- stripHidden(stripEncoded(stripTags(stripZUIDs(combinedString))))
- )
- );
- combinedString = combinedString.toLowerCase();
-
- // Meta: build combined string
- let combinedMetaString =
- props.meta.metaTitle +
- " " +
- props.meta.path +
- " " +
- props.meta.metaDescription;
- // Meta: clean the string
- combinedMetaString = stripDoubleSpace(
- stripPunctuation(stripDashesAndSlashes(combinedMetaString.toLowerCase()))
- );
-
- // Content: get total word counts with initial split
- let splitWords = combinedString.split(" ");
- let totalWords = splitWords.length;
-
- // Content & Meta: remove common words
- commonwords.forEach((commonWord) => {
- let re = new RegExp("\\b" + commonWord.toLowerCase() + "\\b", "ig");
- combinedString = combinedString.replace(re, "");
- combinedMetaString = combinedMetaString.replace(re, "");
- });
- // Content: strip left over double spaces
- combinedString = stripDoubleSpace(combinedString);
- splitWords = combinedString.split(" ");
- let totalNonCommonWords = splitWords.length;
-
- // Content: use split words to tally total count of numbers
- splitWords.forEach((word) => {
- if (!wordCount.hasOwnProperty(word)) {
- wordCount[word] = 1;
- } else {
- wordCount[word]++;
- }
- });
-
- // Meta: use split meta words to tally total count of numbers
- let splitMetaWords = combinedMetaString.split(" ");
- splitMetaWords.forEach((word) => {
- if (!metaWordCount.hasOwnProperty(word)) {
- metaWordCount[word] = 1;
- } else {
- metaWordCount[word]++;
- }
- });
- // Meta: Build Word Lists for output
- let metaWordArray = [];
- for (const [key, value] of Object.entries(metaWordCount)) {
- if (key != "") {
- metaWordArray.push({
- word: key,
- count: value,
- match: findMatch(key, splitWords),
- });
- }
- }
- // Content: Sort Word List by more occuring
- metaWordArray = metaWordArray.sort((a, b) => {
- return b.count - a.count;
- });
-
- // Content: Build Word Lists for output
- let wordArray = [];
- for (const [key, value] of Object.entries(wordCount)) {
- if (key != "") {
- wordArray.push({
- word: key,
- count: value,
- match: findMatch(key, splitMetaWords),
- });
- }
- }
- let totalUniqueNonCommonWords = wordArray.length;
- // Content: Sort Word List by more occuring
- wordArray = wordArray.sort((a, b) => {
- return b.count - a.count;
- });
-
- return (
-
- }
- title="Content Insights"
- sx={{
- backgroundColor: "grey.100",
- }}
- titleTypographyProps={{
- sx: {
- color: "text.primary",
- },
- }}
- >
-
-
-
- Total Words {totalWords}
-
-
- Non-Common* Words {totalNonCommonWords}
-
-
- Unique Words* {totalUniqueNonCommonWords}
-
-
-
- Content and Meta Matched Words
-
- {metaWordArray.map((item, i) => {
- if (item.match) {
- return (
-
-
-
-
- {item.word}
-
- );
- }
- })}
-
-
-
- Word occurrences from this Content Item (not the fully rendered page)
-
-
- {wordArray.map((item, i) => {
- if (item.count > 1) {
- return (
-
- {item.count}
- {item.word}
-
- );
- }
- })}
- {!showAllWords && (
-
- )}
- {showAllWords && (
-
- )}
-
- {showAllWords && (
-
- {wordArray.map((item, i) => {
- if (item.count == 1) {
- return (
-
- {item.count}
- {item.word}
-
- );
- }
- })}
-
- )}
-
- Word occurrences from the URL, Meta Title and Description
-
- {metaWordArray.map((item, i) => {
- return (
-
- {item.count}
- {item.word}
-
- );
- })}
-
-
-
- );
-}
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.less
deleted file mode 100644
index 394af8d17c..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.less
+++ /dev/null
@@ -1,83 +0,0 @@
-@import "~@zesty-io/core/typography.less";
-@import "~@zesty-io/core/colors.less";
-@roundPx: 6px;
-.ContentInsights {
- .toggleButton {
- padding: 0px 10px !important;
- margin-bottom: auto;
- }
- .level {
- display: flex;
- div {
- flex: 1;
- text-align: center;
- background-color: lighten(@zesty-light-gray, 15%);
- border: 1px lighten(@zesty-light-gray, 15%) solid;
- color: @zesty-gray;
- border-radius: 4px;
- margin-right: 8px;
- overflow: hidden;
- padding-top: 4px;
- &:last-child {
- margin-right: 0px;
- }
- span {
- display: block;
- font-size: 2em;
- background: white;
- color: @zesty-dark-blue;
- padding: 8px 0px;
- margin-top: 4px;
- }
- }
- }
- .wordBank {
- display: flex;
- flex-direction: row;
- flex-wrap: wrap;
- margin-top: 8px;
- .wordGroup {
- display: flex;
- width: min-content;
- font-size: 1em;
- margin-right: 8px;
- margin-bottom: 8px;
- border: 1px lighten(@zesty-light-gray, 15%) solid;
- border-radius: @roundPx;
- overflow: hidden;
-
- strong {
- width: 20px;
- flex: 1;
- display: inline-block;
- background: lighten(@zesty-light-gray, 15%);
- color: @zesty-dark-blue;
-
- padding: 5px 8px;
- font-weight: 600;
- }
- span {
- flex: 5;
- flex-direction: column;
- flex-basis: 100%;
- display: inline-block;
- color: @zesty-dark-blue;
- background: white;
- overflow: hidden;
- text-overflow: ellipsis;
-
- padding: 5px 8px;
- }
- &.hidden {
- display: none;
- }
- &.matched {
- border: 1px lighten(@zesty-green, 20%) solid;
- strong {
- background: lighten(@zesty-green, 20%);
- color: darken(@zesty-green, 20%);
- }
- }
- }
- }
-}
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/index.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/index.js
deleted file mode 100644
index 9ac2579447..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { ContentInsights } from "./ContentInsights";
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/DataSettings.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/DataSettings.js
deleted file mode 100644
index f8796526e4..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/DataSettings.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Component } from "react";
-import cx from "classnames";
-
-import { MetaTitle } from "./settings/MetaTitle";
-import MetaDescription from "./settings/MetaDescription";
-import { MetaKeywords } from "./settings/MetaKeywords";
-import { MetaLinkText } from "./settings/MetaLinkText";
-import { Stack } from "@mui/material";
-
-import styles from "./ItemSettings.less";
-export class DataSettings extends Component {
- onChange = (value, name) => {
- if (!name) {
- throw new Error("Input is missing name attribute");
- }
- this.props.dispatch({
- type: "SET_ITEM_WEB",
- itemZUID: this.props.item.meta.ZUID,
- key: name,
- value: value,
- });
- };
-
- render() {
- let web = this.props.item.web || {};
- return (
-
- );
- }
-}
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.js
deleted file mode 100644
index ca4d408705..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.js
+++ /dev/null
@@ -1,227 +0,0 @@
-import {
- memo,
- Fragment,
- useCallback,
- useMemo,
- useState,
- useEffect,
-} from "react";
-import { useSelector } from "react-redux";
-import { useDispatch } from "react-redux";
-
-import { MetaTitle } from "./settings/MetaTitle";
-import MetaDescription from "./settings/MetaDescription";
-import { MetaKeywords } from "./settings/MetaKeywords";
-import { MetaLinkText } from "./settings/MetaLinkText";
-import { ItemRoute } from "./settings/ItemRoute";
-import { ContentInsights } from "./ContentInsights";
-import { ItemParent } from "./settings/ItemParent";
-import { CanonicalTag } from "./settings/CanonicalTag";
-import { SitemapPriority } from "./settings/SitemapPriority";
-import { useDomain } from "shell/hooks/use-domain";
-
-import Card from "@mui/material/Card";
-import CardHeader from "@mui/material/CardHeader";
-import CardContent from "@mui/material/CardContent";
-import SearchIcon from "@mui/icons-material/Search";
-
-import styles from "./ItemSettings.less";
-import { fetchGlobalItem } from "../../../../../../../../shell/store/content";
-
-export const MaxLengths = {
- metaLinkText: 150,
- metaTitle: 150,
- metaDescription: 160,
- metaKeywords: 255,
-};
-
-export const ItemSettings = memo(
- function ItemSettings(props) {
- const showSiteNameInMetaTitle = useSelector(
- (state) =>
- state.settings.instance.find(
- (setting) => setting.key === "show_in_title"
- )?.value
- );
- const dispatch = useDispatch();
- const domain = useDomain();
- let { data, meta, web } = props.item;
- const [errors, setErrors] = useState({});
-
- data = data || {};
- meta = meta || {};
- web = web || {};
-
- const siteName = useMemo(() => dispatch(fetchGlobalItem())?.site_name, []);
-
- const onChange = useCallback(
- (value, name) => {
- if (!name) {
- throw new Error("Input is missing name attribute");
- }
-
- if (MaxLengths[name]) {
- setErrors({
- ...errors,
- [name]: {
- EXCEEDING_MAXLENGTH:
- value?.length > MaxLengths[name]
- ? value?.length - MaxLengths[name]
- : 0,
- },
- });
- }
-
- props.dispatch({
- type: "SET_ITEM_WEB",
- itemZUID: meta.ZUID,
- key: name,
- value: value,
- });
- },
- [meta.ZUID, errors]
- );
-
- useEffect(() => {
- if (props.saving) {
- setErrors({});
- return;
- }
- }, [props.saving]);
-
- return (
-
-
- {web.pathPart !== "zesty_home" && (
-
-
-
-
- )}
-
-
-
-
-
- {props.item && (
-
- )}
-
-
-
- );
- },
- (prevProps, nextProps) => {
- // NOTE We want to update children when the `item` changes but only when `content` length changes
-
- // If the model we are viewing changes we need to re-render
- if (prevProps.modelZUID !== nextProps.modelZUID) {
- return false;
- }
-
- // If the item referential equailty of the item changes we want to re-render
- // should mean the item has updated data
- if (prevProps.item !== nextProps.item) {
- return false;
- }
-
- // Avoid referential equality check and compare content length to see if new ones where added
- let prevItemsLen = Object.keys(prevProps["content"]).length;
- let nextItemsLen = Object.keys(nextProps["content"]).length;
- if (prevItemsLen !== nextItemsLen) {
- return false;
- }
-
- /**
- * We ignore changes to the `instance` object and `dispatch`
- * as these values should not change.
- */
-
- return true;
- }
-);
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.less
deleted file mode 100644
index 3f0eb24a4f..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.less
+++ /dev/null
@@ -1,79 +0,0 @@
-@import "~@zesty-io/core/colors.less";
-@import "~@zesty-io/core/typography.less";
-
-.Meta {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 32px;
- // flex-direction: row;
- // margin: 32px;
-
- .DataSettings {
- flex: 0.6;
- }
-
- .MetaMain {
- flex: 3;
- // margin-right: 32px;
- min-width: 0px;
-
- &.DataSettings {
- margin-right: 0;
- }
-
- article {
- margin: 16px 0;
- }
- }
-
- .MetaSide {
- flex: 2;
- margin: 16px 0;
- min-width: 0px;
-
- .SearchResult {
- border-radius: 5px;
- background: white;
- border: 1px @zesty-light-gray solid;
- padding: 16px;
-
- .GoogleTitle {
- font-family: Roboto, "Gibson", arial, sans-serif;
- color: #1a0dab; // sampled from google
- word-wrap: break-word;
- font-size: 18px;
- line-height: 21.6px;
- font-weight: 400;
- }
- .GoogleLink {
- color: #006621; // sampled from google
- display: block;
- font-family: Roboto, arial, sans-serif;
- font-size: 14px;
- font-style: normal;
- font-weight: 400;
- height: auto;
- line-height: 16.8px;
- padding: 2px 0 3px 0;
-
- a {
- text-decoration: none;
- color: #006621; // sampled from google
- }
-
- span.Icon {
- margin-left: 5px;
- }
- }
- .GoogleDesc {
- word-wrap: break-word;
- color: rgb(84, 84, 84);
- font-family: Roboto, arial, sans-serif;
- font-size: 13px;
- font-weight: 400;
- height: auto;
- line-height: 18.2px;
- }
- }
- }
-}
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/index.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/index.js
deleted file mode 100644
index 57750330fa..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { ItemSettings } from "./ItemSettings";
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.js
deleted file mode 100644
index c6748bec6c..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.js
+++ /dev/null
@@ -1,263 +0,0 @@
-import { memo, Fragment, useState, useEffect } from "react";
-import { connect, useSelector } from "react-redux";
-import { debounce, uniqBy } from "lodash";
-import { notify } from "shell/store/notifications";
-
-import { Select, Option } from "@zesty-io/core/Select";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { faHome } from "@fortawesome/free-solid-svg-icons";
-
-import { searchItems } from "shell/store/content";
-import { FieldShell } from "../../../../../components/Editor/Field/FieldShell";
-
-import styles from "./ItemParent.less";
-export const ItemParent = connect((state) => {
- return {
- nav: state.navContent.raw,
- };
-})(
- memo(
- function ItemParent(props) {
- const items = useSelector((state) => state.content);
- const [loading, setLoading] = useState(false);
- const [parent, setParent] = useState({
- meta: {
- ZUID: "0", // "0" = root level route
- path: "/",
- },
- });
-
- const [parents, setParents] = useState(
- parentOptions(props.currentItemLangID, props.path, items)
- );
-
- const onSearch = debounce((term) => {
- if (term) {
- setLoading(true);
- props.dispatch(searchItems(term)).then((res) => {
- setLoading(false);
- setParents(
- parentOptions(props.currentItemLangID, props.path, {
- ...items,
- // needs to reduce and converts this data as the same format of the items to
- // prevent having an issue on having an itemZUID with an incorrect format
- // the reason is that the item has a format of {[itemZUID]:data}
- // while the res.data has a value of an array which cause the needs of converting
- // the response to an object with a zuid as a key
- ...res?.data.reduce((acc, curr) => {
- return { ...acc, [curr.meta.ZUID]: curr };
- }, {}),
- })
- );
- });
- }
- }, 250);
-
- /**
- * Recurse nav linked list to find current items parent
- * @param {*} zuid
- * @param {*} count
- */
- const findNavParent = (zuid, count = 0) => {
- count++;
- const navEntry = props.nav.find((el) => el.ZUID === zuid);
- if (navEntry) {
- // This first item should be the model we are resolving for so
- // continue on up the nav tree
- if (count === 0) {
- return findNavParent(navEntry.parentZUID, count);
- } else {
- if (navEntry.type === "item") {
- return navEntry;
- } else if (navEntry.parentZUID) {
- return findNavParent(navEntry.parentZUID, count);
- } else {
- return { ZUID: "0" };
- }
- }
- } else {
- return { ZUID: "0" };
- }
- };
-
- useEffect(() => {
- let parentZUID = props.parentZUID;
-
- // If it's a new item chase down the parentZUID within navigation
- // This way we avoid an API request
- if (props.itemZUID && props.itemZUID.slice(0, 3) === "new") {
- const result = findNavParent(props.modelZUID);
-
- // change for preselection
- parentZUID = result.ZUID;
-
- // Update redux store so if the item is saved we know it's parent
- props.onChange(parentZUID, "parentZUID");
- }
-
- // Try to preselect parent
- if (parentZUID && parentZUID != "0" && parentZUID !== null) {
- const item = items[parentZUID];
- if (item && item.meta && item.meta.ZUID && item.meta.path) {
- setParent(item);
- } else {
- props.dispatch(searchItems(parentZUID)).then((res) => {
- if (res) {
- if (res.data) {
- if (Array.isArray(res.data) && res.data.length) {
- // Handles cases where the model's parent is the homepage. This is no longer possible for newly created models but
- // there are some old models that still have the homepage as their parent models.
- if (res.data[0]?.web?.path === "/") {
- setParent({
- meta: {
- ZUID: "0", // "0" = root level route
- path: "/",
- },
- });
- } else {
- setParent(res.data[0]);
- /**
- * // HACK Because we pre-load all item publishings and store them in the same reducer as the `content`
- * we can't use array length comparision to determine a new parent has been added. Also since updates to the item
- * currently being edited cause a new `content` object to be created in it's reducer we can't use
- * referential equality checks to determine re-rendering. This scenario causes either the parent to not be pre-selected
- * or a performance issue. To work around this we maintain the `parents` state internal and add the new parent we load from the
- * API to allow it to be pre-selected while avoiding re-renders on changes to this item.
- */
-
- setParents(
- parentOptions(props.currentItemLangID, props.path, {
- ...items,
- [res.data[0].meta.ZUID]: res.data[0],
- })
- );
- }
- } else {
- props.dispatch(
- notify({
- kind: "warn",
- heading: `Cannot Save: ${props.metaTitle}`,
- messsage: "Set page parent in SEO Tab",
- })
- );
- }
- } else {
- props.dispatch(
- notify({
- kind: "warn",
- message: `API failed to return data. Try Again.`,
- })
- );
- }
- } else {
- props.dispatch(
- notify({
- kind: "warn",
- heading: `"Cannot Save: ${props.metaTitle}`,
- message: `Page's Parent does not exist or has been deleted`,
- })
- );
- }
- });
- }
- }
- }, []);
-
- return (
-
-
- {/*
- Delay rendering select until we have a parent.
- Sometimes we have to resolve the parent from the API asynchronously
- */}
- {!parent ? (
- "Loading item parent"
- ) : (
-
- )}
-
-
- );
- },
- (prevProps, nextProps) => {
- let isEqual = true;
-
- // Shallow compare all props
- Object.keys(prevProps).forEach((key) => {
- // ignore content, we'll check it seperately after
- if (key !== "content") {
- if (prevProps[key] !== nextProps[key]) {
- isEqual = false;
- }
- }
- });
-
- return isEqual;
- }
- )
-);
-
-function parentOptions(currentItemLangID, path, items) {
- const options = Object.entries(items)
- ?.reduce((acc, [itemZUID, itemData]) => {
- if (
- itemZUID.slice(0, 3) !== "new" && // Exclude new items
- itemData?.meta?.ZUID && // must have a ZUID
- itemData?.web?.path && // must have a path
- itemData?.web.path !== "/" && // Exclude homepage
- itemData?.web.path !== path && // Exclude current item
- itemData?.meta?.langID === currentItemLangID // display only relevant language options
- ) {
- acc.push({
- value: itemZUID,
- text: itemData.web.path,
- });
- }
-
- return acc;
- }, [])
- .sort((a, b) => {
- if (a.text > b.text) {
- return 1;
- } else if (a.text < b.text) {
- return -1;
- } else {
- return 0;
- }
- });
-
- return uniqBy(options, "value");
-}
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.less
deleted file mode 100644
index c088a932ec..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.less
+++ /dev/null
@@ -1,9 +0,0 @@
-@import "~@zesty-io/core/colors.less";
-
-.ItemParent {
- label {
- display: flex;
- color: @zesty-dark-blue;
- font-size: 14px;
- }
-}
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.js
deleted file mode 100644
index da3fc8c513..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.js
+++ /dev/null
@@ -1,186 +0,0 @@
-import { memo, Fragment, useCallback, useState, useEffect } from "react";
-import { connect, useDispatch } from "react-redux";
-import debounce from "lodash/debounce";
-import { searchItems } from "shell/store/content";
-import { notify } from "shell/store/notifications";
-
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { faSpinner, faCheck, faTimes } from "@fortawesome/free-solid-svg-icons";
-import TextField from "@mui/material/TextField";
-import { InputIcon } from "@zesty-io/core/InputIcon";
-import { AppLink } from "@zesty-io/core/AppLink";
-
-import styles from "./ItemRoute.less";
-import { withCursorPosition } from "../../../../../../../../../shell/components/withCursorPosition";
-import { FieldShell } from "../../../../../components/Editor/Field/FieldShell";
-const TextFieldWithCursorPosition = withCursorPosition(TextField);
-
-export const ItemRoute = connect((state) => {
- return {
- content: state.content,
- };
-})(
- memo(function ItemRoute(props) {
- const dispatch = useDispatch();
- const [pathPart, setPathPart] = useState(props.path_part);
- const [loading, setLoading] = useState(false);
- const [unique, setUnique] = useState(true);
-
- const validate = useCallback(
- debounce((path) => {
- if (!path) {
- setUnique(false);
- return;
- }
-
- const parent = props.content[props.parentZUID];
- const fullPath = parent ? parent.web.path + path : path;
-
- setLoading(true);
-
- return dispatch(searchItems(fullPath))
- .then((res) => {
- if (res) {
- if (res.data) {
- if (Array.isArray(res.data) && res.data.length) {
- // check list of partial matches for exact path match
- const matches = res.data.filter((item) => {
- /**
- * Exclude currently viewed item zuid, as it's currently saved path would match.
- * Check if other results have a matching path, if so then it is already taken and
- * can not be used.
- * Result paths come with leading and trailing slashes
- */
- return (
- item.meta.ZUID !== props.ZUID &&
- item.web.path === "/" + fullPath + "/"
- );
- });
- if (matches.length) {
- props.dispatch(
- notify({
- kind: "warn",
- message: (
-
- URL {matches[0].web.path} is
- unavailable. Used by
-
- {matches[0].web.metaLinkText ||
- matches[0].web.metaTitle}
-
-
- ),
- })
- );
- }
-
- setUnique(!matches.length);
- } else {
- setUnique(true);
- }
- } else {
- props.dispatch(
- notify({
- kind: "warn",
- message: `API failed to return data ${res.status}`,
- })
- );
- }
- } else {
- props.dispatch(
- notify({
- kind: "warn",
- message: `API failed to return response ${res.status}`,
- })
- );
- }
- })
- .finally(() => setLoading(false));
- }, 500),
- [props.parentZUID]
- );
-
- const onChange = (evt) => {
- // All URLs are lowercased
- // Replace ampersand characters with 'and'
- // Only allow alphanumeric characters
- const path = evt.target.value
- .trim()
- .toLowerCase()
- .replace(/\&/g, "and")
- .replace(/[^a-zA-Z0-9]/g, "-");
-
- validate(path);
- setPathPart(path);
-
- props.dispatch({
- type: "SET_ITEM_WEB",
- itemZUID: props.ZUID,
- key: "pathPart",
- value: path,
- });
- };
-
- // update internal state if external path part changes
- useEffect(() => {
- if (props.path_part) {
- validate(props.path_part);
- setPathPart(props.path_part);
- }
- }, [props.path_part, props.parentZUID]);
-
- return (
-
-
-
- {props.path_part === "zesty_home" ? (
-
-
- Homepage
-
- ) : (
-
-
-
- {loading && (
-
-
-
- )}
-
- {!loading && unique && (
-
-
-
- )}
-
- {!loading && !unique && (
-
-
-
- )}
-
- )}
-
-
-
- );
- })
-);
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.less
deleted file mode 100644
index c35a9c0d31..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.less
+++ /dev/null
@@ -1,39 +0,0 @@
-@import "~@zesty-io/core/keyframes.less";
-@import "~@zesty-io/core/colors.less";
-
-.ItemRoute {
- label {
- display: flex;
- color: @zesty-dark-blue;
- font-size: 14px;
- }
- .Path {
- display: flex;
- .Parent {
- border-radius: 3px;
- background-color: #4c5567;
- align-items: center;
- display: flex;
- padding: 0 8px;
- color: #fff;
- }
- .Checking {
- margin: 0;
- background-color: #404759;
- border: 1px solid #404759;
- i {
- animation: spin 2s linear infinite;
- }
- }
- .Valid {
- margin: 0;
- background-color: #63a41e;
- border: 1px solid #63a41e;
- }
- .Invalid {
- margin: 0;
- background-color: #9a2803;
- border: 1px solid #9a2803;
- }
- }
-}
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.js
deleted file mode 100644
index 9d786d1385..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import { useState, useEffect } from "react";
-import { connect } from "react-redux";
-import { TextField } from "@mui/material";
-
-import { notify } from "shell/store/notifications";
-import { FieldShell } from "../../../../../components/Editor/Field/FieldShell";
-import { MaxLengths } from "../ItemSettings";
-
-import styles from "./MetaDescription.less";
-export default connect()(function MetaDescription({
- meta_description,
- onChange,
- dispatch,
- errors,
-}) {
- const [error, setError] = useState("");
-
- useEffect(() => {
- if (meta_description) {
- let message = "";
-
- if (!(meta_description.indexOf("\u0152") === -1)) {
- message =
- "Found OE ligature. These special characters are not allowed in meta descriptions.";
- } else if (!(meta_description.indexOf("\u0153") === -1)) {
- message =
- "Found oe ligature. These special characters are not allowed in meta descriptions.";
- } else if (!(meta_description.indexOf("\xAB") === -1)) {
- message =
- "Found << character. These special characters are not allowed in meta descriptions.";
- } else if (!(meta_description.indexOf("\xBB") === -1)) {
- message =
- "Found >> character. These special characters are not allowed in meta descriptions.";
- } else if (/[\u201C\u201D\u201E]/.test(meta_description)) {
- message =
- "Found Microsoft smart double quotes and apostrophe. These special characters are not allowed in meta descriptions.";
- } else if (/[\u2018\u2019\u201A]/.test(meta_description)) {
- message =
- "Found Microsoft Smart single quotes and apostrophe. These special characters are not allowed in meta descriptions.";
- }
-
- setError(message);
- }
- }, [meta_description]);
-
- if (error) {
- dispatch(
- notify({
- kind: "warn",
- message: error,
- })
- );
- }
-
- return (
-
-
- onChange(evt.target.value, "metaDescription")}
- multiline
- rows={6}
- />
-
-
- );
-});
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.less
deleted file mode 100644
index 4e94f5b229..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.less
+++ /dev/null
@@ -1,13 +0,0 @@
-@import "~@zesty-io/core/colors.less";
-
-.MetaDescription {
- display: flex;
- flex-direction: column;
- label {
- display: flex;
- }
- .error {
- background-color: @zesty-highlight;
- padding: 8px;
- }
-}
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.less
deleted file mode 100644
index 5e8029ecc2..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.less
+++ /dev/null
@@ -1,7 +0,0 @@
-.MetaKeywords {
- display: flex;
- flex-direction: column;
- label {
- display: flex;
- }
-}
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.js
deleted file mode 100644
index 07fe483b7d..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import { memo } from "react";
-
-import { TextField } from "@mui/material";
-import { FieldShell } from "../../../../../components/Editor/Field/FieldShell";
-import { MaxLengths } from "../ItemSettings";
-
-import styles from "./MetaLinkText.less";
-export const MetaLinkText = memo(function MetaLinkText({
- meta_link_text,
- onChange,
- errors,
-}) {
- return (
-
-
- onChange(evt.target.value, "metaLinkText")}
- />
-
-
- );
-});
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.less
deleted file mode 100644
index ddbb8b4eda..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.less
+++ /dev/null
@@ -1,7 +0,0 @@
-.MetaLinkText {
- display: flex;
- flex-direction: column;
- label {
- display: flex;
- }
-}
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.js
deleted file mode 100644
index 65dd215ff8..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { memo } from "react";
-
-import { TextField } from "@mui/material";
-
-import { FieldShell } from "../../../../../components/Editor/Field/FieldShell";
-import { MaxLengths } from "../ItemSettings";
-import styles from "./MetaTitle.less";
-export const MetaTitle = memo(function MetaTitle({
- meta_title,
- onChange,
- errors,
-}) {
- return (
-
-
- onChange(evt.target.value, "metaTitle")}
- />
-
-
- );
-});
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.less
deleted file mode 100644
index 7ed0d77cec..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.less
+++ /dev/null
@@ -1,7 +0,0 @@
-.MetaTitle {
- display: flex;
- flex-direction: column;
- label {
- display: flex;
- }
-}
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/SitemapPriority.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/SitemapPriority.js
deleted file mode 100644
index 1f0f13f8e4..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/SitemapPriority.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import { memo } from "react";
-
-import { Select, MenuItem } from "@mui/material";
-
-import { FieldShell } from "../../../../../components/Editor/Field/FieldShell";
-import styles from "./SitemapPriority.less";
-export const SitemapPriority = memo(function SitemapPriority(props) {
- return (
-
-
-
-
-
- );
-});
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.js
deleted file mode 100644
index 34f572cf6e..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { Header } from "../components/Header";
-
-import { ItemSettings } from "./ItemSettings";
-import { DataSettings } from "./ItemSettings/DataSettings";
-
-import styles from "./Meta.less";
-export function Meta(props) {
- return (
-
-
- {props.model && props.model?.type === "dataset" ? (
-
- ) : (
-
- )}
-
-
- );
-}
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.less
deleted file mode 100644
index f73ba1b173..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.less
+++ /dev/null
@@ -1,10 +0,0 @@
-.MetaEdit {
- display: flex;
- flex: 1;
- background-color: #f9fafb;
- overflow: scroll;
- flex-direction: column;
- .MetaWrap {
- margin: 4px 32px 20px 32px;
- }
-}
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/FacebookPreview.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/FacebookPreview.tsx
new file mode 100644
index 0000000000..237399ecf0
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/FacebookPreview.tsx
@@ -0,0 +1,79 @@
+import { Typography, Box, Stack } from "@mui/material";
+import { ImageRounded } from "@mui/icons-material";
+import { useLocation, useParams } from "react-router";
+import { useSelector } from "react-redux";
+
+import { useDomain } from "../../../../../../../../shell/hooks/use-domain";
+import { AppState } from "../../../../../../../../shell/store/types";
+
+type FacebookPreviewProps = {
+ imageURL: string;
+};
+export const FacebookPreview = ({ imageURL }: FacebookPreviewProps) => {
+ const { itemZUID, modelZUID } = useParams<{
+ itemZUID: string;
+ modelZUID: string;
+ }>();
+ const domain = useDomain();
+ const location = useLocation();
+ const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new";
+ const item = useSelector(
+ (state: AppState) =>
+ state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID]
+ );
+
+ return (
+
+ {!!imageURL ? (
+ theme.palette.grey[100],
+ objectFit: "cover",
+ alignSelf: "center",
+ }}
+ height={290}
+ width="100%"
+ src={`${imageURL}?width=500&height=290&fit=cover`}
+ flexShrink={0}
+ />
+ ) : (
+
+
+
+ )}
+
+
+ {domain.replace(/http:\/\/|https:\/\//gm, "")}
+
+
+ {item?.data?.og_title || item?.web?.metaTitle || "Meta Title"}
+
+
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/GooglePreview.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/GooglePreview.tsx
new file mode 100644
index 0000000000..0999c1b62f
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/GooglePreview.tsx
@@ -0,0 +1,171 @@
+import { Fragment, useMemo, useEffect } from "react";
+import { Box, Stack, Typography, Breadcrumbs } from "@mui/material";
+import {
+ MoreVertRounded,
+ ArrowForwardIosRounded,
+ ImageRounded,
+} from "@mui/icons-material";
+import { useSelector } from "react-redux";
+import { useLocation, useParams } from "react-router";
+
+import { useGetInstanceQuery } from "../../../../../../../../shell/services/accounts";
+import { InstanceAvatar } from "../../../../../../../../shell/components/global-sidebar/components/InstanceAvatar";
+import { useDomain } from "../../../../../../../../shell/hooks/use-domain";
+import { AppState } from "../../../../../../../../shell/store/types";
+import { useGetContentModelFieldsQuery } from "../../../../../../../../shell/services/instance";
+
+type GooglePreviewProps = {
+ imageURL: string;
+};
+export const GooglePreview = ({ imageURL }: GooglePreviewProps) => {
+ const { modelZUID, itemZUID } = useParams<{
+ modelZUID: string;
+ itemZUID: string;
+ }>();
+ const { data: instance, isLoading: isLoadingInstance } =
+ useGetInstanceQuery();
+ const domain = useDomain();
+ const location = useLocation();
+ const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new";
+ const items = useSelector((state: AppState) => state.content);
+ const item = items[isCreateItemPage ? `new:${modelZUID}` : itemZUID];
+ const parent = items[item?.web?.parentZUID];
+
+ const fullPathArray = useMemo(() => {
+ let path: string[] = [domain];
+
+ if (parent) {
+ path = [...path, ...(parent.web?.path?.split("/") || [])];
+ }
+
+ // Remove empty strings
+ return path.filter((i) => !!i);
+ }, [domain, parent, item?.web]);
+
+ return (
+
+
+
+
+
+
+ {instance?.name}
+
+
+
+ {fullPathArray.map((path, index) => (
+
+ {path}
+ {index < fullPathArray?.length - 1 && (
+
+ ›
+
+ )}
+
+ ))}
+
+ theme.palette.text.secondary,
+ }}
+ />
+
+
+
+
+ {item?.web?.metaTitle || "Meta Title"}
+
+
+ {item?.web?.metaDescription || "Meta Description"}
+
+
+ {!!imageURL ? (
+ theme.palette.grey[100],
+ objectFit: "cover",
+ }}
+ width={82}
+ height={82}
+ src={`${imageURL}?width=82&height=82&fit=cover`}
+ flexShrink={0}
+ borderRadius={2}
+ />
+ ) : (
+
+
+
+ )}
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/LinkedInPreview.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/LinkedInPreview.tsx
new file mode 100644
index 0000000000..7fb999780c
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/LinkedInPreview.tsx
@@ -0,0 +1,92 @@
+import { Typography, Box, Stack } from "@mui/material";
+import { ImageRounded } from "@mui/icons-material";
+import { useLocation, useParams } from "react-router";
+import { useSelector } from "react-redux";
+
+import { useDomain } from "../../../../../../../../shell/hooks/use-domain";
+import { AppState } from "../../../../../../../../shell/store/types";
+
+type LinkedInPreviewProps = {
+ imageURL: string;
+};
+export const LinkedInPreview = ({ imageURL }: LinkedInPreviewProps) => {
+ const { itemZUID, modelZUID } = useParams<{
+ itemZUID: string;
+ modelZUID: string;
+ }>();
+ const domain = useDomain();
+ const location = useLocation();
+ const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new";
+ const item = useSelector(
+ (state: AppState) =>
+ state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID]
+ );
+
+ return (
+
+ {!!imageURL ? (
+ theme.palette.grey[100],
+ objectFit: "cover",
+ alignSelf: "center",
+ borderRadius: 2,
+ }}
+ height={72}
+ width={128}
+ src={`${imageURL}?width=128&height=72&fit=cover`}
+ flexShrink={0}
+ />
+ ) : (
+
+
+
+ )}
+
+
+ {item?.data?.og_title || item?.web?.metaTitle || "Meta Title"}
+
+
+ {domain.replace(/http:\/\/|https:\/\//gm, "")}
+
+
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/TwitterPreview.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/TwitterPreview.tsx
new file mode 100644
index 0000000000..ac34cac0e8
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/TwitterPreview.tsx
@@ -0,0 +1,122 @@
+import { useEffect } from "react";
+import { Typography, Box, Stack } from "@mui/material";
+import { ImageRounded } from "@mui/icons-material";
+import { useLocation, useParams } from "react-router";
+import { useSelector } from "react-redux";
+
+import { useDomain } from "../../../../../../../../shell/hooks/use-domain";
+import { AppState } from "../../../../../../../../shell/store/types";
+
+type TwitterPreviewProps = {
+ imageURL: string;
+};
+export const TwitterPreview = ({ imageURL }: TwitterPreviewProps) => {
+ const { itemZUID, modelZUID } = useParams<{
+ itemZUID: string;
+ modelZUID: string;
+ }>();
+ const domain = useDomain();
+ const location = useLocation();
+ const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new";
+ const item = useSelector(
+ (state: AppState) =>
+ state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID]
+ );
+
+ return (
+
+ {!!imageURL ? (
+ theme.palette.grey[100],
+ objectFit: "cover",
+ }}
+ width={128}
+ height={128}
+ src={`${imageURL}?width=128&height=128&fit=cover`}
+ flexShrink={0}
+ borderRadius="8px 0 0 8px"
+ />
+ ) : (
+
+
+
+ )}
+
+
+ {domain.replace(/http:\/\/|https:\/\//gm, "")}
+
+
+ {item?.data?.tc_title || item?.web?.metaTitle || "Meta Title"}
+
+
+ {item?.data?.tc_description ||
+ item?.web?.metaDescription ||
+ "Meta Description"}
+
+
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/index.tsx
new file mode 100644
index 0000000000..4ee52f0827
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/index.tsx
@@ -0,0 +1,84 @@
+import { useState } from "react";
+import { Tab, Tabs, Box } from "@mui/material";
+import {
+ Google,
+ Twitter,
+ FacebookRounded,
+ LinkedIn,
+} from "@mui/icons-material";
+import { GooglePreview } from "./GooglePreview";
+import { TwitterPreview } from "./TwitterPreview";
+import { FacebookPreview } from "./FacebookPreview";
+import { LinkedInPreview } from "./LinkedInPreview";
+import { useImageURL } from "./useImageURL";
+
+enum SocialMediaTab {
+ Google,
+ Twitter,
+ Facebook,
+ LinkedIn,
+}
+type SocialMediaPreviewProps = {};
+export const SocialMediaPreview = ({}: SocialMediaPreviewProps) => {
+ const imageURL = useImageURL();
+ const [activeTab, setActiveTab] = useState(
+ SocialMediaTab.Google
+ );
+
+ return (
+ <>
+ `2px solid ${theme?.palette?.border} `,
+ }}
+ >
+ setActiveTab(value)}
+ sx={{
+ position: "relative",
+ top: "2px",
+ }}
+ >
+ }
+ iconPosition="start"
+ label="Google"
+ value={SocialMediaTab.Google}
+ />
+ }
+ iconPosition="start"
+ label="Twitter (X)"
+ value={SocialMediaTab.Twitter}
+ />
+ }
+ iconPosition="start"
+ label="Facebook"
+ value={SocialMediaTab.Facebook}
+ />
+ }
+ iconPosition="start"
+ label="LinkedIn"
+ value={SocialMediaTab.LinkedIn}
+ />
+
+
+ {activeTab === SocialMediaTab.Google && (
+
+ )}
+ {activeTab === SocialMediaTab.Twitter && (
+
+ )}
+ {activeTab === SocialMediaTab.Facebook && (
+
+ )}
+ {activeTab === SocialMediaTab.LinkedIn && (
+
+ )}
+ >
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/useImageURL.ts b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/useImageURL.ts
new file mode 100644
index 0000000000..72213c3193
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/useImageURL.ts
@@ -0,0 +1,97 @@
+import { useEffect, useMemo, useState } from "react";
+import { useSelector } from "react-redux";
+import { useLocation, useParams } from "react-router";
+
+import { useGetContentModelFieldsQuery } from "../../../../../../../../shell/services/instance";
+import { useLazyGetFileQuery } from "../../../../../../../../shell/services/mediaManager";
+import { AppState } from "../../../../../../../../shell/store/types";
+import { fileExtension } from "../../../../../../../media/src/app/utils/fileUtils";
+
+export const useImageURL: () => string = () => {
+ const { modelZUID, itemZUID } = useParams<{
+ modelZUID: string;
+ itemZUID: string;
+ }>();
+ const { data: modelFields } = useGetContentModelFieldsQuery(modelZUID);
+ const [getFile] = useLazyGetFileQuery();
+ const location = useLocation();
+ const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new";
+ const item = useSelector(
+ (state: AppState) =>
+ state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID]
+ );
+ const [imageURL, setImageURL] = useState(null);
+
+ const contentImages = useMemo(() => {
+ if (!modelFields?.length || !Object.keys(item?.data ?? {})?.length) return;
+
+ const mediaFieldsWithImageOnTheName: string[] = [];
+ const otherMediaFields: string[] = [];
+
+ modelFields.forEach((field) => {
+ if (
+ !field.deletedAt &&
+ field.datatype === "images" &&
+ field?.name !== "og_image" &&
+ !!item?.data?.[field.name]
+ ) {
+ if (
+ field.label.toLowerCase().includes("image") ||
+ field.name.toLocaleLowerCase().includes("image")
+ ) {
+ mediaFieldsWithImageOnTheName.push(
+ ...String(item.data[field.name]).split(",")
+ );
+ } else {
+ otherMediaFields.push(...String(item.data[field.name]).split(","));
+ }
+ }
+ });
+
+ return [...mediaFieldsWithImageOnTheName, ...otherMediaFields];
+ }, [modelFields, item?.data]);
+
+ useEffect(() => {
+ if (!!item?.data?.og_image) {
+ if (String(item.data.og_image).startsWith("3-")) {
+ getFile(String(item.data.og_image))
+ .unwrap()
+ .then((res) => {
+ setImageURL(res.url);
+ });
+ } else {
+ setImageURL(String(item.data.og_image));
+ }
+ } else {
+ if (!contentImages?.length) {
+ setImageURL(null);
+ return;
+ }
+
+ let validImages = contentImages.map(async (value) => {
+ const isZestyMediaFile = value.startsWith("3-");
+ // Need to resolve media zuids to determine if these are actually images
+ const res = isZestyMediaFile && (await getFile(value).unwrap());
+ const isImage = [
+ "png",
+ "jpg",
+ "jpeg",
+ "svg",
+ "gif",
+ "tif",
+ "webp",
+ ].includes(fileExtension(isZestyMediaFile ? res.url : value));
+
+ if (isImage) {
+ return isZestyMediaFile ? res.url : value;
+ }
+ });
+
+ Promise.all(validImages).then((data) => {
+ setImageURL(data?.[0]);
+ });
+ }
+ }, [JSON.stringify(contentImages), item?.data?.og_image]);
+
+ return imageURL;
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.js
deleted file mode 100644
index f75005249b..0000000000
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { Meta } from "./Meta";
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx
new file mode 100644
index 0000000000..38525faa07
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx
@@ -0,0 +1,590 @@
+import {
+ useState,
+ useCallback,
+ useMemo,
+ useEffect,
+ forwardRef,
+ useImperativeHandle,
+ useRef,
+} from "react";
+import {
+ Stack,
+ Box,
+ Typography,
+ ThemeProvider,
+ Divider,
+ ListItemIcon,
+ ListItemText,
+ ListItemButton,
+} from "@mui/material";
+import { Brain, theme } from "@zesty-io/material";
+import { useParams, useLocation } from "react-router";
+import { useSelector, useDispatch } from "react-redux";
+import { keyframes } from "@mui/system";
+import { EditRounded } from "@mui/icons-material";
+import { cloneDeep } from "lodash";
+
+import { ContentInsights } from "./ContentInsights";
+import {
+ useGetContentModelQuery,
+ useGetContentModelFieldsQuery,
+} from "../../../../../../../shell/services/instance";
+import { AppState } from "../../../../../../../shell/store/types";
+import { Error } from "../../../components/Editor/Field/FieldShell";
+import { fetchGlobalItem } from "../../../../../../../shell/store/content";
+import {
+ ContentModelField,
+ Web,
+} from "../../../../../../../shell/services/types";
+import { SocialMediaPreview } from "./SocialMediaPreview";
+import { validateMetaDescription } from "./settings/util";
+
+// Fields
+import { MetaImage } from "./settings/MetaImage";
+import { CanonicalTag } from "./settings/CanonicalTag";
+import { ItemParent } from "./settings/ItemParent";
+import { ItemRoute } from "./settings/ItemRoute";
+import MetaDescription from "./settings/MetaDescription";
+import { MetaKeywords } from "./settings/MetaKeywords";
+import { MetaLinkText } from "./settings/MetaLinkText";
+import { MetaTitle } from "./settings/MetaTitle";
+import { SitemapPriority } from "./settings/SitemapPriority";
+import { OGTitle } from "./settings/OGTitle";
+import { OGDescription } from "./settings/OGDescription";
+import { TCTitle } from "./settings/TCTitle";
+import { TCDescription } from "./settings/TCDescription";
+import { FieldError } from "../../../components/Editor/FieldError";
+
+const rotateAnimation = keyframes`
+ 0% {
+ background-position: 0% 0%;
+ }
+ 100% {
+ background-position: 0% 100%;
+ }
+`;
+const FlowType = {
+ AIGenerated: "ai-generated",
+ Manual: "manual",
+} as const;
+const flowButtons = [
+ {
+ flowType: FlowType.AIGenerated,
+ icon: ,
+ primaryText: "Yes, improve with AI Meta Data Assistant",
+ secondaryText:
+ "Our AI will scan your content and generate your meta data for you",
+ },
+ {
+ flowType: FlowType.Manual,
+ icon: ,
+ primaryText: "No, I will improve and edit it myself",
+ secondaryText:
+ "Perfect if you already know what you want your Meta Data to be",
+ },
+];
+export const MaxLengths: Record = {
+ metaLinkText: 150,
+ metaTitle: 150,
+ metaDescription: 160,
+ metaKeywords: 255,
+ og_title: 150,
+ og_description: 160,
+ tc_title: 150,
+ tc_description: 160,
+};
+export const DYNAMIC_META_FIELD_NAMES = [
+ "og_title",
+ "og_description",
+ "tc_title",
+ "tc_description",
+];
+
+type Errors = Record;
+type MetaProps = {
+ isSaving: boolean;
+ onUpdateSEOErrors: (errors: Errors) => void;
+ errors: Errors;
+ errorComponent?: React.ReactNode;
+};
+export const Meta = forwardRef(
+ ({ isSaving, onUpdateSEOErrors, errors, errorComponent }: MetaProps, ref) => {
+ const dispatch = useDispatch();
+ const location = useLocation();
+ const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new";
+ const { modelZUID, itemZUID } = useParams<{
+ modelZUID: string;
+ itemZUID: string;
+ }>();
+ const { data: model } = useGetContentModelQuery(modelZUID, {
+ skip: !modelZUID,
+ });
+ const { data: fields } = useGetContentModelFieldsQuery(modelZUID);
+ const { meta, data, web } = useSelector(
+ (state: AppState) =>
+ state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID]
+ );
+ const [flowType, setFlowType] =
+ useState(null);
+ const metaDescriptionButtonRef = useRef(null);
+ const metaTitleButtonRef = useRef(null);
+
+ // @ts-expect-error untyped
+ const siteName = useMemo(() => dispatch(fetchGlobalItem())?.site_name, []);
+
+ const metaFields = useMemo(() => {
+ if (fields.length) {
+ return fields.reduce(
+ (
+ accu: Record,
+ curr: ContentModelField
+ ) => {
+ if (
+ !curr.deletedAt &&
+ DYNAMIC_META_FIELD_NAMES.includes(curr.name.toLowerCase())
+ ) {
+ accu[curr.name] = curr;
+ }
+
+ return accu;
+ },
+ {}
+ );
+ }
+
+ return {};
+ }, [fields]);
+
+ const REQUIRED_FIELDS = useMemo(() => {
+ const fields = ["metaTitle", "parentZUID", "pathPart"];
+
+ if (model?.type !== "dataset") {
+ fields.push("metaDescription");
+ }
+
+ return fields;
+ }, [model]);
+
+ const handleOnChange = useCallback(
+ (value, name) => {
+ if (!name) {
+ throw new Error("Input is missing name attribute");
+ }
+
+ const currentErrors = cloneDeep(errors);
+
+ if (REQUIRED_FIELDS.includes(name)) {
+ currentErrors[name] = {
+ ...currentErrors?.[name],
+ MISSING_REQUIRED: !value,
+ };
+ }
+
+ if (MaxLengths[name]) {
+ currentErrors[name] = {
+ ...currentErrors?.[name],
+ EXCEEDING_MAXLENGTH:
+ value?.length > MaxLengths[name]
+ ? value?.length - MaxLengths[name]
+ : 0,
+ };
+ }
+
+ if (DYNAMIC_META_FIELD_NAMES.includes(name) && name in metaFields) {
+ const isRequired = metaFields[name].required;
+
+ currentErrors[name] = {
+ ...currentErrors[name],
+ MISSING_REQUIRED: isRequired ? !value : false,
+ };
+ }
+
+ if (name === "metaDescription") {
+ const metaDescriptionError = validateMetaDescription(value);
+
+ currentErrors.metaDescription = {
+ ...currentErrors.metaDescription,
+ CUSTOM_ERROR: !!metaDescriptionError ? metaDescriptionError : "",
+ };
+ }
+
+ onUpdateSEOErrors(currentErrors);
+
+ dispatch({
+ // The og_image is stored as an ordinary field item and not a SEO field item
+ type: [...DYNAMIC_META_FIELD_NAMES, "og_image"].includes(name)
+ ? "SET_ITEM_DATA"
+ : "SET_ITEM_WEB",
+ itemZUID: meta?.ZUID,
+ key: name,
+ value: value,
+ });
+ },
+ [meta?.ZUID, errors]
+ );
+
+ useImperativeHandle(
+ ref,
+ () => {
+ return {
+ validateMetaFields() {
+ const currentErrors = cloneDeep(errors);
+
+ REQUIRED_FIELDS.forEach((fieldName) => {
+ // @ts-expect-error
+ const value = web[fieldName];
+
+ currentErrors[fieldName] = {
+ ...currentErrors?.[fieldName],
+ MISSING_REQUIRED: !value,
+ };
+ });
+
+ Object.keys(MaxLengths).forEach((fieldName) => {
+ const value = DYNAMIC_META_FIELD_NAMES.includes(fieldName)
+ ? data[fieldName]
+ : // @ts-expect-error
+ web[fieldName];
+
+ currentErrors[fieldName] = {
+ ...currentErrors?.[fieldName],
+ EXCEEDING_MAXLENGTH:
+ value?.length > MaxLengths[fieldName]
+ ? value?.length - MaxLengths[fieldName]
+ : 0,
+ };
+ });
+
+ Object.entries(metaFields).forEach(([name, settings]) => {
+ const isRequired = settings.required;
+ const value = data[name] as string;
+
+ currentErrors[name] = {
+ ...currentErrors?.[name],
+ MISSING_REQUIRED: isRequired ? !value : false,
+ };
+ });
+
+ // Validate meta description value
+ const metaDescriptionError = validateMetaDescription(
+ web.metaDescription || ""
+ );
+
+ currentErrors.metaDescription = {
+ ...currentErrors.metaDescription,
+ CUSTOM_ERROR: !!metaDescriptionError ? metaDescriptionError : "",
+ };
+
+ // No need to validate pathPart for datasets
+ if (model?.type === "dataset" || web?.pathPart === "zesty_home") {
+ delete currentErrors.pathPart;
+ delete currentErrors.parentZUID;
+ }
+
+ setTimeout(() => {
+ // Makes sure that the user sees the error blurbs on each
+ // field when in the create item page
+ if (isCreateItemPage) {
+ setFlowType(FlowType.Manual);
+ }
+ onUpdateSEOErrors(currentErrors);
+ });
+
+ return Object.values(currentErrors)
+ ?.map((error) => {
+ return Object.values(error) ?? [];
+ })
+ ?.flat()
+ .some((error) => !!error);
+ },
+ triggerAIGeneratedFlow() {
+ setFlowType(FlowType.AIGenerated);
+ },
+ };
+ },
+ [errors, web, model, metaFields, data]
+ );
+
+ useEffect(() => {
+ if (isSaving) {
+ onUpdateSEOErrors({});
+ return;
+ }
+ }, [isSaving]);
+
+ useEffect(() => {
+ if (!isCreateItemPage) return;
+
+ if (flowType === FlowType.AIGenerated) {
+ metaTitleButtonRef.current?.triggerAIButton?.();
+ }
+ }, [flowType, isCreateItemPage]);
+
+ if (isCreateItemPage && flowType === null) {
+ return (
+
+
+
+
+
+ Would you like to improve your Meta Title & Description?
+
+
+ Our AI Assistant will scan your content and improve your meta
+ title and description to help improve search engine
+ visibility.{" "}
+
+
+ {flowButtons.map((data) => (
+ setFlowType(data.flowType)}
+ sx={{
+ borderRadius: 2,
+ border: 1,
+ borderColor: "border",
+ backgroundColor: "common.white",
+ py: 2,
+ }}
+ >
+ {data.icon}
+
+ {data.primaryText}
+
+ }
+ disableTypography
+ sx={{ my: 0 }}
+ secondary={
+
+ {data.secondaryText}
+
+ }
+ />
+
+ ))}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {!!errorComponent && errorComponent}
+
+
+ SEO & Open Graph Settings
+
+
+ Specify this page's title and description. You can see how
+ they'll look in search engine results pages (SERPs) and social
+ media content in the preview on the right.
+
+
+ {
+ if (flowType === FlowType.AIGenerated) {
+ console.log("reset on meta title");
+ setFlowType(FlowType.Manual);
+ }
+ }}
+ onAIMetaTitleInserted={() => {
+ // Scroll to and open the meta description ai generator to continue
+ // with the AI-assisted flow
+ if (flowType === FlowType.AIGenerated) {
+ metaDescriptionButtonRef.current?.triggerAIButton?.();
+ }
+ }}
+ />
+ {
+ if (flowType === FlowType.AIGenerated) {
+ console.log("reset on meta description");
+ setFlowType(FlowType.Manual);
+ }
+ }}
+ isAIAssistedFlow={flowType === FlowType.AIGenerated}
+ required={REQUIRED_FIELDS.includes("metaDescription")}
+ />
+
+ {"og_title" in metaFields && (
+
+ )}
+ {"og_description" in metaFields && (
+
+ )}
+ {"tc_title" in metaFields && (
+
+ )}
+ {"tc_description" in metaFields && (
+
+ )}
+
+ {model?.type !== "dataset" && web?.pathPart !== "zesty_home" && (
+
+
+
+ URL Settings
+
+
+ Define the URL of your web page
+
+
+
+ {
+ onUpdateSEOErrors({
+ ...errors,
+ [name]: {
+ ...errors?.[name],
+ ...error,
+ },
+ });
+ }}
+ />
+
+ )}
+
+
+
+ Advanced Settings
+
+
+ Optimize your content item's SEO further
+
+
+ {model?.type !== "dataset" && (
+ <>
+
+ {!!web && (
+
+ )}
+ >
+ )}
+
+
+
+
+ {!isCreateItemPage && (
+
+
+ {model?.type !== "dataset" && (
+ <>
+
+
+ >
+ )}
+
+
+
+ )}
+
+
+ );
+ }
+);
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.js
similarity index 80%
rename from src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.js
rename to src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.js
index b6f5f4982e..115c59ebe0 100644
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.js
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.js
@@ -1,25 +1,25 @@
import { memo, useState } from "react";
-import { TextField, Select, MenuItem } from "@mui/material";
+import { TextField, Select, MenuItem, Autocomplete } from "@mui/material";
-import { FieldShell } from "../../../../../components/Editor/Field/FieldShell";
+import { FieldShell } from "../../../../components/Editor/Field/FieldShell";
const CANONICAL_OPTS = [
{
value: 0,
- text: "Off",
+ label: "Off",
},
{
value: 1,
- text: "On (Ignores query parameters)",
+ label: "On (Ignores query parameters)",
},
{
value: 2,
- text: "On - Allow certain parameters",
+ label: "On - Allow certain parameters",
},
{
value: 3,
- text: "On - Custom Path or Custom URL",
+ label: "On - Custom Path or Custom URL",
},
];
@@ -52,7 +52,7 @@ export const CanonicalTag = memo(function CanonicalTag(props) {
settings={{
label: "Canonical Tag",
}}
- customTooltip="Canonical tags help search engines understand authoritative links and can help prevent duplicate content issues. Zesty.io auto creates tags on demand based on your settings."
+ customTooltip="Canonical tags help search engines understand authoritative links and can help prevent duplicate content issues. Zesty.io auto-creates tags on demand based on your settings."
withInteractiveTooltip={false}
>
{zestyStore.getState().instance.settings.seo[
@@ -69,21 +69,15 @@ export const CanonicalTag = memo(function CanonicalTag(props) {
) : (
-
+ renderInput={(params) =>
}
+ onChange={(_, value) => {
+ handleMode(value ? value.value : 1, "canonicalTagMode");
+ }}
+ />
{mode == "2" ? (
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.less
similarity index 100%
rename from src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.less
rename to src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.less
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemParent.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemParent.tsx
new file mode 100644
index 0000000000..a9cb8bd91b
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemParent.tsx
@@ -0,0 +1,262 @@
+import { Box, Autocomplete, TextField, ListItem } from "@mui/material";
+import { FieldShell } from "../../../../components/Editor/Field/FieldShell";
+import { useDispatch, useSelector } from "react-redux";
+import { AppState } from "../../../../../../../../shell/store/types";
+import {
+ ContentItemWithDirtyAndPublishing,
+ ContentNavItem,
+} from "../../../../../../../../shell/services/types";
+import { debounce, uniqBy } from "lodash";
+import { useEffect, useState } from "react";
+import { useLocation, useParams } from "react-router";
+import { notify } from "../../../../../../../../shell/store/notifications";
+import { searchItems } from "../../../../../../../../shell/store/content";
+import { useGetContentNavItemsQuery } from "../../../../../../../../shell/services/instance";
+
+type ParentOption = {
+ value: string;
+ text: string;
+};
+const getParentOptions = (
+ currentItemLangID: number,
+ path: string,
+ items: Record
+) => {
+ const options: ParentOption[] = Object.entries(items)
+ ?.reduce((acc, [itemZUID, itemData]) => {
+ if (
+ itemZUID.slice(0, 3) !== "new" && // Exclude new items
+ itemData?.meta?.ZUID && // must have a ZUID
+ itemData?.web?.path && // must have a path
+ itemData?.web.path !== "/" && // Exclude homepage
+ itemData?.web.path !== path && // Exclude current item
+ itemData?.meta?.langID === currentItemLangID // display only relevant language options
+ ) {
+ acc.push({
+ value: itemZUID,
+ text: itemData.web.path,
+ });
+ }
+
+ return acc;
+ }, [])
+ .sort((a, b) => {
+ if (a.text > b.text) {
+ return 1;
+ } else if (a.text < b.text) {
+ return -1;
+ } else {
+ return 0;
+ }
+ });
+
+ // Insert the home route
+ options.unshift({
+ text: "/",
+ value: "0", // 0 = root level
+ });
+
+ return uniqBy(options, "value");
+};
+
+const findNavParent = (zuid: string, nav: ContentNavItem[], count = 0): any => {
+ count++;
+ const navEntry = nav?.find((el: any) => el.ZUID === zuid);
+ if (navEntry) {
+ // This first item should be the model we are resolving for so
+ // continue on up the nav tree
+ if (count === 0) {
+ return findNavParent(navEntry.parentZUID, nav, count);
+ } else {
+ if (navEntry.type === "item") {
+ return navEntry.ZUID;
+ } else if (navEntry.parentZUID) {
+ return findNavParent(navEntry.parentZUID, nav, count);
+ } else {
+ return "0";
+ }
+ }
+ } else {
+ return "0";
+ }
+};
+
+type ItemParentProps = {
+ onChange: (value: string, name: string) => void;
+};
+export const ItemParent = ({ onChange }: ItemParentProps) => {
+ const { modelZUID, itemZUID } = useParams<{
+ modelZUID: string;
+ itemZUID: string;
+ }>();
+ const dispatch = useDispatch();
+ const location = useLocation();
+ const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new";
+ const items = useSelector((state: AppState) => state.content);
+ const item = items[isCreateItemPage ? `new:${modelZUID}` : itemZUID];
+ const { data: rawNavData } = useGetContentNavItemsQuery();
+ const [selectedParent, setSelectedParent] = useState({
+ value: "0", // "0" = root level route
+ text: "/",
+ });
+ const [options, setOptions] = useState(
+ getParentOptions(item?.meta?.langID, item?.web?.path, items)
+ );
+ const [isLoadingOptions, setIsLoadingOptions] = useState(false);
+
+ const handleSearchOptions = debounce((filterTerm) => {
+ if (filterTerm) {
+ dispatch(searchItems(filterTerm))
+ // @ts-expect-error untyped
+ .then((res) => {
+ setOptions(
+ getParentOptions(item?.meta?.langID, item?.web?.path, {
+ ...items,
+ // needs to reduce and converts this data as the same format of the items to
+ // prevent having an issue on having an itemZUID with an incorrect format
+ // the reason is that the item has a format of {[itemZUID]:data}
+ // while the res.data has a value of an array which cause the needs of converting
+ // the response to an object with a zuid as a key
+ ...res?.data.reduce(
+ (
+ acc: Record,
+ curr: ContentItemWithDirtyAndPublishing
+ ) => {
+ return { ...acc, [curr.meta.ZUID]: curr };
+ },
+ {}
+ ),
+ })
+ );
+ })
+ .finally(() => setIsLoadingOptions(false));
+ }
+ }, 1000);
+
+ useEffect(() => {
+ let { parentZUID } = item?.web;
+ const { ZUID: itemZUID } = item?.meta;
+
+ // If it's a new item chase down the parentZUID within navigation
+ // This way we avoid an API request
+ if (itemZUID && itemZUID.slice(0, 3) === "new") {
+ const result = findNavParent(modelZUID, rawNavData);
+
+ // change for preselection
+ parentZUID = result;
+
+ // Update redux store so if the item is saved we know it's parent
+ onChange(parentZUID, "parentZUID");
+ }
+
+ // Try to preselect parent
+ if (parentZUID && parentZUID !== "0") {
+ const parentItem = items[parentZUID];
+ if (parentItem?.meta?.ZUID && parentItem?.web?.path) {
+ setSelectedParent({
+ value: parentItem.meta.ZUID,
+ text: parentItem.web.path,
+ });
+ } else {
+ dispatch(searchItems(parentZUID))
+ // @ts-expect-error untyped
+ .then((res) => {
+ if (res?.data) {
+ if (Array.isArray(res.data) && res.data.length) {
+ // Handles cases where the model's parent is the homepage. This is no longer possible for newly created models but
+ // there are some old models that still have the homepage as their parent models.
+ if (res.data[0]?.web?.path === "/") {
+ setSelectedParent({
+ value: "0", // "0" = root level route
+ text: "/",
+ });
+ } else {
+ setSelectedParent({
+ value: res.data?.[0]?.meta?.ZUID,
+ text: res.data?.[0]?.web?.path,
+ });
+ /**
+ * // HACK Because we pre-load all item publishings and store them in the same reducer as the `content`
+ * we can't use array length comparision to determine a new parent has been added. Also since updates to the item
+ * currently being edited cause a new `content` object to be created in it's reducer we can't use
+ * referential equality checks to determine re-rendering. This scenario causes either the parent to not be pre-selected
+ * or a performance issue. To work around this we maintain the `parents` state internal and add the new parent we load from the
+ * API to allow it to be pre-selected while avoiding re-renders on changes to this item.
+ */
+
+ setOptions(
+ getParentOptions(item?.meta?.langID, item?.web?.path, {
+ ...items,
+ [res.data[0].meta.ZUID]: res.data[0],
+ })
+ );
+ }
+ } else {
+ dispatch(
+ notify({
+ kind: "warn",
+ heading: `Cannot Save: ${item?.web?.metaTitle}`,
+ message: `Page's Parent does not exist or has been deleted`,
+ })
+ );
+ }
+ } else {
+ dispatch(
+ notify({
+ kind: "warn",
+ message: `API failed to return data. Try Again.`,
+ })
+ );
+ }
+ });
+ }
+ }
+ }, []);
+
+ return (
+
+
+ }
+ renderOption={(props, value) => (
+
+ {value.text}
+
+ )}
+ getOptionLabel={(option) => option.text}
+ onInputChange={(_, filterTerm) => {
+ if (filterTerm !== "/") {
+ setIsLoadingOptions(!!filterTerm);
+ handleSearchOptions(filterTerm);
+ }
+ }}
+ onChange={(_, value) => {
+ // Always default to homepage when no parent is selected
+ setSelectedParent(
+ value !== null ? value : { text: "/", value: "0" }
+ );
+ onChange(value !== null ? value.value : "0", "parentZUID");
+ }}
+ loading={isLoadingOptions}
+ sx={{
+ "& .MuiOutlinedInput-root": {
+ padding: "2px",
+ },
+ }}
+ />
+
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx
new file mode 100644
index 0000000000..806649a69c
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx
@@ -0,0 +1,201 @@
+import { ChangeEvent, useCallback, useEffect, useState } from "react";
+import {
+ TextField,
+ InputAdornment,
+ CircularProgress,
+ Typography,
+ Box,
+} from "@mui/material";
+import { useDispatch, useSelector } from "react-redux";
+import { useLocation, useParams } from "react-router";
+import { debounce } from "lodash";
+import { CheckCircleRounded, CancelRounded } from "@mui/icons-material";
+
+import { withCursorPosition } from "../../../../../../../../shell/components/withCursorPosition";
+import { FieldShell } from "../../../../components/Editor/Field/FieldShell";
+import { searchItems } from "../../../../../../../../shell/store/content";
+import { notify } from "../../../../../../../../shell/store/notifications";
+import { AppState } from "../../../../../../../../shell/store/types";
+import { ContentItemWithDirtyAndPublishing } from "../../../../../../../../shell/services/types";
+import { useDomain } from "../../../../../../../../shell/hooks/use-domain";
+import { Error } from "../../../../components/Editor/Field/FieldShell";
+import { hasErrors } from "./util";
+
+const TextFieldWithCursorPosition = withCursorPosition(TextField);
+
+type ItemRouteProps = {
+ onChange: (value: string, name: string) => void;
+ error: Error;
+ onUpdateErrors: (name: string, error: Error) => void;
+};
+export const ItemRoute = ({
+ onChange,
+ error,
+ onUpdateErrors,
+}: ItemRouteProps) => {
+ const dispatch = useDispatch();
+ const { itemZUID, modelZUID } = useParams<{
+ itemZUID: string;
+ modelZUID: string;
+ }>();
+ const domain = useDomain();
+ const location = useLocation();
+ const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new";
+ const items = useSelector((state: AppState) => state.content);
+ const item = items[isCreateItemPage ? `new:${modelZUID}` : itemZUID];
+ const [pathPart, setPathPart] = useState(item?.web?.pathPart);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isUnique, setIsUnique] = useState(true);
+
+ const parent = items[item?.web?.parentZUID];
+
+ const validate = useCallback(
+ debounce((path) => {
+ if (!path) {
+ setIsUnique(false);
+ return;
+ }
+
+ const fullPath = parent ? `${parent.web?.path}${path}/` : `/${path}/`;
+
+ setIsLoading(true);
+
+ return (
+ dispatch(searchItems(fullPath))
+ // @ts-expect-error untyped
+ .then((res) => {
+ if (res?.data) {
+ if (Array.isArray(res.data) && res.data.length) {
+ // check list of partial matches for exact path match
+ const matches = res.data.filter(
+ (_item: ContentItemWithDirtyAndPublishing) => {
+ /**
+ * Exclude currently viewed item zuid, as it's currently saved path would match.
+ * Check if other results have a matching path, if so then it is already taken and
+ * can not be used.
+ * Result paths come with leading and trailing slashes
+ */
+ return (
+ _item.meta.ZUID !== item?.meta?.ZUID &&
+ _item.web.path === fullPath
+ );
+ }
+ );
+
+ setIsUnique(!matches.length);
+ onUpdateErrors("pathPart", {
+ CUSTOM_ERROR: !!matches.length
+ ? "This URL Path Part is already taken. Please enter a new different URL Path part."
+ : "",
+ });
+ } else {
+ setIsUnique(true);
+ onUpdateErrors("pathPart", {
+ CUSTOM_ERROR: "",
+ });
+ }
+ } else {
+ dispatch(
+ notify({
+ kind: "warn",
+ message: `API failed to return data ${res.status}`,
+ })
+ );
+ }
+ })
+ .finally(() => setIsLoading(false))
+ );
+ }, 1000),
+ [parent]
+ );
+
+ const handleInputChange = (evt: ChangeEvent) => {
+ // All URLs are lowercased
+ // Replace ampersand characters with 'and'
+ // Only allow alphanumeric characters
+ const path = evt.target.value
+ .toLowerCase()
+ .replace(/\&/g, "and")
+ .replace(/[^a-zA-Z0-9]/g, "-");
+
+ validate(path);
+ setPathPart(path);
+
+ onChange(path, "pathPart");
+ };
+
+ // Revalidate when parent path changes
+ useEffect(() => {
+ validate(pathPart);
+ }, [parent?.web, pathPart]);
+
+ useEffect(() => {
+ setPathPart(item?.web?.pathPart || "");
+ }, [item?.web]);
+
+ return (
+
+
+
+ ),
+ }}
+ helperText={
+ !!pathPart &&
+ isUnique && (
+
+ {domain}
+ {parent ? parent.web?.path + pathPart : `/${pathPart}`}
+
+ )
+ }
+ error={hasErrors(error)}
+ />
+
+
+ );
+};
+
+type AdornmentProps = {
+ isLoading: boolean;
+ isUnique: boolean;
+};
+const Adornment = ({ isLoading, isUnique }: AdornmentProps) => {
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {isUnique ? (
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx
new file mode 100644
index 0000000000..8e9668f751
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx
@@ -0,0 +1,72 @@
+import { useState, useEffect, ChangeEvent } from "react";
+import { connect, useDispatch } from "react-redux";
+import { TextField, Box } from "@mui/material";
+
+import { notify } from "../../../../../../../../shell/store/notifications";
+import { FieldShell } from "../../../../components/Editor/Field/FieldShell";
+import { MaxLengths } from "..";
+import { hasErrors } from "./util";
+import { Error } from "../../../../components/Editor/Field/FieldShell";
+import { withAI } from "../../../../../../../../shell/components/withAi";
+import { MutableRefObject } from "react";
+
+const AIFieldShell = withAI(FieldShell);
+
+type MetaDescriptionProps = {
+ value: string;
+ onChange: (value: string, name: string) => void;
+ error: Error;
+ onResetFlowType: () => void;
+ aiButtonRef?: MutableRefObject;
+ isAIAssistedFlow: boolean;
+ required: boolean;
+};
+export default connect()(function MetaDescription({
+ value,
+ onChange,
+ error,
+ onResetFlowType,
+ aiButtonRef,
+ isAIAssistedFlow,
+ required,
+}: MetaDescriptionProps) {
+ return (
+
+ ) => {
+ onChange(evt.target.value, "metaDescription");
+ onResetFlowType?.();
+ }}
+ onResetFlowType={() => {
+ onResetFlowType?.();
+ }}
+ isAIAssistedFlow={isAIAssistedFlow}
+ >
+ onChange(evt.target.value, "metaDescription")}
+ multiline
+ rows={3}
+ error={hasErrors(error)}
+ />
+
+
+ );
+});
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaImage.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaImage.tsx
new file mode 100644
index 0000000000..40f5ee4817
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaImage.tsx
@@ -0,0 +1,334 @@
+import { useState, useEffect, useMemo, useRef } from "react";
+import { Dialog, IconButton, Stack } from "@mui/material";
+import { LoadingButton } from "@mui/lab";
+import { AddRounded, Close, EditRounded } from "@mui/icons-material";
+import { MemoryRouter, useLocation, useParams } from "react-router";
+import { useDispatch, useSelector } from "react-redux";
+
+import { FieldShell } from "../../../../components/Editor/Field/FieldShell";
+import { AppState } from "../../../../../../../../shell/store/types";
+import {
+ useCreateContentModelFieldMutation,
+ useGetContentModelFieldsQuery,
+ useUndeleteContentModelFieldMutation,
+} from "../../../../../../../../shell/services/instance";
+import {
+ FieldTypeMedia,
+ MediaItem,
+} from "../../../../components/FieldTypeMedia";
+import { MediaApp } from "../../../../../../../media/src/app";
+import { useLazyGetFileQuery } from "../../../../../../../../shell/services/mediaManager";
+import { fileExtension } from "../../../../../../../media/src/app/utils/fileUtils";
+import { fetchFields } from "../../../../../../../../shell/store/fields";
+
+type MetaImageProps = {
+ onChange: (value: string, name: string) => void;
+};
+export const MetaImage = ({ onChange }: MetaImageProps) => {
+ const dispatch = useDispatch();
+ const location = useLocation();
+ const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new";
+ const { modelZUID, itemZUID } = useParams<{
+ modelZUID: string;
+ itemZUID: string;
+ }>();
+ const item = useSelector(
+ (state: AppState) =>
+ state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID]
+ );
+ const fieldTypeMedia = useRef(null);
+ const { data: modelFields } = useGetContentModelFieldsQuery(modelZUID);
+ const [
+ createContentModelField,
+ {
+ isLoading: isCreatingOgImageField,
+ isSuccess: isOgImageFieldCreated,
+ error: ogImageFieldCreationError,
+ },
+ ] = useCreateContentModelFieldMutation();
+ const [
+ undeleteContentModelField,
+ { isLoading: isUndeletingField, isSuccess: isFieldUndeleted },
+ ] = useUndeleteContentModelFieldMutation();
+ const [getFile] = useLazyGetFileQuery();
+ const [imageModal, setImageModal] = useState(null);
+ const [autoOpenMediaBrowser, setAutoOpenMediaBrowser] = useState(false);
+ const [temporaryMetaImageURL, setTemporaryMetaImageURL] =
+ useState(null);
+ const [showOGImageField, setShowOGImageField] = useState(false);
+
+ const isBynderSessionValid =
+ localStorage.getItem("cvrt") && localStorage.getItem("cvad");
+
+ const contentImages = useMemo(() => {
+ if (!modelFields?.length || !Object.keys(item?.data ?? {})?.length) return;
+ const mediaFieldsWithImageOnTheName: string[] = [];
+ const otherMediaFields: string[] = [];
+
+ modelFields.forEach((field) => {
+ if (
+ !field.deletedAt &&
+ field.datatype === "images" &&
+ field?.name !== "og_image" &&
+ !!item?.data?.[field.name]
+ ) {
+ if (
+ field.label.toLowerCase().includes("image") ||
+ field.name.toLocaleLowerCase().includes("image")
+ ) {
+ mediaFieldsWithImageOnTheName.push(
+ ...String(item.data[field.name]).split(",")
+ );
+ } else {
+ otherMediaFields.push(...String(item.data[field.name]).split(","));
+ }
+ }
+ });
+
+ return [...mediaFieldsWithImageOnTheName, ...otherMediaFields];
+ }, [modelFields, item?.data]);
+
+ useEffect(() => {
+ if (!contentImages?.length) {
+ setTemporaryMetaImageURL(null);
+ return;
+ }
+
+ let validImages = contentImages.map(async (value) => {
+ const isZestyMediaFile = value.startsWith("3-");
+ // Need to resolve media zuids to determine if these are actually images
+ const res = isZestyMediaFile && (await getFile(value).unwrap());
+ const isImage = [
+ "png",
+ "jpg",
+ "jpeg",
+ "svg",
+ "gif",
+ "tif",
+ "webp",
+ ].includes(fileExtension(isZestyMediaFile ? res.url : value));
+
+ if (isImage) {
+ return value;
+ }
+ });
+
+ Promise.all(validImages).then((data) => {
+ setTemporaryMetaImageURL(data?.[0]);
+ });
+ }, [JSON.stringify(contentImages), temporaryMetaImageURL]);
+
+ const handleCreateOgImageField = () => {
+ const existingOgImageField = modelFields?.find(
+ (field) => field.name === "og_image"
+ );
+
+ if (!!existingOgImageField && !!existingOgImageField.deletedAt) {
+ // If the og_image field already exists in the model but was deactivated, reactivate it
+ undeleteContentModelField({
+ modelZUID,
+ fieldZUID: existingOgImageField.ZUID,
+ });
+ } else {
+ // If the model has no og_image field yet, create it
+ createContentModelField({
+ modelZUID,
+ body: {
+ contentModelZUID: modelZUID,
+ datatype: "images",
+ description:
+ "This field allows you to set an open graph image via the SEO tab. An Open Graph (OG) image is an image that appears on a social media post when a web page is shared.",
+ label: "Meta Image",
+ name: "og_image",
+ required: false,
+ settings: {
+ defaultValue: null,
+ group_id: "",
+ limit: 1,
+ list: false,
+ },
+ sort: modelFields?.length, // Adds it to the end of the current model's field list
+ },
+ });
+ }
+
+ setShowOGImageField(true);
+ };
+
+ useEffect(() => {
+ if (
+ (!isCreatingOgImageField && isOgImageFieldCreated) ||
+ (!isUndeletingField && isFieldUndeleted)
+ ) {
+ // Initiate the empty og_image field
+ onChange(null, "og_image");
+ dispatch(fetchFields(modelZUID));
+ }
+ }, [
+ isOgImageFieldCreated,
+ isCreatingOgImageField,
+ isUndeletingField,
+ isFieldUndeleted,
+ ]);
+
+ useEffect(() => {
+ // Automatically opens the media browser when the og_image field has no value
+ if (autoOpenMediaBrowser && "og_image" in item?.data) {
+ if (!item?.data?.["og_image"]) {
+ fieldTypeMedia.current?.triggerOpenMediaBrowser();
+ }
+ setAutoOpenMediaBrowser(false);
+ }
+ }, [item?.data, autoOpenMediaBrowser]);
+
+ // If there is already a field named og_image and it is storing a value
+ if (
+ "og_image" in item?.data &&
+ (!!item?.data?.og_image || showOGImageField)
+ ) {
+ const ogImageValue = item.data.og_image;
+
+ return (
+ <>
+
+ {
+ setImageModal(opts);
+ }}
+ onChange={(value: string, name: string) => {
+ if (!value) {
+ setShowOGImageField(false);
+ }
+
+ onChange(value, name);
+ }}
+ lockedToGroupId={null}
+ settings={{
+ fileExtensions: [
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".svg",
+ ".gif",
+ ".tif",
+ ".webp",
+ ],
+ fileExtensionsErrorMessage:
+ "Only files with the following extensions are allowed: .png, .jpg, .jpeg, .svg, .gif, .tif, .webp",
+ }}
+ />
+
+ {imageModal && (
+
+
+
+ )}
+ >
+ );
+ }
+
+ // If there is a media field with an API ID containing the word "image" and is storing a file
+ if (!!temporaryMetaImageURL) {
+ return (
+
+
+
+ }
+ variant="outlined"
+ sx={{ width: "fit-content" }}
+ onClick={() => {
+ handleCreateOgImageField();
+ setAutoOpenMediaBrowser(true);
+ }}
+ >
+ Customize Image
+
+
+
+ );
+ }
+
+ // If no image field
+ return (
+
+ }
+ variant="outlined"
+ sx={{ width: "fit-content", mt: 0.75 }}
+ onClick={handleCreateOgImageField}
+ >
+ Add Meta Image
+
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaKeywords.tsx
similarity index 56%
rename from src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.js
rename to src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaKeywords.tsx
index 30f238dde3..6aa050b170 100644
--- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.js
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaKeywords.tsx
@@ -1,17 +1,23 @@
import { memo } from "react";
-import { TextField } from "@mui/material";
+import { TextField, Box } from "@mui/material";
-import { FieldShell } from "../../../../../components/Editor/Field/FieldShell";
-import { MaxLengths } from "../ItemSettings";
-import styles from "./MetaKeywords.less";
+import { FieldShell } from "../../../../components/Editor/Field/FieldShell";
+import { MaxLengths } from "..";
+import { Error } from "../../../../components/Editor/Field/FieldShell";
+
+type MetaKeywordsProps = {
+ value: string;
+ onChange: (value: string, name: string) => void;
+ error: Error;
+};
export const MetaKeywords = memo(function MetaKeywords({
- meta_keywords,
+ value,
onChange,
- errors,
-}) {
+ error,
+}: MetaKeywordsProps) {
return (
-
+
onChange(evt.target.value, "metaKeywords")}
/>
-
+
);
});
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaLinkText.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaLinkText.tsx
new file mode 100644
index 0000000000..17dc9a1f5a
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaLinkText.tsx
@@ -0,0 +1,40 @@
+import { memo } from "react";
+
+import { TextField, Box } from "@mui/material";
+import { FieldShell } from "../../../../components/Editor/Field/FieldShell";
+import { MaxLengths } from "..";
+import { Error } from "../../../../components/Editor/Field/FieldShell";
+
+type MetaLinkTextProps = {
+ value: string;
+ onChange: (value: string, name: string) => void;
+ error: Error;
+};
+export const MetaLinkText = memo(function MetaLinkText({
+ value,
+ onChange,
+ error,
+}: MetaLinkTextProps) {
+ return (
+
+
+ onChange(evt.target.value, "metaLinkText")}
+ />
+
+
+ );
+});
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx
new file mode 100644
index 0000000000..fc15fe43f6
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx
@@ -0,0 +1,67 @@
+import { ChangeEvent, memo, MutableRefObject } from "react";
+
+import { TextField, Box } from "@mui/material";
+
+import { FieldShell } from "../../../../components/Editor/Field/FieldShell";
+import { MaxLengths } from "..";
+import { hasErrors } from "./util";
+import { Error } from "../../../../components/Editor/Field/FieldShell";
+import { withAI } from "../../../../../../../../shell/components/withAi";
+
+const AIFieldShell = withAI(FieldShell);
+
+type MetaTitleProps = {
+ value: string;
+ onChange: (value: string, name: string) => void;
+ error: Error;
+ saveMetaTitleParameters?: boolean;
+ onResetFlowType: () => void;
+ onAIMetaTitleInserted?: () => void;
+ aiButtonRef?: MutableRefObject;
+};
+export const MetaTitle = memo(function MetaTitle({
+ value,
+ onChange,
+ error,
+ saveMetaTitleParameters,
+ onResetFlowType,
+ onAIMetaTitleInserted,
+ aiButtonRef,
+}: MetaTitleProps) {
+ return (
+
+ ) => {
+ onChange(evt.target.value, "metaTitle");
+ onAIMetaTitleInserted?.();
+ }}
+ onResetFlowType={() => {
+ onResetFlowType?.();
+ }}
+ >
+ onChange(evt.target.value, "metaTitle")}
+ error={hasErrors(error)}
+ />
+
+
+ );
+});
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGDescription.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGDescription.tsx
new file mode 100644
index 0000000000..027728fc81
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGDescription.tsx
@@ -0,0 +1,46 @@
+import { TextField, Box } from "@mui/material";
+
+import { FieldShell } from "../../../../components/Editor/Field/FieldShell";
+import { hasErrors } from "./util";
+import { Error } from "../../../../components/Editor/Field/FieldShell";
+import { ContentModelField } from "../../../../../../../../shell/services/types";
+import { MaxLengths } from "..";
+
+type OGDescriptionProps = {
+ value: string;
+ onChange: (value: string, name: string) => void;
+ error: Error;
+ field: ContentModelField;
+};
+export const OGDescription = ({
+ value,
+ onChange,
+ error,
+ field,
+}: OGDescriptionProps) => {
+ return (
+
+
+ onChange(evt.target.value, "og_description")}
+ error={hasErrors(error)}
+ />
+
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGTitle.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGTitle.tsx
new file mode 100644
index 0000000000..fb28d0323f
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGTitle.tsx
@@ -0,0 +1,39 @@
+import { TextField, Box } from "@mui/material";
+
+import { FieldShell } from "../../../../components/Editor/Field/FieldShell";
+import { hasErrors } from "./util";
+import { Error } from "../../../../components/Editor/Field/FieldShell";
+import { ContentModelField } from "../../../../../../../../shell/services/types";
+import { MaxLengths } from "..";
+
+type OGTitleProps = {
+ value: string;
+ onChange: (value: string, name: string) => void;
+ error: Error;
+ field: ContentModelField;
+};
+export const OGTitle = ({ value, onChange, error, field }: OGTitleProps) => {
+ return (
+
+
+ onChange(evt.target.value, "og_title")}
+ error={hasErrors(error)}
+ />
+
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/SitemapPriority.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/SitemapPriority.js
new file mode 100644
index 0000000000..66ab8a19cb
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/SitemapPriority.js
@@ -0,0 +1,88 @@
+import { memo } from "react";
+
+import { Autocomplete, TextField } from "@mui/material";
+
+import { FieldShell } from "../../../../components/Editor/Field/FieldShell";
+import styles from "./SitemapPriority.less";
+
+const OPTIONS = [
+ {
+ value: -1.0,
+ label: "Automatically Set Priority",
+ },
+ {
+ value: 1.0,
+ label: "1.0",
+ },
+ {
+ value: 0.9,
+ label: "0.9",
+ },
+ {
+ value: 0.8,
+ label: "0.8",
+ },
+ {
+ value: 0.7,
+ label: "0.7",
+ },
+ {
+ value: 0.6,
+ label: "0.6",
+ },
+ {
+ value: 0.5,
+ label: "0.5",
+ },
+ {
+ value: 0.4,
+ label: "0.4",
+ },
+ {
+ value: 0.3,
+ label: "0.3",
+ },
+ {
+ value: 0.2,
+ label: "0.2",
+ },
+ {
+ value: 0.1,
+ label: "0.1",
+ },
+ {
+ value: -2.0,
+ label: "Do Not Display in Sitemap",
+ },
+];
+
+export const SitemapPriority = memo(function SitemapPriority(props) {
+ return (
+
+
+ option.value === props.sitemapPriority
+ ) || {
+ value: -1.0,
+ label: "Automatically Set Priority",
+ }
+ }
+ fullWidth
+ renderInput={(params) => }
+ onChange={(_, value) => {
+ props.onChange(value ? value.value : -1.0, "sitemapPriority");
+ }}
+ />
+
+
+ );
+});
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/SitemapPriority.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/SitemapPriority.less
similarity index 100%
rename from src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/SitemapPriority.less
rename to src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/SitemapPriority.less
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCDescription.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCDescription.tsx
new file mode 100644
index 0000000000..5084e76406
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCDescription.tsx
@@ -0,0 +1,46 @@
+import { TextField, Box } from "@mui/material";
+
+import { FieldShell } from "../../../../components/Editor/Field/FieldShell";
+import { hasErrors } from "./util";
+import { Error } from "../../../../components/Editor/Field/FieldShell";
+import { ContentModelField } from "../../../../../../../../shell/services/types";
+import { MaxLengths } from "../index";
+
+type TCDescriptionProps = {
+ value: string;
+ onChange: (value: string, name: string) => void;
+ error: Error;
+ field: ContentModelField;
+};
+export const TCDescription = ({
+ value,
+ onChange,
+ error,
+ field,
+}: TCDescriptionProps) => {
+ return (
+
+
+ onChange(evt.target.value, "tc_description")}
+ error={hasErrors(error)}
+ />
+
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCTitle.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCTitle.tsx
new file mode 100644
index 0000000000..3ebab4a1d5
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCTitle.tsx
@@ -0,0 +1,39 @@
+import { TextField, Box } from "@mui/material";
+
+import { FieldShell } from "../../../../components/Editor/Field/FieldShell";
+import { hasErrors } from "./util";
+import { Error } from "../../../../components/Editor/Field/FieldShell";
+import { ContentModelField } from "../../../../../../../../shell/services/types";
+import { MaxLengths } from "../index";
+
+type TCTitleProps = {
+ value: string;
+ onChange: (value: string, name: string) => void;
+ error: Error;
+ field: ContentModelField;
+};
+export const TCTitle = ({ value, onChange, error, field }: TCTitleProps) => {
+ return (
+
+
+ onChange(evt.target.value, "tc_title")}
+ error={hasErrors(error)}
+ />
+
+
+ );
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts
new file mode 100644
index 0000000000..0aa2242464
--- /dev/null
+++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts
@@ -0,0 +1,35 @@
+import { Error } from "../../../../components/Editor/Field/FieldShell";
+
+export const hasErrors = (errors: Error) => {
+ if (!errors) return false;
+
+ return Object.values(errors).some((error) => !!error);
+};
+
+export const validateMetaDescription = (value: string) => {
+ let message = "";
+
+ if (!value) return message;
+
+ if (!(value.indexOf("\u0152") === -1)) {
+ message =
+ "Found OE ligature. These special characters are not allowed in meta descriptions.";
+ } else if (!(value.indexOf("\u0153") === -1)) {
+ message =
+ "Found oe ligature. These special characters are not allowed in meta descriptions.";
+ } else if (!(value.indexOf("\xAB") === -1)) {
+ message =
+ "Found << character. These special characters are not allowed in meta descriptions.";
+ } else if (!(value.indexOf("\xBB") === -1)) {
+ message =
+ "Found >> character. These special characters are not allowed in meta descriptions.";
+ } else if (/[\u201C\u201D\u201E]/.test(value)) {
+ message =
+ "Found Microsoft Smart double quotes and/or apostrophe. These special characters are not allowed in meta descriptions as it may lead to incorrect rendering, where characters show up as � or other odd symbols. Please use straight quotes (' and \") instead.";
+ } else if (/[\u2018\u2019\u201A]/.test(value)) {
+ message =
+ "Found Microsoft Smart single quotes and/or apostrophe. These special characters are not allowed in meta descriptions as it may lead to incorrect rendering, where characters show up as � or other odd symbols. Please use straight quotes (' and \") instead.";
+ }
+
+ return message;
+};
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx
index 2a9d069f13..07c43a29fd 100644
--- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx
+++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx
@@ -64,8 +64,8 @@ export const ItemEditHeaderActions = ({
itemZUID: string;
}>();
const dispatch = useDispatch();
- const canPublish = usePermission("PUBLISH");
- const canUpdate = usePermission("UPDATE");
+ const canPublish = usePermission("PUBLISH", itemZUID);
+ const canUpdate = usePermission("UPDATE", itemZUID);
const [publishMenu, setPublishMenu] = useState(null);
const [publishAfterSave, setPublishAfterSave] = useState(false);
const [unpublishDialogOpen, setUnpublishDialogOpen] = useState(false);
@@ -229,7 +229,7 @@ export const ItemEditHeaderActions = ({
onClick={() => {
onSave();
}}
- loading={saving && !publishAfterSave}
+ loading={saving}
disabled={!canUpdate}
id="SaveItemButton"
>
@@ -314,7 +314,7 @@ export const ItemEditHeaderActions = ({
setIsConfirmPublishModalOpen(true);
}
}}
- loading={publishing || publishAfterSave || isFetching}
+ loading={publishing || saving || isFetching}
color="success"
variant="contained"
id="PublishButton"
@@ -332,7 +332,7 @@ export const ItemEditHeaderActions = ({
onClick={(e) => {
setPublishMenu(e.currentTarget);
}}
- disabled={publishing || publishAfterSave || isFetching}
+ disabled={publishing || saving || isFetching}
data-cy="PublishMenuButton"
>
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/LanguageSelector.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/LanguageSelector.tsx
index 617c83d054..8ed340b481 100644
--- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/LanguageSelector.tsx
+++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/LanguageSelector.tsx
@@ -11,6 +11,7 @@ import { useDispatch, useSelector } from "react-redux";
import { ContentItem } from "../../../../../../../../shell/services/types";
import { AppState } from "../../../../../../../../shell/store/types";
import { selectLang } from "../../../../../../../../shell/store/user";
+import getFlagEmoji from "../../../../../../../../utility/getFlagEmoji";
const getCountryCode = (langCode: string) => {
const splitTag = langCode.split("-");
@@ -19,18 +20,6 @@ const getCountryCode = (langCode: string) => {
return countryCode;
};
-const getFlagEmojiFromIETFTag = (langCode: string) => {
- const countryCode = getCountryCode(langCode);
-
- // Convert country code to flag emoji.
- // Unicode flag emojis are made up of regional indicator symbols, which are a sequence of two letters.
- const baseOffset = 0x1f1e6;
- return (
- String.fromCodePoint(baseOffset + (countryCode.charCodeAt(0) - 65)) +
- String.fromCodePoint(baseOffset + (countryCode.charCodeAt(1) - 65))
- );
-};
-
export const LanguageSelector = () => {
const dispatch = useDispatch();
const history = useHistory();
@@ -97,7 +86,7 @@ export const LanguageSelector = () => {
data-cy="language-selector"
>
- {getFlagEmojiFromIETFTag(activeLanguage?.code)}
+ {getFlagEmoji(getCountryCode(activeLanguage?.code))}
{" "}
{activeLanguage?.code?.split("-")[0]?.toUpperCase()} (
{getCountryCode(activeLanguage?.code)})
@@ -130,7 +119,7 @@ export const LanguageSelector = () => {
onSelect(language.code);
}}
>
- {getFlagEmojiFromIETFTag(language.code)}{" "}
+ {getFlagEmoji(getCountryCode(language.code))}{" "}
{language.code.split("-")[0]?.toUpperCase()} (
{getCountryCode(language.code)})
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx
index 0ff94721c6..5a5dcb2ced 100644
--- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx
+++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx
@@ -1,10 +1,8 @@
import {
- Chip,
IconButton,
ListItemIcon,
Menu,
MenuItem,
- Typography,
Tooltip,
} from "@mui/material";
import {
@@ -16,23 +14,18 @@ import {
CodeRounded,
DeleteRounded,
CheckRounded,
- DesignServicesRounded,
- VisibilityRounded,
KeyboardArrowRightRounded,
} from "@mui/icons-material";
import { useState } from "react";
import { Database } from "@zesty-io/material";
import { useHistory, useParams } from "react-router";
-import { useSelector } from "react-redux";
-import { AppState } from "../../../../../../../../shell/store/types";
-import { ContentItem } from "../../../../../../../../shell/services/types";
import { DuplicateItemDialog } from "./DuplicateItemDialog";
-import { ApiType } from "../../../../../../../schema/src/app/components/ModelApi";
-import { useGetDomainsQuery } from "../../../../../../../../shell/services/accounts";
import { useFilePath } from "../../../../../../../../shell/hooks/useFilePath";
import { DeleteItemDialog } from "./DeleteItemDialog";
import { useGetContentModelsQuery } from "../../../../../../../../shell/services/instance";
import { usePermission } from "../../../../../../../../shell/hooks/use-permissions";
+import { CascadingMenuItem } from "../../../../../../../../shell/components/CascadingMenuItem";
+import { APIEndpoints } from "../../../../components/APIEndpoints";
export const MoreMenu = () => {
const { modelZUID, itemZUID } = useParams<{
@@ -42,22 +35,13 @@ export const MoreMenu = () => {
const [anchorEl, setAnchorEl] = useState(null);
const [isCopied, setIsCopied] = useState(false);
const [showDuplicateItemDialog, setShowDuplicateItemDialog] = useState(false);
- const [showApiEndpoints, setShowApiEndpoints] = useState(
- null
- );
const [showDeleteItemDialog, setShowDeleteItemDialog] = useState(false);
- const [apiEndpointType, setApiEndpointType] = useState("quick-access");
const history = useHistory();
- const item = useSelector(
- (state: AppState) => state.content[itemZUID] as ContentItem
- );
- const instance = useSelector((state: AppState) => state.instance);
- const { data: domains } = useGetDomainsQuery();
const codePath = useFilePath(modelZUID);
const { data: contentModels } = useGetContentModelsQuery();
const type =
contentModels?.find((model) => model.ZUID === modelZUID)?.type ?? "";
- const canDelete = usePermission("DELETE");
+ const canDelete = usePermission("DELETE", itemZUID);
const handleCopyClick = (data: string) => {
navigator?.clipboard
@@ -73,13 +57,6 @@ export const MoreMenu = () => {
});
};
- const apiTypeEndpointMap: Partial> = {
- "quick-access": `/-/instant/${itemZUID}.json`,
- "site-generators": item ? `/${item?.web?.path}/?toJSON` : "/?toJSON",
- };
-
- const liveDomain = domains?.find((domain) => domain.branch == "live");
-
return (
<>
{
Copy ZUID
-
+
+
{type !== "dataset" && (
-
+
+
)}
-
+
+
{!isDataset && (
-
+
+
)}
-
-
-
diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx
index b791dae062..75d836eea0 100644
--- a/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx
+++ b/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx
@@ -1,12 +1,23 @@
-import { Box, Menu, MenuItem, Button, Typography } from "@mui/material";
+import {
+ Box,
+ Menu,
+ MenuItem,
+ Button,
+ Typography,
+ MenuList,
+ ListItemText,
+} from "@mui/material";
import {
DateFilter,
FilterButton,
UserFilter,
} from "../../../../../../shell/components/Filters";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useState, useContext } from "react";
import { useParams } from "../../../../../../shell/hooks/useParams";
-import { ArrowDropDownOutlined } from "@mui/icons-material";
+import {
+ ChevronRightOutlined,
+ KeyboardArrowDownRounded,
+} from "@mui/icons-material";
import {
useGetContentModelFieldsQuery,
useGetLangsQuery,
@@ -14,12 +25,14 @@ import {
import { useDateFilterParams } from "../../../../../../shell/hooks/useDateFilterParams";
import { useGetUsersQuery } from "../../../../../../shell/services/accounts";
import { useParams as useRouterParams } from "react-router";
+import { CascadingMenuItem } from "../../../../../../shell/components/CascadingMenuItem";
+import { TableSortContext } from "./TableSortProvider";
const SORT_ORDER = {
- dateSaved: "Date Saved",
- datePublished: "Date Published",
- dateCreated: "Date Created",
- status: "Status",
+ lastSaved: "Last Saved",
+ lastPublished: "Last Published",
+ createdOn: "Date Created",
+ version: "Status",
} as const;
const STATUS_FILTER = {
@@ -87,6 +100,9 @@ export const ItemListFilters = () => {
const { data: users } = useGetUsersQuery();
const { data: fields, isFetching: isFieldsFetching } =
useGetContentModelFieldsQuery(modelZUID);
+ const [sortModel, setSortModel] = useContext(TableSortContext);
+
+ const activeSortOrder = sortModel?.[0]?.field;
const userOptions = useMemo(() => {
return users?.map((user) => ({
@@ -97,17 +113,49 @@ export const ItemListFilters = () => {
}));
}, [users]);
+ const handleUpdateSortOrder = (sortType: string) => {
+ setAnchorEl({
+ currentTarget: null,
+ id: "",
+ });
+
+ setSortModel([
+ {
+ field: sortType,
+ sort: "desc",
+ },
+ ]);
+ };
+
+ const getButtonText = (activeSortOrder: string) => {
+ if (!activeSortOrder) {
+ return SORT_ORDER.lastSaved;
+ }
+
+ if (activeSortOrder === "createdBy") {
+ return "Created By";
+ }
+
+ if (activeSortOrder === "zuid") {
+ return "ZUID";
+ }
+
+ if (SORT_ORDER.hasOwnProperty(activeSortOrder)) {
+ return SORT_ORDER[activeSortOrder as keyof typeof SORT_ORDER];
+ }
+
+ const fieldLabel = fields?.find(
+ (field) => field.name === activeSortOrder
+ )?.label;
+ return fieldLabel;
+ };
+
return (
field.name === params.get("sort"))
- ?.label) ??
- SORT_ORDER.dateSaved
- }`}
+ buttonText={`Sort: ${getButtonText(activeSortOrder)}`}
onOpenMenu={(event: React.MouseEvent) => {
setAnchorEl({
currentTarget: event.currentTarget,
@@ -134,23 +182,46 @@ export const ItemListFilters = () => {
>
{Object.entries(SORT_ORDER).map(([key, value]) => (
))}
+
+ More
+
+ >
+ }
+ PaperProps={{
+ sx: {
+ width: 240,
+ },
+ }}
+ >
+
+
+
+
+
{
?.map((field) => (
);
diff --git a/src/apps/media/src/app/components/FileModal/FileTypePreview.tsx b/src/apps/media/src/app/components/FileModal/FileTypePreview.tsx
index 66c22b5b67..7de1e7b234 100644
--- a/src/apps/media/src/app/components/FileModal/FileTypePreview.tsx
+++ b/src/apps/media/src/app/components/FileModal/FileTypePreview.tsx
@@ -32,6 +32,7 @@ import ReportGmailerrorredIcon from "@mui/icons-material/ReportGmailerrorred";
interface Props {
src: string;
filename: string;
+ updatedAt?: string;
imageSettings?: any;
isMediaThumbnail?: boolean;
}
@@ -41,6 +42,7 @@ export const FileTypePreview: FC = ({
filename,
imageSettings,
isMediaThumbnail,
+ updatedAt,
}) => {
const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up("lg"));
@@ -86,6 +88,11 @@ export const FileTypePreview: FC = ({
const defaultImageSettings = {
width: 800,
optimize: "high",
+ // Prevents browser image cache when a certain file has been already replaced
+ ...(!!updatedAt &&
+ !isNaN(new Date(updatedAt).getTime()) && {
+ versionHash: new Date(updatedAt).getTime(),
+ }),
};
if (isLargeScreen) {
@@ -377,7 +384,8 @@ export const FileTypePreview: FC = ({
void;
+ onCancel: () => void;
+};
+export const ReplaceFileModal = ({
+ onClose,
+ originalFile,
+ onCancel,
+}: ReplaceFileModalProps) => {
+ const dispatch = useDispatch();
+ const [newFile, setNewFile] = useState(null);
+ const [showUploadingFileModal, setShowUploadingFileModal] = useState(false);
+ const hiddenFileInput = useRef(null);
+ const { data: currentUserRoles } = useGetCurrentUserRolesQuery();
+ const uploads = useSelector((state: AppState) => state.mediaRevamp.uploads);
+ const filesToUpload = uploads.filter((upload) => upload.status !== "failed");
+
+ const canReplaceImage = currentUserRoles
+ ?.filter((role) => role.entityZUID === instanceZUID)
+ ?.some((role) => ["admin", "owner"].includes(role.name?.toLowerCase()));
+
+ const acceptedExtension =
+ fileExtension(originalFile?.url) === "jpg" ||
+ fileExtension(originalFile?.url) === "jpeg"
+ ? ".jpg, .jpeg"
+ : `.${fileExtension(originalFile?.url)}`;
+
+ useEffect(() => {
+ if (newFile) {
+ dispatch(
+ fileUploadStage([
+ {
+ file: newFile,
+ bin_id: originalFile.bin_id,
+ group_id: originalFile.group_id,
+ replacementFile: true,
+ },
+ ])
+ );
+ setShowUploadingFileModal(true);
+ setNewFile(null);
+ }
+ }, [newFile]);
+
+ const handleCloseUploadingFileModal = () => {
+ dispatch(dismissFileUploads());
+
+ if (filesToUpload.length) {
+ dispatch(
+ mediaManagerApi.util.invalidateTags([
+ { type: "GroupData", id: originalFile.group_id },
+ "BinFiles",
+ ])
+ );
+ }
+
+ onClose();
+ };
+
+ if (!canReplaceImage) {
+ return (
+
+ );
+ }
+
+ if (showUploadingFileModal) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+ {
+ setNewFile(evt.target.files[0]);
+ }}
+ hidden
+ accept={acceptedExtension}
+ style={{ display: "none" }}
+ />
+ >
+ );
+};
diff --git a/src/apps/media/src/app/components/FileModal/index.tsx b/src/apps/media/src/app/components/FileModal/index.tsx
index e726957cd5..282c6877d0 100644
--- a/src/apps/media/src/app/components/FileModal/index.tsx
+++ b/src/apps/media/src/app/components/FileModal/index.tsx
@@ -18,6 +18,7 @@ import { useGetFileQuery } from "../../../../../../shell/services/mediaManager";
import { OTFEditor } from "./OTFEditor";
import { File } from "../../../../../../shell/services/types";
import { useParams } from "../../../../../../shell/hooks/useParams";
+import { ReplaceFileModal } from "./ReplaceFileModal";
const styledModal = {
position: "absolute",
@@ -48,6 +49,8 @@ export const FileModal: FC = ({
const location = useLocation();
const { data, isLoading, isError, isFetching } = useGetFileQuery(fileId);
const [showEdit, setShowEdit] = useState(false);
+ const [showReplaceFileModal, setShowReplaceFileModal] = useState(false);
+ const [fileToReplace, setFileToReplace] = useState(null);
const [params, setParams] = useParams();
const [adjacentFiles, setAdjacentFiles] = useState({
prevFile: null,
@@ -150,128 +153,145 @@ export const FileModal: FC = ({
};
}, []);
+ if (isFetching || (!data && !isError)) {
+ return (
+
+ );
+ }
+
+ if (showReplaceFileModal) {
+ return (
+ setShowReplaceFileModal(false)}
+ onClose={handleCloseModal}
+ />
+ );
+ }
+
+ if (!data) {
+ return <>>;
+ }
+
return (
- <>
- {data && !isError && !isFetching ? (
-
- ) : isFetching || (!data && !isError) ? (
-
+
+
+ )}
+
+ {/* */}
+
-
-
- ) : (
- <>>
- )}
- >
+
+
+
+
+ {showEdit ? (
+
+ ) : (
+ {
+ setShowReplaceFileModal(true);
+ }}
+ />
+ )}
+
+ {/* */}
+
+
);
};
diff --git a/src/apps/media/src/app/components/Thumbnail/ThumbnailContent.tsx b/src/apps/media/src/app/components/Thumbnail/ThumbnailContent.tsx
index 6ce30ef6a5..009ff0de1e 100644
--- a/src/apps/media/src/app/components/Thumbnail/ThumbnailContent.tsx
+++ b/src/apps/media/src/app/components/Thumbnail/ThumbnailContent.tsx
@@ -17,16 +17,20 @@ interface Props {
filename: string;
onFilenameChange?: (value: string) => void;
onTitleChange?: (value: string) => void;
- isEditable?: boolean;
isSelected?: boolean;
+ isFilenameEditable?: boolean;
+ isTitleEditable?: boolean;
+ title?: string;
}
export const ThumbnailContent: FC = ({
filename,
onFilenameChange,
onTitleChange,
- isEditable,
isSelected,
+ isFilenameEditable,
+ isTitleEditable,
+ title,
}) => {
const styledCardContent = {
px: onFilenameChange ? 0 : 1,
@@ -48,13 +52,16 @@ export const ThumbnailContent: FC = ({
{onFilenameChange ? (
-
+
) =>
onFilenameChange(e.target.value.replace(" ", "-"))
}
@@ -79,15 +86,16 @@ export const ThumbnailContent: FC = ({
},
}}
/>
-
+
void;
onTitleChange?: (value: string) => void;
onClick?: () => void;
+ showRemove?: boolean;
+ isFilenameEditable?: boolean;
+ isTitleEditable?: boolean;
+ title?: string;
}
export const Thumbnail: FC = ({
src,
url,
filename,
- isEditable,
+ isDraggable,
showVideo,
onRemove,
onFilenameChange,
@@ -83,6 +87,10 @@ export const Thumbnail: FC = ({
onTitleChange,
imageHeight,
selectable,
+ showRemove = true,
+ isFilenameEditable,
+ isTitleEditable,
+ title,
}) => {
const theme = useTheme();
const imageEl = useRef();
@@ -119,6 +127,10 @@ export const Thumbnail: FC = ({
};
const RemoveIcon = () => {
+ if (!showRemove) {
+ return <>>;
+ }
+
return (
<>
{onRemove && (
@@ -331,7 +343,7 @@ export const Thumbnail: FC = ({
sx={styledCard}
elevation={0}
onClick={isSelecting ? handleSelect : onClick}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
data-cy={id}
>
@@ -408,10 +420,12 @@ export const Thumbnail: FC = ({
file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -425,7 +439,7 @@ export const Thumbnail: FC = ({
sx={styledCard}
elevation={0}
onClick={isSelecting ? handleSelect : onClick}
- draggable={!isEditable}
+ draggable={!isDraggable}
data-cy={id}
onDragStart={(evt) => onDragStart(evt)}
>
@@ -503,8 +517,9 @@ export const Thumbnail: FC = ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -516,7 +531,7 @@ export const Thumbnail: FC = ({
sx={styledCard}
elevation={0}
onClick={isSelecting ? handleSelect : onClick}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -588,7 +604,7 @@ export const Thumbnail: FC = ({
sx={styledCard}
elevation={0}
onClick={isSelecting ? handleSelect : onClick}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -663,7 +680,7 @@ export const Thumbnail: FC = ({
elevation={0}
onClick={isSelecting ? handleSelect : onClick}
data-cy={id}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -737,7 +755,7 @@ export const Thumbnail: FC = ({
elevation={0}
onClick={isSelecting ? handleSelect : onClick}
data-cy={id}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -812,7 +831,7 @@ export const Thumbnail: FC = ({
elevation={0}
onClick={isSelecting ? handleSelect : onClick}
data-cy={id}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -889,7 +909,7 @@ export const Thumbnail: FC = ({
elevation={0}
onClick={isSelecting ? handleSelect : onClick}
data-cy={id}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -972,7 +993,7 @@ export const Thumbnail: FC = ({
elevation={0}
data-cy={id}
onClick={isSelecting ? handleSelect : onClick}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -1069,7 +1091,7 @@ export const Thumbnail: FC = ({
elevation={0}
data-cy={id}
onClick={isSelecting ? handleSelect : onClick}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -1147,7 +1170,7 @@ export const Thumbnail: FC = ({
elevation={0}
onClick={isSelecting ? handleSelect : onClick}
data-cy={id}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -1220,7 +1244,7 @@ export const Thumbnail: FC = ({
elevation={0}
onClick={isSelecting ? handleSelect : onClick}
data-cy={id}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -1293,7 +1318,7 @@ export const Thumbnail: FC = ({
elevation={0}
onClick={isSelecting ? handleSelect : onClick}
data-cy={id}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -1366,7 +1392,7 @@ export const Thumbnail: FC = ({
elevation={0}
data-cy={id}
onClick={isSelecting ? handleSelect : onClick}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -1439,7 +1466,7 @@ export const Thumbnail: FC = ({
elevation={0}
data-cy={id}
onClick={isSelecting ? handleSelect : onClick}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -1515,7 +1543,7 @@ export const Thumbnail: FC = ({
elevation={0}
data-cy={id}
onClick={isSelecting ? handleSelect : onClick}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -1595,7 +1624,7 @@ export const Thumbnail: FC = ({
elevation={0}
onClick={isSelecting ? handleSelect : onClick}
data-cy={id}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -1675,7 +1705,7 @@ export const Thumbnail: FC = ({
elevation={0}
onClick={isSelecting ? handleSelect : onClick}
data-cy={id}
- draggable={!isEditable}
+ draggable={!isDraggable}
onDragStart={(evt) => onDragStart(evt)}
>
= ({
filename={filename}
onFilenameChange={onFilenameChange}
onTitleChange={onTitleChange}
- isEditable={isEditable}
isSelected={selectedFiles.some((file) => file.id === id)}
+ isTitleEditable={isTitleEditable}
+ isFilenameEditable={isFilenameEditable}
/>
);
@@ -1759,6 +1790,6 @@ export const Thumbnail: FC = ({
};
Thumbnail.defaultProps = {
- isEditable: false,
+ isDraggable: false,
showVideo: true,
};
diff --git a/src/apps/media/src/app/components/UploadModal.tsx b/src/apps/media/src/app/components/UploadModal.tsx
index d7cee6d083..3c2c4e0eec 100644
--- a/src/apps/media/src/app/components/UploadModal.tsx
+++ b/src/apps/media/src/app/components/UploadModal.tsx
@@ -31,9 +31,13 @@ import pluralizeWord from "../../../../../utility/pluralizeWord";
export const UploadModal: FC = () => {
const dispatch = useDispatch();
- const uploads = useSelector((state: AppState) => state.mediaRevamp.uploads);
+ const uploads = useSelector((state: AppState) =>
+ state.mediaRevamp.uploads.filter((upload) => !upload.replacementFile)
+ );
const filesToUpload = useSelector((state: AppState) =>
- state.mediaRevamp.uploads.filter((upload) => upload.status !== "failed")
+ state.mediaRevamp.uploads.filter(
+ (upload) => upload.status !== "failed" && !upload.replacementFile
+ )
);
const ids = filesToUpload.length && {
currentBinId: filesToUpload[0].bin_id,
@@ -186,8 +190,14 @@ const UploadErrors = () => {
type UploadHeaderTextProps = {
uploads: Upload[];
+ headerKeyword?: string;
+ showCount?: boolean;
};
-const UploadHeaderText = ({ uploads }: UploadHeaderTextProps) => {
+export const UploadHeaderText = ({
+ uploads,
+ headerKeyword = "File",
+ showCount = true,
+}: UploadHeaderTextProps) => {
const filesUploading = uploads?.filter(
(upload) => upload.status === "inProgress"
);
@@ -225,12 +235,18 @@ const UploadHeaderText = ({ uploads }: UploadHeaderTextProps) => {
)}
+ {showCount ? (
+ filesUploading?.length > 0 ? (
+ filesUploading.length
+ ) : (
+ filesUploaded.length
+ )
+ ) : (
+ <>>
+ )}{" "}
{filesUploading?.length > 0
- ? filesUploading.length
- : filesUploaded.length}{" "}
- {filesUploading?.length > 0
- ? pluralizeWord("File", filesUploading.length)
- : pluralizeWord("File", filesUploaded.length)}{" "}
+ ? pluralizeWord(headerKeyword, filesUploading.length)
+ : pluralizeWord(headerKeyword, filesUploaded.length)}{" "}
{filesUploading?.length > 0 ? "Uploading" : "Uploaded"}
diff --git a/src/apps/media/src/app/components/UploadThumbnail.tsx b/src/apps/media/src/app/components/UploadThumbnail.tsx
index 83504e031f..35d7f0b5c2 100644
--- a/src/apps/media/src/app/components/UploadThumbnail.tsx
+++ b/src/apps/media/src/app/components/UploadThumbnail.tsx
@@ -13,13 +13,23 @@ import {
Upload,
fileUploadSetFilename,
deleteUpload,
+ replaceFile,
} from "../../../../../shell/store/media-revamp";
+import { File as ZestyMediaFile } from "../../../../../shell/services/types";
interface Props {
file: Upload;
+ action?: "new" | "replace";
+ originalFile?: ZestyMediaFile;
+ showRemove?: boolean;
}
-export const UploadThumbnail: FC = ({ file }) => {
+export const UploadThumbnail: FC = ({
+ file,
+ action = "new",
+ originalFile,
+ showRemove = true,
+}) => {
const dispatch = useDispatch();
const { data: bin } = mediaManagerApi.useGetBinQuery(file.bin_id, {
@@ -28,7 +38,11 @@ export const UploadThumbnail: FC = ({ file }) => {
useEffect(() => {
if (bin && file.status === "staged") {
- dispatch(uploadFile(file, bin[0]));
+ if (action === "new") {
+ dispatch(uploadFile(file, bin[0]));
+ } else {
+ dispatch(replaceFile(file, originalFile));
+ }
}
}, [bin]);
@@ -61,10 +75,14 @@ export const UploadThumbnail: FC = ({ file }) => {
>
{
if (file.status === "success") {
dispatch(
diff --git a/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx b/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx
index 4a57292a00..d0b6a5427a 100644
--- a/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx
+++ b/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx
@@ -27,6 +27,7 @@ type DefaultValueProps = {
relatedFieldZUID: string;
};
options: FieldSettingsOptions[];
+ currency?: string;
};
export const DefaultValue = ({
@@ -39,6 +40,7 @@ export const DefaultValue = ({
mediaRules,
relationshipFields,
options,
+ currency,
}: DefaultValueProps) => {
return (
@@ -69,15 +71,16 @@ export const DefaultValue = ({
variant="body3"
color="text.secondary"
fontWeight="600"
- sx={{ mb: 1, display: "block" }}
+ sx={{ display: "block" }}
>
Set a predefined value for this field
}
/>
-
- {isDefaultValueEnabled && (
+
+ {isDefaultValueEnabled && (
+
@@ -102,8 +106,8 @@ export const DefaultValue = ({
- )}
-
+
+ )}
);
};
diff --git a/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx b/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx
index 633776d5a9..91c52679db 100644
--- a/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx
+++ b/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx
@@ -65,6 +65,7 @@ type DefaultValueInputProps = {
relatedFieldZUID: string;
};
options: FieldSettingsOptions[];
+ currency?: string;
};
export const DefaultValueInput = ({
@@ -75,6 +76,7 @@ export const DefaultValueInput = ({
mediaRules,
relationshipFields: { relatedModelZUID, relatedFieldZUID },
options,
+ currency,
}: DefaultValueInputProps) => {
const [imageModal, setImageModal] = useState(null);
const dispatch = useDispatch();
@@ -573,12 +575,11 @@ export const DefaultValueInput = ({
return (
);
case "date":
diff --git a/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx b/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx
index a41e038880..7517f00c6d 100644
--- a/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx
+++ b/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx
@@ -14,6 +14,10 @@ import {
Button,
IconButton,
Stack,
+ AutocompleteProps,
+ InputProps,
+ OutlinedInputProps,
+ FilledInputProps,
} from "@mui/material";
import { SelectChangeEvent } from "@mui/material/Select";
import InfoRoundedIcon from "@mui/icons-material/InfoRounded";
@@ -25,6 +29,7 @@ import { FormValue } from "./views/FieldForm";
import { FieldSettingsOptions } from "../../../../../../shell/services/types";
import { convertDropdownValue } from "../../utils";
import { withCursorPosition } from "../../../../../../shell/components/withCursorPosition";
+import { Currency } from "../../../../../../shell/components/FieldTypeCurrency/currencies";
const TextFieldWithCursorPosition = withCursorPosition(TextField);
@@ -49,7 +54,10 @@ export type FieldNames =
| "regexRestrictPattern"
| "regexRestrictErrorMessage"
| "minValue"
- | "maxValue";
+ | "maxValue"
+ | "currency"
+ | "fileExtensions"
+ | "fileExtensionsErrorMessage";
type FieldType =
| "input"
| "checkbox"
@@ -78,7 +86,14 @@ export interface DropdownOptions {
label: string;
value: string;
}
-interface FieldFormInputProps {
+export type AutocompleteConfig = {
+ inputProps?:
+ | Partial
+ | Partial
+ | Partial;
+ maxHeight?: number;
+};
+type FieldFormInputProps = {
fieldConfig: InputField;
errorMsg?: string | [string, string][];
onDataChange: ({
@@ -89,9 +104,13 @@ interface FieldFormInputProps {
value: FormValue;
}) => void;
prefillData?: FormValue;
- dropdownOptions?: DropdownOptions[];
+ dropdownOptions?: DropdownOptions[] | Currency[];
disabled?: boolean;
-}
+ autocompleteConfig?: AutocompleteConfig;
+} & Pick<
+ AutocompleteProps,
+ "renderOption" | "filterOptions"
+>;
export const FieldFormInput = ({
fieldConfig,
errorMsg,
@@ -99,6 +118,9 @@ export const FieldFormInput = ({
prefillData,
dropdownOptions,
disabled,
+ renderOption,
+ filterOptions,
+ autocompleteConfig,
}: FieldFormInputProps) => {
const options =
fieldConfig.type === "options" ||
@@ -212,9 +234,19 @@ export const FieldFormInput = ({
{fieldConfig.type === "autocomplete" && (
<>
-
- {fieldConfig.label}
-
+
+
+ {fieldConfig.label}
+
+ {fieldConfig.tooltip && (
+
+
+
+ )}
+
)}
isOptionEqualToValue={(option, value) =>
@@ -246,16 +284,22 @@ export const FieldFormInput = ({
height: "40px",
},
}}
+ renderOption={renderOption}
+ filterOptions={filterOptions}
+ slotProps={{
+ paper: {
+ sx: {
+ "& .MuiAutocomplete-listbox": {
+ maxHeight: autocompleteConfig?.maxHeight || "40vh",
+ boxSizing: "border-box",
+ },
+ },
+ },
+ }}
/>
{prefillData &&
!dropdownOptions.find((option) => option.value === prefillData) && (
-
+
{fieldConfig.name === "group_id" &&
"The folder this was locked to has been deleted"}
{fieldConfig.name === "relatedModelZUID" &&
@@ -324,12 +368,9 @@ export const FieldFormInput = ({
error={Boolean(errorMsg)}
helperText={
errorMsg && (
-
+
{errorMsg}
-
+
)
}
type={fieldConfig.inputType || "text"}
@@ -445,9 +486,9 @@ const KeyValueInput = ({
handleDataChanged("value", e.target?.value);
}}
helperText={
-
+
{labelErrorMsg}
-
+
}
error={Boolean(labelErrorMsg)}
disabled={disabledFields.includes("value")}
@@ -463,9 +504,9 @@ const KeyValueInput = ({
handleDataChanged("key", e.target?.value);
}}
helperText={
-
+
{valueErrorMsg}
-
+
}
error={Boolean(valueErrorMsg)}
disabled={disabledFields.includes("key")}
diff --git a/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx b/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx
index 18223ec1a1..101eb794fc 100644
--- a/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx
+++ b/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx
@@ -4,14 +4,22 @@ import {
Checkbox,
Typography,
Stack,
+ InputLabel,
+ Autocomplete,
+ TextField,
+ Chip,
} from "@mui/material";
-import { FormValue } from "./views/FieldForm";
+import { Errors, FormValue } from "./views/FieldForm";
import { CustomGroup } from "../hooks/useMediaRules";
-
import { InputField } from "./FieldFormInput";
import { FieldFormInput, FieldNames } from "./FieldFormInput";
+import { useEffect, useState } from "react";
+
+type MediaFieldName = Extract<
+ FieldNames,
+ "limit" | "group_id" | "fileExtensions"
+>;
-type MediaFieldName = Extract;
const MediaLabelsConfig: {
[key in MediaFieldName]: { label: string; subLabel: string };
} = {
@@ -24,8 +32,83 @@ const MediaLabelsConfig: {
label: "Lock to a folder",
subLabel: "Ensures files can only be selected from a specific folder",
},
+ fileExtensions: {
+ label: "Limit File Types",
+ subLabel: "Ensures only certain file types can be accepted",
+ },
};
+const ExtensionPresets = [
+ {
+ label: "Images",
+ value: [".png", ".jpg", ".jpeg", ".svg", ".gif", ".tif", ".webp"],
+ },
+ {
+ label: "Videos",
+ value: [
+ ".mob",
+ ".avi",
+ ".wmv",
+ ".mp4",
+ ".mpeg",
+ ".mkv",
+ ".m4v",
+ ".mpg",
+ ".webm",
+ ],
+ },
+ {
+ label: "Audios",
+ value: [
+ ".mp3",
+ ".flac",
+ ".wav",
+ ".m4a",
+ ".aac",
+ ".ape",
+ ".opus",
+ ".aiff",
+ ".aif",
+ ],
+ },
+ {
+ label: "Documents",
+ value: [".doc", ".pdf", ".docx", ".txt", ".rtf", ".odt", ".pages"],
+ },
+ {
+ label: "Presentations",
+ value: [
+ ".ppt",
+ ".pptx",
+ ".key",
+ ".odp",
+ ".pps",
+ ".ppsx",
+ ".sldx",
+ ".potx",
+ ".otp",
+ ".sxi",
+ ],
+ },
+ {
+ label: "Spreadsheets",
+ value: [
+ ".xls",
+ ".xlsx",
+ ".csv",
+ ".tsv",
+ ".numbers",
+ ".ods",
+ ".xlsm",
+ ".xlsb",
+ ".xlt",
+ ".xltx",
+ ],
+ },
+] as const;
+
+const RestrictedExtensions = [".exe", ".dmg"];
+
interface Props {
fieldConfig: InputField[];
groups: CustomGroup[];
@@ -37,13 +120,90 @@ interface Props {
value: FormValue;
}) => void;
fieldData: { [key: string]: FormValue };
+ errors: Errors;
}
+
export const MediaRules = ({
fieldConfig,
onDataChange,
groups,
fieldData,
+ errors,
}: Props) => {
+ const [inputValue, setInputValue] = useState("");
+ const [autoFill, setAutoFill] = useState(
+ !fieldData.fileExtensionsErrorMessage
+ );
+ const [extensionsError, setExtensionsError] = useState(false);
+
+ useEffect(() => {
+ if (autoFill) {
+ onDataChange({
+ inputName: "fileExtensionsErrorMessage",
+ value:
+ "Only files with the following extensions are allowed: " +
+ (fieldData["fileExtensions"] as string[])?.join(", "),
+ });
+ }
+ }, [autoFill, fieldData["fileExtensions"]]);
+
+ const handleInputChange = (
+ event: any,
+ newInputValue: string,
+ ruleName: string
+ ) => {
+ const formattedInput = newInputValue.trim().toLowerCase();
+ if (formattedInput && formattedInput[0] !== ".") {
+ setInputValue(`.${formattedInput}`);
+ } else {
+ setInputValue(formattedInput);
+ }
+ };
+
+ const handleKeyDown = (event: any, ruleName: string) => {
+ if (
+ (event.key === "Enter" || event.key === "," || event.key === " ") &&
+ inputValue
+ ) {
+ event.preventDefault();
+ const newOption = inputValue.toLowerCase().trim();
+ if (
+ newOption &&
+ !(fieldData[ruleName] as string[]).includes(newOption) &&
+ !RestrictedExtensions.includes(newOption)
+ ) {
+ onDataChange({
+ inputName: ruleName,
+ value: [...(fieldData[ruleName] as string[]), newOption],
+ });
+ setInputValue("");
+ }
+ } else if (event.key === "Backspace" && !inputValue) {
+ const newTags = [...(fieldData[ruleName] as string[])];
+ newTags.pop();
+ if (!newTags.length) {
+ setExtensionsError(true);
+ }
+ onDataChange({
+ inputName: ruleName,
+ value: newTags,
+ });
+ }
+ };
+
+ const handleDelete = (option: string, ruleName: string) => {
+ const newTags = (fieldData[ruleName] as string[]).filter(
+ (item) => item.trim() !== option.trim()
+ );
+ if (!newTags.length) {
+ setExtensionsError(true);
+ }
+ onDataChange({
+ inputName: ruleName,
+ value: newTags,
+ });
+ };
+
return (
{fieldConfig?.map((rule: InputField, key: number) => {
- if (rule.name === "defaultValue") return;
+ if (
+ rule.name === "defaultValue" ||
+ rule.name === "fileExtensionsErrorMessage"
+ )
+ return null;
return (
@@ -101,7 +274,9 @@ export const MediaRules = ({
}
/>
- {Boolean(fieldData[rule.name]) && (
+ {Boolean(
+ fieldData[rule.name] && rule.name !== "fileExtensions"
+ ) && (
)}
+
+ {Boolean(fieldData[rule.name]) && rule.name === "fileExtensions" && (
+
+ Extensions *
+ (
+ handleKeyDown(event, rule.name)}
+ />
+ )}
+ onInputChange={(event, newInputValue) =>
+ handleInputChange(event, newInputValue, rule.name)
+ }
+ renderTags={(tagValue, getTagProps) =>
+ tagValue.map((option, index) => (
+ handleDelete(option, rule.name)}
+ clickable={false}
+ sx={{
+ backgroundColor: "common.white",
+ borderColor: "grey.300",
+ borderWidth: 1,
+ borderStyle: "solid",
+ }}
+ />
+ ))
+ }
+ />
+ {errors["fileExtensions"] && extensionsError && (
+
+ {errors["fileExtensions"]}
+
+ )}
+
+ Add:
+ {ExtensionPresets.map((preset) => (
+ {
+ const newTags = fieldData[rule.name] as string[];
+ const tags = new Set(newTags);
+ preset.value.forEach((tag) => tags.add(tag));
+ onDataChange({
+ inputName: rule.name,
+ value: Array.from(tags),
+ });
+ }}
+ sx={{
+ backgroundColor: "common.white",
+ borderColor: "grey.300",
+ borderWidth: 1,
+ borderStyle: "solid",
+ }}
+ />
+ ))}
+
+
+ Custom Error Message *
+
+ {
+ setAutoFill(false);
+ onDataChange({
+ inputName: "fileExtensionsErrorMessage",
+ value: e.target.value,
+ });
+ }}
+ />
+ {errors["fileExtensionsErrorMessage"] && (
+
+ {errors["fileExtensionsErrorMessage"]}
+
+ )}
+
+ )}
);
})}
diff --git a/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx b/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx
index c315404b73..661edcf3ff 100644
--- a/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx
+++ b/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx
@@ -13,6 +13,11 @@ import {
Button,
Grid,
Stack,
+ ListItem,
+ FilledInputProps,
+ InputProps,
+ OutlinedInputProps,
+ InputAdornment,
} from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import { isEmpty } from "lodash";
@@ -27,7 +32,11 @@ import PauseCircleOutlineRoundedIcon from "@mui/icons-material/PauseCircleOutlin
import PlayCircleOutlineRoundedIcon from "@mui/icons-material/PlayCircleOutlineRounded";
import { FieldIcon } from "../../Field/FieldIcon";
-import { FieldFormInput, DropdownOptions } from "../FieldFormInput";
+import {
+ FieldFormInput,
+ DropdownOptions,
+ AutocompleteConfig,
+} from "../FieldFormInput";
import { useMediaRules } from "../../hooks/useMediaRules";
import { MediaRules } from "../MediaRules";
import {
@@ -64,6 +73,11 @@ import { DefaultValue } from "../DefaultValue";
import { CharacterLimit } from "../CharacterLimit";
import { Rules } from "./Rules";
import { MaxLengths } from "../../../../../../content-editor/src/app/components/Editor/Editor";
+import {
+ Currency,
+ currencies,
+} from "../../../../../../../shell/components/FieldTypeCurrency/currencies";
+import getFlagEmoji from "../../../../../../../utility/getFlagEmoji";
type ActiveTab = "details" | "rules" | "learn";
type Params = {
@@ -223,6 +237,12 @@ export const FieldForm = ({
formFields[field.name] = fieldData.settings[field.name] ?? null;
} else if (field.name === "maxValue") {
formFields[field.name] = fieldData.settings[field.name] ?? null;
+ } else if (field.name === "currency") {
+ formFields[field.name] = fieldData.settings?.currency ?? "USD";
+ } else if (field.name === "fileExtensions") {
+ formFields[field.name] = fieldData.settings[field.name] ?? null;
+ } else if (field.name === "fileExtensionsErrorMessage") {
+ formFields[field.name] = fieldData.settings[field.name] ?? null;
} else {
formFields[field.name] = fieldData[field.name] as FormValue;
}
@@ -249,7 +269,9 @@ export const FieldForm = ({
field.name === "regexRestrictPattern" ||
field.name === "regexRestrictErrorMessage" ||
field.name === "minValue" ||
- field.name === "maxValue"
+ field.name === "maxValue" ||
+ field.name === "fileExtensions" ||
+ field.name === "fileExtensionsErrorMessage"
) {
formFields[field.name] = null;
} else {
@@ -393,6 +415,26 @@ export const FieldForm = ({
}
}
+ if (inputName === "currency" && !formData.currency) {
+ newErrorsObj[inputName] = "Please select a currency";
+ }
+
+ if (
+ inputName === "fileExtensions" &&
+ formData.fileExtensions !== null &&
+ !(formData.fileExtensions as string[])?.length
+ ) {
+ newErrorsObj[inputName] = "This field is required";
+ }
+
+ if (
+ inputName === "fileExtensionsErrorMessage" &&
+ formData.fileExtensions !== null &&
+ formData.fileExtensionsErrorMessage === ""
+ ) {
+ newErrorsObj[inputName] = "This field is required";
+ }
+
if (
inputName in errors &&
![
@@ -405,6 +447,9 @@ export const FieldForm = ({
"regexRestrictErrorMessage",
"minValue",
"maxValue",
+ "currency",
+ "fileExtensions",
+ "fileExtensionsErrorMessage",
].includes(inputName)
) {
const { maxLength, label, validate } = FORM_CONFIG[type].details.find(
@@ -508,7 +553,9 @@ export const FieldForm = ({
errors.regexRestrictPattern ||
errors.regexRestrictErrorMessage ||
errors.minValue ||
- errors.maxValue
+ errors.maxValue ||
+ errors.fileExtensions ||
+ errors.fileExtensionsErrorMessage
) {
setActiveTab("rules");
} else {
@@ -562,6 +609,16 @@ export const FieldForm = ({
...(formData.maxValue !== null && {
maxValue: formData.maxValue as number,
}),
+ ...(formData.currency !== null && {
+ currency: formData.currency as string,
+ }),
+ ...(formData.fileExtensions && {
+ fileExtensions: formData.fileExtensions as string[],
+ }),
+ ...(formData.fileExtensionsErrorMessage && {
+ fileExtensionsErrorMessage:
+ formData.fileExtensionsErrorMessage as string,
+ }),
},
sort: isUpdateField ? fieldData.sort : sort, // Just use the length since sort starts at 0
};
@@ -757,6 +814,9 @@ export const FieldForm = ({
let dropdownOptions: DropdownOptions[];
let disabled = false;
+ let renderOption: any;
+ let filterOptions: any;
+ let autocompleteConfig: AutocompleteConfig = {};
if (fieldConfig.name === "relatedModelZUID") {
dropdownOptions = modelsOptions;
@@ -768,6 +828,74 @@ export const FieldForm = ({
disabled = isFetchingSelectedModelFields;
}
+ if (fieldConfig.name === "currency") {
+ const selectedValue = currencies.find(
+ (currency) => currency.value === formData.currency
+ );
+ dropdownOptions = currencies;
+ renderOption = (props: any, value: Currency) => (
+
+
+
+ {value.value} {value.symbol_native}
+
+ {value.label}
+
+ );
+ filterOptions = (options: Currency[], state: any) => {
+ if (state.inputValue) {
+ return options.filter(
+ (option) =>
+ option.label
+ ?.toLowerCase()
+ .includes(state.inputValue.toLowerCase()) ||
+ option.value
+ ?.toLowerCase()
+ .includes(state.inputValue.toLowerCase())
+ );
+ } else {
+ return options;
+ }
+ };
+ autocompleteConfig.inputProps = {
+ startAdornment: !!selectedValue && (
+
+
+
+ {selectedValue.value} {selectedValue.symbol_native}
+
+
+ ),
+ };
+ autocompleteConfig.maxHeight = 256;
+ }
+
return (
);
})}
diff --git a/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx b/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx
index 8c7d473707..a92b5caf27 100644
--- a/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx
+++ b/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx
@@ -48,18 +48,6 @@ export const Rules = ({
return (
- {type === "images" && (
-
- )}
-
+ {type === "images" && (
+
+ )}
+
{(type === "text" || type === "textarea") && (
<>
{
+ const { contentModelZUID } = useParams<{ contentModelZUID: string }>();
+ const { data: modelData } = useGetContentModelQuery(contentModelZUID, {
+ skip: !contentModelZUID,
+ });
+
+ const filteredApiTypes = useMemo(() => {
+ if (modelData?.type === "dataset") {
+ return apiTypes.filter((apiType) => apiType !== "site-generators");
+ }
+
+ return apiTypes;
+ }, [modelData]);
+
return (
{
height: "100%",
}}
>
- {apiTypes.map((apiType) => (
+ {filteredApiTypes?.map((apiType) => (
))}
diff --git a/src/apps/schema/src/app/components/ModelApi/ApiDetails.tsx b/src/apps/schema/src/app/components/ModelApi/ApiDetails.tsx
index 0e14b4327a..89822d8800 100644
--- a/src/apps/schema/src/app/components/ModelApi/ApiDetails.tsx
+++ b/src/apps/schema/src/app/components/ModelApi/ApiDetails.tsx
@@ -8,7 +8,7 @@ import {
Stack,
CircularProgress,
} from "@mui/material";
-import { useHistory, useLocation } from "react-router";
+import { useHistory, useLocation, useParams } from "react-router";
import { SvgIconComponent } from "@mui/icons-material";
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
import BoltRoundedIcon from "@mui/icons-material/BoltRounded";
@@ -24,8 +24,12 @@ import { useSelector } from "react-redux";
import { AppState } from "../../../../../../shell/store/types";
import { ApiDomainEndpoints } from "./ApiDomainEndpoints";
import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
-import { useGetInstanceSettingsQuery } from "../../../../../../shell/services/instance";
+import {
+ useGetContentModelQuery,
+ useGetInstanceSettingsQuery,
+} from "../../../../../../shell/services/instance";
import { HeadlessSwitcher } from "./HeadlessSwitcher";
+import { useEffect, useMemo } from "react";
const apiTypeIconMap: Record = {
"quick-access": BoltRoundedIcon,
@@ -46,6 +50,10 @@ const apiTypesWithEndpoints = [
export const ApiDetails = () => {
const history = useHistory();
const location = useLocation();
+ const { contentModelZUID } = useParams<{ contentModelZUID: string }>();
+ const { data: modelData } = useGetContentModelQuery(contentModelZUID, {
+ skip: !contentModelZUID,
+ });
const installedApps = useSelector((state: AppState) => state.apps.installed);
const selectedType = location.pathname.split("/").pop() as ApiType;
const { data: instanceSettings, isFetching } = useGetInstanceSettingsQuery(
@@ -56,6 +64,14 @@ export const ApiDetails = () => {
instanceSettings?.find((setting) => setting.key === "mode")?.value !==
"traditional";
+ const filteredApiTypes = useMemo(() => {
+ if (modelData?.type === "dataset") {
+ return apiTypes.filter((apiType) => apiType !== "site-generators");
+ }
+
+ return apiTypes;
+ }, [modelData]);
+
const handleVisualLayoutClick = () => {
const layoutApp = installedApps?.find(
(app: any) => app?.name === "layouts"
@@ -67,6 +83,12 @@ export const ApiDetails = () => {
}
};
+ useEffect(() => {
+ if (selectedType === "site-generators" && modelData?.type === "dataset") {
+ history.replace(`${location.pathname.split("/").slice(0, -1).join("/")}`);
+ }
+ }, [selectedType, modelData]);
+
return (
{
- {apiTypes.map((type) => (
+ {filteredApiTypes?.map((type) => (
= {
rules: [...COMMON_RULES],
},
currency: {
- details: [...COMMON_FIELDS],
+ details: [
+ {
+ name: "currency",
+ type: "autocomplete",
+ label: "Currency",
+ required: true,
+ gridSize: 12,
+ tooltip:
+ "The selected currency code, symbol, and flag will be displayed for this field in the content item and can be accessed through the field settings via the API.",
+ placeholder: "Select a Currency",
+ autoFocus: true,
+ },
+ {
+ ...COMMON_FIELDS[0],
+ autoFocus: false,
+ },
+ ...COMMON_FIELDS.slice(1),
+ ],
rules: [...COMMON_RULES, ...INPUT_RANGE_RULES],
},
date: {
@@ -556,6 +573,20 @@ const FORM_CONFIG: Record = {
required: false,
gridSize: 12,
},
+ {
+ name: "fileExtensions",
+ type: "input",
+ label: "File Extensions",
+ required: false,
+ gridSize: 12,
+ },
+ {
+ name: "fileExtensionsErrorMessage",
+ type: "input",
+ label: "File extensions error message",
+ required: false,
+ gridSize: 12,
+ },
...COMMON_RULES,
],
},
diff --git a/src/apps/seo/src/views/RedirectsManager/RedirectsTable/RedirectCreator/RedirectCreator.js b/src/apps/seo/src/views/RedirectsManager/RedirectsTable/RedirectCreator/RedirectCreator.js
index 2b5e624c94..50b0fc2646 100644
--- a/src/apps/seo/src/views/RedirectsManager/RedirectsTable/RedirectCreator/RedirectCreator.js
+++ b/src/apps/seo/src/views/RedirectsManager/RedirectsTable/RedirectCreator/RedirectCreator.js
@@ -48,7 +48,6 @@ export function RedirectCreator(props) {
)
.then(() => {
setFrom("");
- setTo("");
setContentSearchValue("");
});
};
@@ -123,7 +122,7 @@ export function RedirectCreator(props) {
variant="outlined"
size="small"
fullWidth
- defaultValue={to}
+ value={to}
InputProps={{
startAdornment: (
diff --git a/src/shell/app.config.js b/src/shell/app.config.js
index 2a15352d20..ab721713b8 100644
--- a/src/shell/app.config.js
+++ b/src/shell/app.config.js
@@ -8,6 +8,7 @@ module.exports = {
API_METRICS: "https://metrics.api.zesty.io",
API_INSTANCE: ".api.zesty.io/v1",
API_INSTANCE_PROTOCOL: "https://",
+ API_ANALYTICS: "https://analytics.api.zesty.io",
CLOUD_FUNCTIONS_DOMAIN: "https://us-central1-zesty-prod.cloudfunctions.net",
@@ -56,6 +57,7 @@ module.exports = {
API_METRICS: "https://metrics.api.stage.zesty.io",
API_INSTANCE: ".api.stage.zesty.io/v1",
API_INSTANCE_PROTOCOL: "https://",
+ API_ANALYTICS: "https://analytics.api.stage.zesty.io",
CLOUD_FUNCTIONS_DOMAIN:
"https://us-central1-zesty-stage.cloudfunctions.net",
@@ -80,7 +82,7 @@ module.exports = {
URL_ACCOUNTS: "https://accounts.stage.zesty.io",
URL_MARKETPLACE:
"https://kfg6bckb-dev.webengine.zesty.io/marketplace/apps/",
- URL_APPS: "https://apps.stage.zesty.io",
+ URL_APPS: "https://apps-beta.zesty.io",
COOKIE_NAME: "STAGE_APP_SID",
COOKIE_DOMAIN: ".zesty.io",
@@ -101,6 +103,7 @@ module.exports = {
API_METRICS: "https://metrics.api.dev.zesty.io",
API_INSTANCE: ".api.dev.zesty.io/v1",
API_INSTANCE_PROTOCOL: "https://",
+ API_ANALYTICS: "https://analytics-api-m3rbwjxm5q-uc.a.run.app",
CLOUD_FUNCTIONS_DOMAIN: "https://us-central1-zesty-dev.cloudfunctions.net",
@@ -129,7 +132,7 @@ module.exports = {
URL_ACCOUNTS: "https://zesty.io",
URL_MARKETPLACE:
"https://kfg6bckb-dev.webengine.zesty.io/marketplace/apps/",
- URL_APPS: "https://apps.dev.zesty.io",
+ URL_APPS: "https://apps-beta.zesty.io",
COOKIE_NAME: "DEV_APP_SID",
COOKIE_DOMAIN: ".zesty.io",
@@ -147,6 +150,7 @@ module.exports = {
API_ACCOUNTS: "//accounts.api.zesty.localdev:3022/v1",
API_INSTANCE: ".api.zesty.localdev:3023/v1",
API_INSTANCE_PROTOCOL: "http://",
+ API_ANALYTICS: "https://analytics-api-m3rbwjxm5q-uc.a.run.app",
SERVICE_AUTH: "http://auth.api.zesty.localdev:3011",
SERVICE_EMAIL: "",
diff --git a/src/shell/components/CascadingMenuItem/index.tsx b/src/shell/components/CascadingMenuItem/index.tsx
index b929507ad7..abd0bdce44 100644
--- a/src/shell/components/CascadingMenuItem/index.tsx
+++ b/src/shell/components/CascadingMenuItem/index.tsx
@@ -1,4 +1,4 @@
-import React, { FC, useState } from "react";
+import React, { FC, useEffect, useState } from "react";
import {
MenuItem,
MenuItemProps,
@@ -23,11 +23,37 @@ export const CascadingMenuItem: FC = ({
...props
}) => {
const [anchorEl, setAnchorEl] = useState(null);
+ const [isChildHovered, setIsChildHovered] = useState(false);
+ const [isParentHovered, setIsParentHovered] = useState(false);
+
+ /** Note: This essentially adds a small delay to allow a user to move their mouse
+ * to the child component instead of just immediately closing it outright
+ */
+ useEffect(() => {
+ let timeoutId: NodeJS.Timeout;
+
+ if (!isParentHovered) {
+ timeoutId = setTimeout(() => {
+ if (!isChildHovered) {
+ setAnchorEl(null);
+ }
+ }, 100);
+ }
+
+ return () => {
+ clearTimeout(timeoutId);
+ };
+ }, [isParentHovered, isChildHovered]);
return (
setAnchorEl(evt.currentTarget)}
- onMouseLeave={() => setAnchorEl(null)}
+ onMouseEnter={(evt) => {
+ setAnchorEl(evt.currentTarget);
+ setIsParentHovered(true);
+ }}
+ onMouseLeave={() => {
+ setIsParentHovered(false);
+ }}
sx={{
// HACK: Prevents the menu item to be in active style state when the sub-menu is opened.
"&.MuiMenuItem-root": {
@@ -51,7 +77,17 @@ export const CascadingMenuItem: FC = ({
}}
{...PopperProps}
>
-
+ {
+ setIsChildHovered(true);
+ }}
+ onMouseLeave={() => {
+ setIsChildHovered(false);
+ setAnchorEl(null);
+ }}
+ >
{children}
diff --git a/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.js b/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.js
deleted file mode 100644
index 19ccb43fea..0000000000
--- a/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import React, { useState } from "react";
-
-import { Input } from "@zesty-io/core/Input";
-import { Select, Option } from "@zesty-io/core/Select";
-import { currencies } from "./currencies";
-
-import styles from "./FieldTypeCurrency.less";
-export const FieldTypeCurrency = React.memo(function FieldTypeCurrency(props) {
- // console.log("FieldTypeCurrency:render");
-
- const [monetaryValue, setMonetaryValue] = useState(props.value || "0.00");
- const [currency, setCurrency] = useState(
- (props.code && currencies[props.code]) || currencies["USD"]
- );
-
- return (
-
- );
-});
diff --git a/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.less b/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.less
deleted file mode 100644
index 03c4c02fe0..0000000000
--- a/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.less
+++ /dev/null
@@ -1,52 +0,0 @@
-@import "~@zesty-io/core/colors.less";
-@import "~@zesty-io/core/typography.less";
-
-.FieldTypeCurrency {
- display: flex;
- flex-direction: column;
- max-width: 300px;
-
- .FieldTypeCurrencyLabel {
- display: flex;
- justify-content: space-between;
- margin-bottom: 3px;
- font-size: @font-size-label;
- span {
- display: flex;
- }
- }
-
- .CurrencyFields {
- display: flex;
- .SelectCurrency {
- width: 100px;
- span > span {
- background: @zesty-tab-blue;
- color: @white;
- border: @zesty-tab-blue;
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- }
-
- // &:active,
- // &:focus,
- // &:hover {
- // span > span {
- // color: #5b667d;
- // }
- // }
-
- ul {
- left: 0;
- min-width: 320px;
- }
- }
- .CurrencyInput {
- width: 100%;
- border-radius: 0px 8px 8px 0px;
- }
- }
-}
-.CurrencyInput {
- border: 1px solid #f2f4f7;
-}
diff --git a/src/shell/components/FieldTypeCurrency/currencies.js b/src/shell/components/FieldTypeCurrency/currencies.ts
similarity index 62%
rename from src/shell/components/FieldTypeCurrency/currencies.js
rename to src/shell/components/FieldTypeCurrency/currencies.ts
index 10102d6fa9..ab439b5265 100644
--- a/src/shell/components/FieldTypeCurrency/currencies.js
+++ b/src/shell/components/FieldTypeCurrency/currencies.ts
@@ -1,1064 +1,1193 @@
-export const currencies = {
- USD: {
- symbol: " $ ",
- name: "US Dollar",
- symbol_native: "$",
- decimal_digits: 2,
- rounding: 0,
- code: "USD",
- name_plural: "US dollars",
- },
- CAD: {
- symbol: "CA$ ",
- name: "Canadian Dollar",
- symbol_native: "$",
- decimal_digits: 2,
- rounding: 0,
- code: "CAD",
- name_plural: "Canadian dollars",
- },
- EUR: {
- symbol: " € ",
- name: "Euro",
- symbol_native: "€",
- decimal_digits: 2,
- rounding: 0,
- code: "EUR",
- name_plural: "euros",
- },
- AED: {
- symbol: "AED",
- name: "United Arab Emirates Dirham",
- symbol_native: "د.إ.",
- decimal_digits: 2,
- rounding: 0,
- code: "AED",
- name_plural: "UAE dirhams",
- },
- AFN: {
+export type Currency = {
+ symbol: string;
+ label: string;
+ symbol_native: string;
+ decimal_digits: number;
+ rounding: number;
+ value: string;
+ name_plural: string;
+ countryCode: string;
+};
+
+export const currencies: Currency[] = [
+ {
symbol: "Af ",
- name: "Afghan Afghani",
+ label: "Afghan Afghani",
symbol_native: "؋",
decimal_digits: 0,
rounding: 0,
- code: "AFN",
+ value: "AFN",
name_plural: "Afghan Afghanis",
+ countryCode: "AF",
},
- ALL: {
+ {
symbol: "ALL",
- name: "Albanian Lek",
+ label: "Albanian Lek",
symbol_native: "Lek",
decimal_digits: 0,
rounding: 0,
- code: "ALL",
+ value: "ALL",
name_plural: "Albanian lekë",
+ countryCode: "AL",
},
- AMD: {
- symbol: "AMD",
- name: "Armenian Dram",
- symbol_native: "դր.",
- decimal_digits: 0,
+ {
+ symbol: "DA",
+ label: "Algerian Dinar",
+ symbol_native: "د.ج.",
+ decimal_digits: 2,
rounding: 0,
- code: "AMD",
- name_plural: "Armenian drams",
+ value: "DZD",
+ name_plural: "Algerian dinars",
+ countryCode: "DZ",
},
- ARS: {
+ {
symbol: "AR$",
- name: "Argentine Peso",
+ label: "Argentine Peso",
symbol_native: "$",
decimal_digits: 2,
rounding: 0,
- code: "ARS",
+ value: "ARS",
name_plural: "Argentine pesos",
+ countryCode: "AR",
},
- AUD: {
+ {
+ symbol: "AMD",
+ label: "Armenian Dram",
+ symbol_native: "դր.",
+ decimal_digits: 0,
+ rounding: 0,
+ value: "AMD",
+ name_plural: "Armenian drams",
+ countryCode: "AM",
+ },
+ {
symbol: "AU$",
- name: "Australian Dollar",
+ label: "Australian Dollar",
symbol_native: "$",
decimal_digits: 2,
rounding: 0,
- code: "AUD",
+ value: "AUD",
name_plural: "Australian dollars",
+ countryCode: "AU",
},
- AZN: {
+ {
symbol: "man.",
- name: "Azerbaijani Manat",
+ label: "Azerbaijani Manat",
symbol_native: "ман.",
decimal_digits: 2,
rounding: 0,
- code: "AZN",
+ value: "AZN",
name_plural: "Azerbaijani manats",
+ countryCode: "AZ",
},
- BAM: {
- symbol: "KM",
- name: "Bosnia-Herzegovina Convertible Mark",
- symbol_native: "KM",
- decimal_digits: 2,
+ {
+ symbol: "BD",
+ label: "Bahraini Dinar",
+ symbol_native: "د.ب.",
+ decimal_digits: 3,
rounding: 0,
- code: "BAM",
- name_plural: "Bosnia-Herzegovina convertible marks",
+ value: "BHD",
+ name_plural: "Bahraini dinars",
+ countryCode: "BH",
},
- BDT: {
+ {
symbol: "Tk",
- name: "Bangladeshi Taka",
+ label: "Bangladeshi Taka",
symbol_native: "৳",
decimal_digits: 2,
rounding: 0,
- code: "BDT",
+ value: "BDT",
name_plural: "Bangladeshi takas",
+ countryCode: "BD",
},
- BGN: {
- symbol: "BGN",
- name: "Bulgarian Lev",
- symbol_native: "лв.",
- decimal_digits: 2,
- rounding: 0,
- code: "BGN",
- name_plural: "Bulgarian leva",
- },
- BHD: {
- symbol: "BD",
- name: "Bahraini Dinar",
- symbol_native: "د.ب.",
- decimal_digits: 3,
- rounding: 0,
- code: "BHD",
- name_plural: "Bahraini dinars",
- },
- BIF: {
- symbol: "FBu",
- name: "Burundian Franc",
- symbol_native: "FBu",
+ {
+ symbol: "BYR",
+ label: "Belarusian Ruble",
+ symbol_native: "BYR",
decimal_digits: 0,
rounding: 0,
- code: "BIF",
- name_plural: "Burundian francs",
+ value: "BYR",
+ name_plural: "Belarusian rubles",
+ countryCode: "BY",
},
- BND: {
- symbol: "BN$",
- name: "Brunei Dollar",
+ {
+ symbol: "BZ$",
+ label: "Belize Dollar",
symbol_native: "$",
decimal_digits: 2,
rounding: 0,
- code: "BND",
- name_plural: "Brunei dollars",
+ value: "BZD",
+ name_plural: "Belize dollars",
+ countryCode: "BZ",
},
- BOB: {
+ {
symbol: "Bs",
- name: "Bolivian Boliviano",
+ label: "Bolivian Boliviano",
symbol_native: "Bs",
decimal_digits: 2,
rounding: 0,
- code: "BOB",
+ value: "BOB",
name_plural: "Bolivian bolivianos",
+ countryCode: "BO",
},
- BRL: {
- symbol: "R$",
- name: "Brazilian Real",
- symbol_native: "R$",
+ {
+ symbol: "KM",
+ label: "Bosnia-Herzegovina Convertible Mark",
+ symbol_native: "KM",
decimal_digits: 2,
rounding: 0,
- code: "BRL",
- name_plural: "Brazilian reals",
+ value: "BAM",
+ name_plural: "Bosnia-Herzegovina convertible marks",
+ countryCode: "BA",
},
- BWP: {
+ {
symbol: "BWP",
- name: "Botswanan Pula",
+ label: "Botswanan Pula",
symbol_native: "P",
decimal_digits: 2,
rounding: 0,
- code: "BWP",
+ value: "BWP",
name_plural: "Botswanan pulas",
+ countryCode: "BW",
},
- BYR: {
- symbol: "BYR",
- name: "Belarusian Ruble",
- symbol_native: "BYR",
- decimal_digits: 0,
+ {
+ symbol: "R$",
+ label: "Brazilian Real",
+ symbol_native: "R$",
+ decimal_digits: 2,
rounding: 0,
- code: "BYR",
- name_plural: "Belarusian rubles",
+ value: "BRL",
+ name_plural: "Brazilian reals",
+ countryCode: "BR",
},
- BZD: {
- symbol: "BZ$",
- name: "Belize Dollar",
+ {
+ symbol: "£",
+ label: "British Pound Sterling",
+ symbol_native: "£",
+ decimal_digits: 2,
+ rounding: 0,
+ value: "GBP",
+ name_plural: "British pounds sterling",
+ countryCode: "GB",
+ },
+ {
+ symbol: "BN$",
+ label: "Brunei Dollar",
symbol_native: "$",
decimal_digits: 2,
rounding: 0,
- code: "BZD",
- name_plural: "Belize dollars",
+ value: "BND",
+ name_plural: "Brunei dollars",
+ countryCode: "BN",
},
- CDF: {
- symbol: "CDF",
- name: "Congolese Franc",
- symbol_native: "FrCD",
+ {
+ symbol: "BGN",
+ label: "Bulgarian Lev",
+ symbol_native: "лв.",
decimal_digits: 2,
rounding: 0,
- code: "CDF",
- name_plural: "Congolese francs",
+ value: "BGN",
+ name_plural: "Bulgarian leva",
+ countryCode: "BG",
},
- CHF: {
- symbol: "CHF",
- name: "Swiss Franc",
- symbol_native: "CHF",
+ {
+ symbol: "FBu",
+ label: "Burundian Franc",
+ symbol_native: "FBu",
+ decimal_digits: 0,
+ rounding: 0,
+ value: "BIF",
+ name_plural: "Burundian francs",
+ countryCode: "BI",
+ },
+ {
+ symbol: "KHR",
+ label: "Cambodian Riel",
+ symbol_native: "៛",
decimal_digits: 2,
- rounding: 0.05,
- code: "CHF",
- name_plural: "Swiss francs",
+ rounding: 0,
+ value: "KHR",
+ name_plural: "Cambodian riels",
+ countryCode: "KH",
+ },
+ {
+ symbol: "CA$ ",
+ label: "Canadian Dollar",
+ symbol_native: "$",
+ decimal_digits: 2,
+ rounding: 0,
+ value: "CAD",
+ name_plural: "Canadian dollars",
+ countryCode: "CA",
+ },
+ {
+ symbol: "CV$",
+ label: "Cape Verdean Escudo",
+ symbol_native: "CV$",
+ decimal_digits: 2,
+ rounding: 0,
+ value: "CVE",
+ name_plural: "Cape Verdean escudos",
+ countryCode: "CV",
},
- CLP: {
+ {
+ symbol: "CFA",
+ label: "CFA Franc BCEAO",
+ symbol_native: "CFA",
+ decimal_digits: 0,
+ rounding: 0,
+ value: "XOF",
+ name_plural: "CFA francs BCEAO",
+ countryCode: "CI",
+ },
+ {
+ symbol: "FCFA",
+ label: "CFA Franc BEAC",
+ symbol_native: "FCFA",
+ decimal_digits: 0,
+ rounding: 0,
+ value: "XAF",
+ name_plural: "CFA francs BEAC",
+ countryCode: "CF",
+ },
+ {
symbol: "CL$",
- name: "Chilean Peso",
+ label: "Chilean Peso",
symbol_native: "$",
decimal_digits: 0,
rounding: 0,
- code: "CLP",
+ value: "CLP",
name_plural: "Chilean pesos",
+ countryCode: "CL",
},
- CNY: {
+ {
symbol: "CN¥",
- name: "Chinese Yuan",
+ label: "Chinese Yuan",
symbol_native: "CN¥",
decimal_digits: 2,
rounding: 0,
- code: "CNY",
+ value: "CNY",
name_plural: "Chinese yuan",
+ countryCode: "CN",
},
- COP: {
+ {
symbol: "CO$",
- name: "Colombian Peso",
+ label: "Colombian Peso",
symbol_native: "$",
decimal_digits: 0,
rounding: 0,
- code: "COP",
+ value: "COP",
name_plural: "Colombian pesos",
+ countryCode: "CO",
+ },
+ {
+ symbol: "CF",
+ label: "Comorian Franc",
+ symbol_native: "FC",
+ decimal_digits: 0,
+ rounding: 0,
+ value: "KMF",
+ name_plural: "Comorian francs",
+ countryCode: "KM",
},
- CRC: {
+ {
+ symbol: "CDF",
+ label: "Congolese Franc",
+ symbol_native: "FrCD",
+ decimal_digits: 2,
+ rounding: 0,
+ value: "CDF",
+ name_plural: "Congolese francs",
+ countryCode: "CD",
+ },
+ {
symbol: "₡",
- name: "Costa Rican Colón",
+ label: "Costa Rican Colón",
symbol_native: "₡",
decimal_digits: 0,
rounding: 0,
- code: "CRC",
+ value: "CRC",
name_plural: "Costa Rican colóns",
+ countryCode: "CR",
},
- CVE: {
- symbol: "CV$",
- name: "Cape Verdean Escudo",
- symbol_native: "CV$",
+ {
+ symbol: "kn",
+ label: "Croatian Kuna",
+ symbol_native: "kn",
decimal_digits: 2,
rounding: 0,
- code: "CVE",
- name_plural: "Cape Verdean escudos",
+ value: "HRK",
+ name_plural: "Croatian kunas",
+ countryCode: "HR",
},
- CZK: {
+ {
symbol: "Kč",
- name: "Czech Republic Koruna",
+ label: "Czech Republic Koruna",
symbol_native: "Kč",
decimal_digits: 2,
rounding: 0,
- code: "CZK",
+ value: "CZK",
name_plural: "Czech Republic korunas",
+ countryCode: "CZ",
},
- DJF: {
- symbol: "Fdj",
- name: "Djiboutian Franc",
- symbol_native: "Fdj",
- decimal_digits: 0,
- rounding: 0,
- code: "DJF",
- name_plural: "Djiboutian francs",
- },
- DKK: {
+ {
symbol: "Dkr",
- name: "Danish Krone",
+ label: "Danish Krone",
symbol_native: "kr",
decimal_digits: 2,
rounding: 0,
- code: "DKK",
+ value: "DKK",
name_plural: "Danish kroner",
+ countryCode: "DK",
},
- DOP: {
+ {
+ symbol: "Fdj",
+ label: "Djiboutian Franc",
+ symbol_native: "Fdj",
+ decimal_digits: 0,
+ rounding: 0,
+ value: "DJF",
+ name_plural: "Djiboutian francs",
+ countryCode: "DJ",
+ },
+ {
symbol: "RD$",
- name: "Dominican Peso",
+ label: "Dominican Peso",
symbol_native: "RD$",
decimal_digits: 2,
rounding: 0,
- code: "DOP",
+ value: "DOP",
name_plural: "Dominican pesos",
+ countryCode: "DO",
},
- DZD: {
- symbol: "DA",
- name: "Algerian Dinar",
- symbol_native: "د.ج.",
- decimal_digits: 2,
- rounding: 0,
- code: "DZD",
- name_plural: "Algerian dinars",
- },
- EEK: {
- symbol: "Ekr",
- name: "Estonian Kroon",
- symbol_native: "kr",
- decimal_digits: 2,
- rounding: 0,
- code: "EEK",
- name_plural: "Estonian kroons",
- },
- EGP: {
+ {
symbol: "EGP",
- name: "Egyptian Pound",
+ label: "Egyptian Pound",
symbol_native: "ج.م.",
decimal_digits: 2,
rounding: 0,
- code: "EGP",
+ value: "EGP",
name_plural: "Egyptian pounds",
+ countryCode: "EG",
},
- ERN: {
+ {
symbol: "Nfk",
- name: "Eritrean Nakfa",
+ label: "Eritrean Nakfa",
symbol_native: "Nfk",
decimal_digits: 2,
rounding: 0,
- code: "ERN",
+ value: "ERN",
name_plural: "Eritrean nakfas",
+ countryCode: "ER",
},
- ETB: {
+ {
+ symbol: "Ekr",
+ label: "Estonian Kroon",
+ symbol_native: "kr",
+ decimal_digits: 2,
+ rounding: 0,
+ value: "EEK",
+ name_plural: "Estonian kroons",
+ countryCode: "EE",
+ },
+ {
symbol: "Br",
- name: "Ethiopian Birr",
+ label: "Ethiopian Birr",
symbol_native: "Br",
decimal_digits: 2,
rounding: 0,
- code: "ETB",
+ value: "ETB",
name_plural: "Ethiopian birrs",
+ countryCode: "ET",
},
- GBP: {
- symbol: "£",
- name: "British Pound Sterling",
- symbol_native: "£",
+ {
+ symbol: " € ",
+ label: "Euro",
+ symbol_native: "€",
decimal_digits: 2,
rounding: 0,
- code: "GBP",
- name_plural: "British pounds sterling",
+ value: "EUR",
+ name_plural: "euros",
+ countryCode: "EU",
},
- GEL: {
+ {
symbol: "GEL",
- name: "Georgian Lari",
+ label: "Georgian Lari",
symbol_native: "GEL",
decimal_digits: 2,
rounding: 0,
- code: "GEL",
+ value: "GEL",
name_plural: "Georgian laris",
+ countryCode: "GE",
},
- GHS: {
+ {
symbol: "GH₵",
- name: "Ghanaian Cedi",
+ label: "Ghanaian Cedi",
symbol_native: "GH₵",
decimal_digits: 2,
rounding: 0,
- code: "GHS",
+ value: "GHS",
name_plural: "Ghanaian cedis",
+ countryCode: "GH",
},
- GNF: {
- symbol: "FG",
- name: "Guinean Franc",
- symbol_native: "FG",
- decimal_digits: 0,
- rounding: 0,
- code: "GNF",
- name_plural: "Guinean francs",
- },
- GTQ: {
+ {
symbol: "GTQ",
- name: "Guatemalan Quetzal",
+ label: "Guatemalan Quetzal",
symbol_native: "Q",
decimal_digits: 2,
rounding: 0,
- code: "GTQ",
+ value: "GTQ",
name_plural: "Guatemalan quetzals",
+ countryCode: "GT",
},
- HKD: {
- symbol: "HK$",
- name: "Hong Kong Dollar",
- symbol_native: "$",
- decimal_digits: 2,
+ {
+ symbol: "FG",
+ label: "Guinean Franc",
+ symbol_native: "FG",
+ decimal_digits: 0,
rounding: 0,
- code: "HKD",
- name_plural: "Hong Kong dollars",
+ value: "GNF",
+ name_plural: "Guinean francs",
+ countryCode: "GN",
},
- HNL: {
+ {
symbol: "HNL",
- name: "Honduran Lempira",
+ label: "Honduran Lempira",
symbol_native: "L",
decimal_digits: 2,
rounding: 0,
- code: "HNL",
+ value: "HNL",
name_plural: "Honduran lempiras",
+ countryCode: "HN",
},
- HRK: {
- symbol: "kn",
- name: "Croatian Kuna",
- symbol_native: "kn",
+ {
+ symbol: "HK$",
+ label: "Hong Kong Dollar",
+ symbol_native: "$",
decimal_digits: 2,
rounding: 0,
- code: "HRK",
- name_plural: "Croatian kunas",
+ value: "HKD",
+ name_plural: "Hong Kong dollars",
+ countryCode: "HK",
},
- HUF: {
+ {
symbol: "Ft",
- name: "Hungarian Forint",
+ label: "Hungarian Forint",
symbol_native: "Ft",
decimal_digits: 0,
rounding: 0,
- code: "HUF",
+ value: "HUF",
name_plural: "Hungarian forints",
+ countryCode: "HU",
},
- IDR: {
- symbol: "Rp",
- name: "Indonesian Rupiah",
- symbol_native: "Rp",
+ {
+ symbol: "Ikr",
+ label: "Icelandic Króna",
+ symbol_native: "kr",
decimal_digits: 0,
rounding: 0,
- code: "IDR",
- name_plural: "Indonesian rupiahs",
- },
- ILS: {
- symbol: "₪",
- name: "Israeli New Sheqel",
- symbol_native: "₪",
- decimal_digits: 2,
- rounding: 0,
- code: "ILS",
- name_plural: "Israeli new sheqels",
+ value: "ISK",
+ name_plural: "Icelandic krónur",
+ countryCode: "IS",
},
- INR: {
+ {
symbol: "Rs",
- name: "Indian Rupee",
- symbol_native: "টকা",
+ label: "Indian Rupee",
+ symbol_native: "₹",
decimal_digits: 2,
rounding: 0,
- code: "INR",
+ value: "INR",
name_plural: "Indian rupees",
+ countryCode: "IN",
},
- IQD: {
- symbol: "IQD",
- name: "Iraqi Dinar",
- symbol_native: "د.ع.",
+ {
+ symbol: "Rp",
+ label: "Indonesian Rupiah",
+ symbol_native: "Rp",
decimal_digits: 0,
rounding: 0,
- code: "IQD",
- name_plural: "Iraqi dinars",
+ value: "IDR",
+ name_plural: "Indonesian rupiahs",
+ countryCode: "ID",
},
- IRR: {
+ {
symbol: "IRR",
- name: "Iranian Rial",
+ label: "Iranian Rial",
symbol_native: "﷼",
decimal_digits: 0,
rounding: 0,
- code: "IRR",
+ value: "IRR",
name_plural: "Iranian rials",
+ countryCode: "IR",
},
- ISK: {
- symbol: "Ikr",
- name: "Icelandic Króna",
- symbol_native: "kr",
+ {
+ symbol: "IQD",
+ label: "Iraqi Dinar",
+ symbol_native: "د.ع.",
decimal_digits: 0,
rounding: 0,
- code: "ISK",
- name_plural: "Icelandic krónur",
+ value: "IQD",
+ name_plural: "Iraqi dinars",
+ countryCode: "IQ",
+ },
+ {
+ symbol: "₪",
+ label: "Israeli New Sheqel",
+ symbol_native: "₪",
+ decimal_digits: 2,
+ rounding: 0,
+ value: "ILS",
+ name_plural: "Israeli new sheqels",
+ countryCode: "IL",
},
- JMD: {
+ {
symbol: "J$",
- name: "Jamaican Dollar",
+ label: "Jamaican Dollar",
symbol_native: "$",
decimal_digits: 2,
rounding: 0,
- code: "JMD",
+ value: "JMD",
name_plural: "Jamaican dollars",
+ countryCode: "JM",
},
- JOD: {
- symbol: "JD",
- name: "Jordanian Dinar",
- symbol_native: "د.أ.",
- decimal_digits: 3,
- rounding: 0,
- code: "JOD",
- name_plural: "Jordanian dinars",
- },
- JPY: {
+ {
symbol: "¥",
- name: "Japanese Yen",
+ label: "Japanese Yen",
symbol_native: "¥",
decimal_digits: 0,
rounding: 0,
- code: "JPY",
+ value: "JPY",
name_plural: "Japanese yen",
+ countryCode: "JP",
},
- KES: {
- symbol: "Ksh",
- name: "Kenyan Shilling",
- symbol_native: "Ksh",
- decimal_digits: 2,
- rounding: 0,
- code: "KES",
- name_plural: "Kenyan shillings",
- },
- KHR: {
- symbol: "KHR",
- name: "Cambodian Riel",
- symbol_native: "៛",
- decimal_digits: 2,
- rounding: 0,
- code: "KHR",
- name_plural: "Cambodian riels",
- },
- KMF: {
- symbol: "CF",
- name: "Comorian Franc",
- symbol_native: "FC",
- decimal_digits: 0,
- rounding: 0,
- code: "KMF",
- name_plural: "Comorian francs",
- },
- KRW: {
- symbol: "₩",
- name: "South Korean Won",
- symbol_native: "₩",
- decimal_digits: 0,
- rounding: 0,
- code: "KRW",
- name_plural: "South Korean won",
- },
- KWD: {
- symbol: "KD",
- name: "Kuwaiti Dinar",
- symbol_native: "د.ك.",
+ {
+ symbol: "JD",
+ label: "Jordanian Dinar",
+ symbol_native: "د.أ.",
decimal_digits: 3,
rounding: 0,
- code: "KWD",
- name_plural: "Kuwaiti dinars",
+ value: "JOD",
+ name_plural: "Jordanian dinars",
+ countryCode: "JO",
},
- KZT: {
+ {
symbol: "KZT",
- name: "Kazakhstani Tenge",
+ label: "Kazakhstani Tenge",
symbol_native: "тңг.",
- decimal_digits: 2,
- rounding: 0,
- code: "KZT",
- name_plural: "Kazakhstani tenges",
- },
- LBP: {
- symbol: "LB£",
- name: "Lebanese Pound",
- symbol_native: "ل.ل.",
- decimal_digits: 0,
+ decimal_digits: 2,
rounding: 0,
- code: "LBP",
- name_plural: "Lebanese pounds",
+ value: "KZT",
+ name_plural: "Kazakhstani tenges",
+ countryCode: "KZ",
},
- LKR: {
- symbol: "SLRs",
- name: "Sri Lankan Rupee",
- symbol_native: "SL Re",
+ {
+ symbol: "Ksh",
+ label: "Kenyan Shilling",
+ symbol_native: "Ksh",
decimal_digits: 2,
rounding: 0,
- code: "LKR",
- name_plural: "Sri Lankan rupees",
+ value: "KES",
+ name_plural: "Kenyan shillings",
+ countryCode: "KE",
},
- LTL: {
- symbol: "Lt",
- name: "Lithuanian Litas",
- symbol_native: "Lt",
- decimal_digits: 2,
+ {
+ symbol: "KD",
+ label: "Kuwaiti Dinar",
+ symbol_native: "د.ك.",
+ decimal_digits: 3,
rounding: 0,
- code: "LTL",
- name_plural: "Lithuanian litai",
+ value: "KWD",
+ name_plural: "Kuwaiti dinars",
+ countryCode: "KW",
},
- LVL: {
+ {
symbol: "Ls",
- name: "Latvian Lats",
+ label: "Latvian Lats",
symbol_native: "Ls",
decimal_digits: 2,
rounding: 0,
- code: "LVL",
+ value: "LVL",
name_plural: "Latvian lati",
+ countryCode: "LV",
+ },
+ {
+ symbol: "LB£",
+ label: "Lebanese Pound",
+ symbol_native: "ل.ل.",
+ decimal_digits: 0,
+ rounding: 0,
+ value: "LBP",
+ name_plural: "Lebanese pounds",
+ countryCode: "LB",
},
- LYD: {
+ {
symbol: "LD",
- name: "Libyan Dinar",
+ label: "Libyan Dinar",
symbol_native: "د.ل.",
decimal_digits: 3,
rounding: 0,
- code: "LYD",
+ value: "LYD",
name_plural: "Libyan dinars",
+ countryCode: "LY",
},
- MAD: {
- symbol: "MAD",
- name: "Moroccan Dirham",
- symbol_native: "د.م.",
+ {
+ symbol: "Lt",
+ label: "Lithuanian Litas",
+ symbol_native: "Lt",
decimal_digits: 2,
rounding: 0,
- code: "MAD",
- name_plural: "Moroccan dirhams",
+ value: "LTL",
+ name_plural: "Lithuanian litai",
+ countryCode: "LT",
},
- MDL: {
- symbol: "MDL",
- name: "Moldovan Leu",
- symbol_native: "MDL",
+ {
+ symbol: "MOP$",
+ label: "Macanese Pataca",
+ symbol_native: "MOP$",
decimal_digits: 2,
rounding: 0,
- code: "MDL",
- name_plural: "Moldovan lei",
- },
- MGA: {
- symbol: "MGA",
- name: "Malagasy Ariary",
- symbol_native: "MGA",
- decimal_digits: 0,
- rounding: 0,
- code: "MGA",
- name_plural: "Malagasy Ariaries",
+ value: "MOP",
+ name_plural: "Macanese patacas",
+ countryCode: "MO",
},
- MKD: {
+ {
symbol: "MKD",
- name: "Macedonian Denar",
- symbol_native: "MKD",
+ label: "Macedonian Denar",
+ symbol_native: "ден",
decimal_digits: 2,
rounding: 0,
- code: "MKD",
+ value: "MKD",
name_plural: "Macedonian denari",
+ countryCode: "MK",
},
- MMK: {
- symbol: "MMK",
- name: "Myanma Kyat",
- symbol_native: "K",
+ {
+ symbol: "MGA",
+ label: "Malagasy Ariary",
+ symbol_native: "MGA",
decimal_digits: 0,
rounding: 0,
- code: "MMK",
- name_plural: "Myanma kyats",
+ value: "MGA",
+ name_plural: "Malagasy Ariaries",
+ countryCode: "MG",
},
- MOP: {
- symbol: "MOP$",
- name: "Macanese Pataca",
- symbol_native: "MOP$",
+ {
+ symbol: "RM",
+ label: "Malaysian Ringgit",
+ symbol_native: "RM",
decimal_digits: 2,
rounding: 0,
- code: "MOP",
- name_plural: "Macanese patacas",
+ value: "MYR",
+ name_plural: "Malaysian ringgits",
+ countryCode: "MY",
},
- MUR: {
+ {
symbol: "MURs",
- name: "Mauritian Rupee",
+ label: "Mauritian Rupee",
symbol_native: "MURs",
decimal_digits: 0,
rounding: 0,
- code: "MUR",
+ value: "MUR",
name_plural: "Mauritian rupees",
+ countryCode: "MU",
},
- MXN: {
+ {
symbol: "MX$",
- name: "Mexican Peso",
+ label: "Mexican Peso",
symbol_native: "$",
decimal_digits: 2,
rounding: 0,
- code: "MXN",
+ value: "MXN",
name_plural: "Mexican pesos",
+ countryCode: "MX",
},
- MYR: {
- symbol: "RM",
- name: "Malaysian Ringgit",
- symbol_native: "RM",
+ {
+ symbol: "MDL",
+ label: "Moldovan Leu",
+ symbol_native: "MDL",
decimal_digits: 2,
rounding: 0,
- code: "MYR",
- name_plural: "Malaysian ringgits",
+ value: "MDL",
+ name_plural: "Moldovan lei",
+ countryCode: "MD",
+ },
+ {
+ symbol: "MAD",
+ label: "Moroccan Dirham",
+ symbol_native: "د.م.",
+ decimal_digits: 2,
+ rounding: 0,
+ value: "MAD",
+ name_plural: "Moroccan dirhams",
+ countryCode: "MA",
},
- MZN: {
+ {
symbol: "MTn",
- name: "Mozambican Metical",
+ label: "Mozambican Metical",
symbol_native: "MTn",
decimal_digits: 2,
rounding: 0,
- code: "MZN",
+ value: "MZN",
name_plural: "Mozambican meticals",
+ countryCode: "MZ",
+ },
+ {
+ symbol: "MMK",
+ label: "Myanma Kyat",
+ symbol_native: "K",
+ decimal_digits: 0,
+ rounding: 0,
+ value: "MMK",
+ name_plural: "Myanma kyats",
+ countryCode: "MM",
},
- NAD: {
+ {
symbol: "N$",
- name: "Namibian Dollar",
+ label: "Namibian Dollar",
symbol_native: "N$",
decimal_digits: 2,
rounding: 0,
- code: "NAD",
+ value: "NAD",
name_plural: "Namibian dollars",
+ countryCode: "NA",
},
- NGN: {
- symbol: "₦",
- name: "Nigerian Naira",
- symbol_native: "₦",
+ {
+ symbol: "NPRs",
+ label: "Nepalese Rupee",
+ symbol_native: "नेरू",
decimal_digits: 2,
rounding: 0,
- code: "NGN",
- name_plural: "Nigerian nairas",
+ value: "NPR",
+ name_plural: "Nepalese rupees",
+ countryCode: "NP",
},
- NIO: {
- symbol: "C$",
- name: "Nicaraguan Córdoba",
- symbol_native: "C$",
+ {
+ symbol: "NT$",
+ label: "New Taiwan Dollar",
+ symbol_native: "NT$",
decimal_digits: 2,
rounding: 0,
- code: "NIO",
- name_plural: "Nicaraguan córdobas",
+ value: "TWD",
+ name_plural: "New Taiwan dollars",
+ countryCode: "TW",
},
- NOK: {
- symbol: "Nkr",
- name: "Norwegian Krone",
- symbol_native: "kr",
+ {
+ symbol: "NZ$",
+ label: "New Zealand Dollar",
+ symbol_native: "$",
decimal_digits: 2,
rounding: 0,
- code: "NOK",
- name_plural: "Norwegian kroner",
+ value: "NZD",
+ name_plural: "New Zealand dollars",
+ countryCode: "NZ",
},
- NPR: {
- symbol: "NPRs",
- name: "Nepalese Rupee",
- symbol_native: "नेरू",
+ {
+ symbol: "C$",
+ label: "Nicaraguan Córdoba",
+ symbol_native: "C$",
decimal_digits: 2,
rounding: 0,
- code: "NPR",
- name_plural: "Nepalese rupees",
+ value: "NIO",
+ name_plural: "Nicaraguan córdobas",
+ countryCode: "NI",
},
- NZD: {
- symbol: "NZ$",
- name: "New Zealand Dollar",
- symbol_native: "$",
+ {
+ symbol: "₦",
+ label: "Nigerian Naira",
+ symbol_native: "₦",
decimal_digits: 2,
rounding: 0,
- code: "NZD",
- name_plural: "New Zealand dollars",
+ value: "NGN",
+ name_plural: "Nigerian nairas",
+ countryCode: "NG",
+ },
+ {
+ symbol: "Nkr",
+ label: "Norwegian Krone",
+ symbol_native: "kr",
+ decimal_digits: 2,
+ rounding: 0,
+ value: "NOK",
+ name_plural: "Norwegian kroner",
+ countryCode: "NO",
},
- OMR: {
+ {
symbol: "OMR",
- name: "Omani Rial",
+ label: "Omani Rial",
symbol_native: "ر.ع.",
decimal_digits: 3,
rounding: 0,
- code: "OMR",
+ value: "OMR",
name_plural: "Omani rials",
+ countryCode: "OM",
+ },
+ {
+ symbol: "PKRs",
+ label: "Pakistani Rupee",
+ symbol_native: "₨",
+ decimal_digits: 0,
+ rounding: 0,
+ value: "PKR",
+ name_plural: "Pakistani rupees",
+ countryCode: "PK",
},
- PAB: {
+ {
symbol: "B/.",
- name: "Panamanian Balboa",
+ label: "Panamanian Balboa",
symbol_native: "B/.",
decimal_digits: 2,
rounding: 0,
- code: "PAB",
+ value: "PAB",
name_plural: "Panamanian balboas",
+ countryCode: "PA",
+ },
+ {
+ symbol: "₲",
+ label: "Paraguayan Guarani",
+ symbol_native: "₲",
+ decimal_digits: 0,
+ rounding: 0,
+ value: "PYG",
+ name_plural: "Paraguayan guaranis",
+ countryCode: "PY",
},
- PEN: {
+ {
symbol: "S/.",
- name: "Peruvian Nuevo Sol",
+ label: "Peruvian Nuevo Sol",
symbol_native: "S/.",
decimal_digits: 2,
rounding: 0,
- code: "PEN",
+ value: "PEN",
name_plural: "Peruvian nuevos soles",
+ countryCode: "PE",
},
- PHP: {
+ {
symbol: "₱",
- name: "Philippine Peso",
+ label: "Philippine Peso",
symbol_native: "₱",
decimal_digits: 2,
rounding: 0,
- code: "PHP",
+ value: "PHP",
name_plural: "Philippine pesos",
+ countryCode: "PH",
},
- PKR: {
- symbol: "PKRs",
- name: "Pakistani Rupee",
- symbol_native: "₨",
- decimal_digits: 0,
- rounding: 0,
- code: "PKR",
- name_plural: "Pakistani rupees",
- },
- PLN: {
+ {
symbol: "zł",
- name: "Polish Zloty",
+ label: "Polish Zloty",
symbol_native: "zł",
decimal_digits: 2,
rounding: 0,
- code: "PLN",
+ value: "PLN",
name_plural: "Polish zlotys",
+ countryCode: "PL",
},
- PYG: {
- symbol: "₲",
- name: "Paraguayan Guarani",
- symbol_native: "₲",
- decimal_digits: 0,
- rounding: 0,
- code: "PYG",
- name_plural: "Paraguayan guaranis",
- },
- QAR: {
+ {
symbol: "QR",
- name: "Qatari Rial",
+ label: "Qatari Rial",
symbol_native: "ر.ق.",
decimal_digits: 2,
rounding: 0,
- code: "QAR",
+ value: "QAR",
name_plural: "Qatari rials",
+ countryCode: "QA",
},
- RON: {
+ {
symbol: "RON",
- name: "Romanian Leu",
+ label: "Romanian Leu",
symbol_native: "RON",
decimal_digits: 2,
rounding: 0,
- code: "RON",
+ value: "RON",
name_plural: "Romanian lei",
+ countryCode: "RO",
},
- RSD: {
- symbol: "din.",
- name: "Serbian Dinar",
- symbol_native: "дин.",
- decimal_digits: 0,
- rounding: 0,
- code: "RSD",
- name_plural: "Serbian dinars",
- },
- RUB: {
+ {
symbol: "RUB",
- name: "Russian Ruble",
+ label: "Russian Ruble",
symbol_native: "руб.",
decimal_digits: 2,
rounding: 0,
- code: "RUB",
+ value: "RUB",
name_plural: "Russian rubles",
+ countryCode: "RU",
},
- RWF: {
+ {
symbol: "RWF",
- name: "Rwandan Franc",
+ label: "Rwandan Franc",
symbol_native: "FR",
decimal_digits: 0,
rounding: 0,
- code: "RWF",
+ value: "RWF",
name_plural: "Rwandan francs",
+ countryCode: "RW",
},
- SAR: {
+ {
symbol: "SR",
- name: "Saudi Riyal",
+ label: "Saudi Riyal",
symbol_native: "ر.س.",
decimal_digits: 2,
rounding: 0,
- code: "SAR",
+ value: "SAR",
name_plural: "Saudi riyals",
+ countryCode: "SA",
},
- SDG: {
- symbol: "SDG",
- name: "Sudanese Pound",
- symbol_native: "SDG",
- decimal_digits: 2,
- rounding: 0,
- code: "SDG",
- name_plural: "Sudanese pounds",
- },
- SEK: {
- symbol: "Skr",
- name: "Swedish Krona",
- symbol_native: "kr",
- decimal_digits: 2,
+ {
+ symbol: "din.",
+ label: "Serbian Dinar",
+ symbol_native: "дин.",
+ decimal_digits: 0,
rounding: 0,
- code: "SEK",
- name_plural: "Swedish kronor",
+ value: "RSD",
+ name_plural: "Serbian dinars",
+ countryCode: "RS",
},
- SGD: {
+ {
symbol: "S$",
- name: "Singapore Dollar",
+ label: "Singapore Dollar",
symbol_native: "$",
decimal_digits: 2,
rounding: 0,
- code: "SGD",
+ value: "SGD",
name_plural: "Singapore dollars",
+ countryCode: "SG",
},
- SOS: {
+ {
symbol: "Ssh",
- name: "Somali Shilling",
+ label: "Somali Shilling",
symbol_native: "Ssh",
decimal_digits: 0,
rounding: 0,
- code: "SOS",
+ value: "SOS",
name_plural: "Somali shillings",
+ countryCode: "SO",
+ },
+ {
+ symbol: "R",
+ label: "South African Rand",
+ symbol_native: "R",
+ decimal_digits: 2,
+ rounding: 0,
+ value: "ZAR",
+ name_plural: "South African rand",
+ countryCode: "ZA",
+ },
+ {
+ symbol: "₩",
+ label: "South Korean Won",
+ symbol_native: "₩",
+ decimal_digits: 0,
+ rounding: 0,
+ value: "KRW",
+ name_plural: "South Korean won",
+ countryCode: "KR",
+ },
+ {
+ symbol: "SLRs",
+ label: "Sri Lankan Rupee",
+ symbol_native: "SL Re",
+ decimal_digits: 2,
+ rounding: 0,
+ value: "LKR",
+ name_plural: "Sri Lankan rupees",
+ countryCode: "LK",
+ },
+ {
+ symbol: "SDG",
+ label: "Sudanese Pound",
+ symbol_native: "SDG",
+ decimal_digits: 2,
+ rounding: 0,
+ value: "SDG",
+ name_plural: "Sudanese pounds",
+ countryCode: "SD",
+ },
+ {
+ symbol: "Skr",
+ label: "Swedish Krona",
+ symbol_native: "kr",
+ decimal_digits: 2,
+ rounding: 0,
+ value: "SEK",
+ name_plural: "Swedish kronor",
+ countryCode: "SE",
+ },
+ {
+ symbol: "CHF",
+ label: "Swiss Franc",
+ symbol_native: "CHF",
+ decimal_digits: 2,
+ rounding: 0.05,
+ value: "CHF",
+ name_plural: "Swiss francs",
+ countryCode: "CH",
},
- SYP: {
+ {
symbol: "SY£",
- name: "Syrian Pound",
+ label: "Syrian Pound",
symbol_native: "ل.س.",
decimal_digits: 0,
rounding: 0,
- code: "SYP",
+ value: "SYP",
name_plural: "Syrian pounds",
+ countryCode: "SY",
+ },
+ {
+ symbol: "TSh",
+ label: "Tanzanian Shilling",
+ symbol_native: "TSh",
+ decimal_digits: 0,
+ rounding: 0,
+ value: "TZS",
+ name_plural: "Tanzanian shillings",
+ countryCode: "TZ",
},
- THB: {
+ {
symbol: "฿",
- name: "Thai Baht",
+ label: "Thai Baht",
symbol_native: "฿",
decimal_digits: 2,
rounding: 0,
- code: "THB",
+ value: "THB",
name_plural: "Thai baht",
+ countryCode: "TH",
},
- TND: {
- symbol: "DT",
- name: "Tunisian Dinar",
- symbol_native: "د.ت.",
- decimal_digits: 3,
- rounding: 0,
- code: "TND",
- name_plural: "Tunisian dinars",
- },
- TOP: {
+ {
symbol: "T$",
- name: "Tongan Paʻanga",
+ label: "Tongan Paʻanga",
symbol_native: "T$",
decimal_digits: 2,
rounding: 0,
- code: "TOP",
+ value: "TOP",
name_plural: "Tongan paʻanga",
+ countryCode: "TO",
},
- TRY: {
- symbol: "TL",
- name: "Turkish Lira",
- symbol_native: "TL",
- decimal_digits: 2,
- rounding: 0,
- code: "TRY",
- name_plural: "Turkish Lira",
- },
- TTD: {
+ {
symbol: "TT$",
- name: "Trinidad and Tobago Dollar",
+ label: "Trinidad and Tobago Dollar",
symbol_native: "$",
decimal_digits: 2,
rounding: 0,
- code: "TTD",
+ value: "TTD",
name_plural: "Trinidad and Tobago dollars",
+ countryCode: "TT",
},
- TWD: {
- symbol: "NT$",
- name: "New Taiwan Dollar",
- symbol_native: "NT$",
+ {
+ symbol: "DT",
+ label: "Tunisian Dinar",
+ symbol_native: "د.ت.",
+ decimal_digits: 3,
+ rounding: 0,
+ value: "TND",
+ name_plural: "Tunisian dinars",
+ countryCode: "TN",
+ },
+ {
+ symbol: "TL",
+ label: "Turkish Lira",
+ symbol_native: "TL",
decimal_digits: 2,
rounding: 0,
- code: "TWD",
- name_plural: "New Taiwan dollars",
+ value: "TRY",
+ name_plural: "Turkish Lira",
+ countryCode: "TR",
},
- TZS: {
- symbol: "TSh",
- name: "Tanzanian Shilling",
- symbol_native: "TSh",
+ {
+ symbol: "USh",
+ label: "Ugandan Shilling",
+ symbol_native: "USh",
decimal_digits: 0,
rounding: 0,
- code: "TZS",
- name_plural: "Tanzanian shillings",
+ value: "UGX",
+ name_plural: "Ugandan shillings",
+ countryCode: "UG",
},
- UAH: {
+ {
symbol: "₴",
- name: "Ukrainian Hryvnia",
+ label: "Ukrainian Hryvnia",
symbol_native: "₴",
decimal_digits: 2,
rounding: 0,
- code: "UAH",
+ value: "UAH",
name_plural: "Ukrainian hryvnias",
+ countryCode: "UA",
},
- UGX: {
- symbol: "USh",
- name: "Ugandan Shilling",
- symbol_native: "USh",
- decimal_digits: 0,
+ {
+ symbol: "AED",
+ label: "United Arab Emirates Dirham",
+ symbol_native: "د.إ.",
+ decimal_digits: 2,
rounding: 0,
- code: "UGX",
- name_plural: "Ugandan shillings",
+ value: "AED",
+ name_plural: "UAE dirhams",
+ countryCode: "AE",
+ },
+ {
+ symbol: " $ ",
+ label: "United States Dollar",
+ symbol_native: "$",
+ decimal_digits: 2,
+ rounding: 0,
+ value: "USD",
+ name_plural: "US dollars",
+ countryCode: "US",
},
- UYU: {
+ {
symbol: "$U",
- name: "Uruguayan Peso",
+ label: "Uruguayan Peso",
symbol_native: "$",
decimal_digits: 2,
rounding: 0,
- code: "UYU",
+ value: "UYU",
name_plural: "Uruguayan pesos",
+ countryCode: "UY",
},
- UZS: {
+ {
symbol: "UZS",
- name: "Uzbekistan Som",
+ label: "Uzbekistan Som",
symbol_native: "UZS",
decimal_digits: 0,
rounding: 0,
- code: "UZS",
+ value: "UZS",
name_plural: "Uzbekistan som",
+ countryCode: "UZ",
},
- VEF: {
+ {
symbol: "Bs.F.",
- name: "Venezuelan Bolívar",
+ label: "Venezuelan Bolívar",
symbol_native: "Bs.F.",
decimal_digits: 2,
rounding: 0,
- code: "VEF",
+ value: "VEF",
name_plural: "Venezuelan bolívars",
+ countryCode: "VE",
},
- VND: {
+ {
symbol: "₫",
- name: "Vietnamese Dong",
+ label: "Vietnamese Dong",
symbol_native: "₫",
decimal_digits: 0,
rounding: 0,
- code: "VND",
+ value: "VND",
name_plural: "Vietnamese dong",
+ countryCode: "VN",
},
- XAF: {
- symbol: "FCFA",
- name: "CFA Franc BEAC",
- symbol_native: "FCFA",
- decimal_digits: 0,
- rounding: 0,
- code: "XAF",
- name_plural: "CFA francs BEAC",
- },
- XOF: {
- symbol: "CFA",
- name: "CFA Franc BCEAO",
- symbol_native: "CFA",
- decimal_digits: 0,
- rounding: 0,
- code: "XOF",
- name_plural: "CFA francs BCEAO",
- },
- YER: {
+ {
symbol: "YR",
- name: "Yemeni Rial",
+ label: "Yemeni Rial",
symbol_native: "ر.ي.",
decimal_digits: 0,
rounding: 0,
- code: "YER",
+ value: "YER",
name_plural: "Yemeni rials",
+ countryCode: "YE",
},
- ZAR: {
- symbol: "R",
- name: "South African Rand",
- symbol_native: "R",
- decimal_digits: 2,
- rounding: 0,
- code: "ZAR",
- name_plural: "South African rand",
- },
- ZMK: {
+ {
symbol: "ZK",
- name: "Zambian Kwacha",
+ label: "Zambian Kwacha",
symbol_native: "ZK",
decimal_digits: 0,
rounding: 0,
- code: "ZMK",
+ value: "ZMK",
name_plural: "Zambian kwachas",
+ countryCode: "ZM",
},
-};
+];
diff --git a/src/shell/components/FieldTypeCurrency/index.js b/src/shell/components/FieldTypeCurrency/index.js
deleted file mode 100644
index ad300a9373..0000000000
--- a/src/shell/components/FieldTypeCurrency/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export { FieldTypeCurrency } from "./FieldTypeCurrency";
diff --git a/src/shell/components/FieldTypeCurrency/index.tsx b/src/shell/components/FieldTypeCurrency/index.tsx
new file mode 100644
index 0000000000..a74cad89c9
--- /dev/null
+++ b/src/shell/components/FieldTypeCurrency/index.tsx
@@ -0,0 +1,64 @@
+import { useMemo } from "react";
+import { TextField, Typography, Box, Stack } from "@mui/material";
+
+import { currencies } from "./currencies";
+import { NumberFormatInput } from "../NumberFormatInput";
+
+type FieldTypeCurrencyProps = {
+ name: string;
+ value: string;
+ currency: string;
+ error: boolean;
+ onChange: (value: string, name: string) => void;
+};
+export const FieldTypeCurrency = ({
+ name,
+ currency,
+ value,
+ error,
+ onChange,
+ ...otherProps
+}: FieldTypeCurrencyProps) => {
+ const selectedCurrency = useMemo(() => {
+ return currencies.find((_currency) => _currency.value === currency);
+ }, [currency]);
+
+ return (
+ onChange(evt?.target?.value?.value, name)}
+ InputProps={{
+ inputComponent: NumberFormatInput as any,
+ inputProps: {
+ thousandSeparator: true,
+ valueIsNumericString: true,
+ },
+ startAdornment: (
+
+ {selectedCurrency?.symbol_native}
+
+ ),
+ endAdornment: (
+
+
+
+ {selectedCurrency.value}
+
+
+ ),
+ }}
+ />
+ );
+};
diff --git a/src/shell/components/FieldTypeNumber.tsx b/src/shell/components/FieldTypeNumber.tsx
index aacf8169f1..5329ea7020 100644
--- a/src/shell/components/FieldTypeNumber.tsx
+++ b/src/shell/components/FieldTypeNumber.tsx
@@ -66,8 +66,10 @@ export const FieldTypeNumber = ({
value={value || 0}
name={name}
required={required}
- onChange={(evt) => {
- onChange(+evt.target.value?.toString()?.replace(/^0+/, "") ?? 0, name);
+ onChange={(evt: any) => {
+ const value = evt?.target?.value?.floatValue ?? 0;
+
+ onChange(+value?.toString()?.replace(/^0+/, "") ?? 0, name);
}}
onKeyDown={(evt) => {
if ((evt.key === "Backspace" || evt.key === "Delete") && value === 0) {
diff --git a/src/shell/components/Filters/FilterButton.tsx b/src/shell/components/Filters/FilterButton.tsx
index 04f3874e6f..917d7d5e1e 100644
--- a/src/shell/components/Filters/FilterButton.tsx
+++ b/src/shell/components/Filters/FilterButton.tsx
@@ -1,6 +1,6 @@
import { FC } from "react";
import { Button, ButtonGroup, Typography } from "@mui/material";
-import ArrowDropDownOutlinedIcon from "@mui/icons-material/ArrowDropDownOutlined";
+import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded";
import CheckIcon from "@mui/icons-material/Check";
import CloseRoundedIcon from "@mui/icons-material/CloseRounded";
@@ -53,7 +53,7 @@ export const FilterButton: FC = ({
variant="outlined"
size="small"
color="inherit"
- endIcon={}
+ endIcon={}
onClick={onOpenMenu}
data-cy={`${filterId}_default`}
sx={{
diff --git a/src/shell/components/Head/Head.less b/src/shell/components/Head/Head.less
index 92c16a8803..b58ab408d2 100644
--- a/src/shell/components/Head/Head.less
+++ b/src/shell/components/Head/Head.less
@@ -2,12 +2,13 @@
@import "~@zesty-io/core/typography.less";
.Head {
+ height: 100%;
display: flex;
flex-direction: column-reverse;
display: grid;
grid-template-columns: minmax(400px, 1fr) 1fr;
.Tags {
- height: calc(100vh - 54px);
+ height: 100%;
overflow-y: scroll;
margin-left: 16px;
}
diff --git a/src/shell/components/Head/Preview/Preview.less b/src/shell/components/Head/Preview/Preview.less
index a50a626fad..8218cdb6ca 100644
--- a/src/shell/components/Head/Preview/Preview.less
+++ b/src/shell/components/Head/Preview/Preview.less
@@ -3,6 +3,7 @@
overflow: scroll;
.TagPreview {
+ height: 100%;
font-size: 14px;
font-family: "Courier New", Courier, monospace;
padding: 16px;
diff --git a/src/shell/components/InviteMembersModal/index.tsx b/src/shell/components/InviteMembersModal/index.tsx
index 5c5b5ce2b0..5e6b9a7035 100644
--- a/src/shell/components/InviteMembersModal/index.tsx
+++ b/src/shell/components/InviteMembersModal/index.tsx
@@ -21,7 +21,7 @@ import {
useGetCurrentUserRolesQuery,
} from "../../services/accounts";
import { LoadingButton } from "@mui/lab";
-import { NoPermission } from "./NoPermission";
+import { NoPermission } from "../NoPermission";
import instanzeZUID from "../../../utility/instanceZUID";
import { ConfirmationModal } from "./ConfirmationDialog";
diff --git a/src/shell/components/InviteMembersModal/NoPermission.tsx b/src/shell/components/NoPermission.tsx
similarity index 80%
rename from src/shell/components/InviteMembersModal/NoPermission.tsx
rename to src/shell/components/NoPermission.tsx
index b22c8d1bc3..9ca0ec879f 100644
--- a/src/shell/components/InviteMembersModal/NoPermission.tsx
+++ b/src/shell/components/NoPermission.tsx
@@ -15,14 +15,20 @@ import {
} from "@mui/material";
import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
-import { useGetUsersRolesQuery } from "../../services/accounts";
-import { MD5 } from "../../../utility/md5";
+import { useGetUsersRolesQuery } from "../services/accounts";
+import { MD5 } from "../../utility/md5";
type NoPermissionProps = {
onClose: () => void;
+ headerTitle?: string;
+ headerSubtitle?: string;
};
-export const NoPermission = ({ onClose }: NoPermissionProps) => {
+export const NoPermission = ({
+ onClose,
+ headerSubtitle,
+ headerTitle,
+}: NoPermissionProps) => {
const { data: users } = useGetUsersRolesQuery();
const ownersAndAdmins = useMemo(() => {
@@ -51,12 +57,14 @@ export const NoPermission = ({ onClose }: NoPermissionProps) => {
}}
/>
- You do not have permission to invite users
+ {headerTitle
+ ? headerTitle
+ : "You do not have permission to invite users"}
- Contact your instance owners or administrators listed below to change
- your role to Admin or Owner on this instance for user invitation
- priveleges.
+ {headerSubtitle
+ ? headerSubtitle
+ : "Contact your instance owners or administrators listed below to change your role to Admin or Owner on this instance for user invitation priveleges."}
diff --git a/src/shell/components/NumberFormatInput/index.tsx b/src/shell/components/NumberFormatInput/index.tsx
index 6efa07b581..db7effd76a 100644
--- a/src/shell/components/NumberFormatInput/index.tsx
+++ b/src/shell/components/NumberFormatInput/index.tsx
@@ -3,10 +3,17 @@ import {
NumericFormatProps,
InputAttributes,
NumericFormat,
+ NumberFormatValues,
} from "react-number-format";
+export type NumberFormatInputEvent = {
+ target: {
+ name: string;
+ value: NumberFormatValues;
+ };
+};
type NumberFormatInputProps = {
- onChange: (event: { target: { name: string; value: number } }) => void;
+ onChange: (event: NumberFormatInputEvent) => void;
name: string;
};
export const NumberFormatInput = forwardRef<
@@ -23,7 +30,7 @@ export const NumberFormatInput = forwardRef<
onChange({
target: {
name: props.name,
- value: values.floatValue || 0,
+ value: values,
},
});
}}
diff --git a/src/shell/components/global-sidebar/components/InstanceAvatar.tsx b/src/shell/components/global-sidebar/components/InstanceAvatar.tsx
index e8d29ed5e2..0ad3492d8d 100644
--- a/src/shell/components/global-sidebar/components/InstanceAvatar.tsx
+++ b/src/shell/components/global-sidebar/components/InstanceAvatar.tsx
@@ -1,6 +1,6 @@
import { FC, useMemo } from "react";
import { useSelector, useDispatch } from "react-redux";
-import { Avatar, Skeleton, Box } from "@mui/material";
+import { Avatar, Skeleton, Box, SxProps, Theme } from "@mui/material";
import ImageRoundedIcon from "@mui/icons-material/ImageRounded";
import { useGetHeadTagsQuery } from "../../../services/instance";
@@ -11,10 +11,12 @@ import { actions } from "../../../store/ui";
interface InstanceAvatar {
canUpdateAvatar?: boolean;
onFaviconModalOpen?: () => void;
+ avatarSx?: SxProps;
}
export const InstanceAvatar: FC = ({
canUpdateAvatar = true,
onFaviconModalOpen,
+ avatarSx,
}) => {
const ui = useSelector((state: AppState) => state.ui);
const dispatch = useDispatch();
@@ -65,6 +67,7 @@ export const InstanceAvatar: FC = ({
height: 32,
width: 32,
backgroundColor: faviconURL ? "common.white" : "info.main",
+ ...avatarSx,
}}
>
{(!faviconURL && instance?.name[0]?.toUpperCase()) || "A"}
diff --git a/src/shell/components/withAi/AIGenerator.tsx b/src/shell/components/withAi/AIGenerator.tsx
index 6306cf650e..4b72c46f14 100644
--- a/src/shell/components/withAi/AIGenerator.tsx
+++ b/src/shell/components/withAi/AIGenerator.tsx
@@ -1,4 +1,11 @@
-import { useEffect, useState, useRef } from "react";
+import {
+ useEffect,
+ useState,
+ useRef,
+ useMemo,
+ useContext,
+ useReducer,
+} from "react";
import {
Button,
Box,
@@ -10,49 +17,225 @@ import {
MenuItem,
Autocomplete,
CircularProgress,
+ Stack,
+ InputAdornment,
+ Tooltip,
+ alpha,
+ ListItemButton,
} from "@mui/material";
-import CloseIcon from "@mui/icons-material/Close";
import StopRoundedIcon from "@mui/icons-material/StopRounded";
import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
import RefreshRoundedIcon from "@mui/icons-material/RefreshRounded";
-import { useAiGenerationMutation } from "../../services/cloudFunctions";
-import { useGetLangsMappingQuery } from "../../services/instance";
+import LanguageRoundedIcon from "@mui/icons-material/LanguageRounded";
+import InfoRoundedIcon from "@mui/icons-material/InfoRounded";
import { Brain } from "@zesty-io/material";
-import { useDispatch } from "react-redux";
+import { useDispatch, useSelector } from "react-redux";
+import { useLocation, useParams } from "react-router";
+
import { notify } from "../../store/notifications";
+import openAIBadge from "../../../../public/images/openai-badge.svg";
+import { FieldTypeNumber } from "../FieldTypeNumber";
+import { useAiGenerationMutation } from "../../services/cloudFunctions";
+import {
+ useGetContentModelFieldsQuery,
+ useGetLangsMappingQuery,
+} from "../../services/instance";
+import { AppState } from "../../store/types";
+import { AIGeneratorContext } from "./AIGeneratorProvider";
+
+const DEFAULT_LIMITS: Record = {
+ text: 150,
+ paragraph: 3,
+ word: 1500,
+ description: 160,
+ title: 150,
+};
+export const TONE_OPTIONS = [
+ {
+ value: "intriguing",
+ label: "Intriguing - Curious, mysterious, and thought-provoking",
+ },
+ {
+ value: "professional",
+ label: "Professional - Serious, formal, and authoritative",
+ },
+ { value: "playful", label: "Playful - Fun, light-hearted, and whimsical" },
+ {
+ value: "sensational",
+ label: "Sensational - Bold, dramatic, and attention-grabbing",
+ },
+ { value: "succint", label: "Succinct - Clear, factual, with no hyperbole" },
+] as const;
+export type ToneOption =
+ | "intriguing"
+ | "professional"
+ | "playful"
+ | "sensational"
+ | "succint";
+
+type FieldData = {
+ topic?: string;
+ audienceDescription: string;
+ tone: ToneOption;
+ keywords?: string;
+ limit?: number;
+ language: {
+ label: string;
+ value: string;
+ };
+};
+// description and title are used for seo meta title & description
+type AIType = "text" | "paragraph" | "description" | "title" | "word";
interface Props {
onApprove: (data: string) => void;
- onClose: () => void;
- aiType: string;
+ onClose: (reason: "close" | "insert") => void;
+ aiType: AIType;
label: string;
+ fieldZUID: string;
+ isAIAssistedFlow: boolean;
}
-export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => {
+export const AIGenerator = ({
+ onApprove,
+ onClose,
+ aiType,
+ label,
+ fieldZUID,
+ isAIAssistedFlow,
+}: Props) => {
const dispatch = useDispatch();
- const [topic, setTopic] = useState("");
- const [limit, setLimit] = useState(aiType === "text" ? "150" : "3");
- const request = useRef(null);
- const [language, setLanguage] = useState({
- label: "English (United States)",
- value: "en-US",
+ const location = useLocation();
+ const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new";
+ const [hasFieldError, setHasFieldError] = useState(false);
+ const { modelZUID, itemZUID } = useParams<{
+ modelZUID: string;
+ itemZUID: string;
+ }>();
+ const item = useSelector(
+ (state: AppState) =>
+ state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID]
+ );
+ const { data: fields } = useGetContentModelFieldsQuery(modelZUID, {
+ skip: !modelZUID,
});
-
- const [data, setData] = useState("");
+ const [selectedContent, setSelectedContent] = useState(null);
+ const request = useRef(null);
+ const [fieldData, updateFieldData] = useReducer(
+ (state: FieldData, action: Partial) => {
+ return {
+ ...state,
+ ...action,
+ };
+ },
+ {
+ topic: "",
+ audienceDescription: "",
+ tone: "professional",
+ keywords: "",
+ limit: DEFAULT_LIMITS[aiType],
+ language: {
+ label: "English (United States)",
+ value: "en-US",
+ },
+ }
+ );
+ const [data, setData] = useState([]);
const { data: langMappings } = useGetLangsMappingQuery();
+ const [
+ lastOpenedZUID,
+ updateLastOpenedZUID,
+ parameterData,
+ updateParameterData,
+ ] = useContext(AIGeneratorContext);
const [aiGenerate, { isLoading, isError, data: aiResponse }] =
useAiGenerationMutation();
+ const allTextFieldContent = useMemo(() => {
+ // This is really only needed for seo meta title & description
+ // so we skip it for other types
+ if (
+ (aiType !== "title" && aiType !== "description") ||
+ !fields?.length ||
+ !Object.keys(item?.data)?.length
+ )
+ return "";
+
+ const textFieldTypes = [
+ "text",
+ "wysiwyg_basic",
+ "wysiwyg_advanced",
+ "article_writer",
+ "markdown",
+ "textarea",
+ ];
+
+ return fields.reduce((accu, curr) => {
+ if (!curr.deletedAt && textFieldTypes.includes(curr.datatype)) {
+ return (accu = `${accu} ${item.data[curr.name] || ""}`);
+ }
+
+ return accu;
+ }, "");
+ }, [fields, item?.data]);
+
const handleGenerate = () => {
- request.current = aiGenerate({
- type: aiType,
- length: limit,
- phrase: topic,
- lang: language.value,
- });
+ if (aiType === "description" || aiType === "title") {
+ request.current = aiGenerate({
+ type: aiType,
+ lang: fieldData.language.value,
+ tone: fieldData.tone,
+ audience: fieldData.audienceDescription,
+ content: allTextFieldContent,
+ keywords: fieldData.keywords,
+ });
+ } else {
+ if (fieldData.topic) {
+ request.current = aiGenerate({
+ type: aiType,
+ length: fieldData.limit,
+ phrase: fieldData.topic,
+ lang: fieldData.language.value,
+ tone: fieldData.tone,
+ audience: fieldData.audienceDescription,
+ });
+ } else {
+ setHasFieldError(true);
+ }
+ }
};
+ useEffect(() => {
+ // Used to automatically popuplate the data if they reopened the AI Generator
+ // on the same field or if the current field is the metaDescription field and
+ // is currently going through the AI assisted flow
+ if (
+ lastOpenedZUID === fieldZUID ||
+ (isAIAssistedFlow && fieldZUID === "metaDescription")
+ ) {
+ try {
+ const key =
+ isAIAssistedFlow && fieldZUID === "metaDescription"
+ ? "metaTitle"
+ : fieldZUID;
+ const { topic, audienceDescription, tone, keywords, limit, language } =
+ parameterData[key];
+
+ updateFieldData({
+ topic,
+ audienceDescription,
+ tone,
+ ...(!!limit && { limit: limit }),
+ language,
+ keywords,
+ });
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ }, [parameterData, lastOpenedZUID, isAIAssistedFlow]);
+
useEffect(() => {
if (isError) {
dispatch(
@@ -66,7 +249,24 @@ export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => {
useEffect(() => {
if (aiResponse?.data) {
- setData(aiResponse.data);
+ // For description and title, response will be a stringified array
+ if (aiType === "description" || aiType === "title") {
+ try {
+ const responseArr = JSON.parse(aiResponse.data);
+
+ if (Array.isArray(responseArr)) {
+ const cleanedResponse = responseArr.map((response) =>
+ response?.replace(/^"(.*)"$/, "$1")
+ );
+
+ setData(cleanedResponse);
+ }
+ } catch (err) {
+ console.error("Error parsing AI response: ", err);
+ }
+ } else {
+ setData([aiResponse.data.replace(/^"(.*)"$/, "$1")]);
+ }
}
}, [aiResponse]);
@@ -77,147 +277,584 @@ export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => {
})
);
+ const handleClose = (reason: "close" | "insert") => {
+ // Temporarily save all the inputs when closing the popup so
+ // that if they reopen it again, we can repopulate the fields
+ updateLastOpenedZUID(fieldZUID);
+ updateParameterData({
+ [fieldZUID]: {
+ topic: fieldData.topic,
+ limit: fieldData.limit,
+ language: fieldData.language,
+ tone: fieldData.tone,
+ audienceDescription: fieldData.audienceDescription,
+ keywords: fieldData.keywords,
+ },
+ });
+
+ // Reason is used to determine if the AI assisted flow will be cancelled
+ // or not
+ onClose(reason);
+ };
+
+ // Loading
if (isLoading) {
return (
-
-
-
-
+
+
+
+
+
+
+ Generating
+ {aiType === "title"
+ ? " Meta Title"
+ : aiType === "description"
+ ? " Meta Description"
+ : " Content"}
+
+
+ {aiType === "title"
+ ? "Our AI assistant is scanning your content and generating your meta title "
+ : aiType === "description"
+ ? "Our AI assistant is scanning your content and generating your meta description "
+ : "Our AI assistant is generating your content "}
+ based on your parameters
+
+ }
+ sx={{ mt: 4 }}
+ onClick={() => request.current?.abort()}
+ >
+ Stop
+
+
+
+ );
+ }
+
+ // Meta Title and Meta Description field types
+ if (aiType === "title" || aiType === "description") {
+ return (
+
+
+
+
+ theme.palette.common.white }} />
+
+
+
+
+
+ {!!data?.length ? "Select" : "Generate"} Meta{" "}
+ {aiType === "title" ? "Title" : "Description"}
+
+
+ {!!data?.length
+ ? `Select 1 out of the 3 Meta ${
+ aiType === "title" ? "Titles" : "Descriptions"
+ } our AI has generated for you.`
+ : `Our AI will scan your content and generate your meta ${
+ aiType === "title" ? "title" : "description"
+ } for you based on your parameters set below`}
+
+
+
+
+ {!!data?.length ? (
+
+ {data.map((value, index) => (
+ setSelectedContent(index)}
+ sx={{
+ borderRadius: 2,
+ border: 1,
+ borderColor: "border",
+ backgroundColor: "common.white",
+ px: 1.5,
+ py: 2,
+ flexDirection: "column",
+ alignItems: "flex-start",
+
+ "&.Mui-selected": {
+ borderColor: "primary.main",
+ },
+ }}
+ >
+
+ OPTION {index + 1}
+
+
+ {String(value)}
+
+
+ ))}
+
+ ) : (
+
+
+ Describe your Audience
+
+ updateFieldData({ audienceDescription: evt.target.value })
+ }
+ placeholder="e.g. Freelancers, Designers, ....."
+ fullWidth
+ />
+
+
+
+ Keywords to Include (separated by commas)
+
+
+ updateFieldData({ keywords: evt.target.value })
+ }
+ placeholder="e.g. Hikes, snow"
+ fullWidth
+ />
+
+
+
+ Tone
+
+
+
+
+
+ option.value === value.value
+ }
+ onChange={(_, value) =>
+ updateFieldData({ tone: value.value })
+ }
+ value={TONE_OPTIONS.find(
+ (option) => option.value === fieldData.tone
+ )}
+ options={TONE_OPTIONS}
+ renderInput={(params: any) => (
+
+ )}
+ />
+
+
+
+ Language
+
+
+
+
+
+ option.value === value.value
+ }
+ onChange={(event, value) =>
+ updateFieldData({ language: value })
+ }
+ value={fieldData.language as any}
+ options={languageOptions}
+ renderInput={(params: any) => (
+
+
+
+ ),
+ }}
+ />
+ )}
+ slotProps={{
+ paper: {
+ sx: {
+ maxHeight: 300,
+ },
+ },
+ }}
+ />
+
+
+ )}
-
- Generating Content
-
- }
- sx={{ mt: 3 }}
- onClick={() => request.current?.abort()}
+
- Stop
-
-
+
+ {!!data?.length ? (
+
+ }
+ onClick={() => setData(null)}
+ >
+ Regenerate
+
+
+
+ ) : (
+
+ )}
+
+
);
}
+ // Content item field types
return (
-
-
+ `1px solid ${theme.palette.border}`,
- }}
+ p={2.25}
+ gap={1.5}
+ bgcolor="background.paper"
+ borderRadius="2px 2px 0 0"
>
-
-
-
- {data ? "Your Content is Generated!" : "Generate Content"}
+
+
+ theme.palette.common.white }} />
+
+
+
+
+
+ {!!data?.length ? "Your Content is Generated!" : "Generate Content"}
-
-
-
-
-
+
+ {!!data?.length
+ ? "Our AI assistant can make mistakes. Please check important info."
+ : "Use our AI assistant to write content for you"}
+
+
+
`1px solid ${theme.palette.border}`,
+ overflowY: "auto",
+ borderTop: 1,
+ borderBottom: 1,
+ borderColor: "border",
}}
>
- {data ? (
+ {!!data?.length ? (
- {label}
+ Generated Content
setData(event.target.value)}
+ value={data[0]}
+ onChange={(event) => setData([event.target.value])}
multiline
- rows={8}
+ rows={15}
fullWidth
/>
) : (
- <>
- Topic
-
- Describe what you want the AI to write for you
-
- setTopic(event.target.value)}
- placeholder={`e.g. "Hikes in Washington"`}
- multiline
- rows={2}
- fullWidth
- />
-
-
- {aiType === "text" && (
- <>
- Character Limit
- setLimit(event.target.value)}
- fullWidth
- />
- >
+
+
+ Topic *
+ {
+ if (!!event.target.value) {
+ setHasFieldError(false);
+ }
+
+ updateFieldData({ topic: event.target.value });
+ }}
+ placeholder={`e.g. Hikes in Washington`}
+ multiline
+ rows={3}
+ fullWidth
+ error={hasFieldError}
+ helperText={
+ hasFieldError &&
+ "This is field is required. Please enter a value."
+ }
+ />
+
+
+ Describe your Audience
+
+ updateFieldData({ audienceDescription: evt.target.value })
+ }
+ placeholder="e.g. Freelancers, Designers, ....."
+ fullWidth
+ />
+
+
+
+ Tone
+
+
+
+
+
+ option.value === value.value
+ }
+ onChange={(_, value) => updateFieldData({ tone: value.value })}
+ value={TONE_OPTIONS.find(
+ (option) => option.value === fieldData.tone
)}
- {aiType === "paragraph" && (
- <>
- Paragraph Limit
-
- >
+ options={TONE_OPTIONS}
+ renderInput={(params: any) => (
+
)}
+ />
+
+
+
+
+
+ {aiType === "text" && "Character"}
+ {aiType === "paragraph" && "Word"} Limit
+
+
+
+
+
+ updateFieldData({ limit: value })}
+ hasError={false}
+ />
-
- Language
+
+
+ Language
+
+
+
+
option.value === value.value
}
- onChange={(event, value) => setLanguage(value)}
- value={language as any}
+ onChange={(event, value) =>
+ updateFieldData({ language: value })
+ }
+ value={fieldData.language as any}
options={languageOptions}
renderInput={(params: any) => (
-
+
+
+
+ ),
+ }}
+ />
)}
+ slotProps={{
+ paper: {
+ sx: {
+ maxHeight: 300,
+ },
+ },
+ }}
/>
-
- >
+
+
)}
-
-
)}
-
+
);
};
diff --git a/src/shell/components/withAi/AIGeneratorProvider.tsx b/src/shell/components/withAi/AIGeneratorProvider.tsx
new file mode 100644
index 0000000000..454832ce17
--- /dev/null
+++ b/src/shell/components/withAi/AIGeneratorProvider.tsx
@@ -0,0 +1,68 @@
+import {
+ Dispatch,
+ createContext,
+ useReducer,
+ useState,
+ ReactNode,
+} from "react";
+
+import { ToneOption } from "./AIGenerator";
+
+type ParameterData = {
+ topic?: string;
+ audienceDescription: string;
+ tone: ToneOption;
+ keywords?: string;
+ limit?: number;
+ language: {
+ label: string;
+ value: string;
+ };
+};
+type AIGeneratorContextType = [
+ string | null,
+ Dispatch,
+ Record,
+ Dispatch>
+];
+
+export const AIGeneratorContext = createContext([
+ null,
+ () => {},
+ null,
+ () => {},
+]);
+
+type AIGeneratorProviderProps = {
+ children?: ReactNode;
+};
+export const AIGeneratorProvider = ({ children }: AIGeneratorProviderProps) => {
+ const [lastOpenedItem, setLastOpenedItem] = useState(null);
+ // const [parameterData, setParameterData] = useState>(null);
+ const [parameterData, updateParameterData] = useReducer(
+ (
+ state: Record,
+ action: Record
+ ) => {
+ const newState = {
+ ...state,
+ ...action,
+ };
+ return newState;
+ },
+ {}
+ );
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/shell/components/withAi/index.tsx b/src/shell/components/withAi/index.tsx
index ce93f8a519..2681deee23 100644
--- a/src/shell/components/withAi/index.tsx
+++ b/src/shell/components/withAi/index.tsx
@@ -1,13 +1,24 @@
-import { memo, useMemo } from "react";
-import { Popover, IconButton } from "@mui/material";
+import { useRef, forwardRef, useImperativeHandle } from "react";
+import { Popover, Button, IconButton, alpha } from "@mui/material";
import { Brain, theme } from "@zesty-io/material";
import { ThemeProvider } from "@mui/material/styles";
import { ComponentType, MouseEvent, useState } from "react";
-import { AIGenerator } from "./AIGenerator";
import { useSelector } from "react-redux";
-import { AppState } from "../../store/types";
+import { keyframes } from "@mui/system";
import moment from "moment-timezone";
+
+import { AppState } from "../../store/types";
import instanceZUID from "../../../utility/instanceZUID";
+import { AIGenerator, TONE_OPTIONS } from "./AIGenerator";
+
+const rotateAnimation = keyframes`
+ 0% {
+ background-position: 0% 0%;
+ }
+ 100% {
+ background-position: 0% 100%;
+ }
+`;
// This date is used determine if the AI feature is enabled
const enabledDate = "2023-01-13";
@@ -26,109 +37,188 @@ const paragraphFormat = (text: string) => {
.join("")}
`;
};
-export const withAI = (WrappedComponent: ComponentType) => (props: any) => {
- const instanceCreatedAt = useSelector(
- (state: AppState) => state.instance.createdAt
- );
- const isEnabled =
- moment(instanceCreatedAt).isSameOrAfter(moment(enabledDate)) ||
- enabledZUIDs.includes(instanceZUID);
- const [anchorEl, setAnchorEl] = useState(null);
- const [focused, setFocused] = useState(false);
- const [key, setKey] = useState(0);
-
- const handleClick = (event: React.MouseEvent) => {
- setAnchorEl(event.currentTarget);
- };
-
- const handleClose = () => {
- setAnchorEl(null);
- };
-
- const handleApprove = (generatedText: string) => {
- if (
- props.datatype === "article_writer" ||
- props.datatype === "markdown" ||
- props.datatype === "wysiwyg_advanced" ||
- props.datatype === "wysiwyg_basic"
- ) {
- props.onChange(
- `${props.value || ""}${
- props.datatype === "markdown"
- ? generatedText
- : paragraphFormat(generatedText)
- }`,
- props.name,
- props.datatype
+export const withAI = (WrappedComponent: ComponentType) =>
+ forwardRef((props: any, ref) => {
+ const instanceCreatedAt = useSelector(
+ (state: AppState) => state.instance.createdAt
+ );
+ const isEnabled =
+ moment(instanceCreatedAt).isSameOrAfter(moment(enabledDate)) ||
+ enabledZUIDs.includes(instanceZUID);
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [focused, setFocused] = useState(false);
+ const [key, setKey] = useState(0);
+ const aiButtonRef = useRef(null);
+
+ useImperativeHandle(
+ ref,
+ () => {
+ return {
+ triggerAIButton() {
+ if (!anchorEl) {
+ aiButtonRef.current?.scrollIntoView({ behavior: "smooth" });
+
+ // Makes sure that the popup is placed correctly after
+ // the scrollIntoView function is ran
+ setTimeout(() => {
+ aiButtonRef.current?.click();
+ }, 500);
+ }
+ },
+ };
+ },
+ []
+ );
+
+ const handleClick = (event: React.MouseEvent) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = (reason: "close" | "insert") => {
+ if (
+ reason === "close" ||
+ (reason === "insert" && props.ZUID === "metaDescription")
+ ) {
+ // Reset the meta details flow type
+ props.onResetFlowType?.();
+ }
+ setAnchorEl(null);
+ };
+
+ const handleApprove = (generatedText: string) => {
+ if (
+ props.datatype === "article_writer" ||
+ props.datatype === "markdown" ||
+ props.datatype === "wysiwyg_advanced" ||
+ props.datatype === "wysiwyg_basic"
+ ) {
+ props.onChange(
+ `${props.value || ""}${
+ props.datatype === "markdown"
+ ? generatedText
+ : paragraphFormat(generatedText)
+ }`,
+ props.name,
+ props.datatype
+ );
+ // Force re-render after appending generated AI text due to uncontrolled component
+ setKey(key + 1);
+ } else {
+ props.onChange(
+ { target: { value: `${props.value || ""}${generatedText}` } },
+ props.name
+ );
+ }
+ };
+
+ if (isEnabled) {
+ return (
+ <>
+
+ }
+ variant="text"
+ color="inherit"
+ onClick={handleClick}
+ ref={aiButtonRef}
+ sx={{
+ backgroundColor: (theme) =>
+ Boolean(anchorEl)
+ ? alpha(theme.palette.primary.main, 0.08)
+ : "transparent",
+ minWidth: 0,
+ fontWeight: 600,
+ fontSize: 14,
+ lineHeight: "14px",
+ px: 0.5,
+ py: 0.25,
+ color: Boolean(anchorEl) ? "primary.main" : "text.disabled",
+
+ "&:hover": {
+ backgroundColor: (theme) =>
+ alpha(theme.palette.primary.main, 0.08),
+ color: "primary.main",
+ },
+
+ "&:hover .MuiButton-endIcon .MuiSvgIcon-root": {
+ fill: (theme) => theme.palette.primary.main,
+ },
+
+ "& .MuiButton-endIcon": {
+ ml: 0.5,
+ mr: 0,
+ },
+
+ "& .MuiButton-endIcon .MuiSvgIcon-root": {
+ fontSize: 16,
+ fill: (theme) =>
+ Boolean(anchorEl)
+ ? theme.palette.primary.main
+ : theme.palette.action.active,
+ },
+ }}
+ >
+ AI
+
+
+ }
+ onFocus={() => setFocused(true)}
+ onBlur={() => setFocused(false)}
+ />
+
+ {
+ console.log("closing ai generator");
+ handleClose("close");
+ }}
+ slotProps={{
+ paper: {
+ sx: {
+ overflowY: "hidden",
+
+ "&:after": {
+ content: '""',
+ position: "absolute",
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ background:
+ "linear-gradient(0deg, rgba(255,93,10,1) 0%, rgba(18,183,106,1) 25%, rgba(11,165,236,1) 50%, rgba(238,70,188,1) 75%, rgba(105,56,239,1) 100%)",
+ animation: `${rotateAnimation} 1.5s linear alternate infinite`,
+ backgroundSize: "300% 300%",
+ },
+ },
+ },
+ }}
+ >
+ handleClose(reason)}
+ aiType={props.aiType}
+ label={props.label}
+ isAIAssistedFlow={props.isAIAssistedFlow}
+ />
+
+
+ >
);
- // Force re-render after appending generated AI text due to uncontrolled component
- setKey(key + 1);
} else {
- props.onChange(
- { target: { value: `${props.value}${generatedText}` } },
- props.name
- );
+ return ;
}
- };
-
- if (isEnabled) {
- return (
- <>
-
-
- focused
- ? "primary.main"
- : `${theme.palette.action.active}`,
- },
- "svg:hover": {
- color: "primary.main",
- },
- }}
- onClick={(event: MouseEvent) => {
- const target = event.target as HTMLElement;
- if (target.nodeName === "svg" || target.nodeName === "path") {
- handleClick(event);
- }
- }}
- size="xxsmall"
- >
-
-
-
- }
- onFocus={() => setFocused(true)}
- onBlur={() => setFocused(false)}
- />
-
-
-
-
-
- >
- );
- } else {
- return ;
- }
-};
+ });
diff --git a/src/shell/components/withCursorPosition/index.tsx b/src/shell/components/withCursorPosition/index.tsx
index 94084b9b32..4bb01d4276 100644
--- a/src/shell/components/withCursorPosition/index.tsx
+++ b/src/shell/components/withCursorPosition/index.tsx
@@ -13,11 +13,19 @@ export const withCursorPosition = (WrappedComponent: ComponentType) =>
const inputRef = useRef(null);
useEffect(() => {
- inputRef.current?.setSelectionRange(cursorPosition, cursorPosition);
+ /*
+ In Safari, setting the cursor position can cause the input to refocus,
+ leading to a poor user experience if the input isn't already focused.
+ This conditional check ensures the cursor position is only set if the input is focused,
+ preventing unnecessary refocusing on value changes, which is a problem in Safari.
+ */
+ if (document.activeElement === inputRef.current) {
+ inputRef.current?.setSelectionRange(cursorPosition, cursorPosition);
+ }
}, [props.value]);
const handleChange = (e: React.ChangeEvent) => {
- setCursorPosition(e.target.selectionStart);
+ setCursorPosition(e.target.selectionStart || 0);
props.onChange && props.onChange(e);
};
diff --git a/src/shell/hooks/use-permissions.js b/src/shell/hooks/use-permissions.js
index 23c00df880..f2886bcdfc 100644
--- a/src/shell/hooks/use-permissions.js
+++ b/src/shell/hooks/use-permissions.js
@@ -33,9 +33,14 @@ export function usePermission(action, zuid = instanceZUID) {
return true;
}
- const granularRole = role?.granularRoles?.find(
- (r) => r.resourceZUID === zuid
- );
+ /*
+ If the user is not a super user, check granular roles.
+ First check specific resource, if not found check instance level.
+ TODO: Check additional granular roles for parent resources depending on resource type (e.g. content model when checking content item)
+ */
+ const granularRole =
+ role?.granularRoles?.find((r) => r.resourceZUID === zuid) ||
+ role?.granularRoles?.find((r) => r.resourceZUID === instanceZUID);
// Check system
switch (action) {
diff --git a/src/shell/services/analytics.ts b/src/shell/services/analytics.ts
new file mode 100644
index 0000000000..d14de01a81
--- /dev/null
+++ b/src/shell/services/analytics.ts
@@ -0,0 +1,85 @@
+import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
+import { getResponseData, prepareHeaders } from "./util";
+import instanceZUID from "../../utility/instanceZUID";
+
+//Define service using a base URL and expected endpoints
+export const analyticsApi = createApi({
+ reducerPath: "analyticsApi",
+ baseQuery: fetchBaseQuery({
+ // @ts-ignore
+ baseUrl: `${__CONFIG__.API_ANALYTICS}`,
+ prepareHeaders,
+ }),
+ endpoints: (builder) => ({
+ getAnalyticsPropertyDataByQuery: builder.query({
+ query: (body) => {
+ return {
+ url: `ga4/reports`,
+ method: "POST",
+ body,
+ params: {
+ zuid: instanceZUID,
+ },
+ };
+ },
+ }),
+ getAnalyticsProperties: builder.query({
+ query: () => {
+ return {
+ url: `ga4/properties`,
+ method: "GET",
+ params: {
+ zuid: instanceZUID,
+ },
+ };
+ },
+ }),
+ getAnalyticsPagePathsByFilter: builder.query<
+ string[],
+ {
+ filter: "popular" | "gainer" | "loser";
+ startDate: string;
+ endDate: string;
+ propertyId: string;
+ limit: number;
+ order: "asc" | "desc";
+ }
+ >({
+ query: ({ filter, startDate, endDate, propertyId, limit, order }) => {
+ return {
+ url: `ga4/page-paths`,
+ method: "GET",
+ params: {
+ q: filter,
+ date_start: startDate,
+ date_end: endDate,
+ property_id: propertyId,
+ limit,
+ order,
+ zuid: instanceZUID,
+ },
+ };
+ },
+ }),
+ disconnectGoogleAnalytics: builder.mutation({
+ query: () => {
+ return {
+ url: `ga4/auth/disconnect`,
+ method: "DELETE",
+ params: {
+ zuid: instanceZUID,
+ },
+ };
+ },
+ }),
+ }),
+});
+
+// Export hooks for usage in functional components, which are
+// auto-generated based on the defined endpoints
+export const {
+ useGetAnalyticsPropertiesQuery,
+ useGetAnalyticsPropertyDataByQueryQuery,
+ useGetAnalyticsPagePathsByFilterQuery,
+ useDisconnectGoogleAnalyticsMutation,
+} = analyticsApi;
diff --git a/src/shell/services/cloudFunctions.ts b/src/shell/services/cloudFunctions.ts
index 34a973ba03..315a17b6ac 100644
--- a/src/shell/services/cloudFunctions.ts
+++ b/src/shell/services/cloudFunctions.ts
@@ -23,77 +23,10 @@ export const cloudFunctionsApi = createApi({
};
},
}),
- getAnalyticsPropertyDataByQuery: builder.query({
- query: (body) => {
- return {
- url: `getPropertyDataByQuery`,
- method: "POST",
- body,
- params: {
- zuid: instanceZUID,
- },
- };
- },
- }),
- getAnalyticsProperties: builder.query({
- query: () => {
- return {
- url: `getPropertyList`,
- method: "GET",
- params: {
- zuid: instanceZUID,
- },
- };
- },
- }),
- getAnalyticsPagePathsByFilter: builder.query<
- string[],
- {
- filter: "popular" | "gainer" | "loser";
- startDate: string;
- endDate: string;
- propertyId: string;
- limit: number;
- order: "asc" | "desc";
- }
- >({
- query: ({ filter, startDate, endDate, propertyId, limit, order }) => {
- return {
- url: `getPagePathByFilter`,
- method: "GET",
- params: {
- q: filter,
- date_start: startDate,
- date_end: endDate,
- property_id: propertyId,
- limit,
- order,
- zuid: instanceZUID,
- },
- };
- },
- }),
- disconnectGoogleAnalytics: builder.mutation({
- query: () => {
- return {
- url: `disconnectGoogleAnalytics`,
- method: "DELETE",
- params: {
- zuid: instanceZUID,
- },
- };
- },
- }),
}),
});
// Export hooks for usage in functional components, which are
// auto-generated based on the defined endpoints
-export const {
- useRefreshCacheMutation,
- useAiGenerationMutation,
- useGetAnalyticsPropertiesQuery,
- useGetAnalyticsPropertyDataByQueryQuery,
- useGetAnalyticsPagePathsByFilterQuery,
- useDisconnectGoogleAnalyticsMutation,
-} = cloudFunctionsApi;
+export const { useRefreshCacheMutation, useAiGenerationMutation } =
+ cloudFunctionsApi;
diff --git a/src/shell/services/marketing.ts b/src/shell/services/marketing.ts
index 3a0f367010..a23b553684 100644
--- a/src/shell/services/marketing.ts
+++ b/src/shell/services/marketing.ts
@@ -43,7 +43,7 @@ export const marketingApi = createApi({
video_link,
start_date_and_time,
end_date_and_time,
- created_at: currVal?.version?.history?.data?.pop()?.createdAt,
+ created_at: currVal?.version?.createdAt,
},
];
}
diff --git a/src/shell/services/mediaManager.ts b/src/shell/services/mediaManager.ts
index e2d68fca3d..bfc2506a12 100644
--- a/src/shell/services/mediaManager.ts
+++ b/src/shell/services/mediaManager.ts
@@ -369,6 +369,7 @@ export const mediaManagerApi = createApi({
// auto-generated based on the defined endpoints
export const {
useGetFileQuery,
+ useLazyGetFileQuery,
useGetBinQuery,
useGetBinsQuery,
useGetAllBinFilesQuery,
diff --git a/src/shell/services/types.ts b/src/shell/services/types.ts
index 62c7cd1aac..b8658b0d48 100644
--- a/src/shell/services/types.ts
+++ b/src/shell/services/types.ts
@@ -60,6 +60,8 @@ export interface File {
deleted_at?: string;
deleted_from_storage_at?: string;
thumbnail: string;
+ storage_driver: string;
+ storage_name: string;
}
export type ModelType = "pageset" | "templateset" | "dataset";
@@ -203,12 +205,16 @@ export interface FieldSettings {
regexRestrictErrorMessage?: string;
minValue?: number;
maxValue?: number;
+ currency?: string;
+ fileExtensions?: string[];
+ fileExtensionsErrorMessage?: string;
}
export type ContentModelFieldValue =
| string
| number
| boolean
+ | string[]
| FieldSettings
| FieldSettingsOptions[];
diff --git a/src/shell/services/util.js b/src/shell/services/util.js
index a8e232048c..1b3236a582 100644
--- a/src/shell/services/util.js
+++ b/src/shell/services/util.js
@@ -10,5 +10,13 @@ export const prepareHeaders = (headers) => {
return headers;
};
-export const generateThumbnail = (file) =>
- `${file.url}?width=300&height=300&fit=bounds`;
+export const generateThumbnail = (file) => {
+ if (!!file.updated_at && !isNaN(new Date(file.updated_at).getTime())) {
+ // Prevents browser image cache when a certain file has been already replaced
+ return `${file.url}?width=300&height=300&fit=bounds&versionHash=${new Date(
+ file.updated_at
+ ).getTime()}`;
+ }
+
+ return `${file.url}?width=300&height=300&fit=bounds`;
+};
diff --git a/src/shell/store/content.js b/src/shell/store/content.js
index 1a89b29c52..401358d1bb 100644
--- a/src/shell/store/content.js
+++ b/src/shell/store/content.js
@@ -208,13 +208,13 @@ export function content(state = {}, action) {
}
// create the new item in the store
-export function generateItem(modelZUID) {
+export function generateItem(modelZUID, data = {}) {
return (dispatch, getState) => {
const state = getState();
const itemZUID = `new:${modelZUID}`;
const item = {
dirty: false,
- data: {},
+ data,
web: {
canonicalTagMode: 1,
},
@@ -379,7 +379,11 @@ export function fetchItems(modelZUID, options = {}) {
// };
// }
-export function saveItem(itemZUID, action = "") {
+export function saveItem({
+ itemZUID,
+ action = "",
+ skipContentItemValidation = false,
+}) {
return (dispatch, getState) => {
const state = getState();
const item = cloneDeep(state.content[itemZUID]);
@@ -431,12 +435,15 @@ export function saveItem(itemZUID, action = "") {
item.data[field.name] > field.settings?.maxValue)
);
+ // When skipContentItemValidation is true, this means that only the
+ // SEO meta tags were changed, so we skip validating the content item
if (
- missingRequired?.length ||
- lackingCharLength?.length ||
- regexPatternMismatch?.length ||
- regexRestrictPatternMatch?.length ||
- invalidRange?.length
+ !skipContentItemValidation &&
+ (missingRequired?.length ||
+ lackingCharLength?.length ||
+ regexPatternMismatch?.length ||
+ regexRestrictPatternMatch?.length ||
+ invalidRange?.length)
) {
return Promise.resolve({
err: "VALIDATION_ERROR",
@@ -507,7 +514,7 @@ export function saveItem(itemZUID, action = "") {
};
}
-export function createItem(modelZUID, itemZUID) {
+export function createItem({ modelZUID, itemZUID, skipPathPartValidation }) {
return (dispatch, getState) => {
const state = getState();
@@ -536,9 +543,16 @@ export function createItem(modelZUID, itemZUID) {
item.meta.createdByUserZUID = state.user.user_zuid;
}
- // Check required fields are not empty
+ // Check required fields are not empty, except the og and tc fields since these
+ // are handled by the meta component
const missingRequired = fields.filter((field) => {
- if (!field.deletedAt && field.required) {
+ if (
+ !field.deletedAt &&
+ !["og_title", "og_description", "tc_title", "tc_description"].includes(
+ field.name
+ ) &&
+ field.required
+ ) {
if (!item.data[field.name] && item.data[field.name] != 0) {
return true;
}
@@ -546,6 +560,12 @@ export function createItem(modelZUID, itemZUID) {
return false;
});
+ const hasMissingRequiredSEOFields = skipPathPartValidation
+ ? !item?.web?.metaTitle
+ : !item?.web?.metaTitle ||
+ !item?.web?.metaDescription ||
+ !item?.web?.pathPart;
+
// Check minlength is satisfied
const lackingCharLength = fields?.filter(
(field) =>
@@ -583,7 +603,8 @@ export function createItem(modelZUID, itemZUID) {
lackingCharLength?.length ||
regexPatternMismatch?.length ||
regexRestrictPatternMatch?.length ||
- invalidRange?.length
+ invalidRange?.length ||
+ hasMissingRequiredSEOFields
) {
return Promise.resolve({
err: "VALIDATION_ERROR",
diff --git a/src/shell/store/index.js b/src/shell/store/index.js
index 0ceea53fbe..42dec2b778 100644
--- a/src/shell/store/index.js
+++ b/src/shell/store/index.js
@@ -39,6 +39,7 @@ import { mediaManagerApi } from "../services/mediaManager";
import { metricsApi } from "../services/metrics";
import { cloudFunctionsApi } from "../services/cloudFunctions";
import { marketingApi } from "../services/marketing";
+import { analyticsApi } from "../services/analytics";
// Middleware is applied in order of array
const middlewares = [
@@ -56,6 +57,7 @@ const middlewares = [
metricsApi.middleware,
cloudFunctionsApi.middleware,
marketingApi.middleware,
+ analyticsApi.middleware,
];
/**
@@ -122,6 +124,7 @@ function createReducer(asyncReducers) {
[metricsApi.reducerPath]: metricsApi.reducer,
[cloudFunctionsApi.reducerPath]: cloudFunctionsApi.reducer,
[marketingApi.reducerPath]: marketingApi.reducer,
+ [analyticsApi.reducerPath]: analyticsApi.reducer,
};
return combineReducers({
diff --git a/src/shell/store/media-revamp.ts b/src/shell/store/media-revamp.ts
index f75d83af73..239303430b 100644
--- a/src/shell/store/media-revamp.ts
+++ b/src/shell/store/media-revamp.ts
@@ -27,12 +27,18 @@ export type UploadFile = {
loading?: boolean;
bin_id?: string;
group_id?: string;
+ replacementFile?: boolean;
};
type FileUploadStart = StoreFile & { file: File };
type FileUploadSuccess = StoreFile & FileBase & { id: string };
type FileUploadProgress = { uploadID: string; progress: number };
-type FileUploadStageArg = { file: File; bin_id: string; group_id: string };
+type FileUploadStageArg = {
+ file: File;
+ bin_id: string;
+ group_id: string;
+ replacementFile?: boolean;
+};
type StagedUpload = {
status: "staged";
@@ -146,6 +152,7 @@ const mediaSlice = createSlice({
uploadID: uuidv4(),
url: URL.createObjectURL(file.file),
filename: file.file.name,
+ replacementFile: file.replacementFile,
...file,
};
});
@@ -334,11 +341,11 @@ type FileAugmentation = {
group_id?: string;
};
-async function getSignedUrl(file: any, bin: Bin) {
+async function getSignedUrl(filename: string, storageName: string) {
try {
return request(
//@ts-expect-error
- `${CONFIG.SERVICE_MEDIA_STORAGE}/signed-url/${bin.storage_name}/${file.file.name}`
+ `${CONFIG.SERVICE_MEDIA_STORAGE}/signed-url/${storageName}/${filename}`
).then((res) => res.data.url);
} catch (err) {
console.error(err);
@@ -349,6 +356,152 @@ async function getSignedUrl(file: any, bin: Bin) {
}
}
+export function replaceFile(newFile: UploadFile, originalFile: FileBase) {
+ return async (dispatch: Dispatch, getState: () => AppState) => {
+ const bodyData = new FormData();
+ const req = new XMLHttpRequest();
+ const file = {
+ progress: 0,
+ loading: true,
+ ...newFile,
+ };
+
+ bodyData.append("file", file.file, originalFile.filename);
+ bodyData.append("file_id", originalFile.id);
+
+ req.upload.addEventListener("progress", function (e) {
+ file.progress = (e.loaded / e.total) * 100;
+
+ dispatch(fileUploadProgress(file));
+ });
+
+ function handleError() {
+ dispatch(fileUploadError(file));
+ dispatch(
+ notify({
+ message: "Failed uploading file",
+ kind: "error",
+ })
+ );
+ }
+
+ req.addEventListener("abort", handleError);
+ req.addEventListener("error", handleError);
+ req.addEventListener("load", (_) => {
+ if (req.status === 200) {
+ dispatch(
+ notify({
+ message: `File Replaced: ${originalFile.filename}`,
+ kind: "success",
+ })
+ );
+ const successFile = {
+ ...originalFile,
+ uploadID: file.uploadID,
+ progress: 100,
+ loading: false,
+ url: URL.createObjectURL(file.file),
+ };
+ dispatch(fileUploadSuccess(successFile));
+ } else {
+ dispatch(
+ notify({
+ message: "Failed uploading file",
+ kind: "error",
+ })
+ );
+ dispatch(fileUploadError(file));
+ }
+ });
+
+ // Use signed url flow for large files
+ if (file.file.size > 32000000) {
+ /**
+ * GAE has an inherent 32mb limit at their global nginx load balancer
+ * We use a signed url for large file uploads directly to the assocaited bucket
+ */
+
+ const signedUrl = await getSignedUrl(
+ originalFile?.filename,
+ originalFile?.storage_name
+ );
+ req.open("PUT", signedUrl);
+
+ // The sent content-type needs to match what was provided when generating the signed url
+ // @see https://medium.com/imersotechblog/upload-files-to-google-cloud-storage-gcs-from-the-browser-159810bb11e3
+ req.setRequestHeader("Content-Type", file.file.type);
+
+ req.addEventListener("load", () => {
+ if (req.status === 200) {
+ return request(
+ //@ts-expect-error
+ `${CONFIG.SERVICE_MEDIA_MANAGER}/file/${originalFile?.id}/purge?triggerUpdate=true`,
+ {
+ method: "POST",
+ json: true,
+ }
+ )
+ .then((res) => {
+ if (res.status === 200) {
+ const state: State = getState().mediaRevamp;
+ if (state.uploads.length) {
+ dispatch(
+ fileUploadSuccess({
+ ...res.data,
+ uploadID: file.uploadID,
+ })
+ );
+ } else {
+ dispatch(
+ notify({
+ message: `Successfully uploaded file`,
+ kind: "success",
+ })
+ );
+ }
+ } else {
+ throw res;
+ }
+ })
+ .catch((err) => {
+ dispatch(fileUploadError(file));
+ dispatch(
+ notify({
+ message:
+ "Failed creating file record after signed url upload",
+ kind: "error",
+ })
+ );
+ });
+ } else {
+ dispatch(fileUploadError(file));
+ dispatch(
+ notify({
+ message: "Failed uploading file to signed url",
+ kind: "error",
+ })
+ );
+ }
+ });
+
+ // When sending directly to bucket it needs to be just the file
+ // and not the extra meta data for the zesty services
+ req.send(file.file);
+ } else {
+ req.withCredentials = true;
+ req.open(
+ "PUT",
+ //@ts-expect-error
+ `${CONFIG.SERVICE_MEDIA_STORAGE}/replace/${originalFile?.storage_driver}/${originalFile?.storage_name}`
+ );
+
+ req.send(bodyData);
+ }
+
+ dispatch(fileUploadStart(file));
+ };
+}
+
//type FileMonstrosity = {file: File } & FileAugmentation & FileBase
export function uploadFile(fileArg: UploadFile, bin: Bin) {
return async (dispatch: Dispatch, getState: () => AppState) => {
@@ -417,7 +570,7 @@ export function uploadFile(fileArg: UploadFile, bin: Bin) {
* We use a signed url for large file uploads directly to the assocaited bucket
*/
- const signedUrl = await getSignedUrl(file, bin);
+ const signedUrl = await getSignedUrl(file.file.name, bin.storage_name);
req.open("PUT", signedUrl);
// The sent content-type needs to match what was provided when generating the signed url
@@ -596,16 +749,18 @@ export function dismissFileUploads() {
);
}
if (successfulUploads.length) {
- dispatch(
- notify({
- message: `Successfully uploaded ${successfulUploads.length} files${
- inProgressUploads.length
- ? `...${inProgressUploads.length} files still in progress`
- : ""
- }`,
- kind: "success",
- })
- );
+ if (!successfulUploads[0].replacementFile) {
+ dispatch(
+ notify({
+ message: `Successfully uploaded ${successfulUploads.length} files${
+ inProgressUploads.length
+ ? `...${inProgressUploads.length} files still in progress`
+ : ""
+ }`,
+ kind: "success",
+ })
+ );
+ }
}
if (failedUploads.length) {
dispatch(
@@ -622,6 +777,12 @@ export function dismissFileUploads() {
kind: "warn",
})
);
+ } else {
+ successfulUploads?.forEach((upload) => {
+ dispatch(
+ mediaManagerApi.util.invalidateTags([{ type: "File", id: upload.id }])
+ );
+ });
}
dispatch(fileUploadReset());
};
diff --git a/src/shell/store/notifications.ts b/src/shell/store/notifications.ts
index 271ac555b4..7c6e89514d 100644
--- a/src/shell/store/notifications.ts
+++ b/src/shell/store/notifications.ts
@@ -26,7 +26,7 @@ type NotifyArgs = {
kind: "warn" | "error" | "success";
HTML?: unknown;
heading?: string;
- message?: string;
+ message: string;
};
type Notification = NotifyArgs & {
diff --git a/src/shell/store/products.js b/src/shell/store/products.js
index 6b5b9befd3..c0e5fece04 100644
--- a/src/shell/store/products.js
+++ b/src/shell/store/products.js
@@ -20,6 +20,7 @@ export function fetchProducts() {
// seo: 31-71cfc74-s30
case "31-71cfc74-0wn3r":
case "31-71cfc74-4dm13":
+ case "31-71cfc74-4cc4dm13":
data = [
"launchpad",
"content",
@@ -35,6 +36,7 @@ export function fetchProducts() {
];
break;
case "31-71cfc74-d3v3l0p3r":
+ case "31-71cfc74-d3vc0n":
data = [
"launchpad",
"content",
diff --git a/src/shell/store/types.ts b/src/shell/store/types.ts
index b3c678b2e7..268bf7dc07 100644
--- a/src/shell/store/types.ts
+++ b/src/shell/store/types.ts
@@ -1,6 +1,10 @@
import { UIState } from "./ui";
import { State as MediaRevampState } from "./media-revamp";
-import { InstalledApp, ModelType } from "../services/types";
+import {
+ InstalledApp,
+ ModelType,
+ ContentItemWithDirtyAndPublishing,
+} from "../services/types";
/*
TODO
The UI state is well typed but the rest of the application state is entirely
@@ -32,7 +36,7 @@ export type AppState = {
languages: any;
models: any;
fields: any;
- content: any;
+ content: Record;
contentVersions: any;
mediaRevamp: MediaRevampState;
media: any;
diff --git a/src/utility/getFlagEmoji.ts b/src/utility/getFlagEmoji.ts
new file mode 100644
index 0000000000..dd4b9854ec
--- /dev/null
+++ b/src/utility/getFlagEmoji.ts
@@ -0,0 +1,9 @@
+export default (countryCode: string) => {
+ // Convert country code to flag emoji.
+ // Unicode flag emojis are made up of regional indicator symbols, which are a sequence of two letters.
+ const baseOffset = 0x1f1e6;
+ return (
+ String.fromCodePoint(baseOffset + (countryCode.charCodeAt(0) - 65)) +
+ String.fromCodePoint(baseOffset + (countryCode.charCodeAt(1) - 65))
+ );
+};