- = $this->text('project-invest') ?>
+ = $invest->invest->getMethod()->isSubscription() ? $this->text('project-invest-from-subscription') : $this->text('project-invest') ?>
= amount_format($invest->amount) ?>
diff --git a/db/migrations/20240620092323_goteo_form_honeypot.php b/db/migrations/20240620092323_goteo_form_honeypot.php
new file mode 100755
index 0000000000..267c41b747
--- /dev/null
+++ b/db/migrations/20240620092323_goteo_form_honeypot.php
@@ -0,0 +1,56 @@
+fetchAll(\PDO::FETCH_OBJ) as $faq) {
+ $slug = Faq::idealiza($faq->title, false, false, 150);
+ try {
+ // If duplicate, let it null
+ Faq::query("UPDATE faq SET slug=:slug WHERE id=:id", [':id' => $faq->id, ':slug' => $slug]);
+ } catch(\PDOException $e) {
+ //
+ }
+ }
+ }
+
+ public function preDown()
+ {
+ // add the pre-migration code here
+ }
+
+ public function postDown()
+ {
+ // add the post-migration code here
+ }
+
+ /**
+ * Return the SQL statements for the Up migration
+ *
+ * @return string The SQL string to execute for the Up migration.
+ */
+ public function getUpSQL()
+ {
+ return "
+
+ CREATE TABLE `faq_section` (
+ `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
+ `name` VARCHAR(255) NOT NULL,
+ `slug` VARCHAR(150) NOT NULL,
+ `icon` varchar(255) NULL,
+ `banner_header` VARCHAR(255) NULL,
+ `button_action` VARCHAR(255) NOT NULL,
+ `button_url` VARCHAR(255) NOT NULL,
+ `lang` VARCHAR(6) NULL,
+ `order` INT(11),
+ PRIMARY KEY (`id`)
+ );
+
+ CREATE TABLE `faq_section_lang` (
+ `id` INT(11) UNSIGNED NOT NULL,
+ `lang` VARCHAR (6),
+ `name` VARCHAR(255) NOT NULL,
+ `button_action` VARCHAR(255) NOT NULL,
+ `button_url` VARCHAR(255) NOT NULL,
+ `pending` TINYINT (1),
+ FOREIGN KEY (`id`) REFERENCES `faq_section`(`id`) ON UPDATE CASCADE ON DELETE CASCADE
+ );
+
+ CREATE TABLE `faq_subsection` (
+ `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
+ `section_id` INT(11) UNSIGNED NOT NULL,
+ `name` VARCHAR(255) NOT NULL,
+ `lang` VARCHAR(6) NULL,
+ `order` INT(11),
+ PRIMARY KEY (`id`),
+ CONSTRAINT `section_id_ibfk_1` FOREIGN KEY (`section_id`) REFERENCES `faq_section` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
+ );
+
+ CREATE TABLE `faq_subsection_lang` (
+ `id` INT(11) UNSIGNED NOT NULL,
+ `lang` VARCHAR (6),
+ `name` VARCHAR(255) NOT NULL,
+ `pending` TINYINT (1),
+ FOREIGN KEY (`id`) REFERENCES `faq_subsection`(`id`) ON UPDATE CASCADE ON DELETE CASCADE
+ );
+
+ ALTER TABLE `faq` ADD COLUMN `slug` VARCHAR(150) AFTER id;
+ ALTER TABLE `faq` ADD COLUMN `subsection_id` INT(11) UNSIGNED NOT NULL AFTER title;
+ ALTER TABLE `faq` DROP `section`;
+ SET FOREIGN_KEY_CHECKS=0;
+ ALTER TABLE `faq` ADD CONSTRAINT `faq_ibfk_2` FOREIGN KEY (`subsection_id`) REFERENCES `faq_subsection`(`id`) ON UPDATE CASCADE ON DELETE CASCADE;
+ SET FOREIGN_KEY_CHECKS=1;
+ ";
+ }
+
+ /**
+ * Return the SQL statements for the Down migration
+ *
+ * @return string The SQL string to execute for the Down migration.
+ */
+ public function getDownSQL()
+ {
+ return "
+ DROP TABLE `faq_subsection_lang`;
+ ALTER TABLE `faq` DROP FOREIGN KEY `faq_ibfk_2`;
+ ALTER TABLE `faq` DROP COLUMN `subsection_id`;
+ ALTER TABLE `faq` DROP COLUMN `slug`;
+ ALTER TABLE `faq` ADD COLUMN `section` VARCHAR(150) AFTER id;
+ DROP TABLE `faq_subsection`;
+ DROP TABLE `faq_section_lang`;
+ DROP TABLE `faq_section`;
+ ";
+ }
+
+}
diff --git a/public/assets/css/ajax-loader.gif b/public/assets/css/ajax-loader.gif
new file mode 100644
index 0000000000..e0e6e9760b
Binary files /dev/null and b/public/assets/css/ajax-loader.gif differ
diff --git a/public/assets/img/faq/Icono/donantes@1x.svg b/public/assets/img/faq/Icono/donantes@1x.svg
new file mode 100644
index 0000000000..834b79b0da
--- /dev/null
+++ b/public/assets/img/faq/Icono/donantes@1x.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/public/assets/img/faq/Icono/impulsores@1x.svg b/public/assets/img/faq/Icono/impulsores@1x.svg
new file mode 100644
index 0000000000..28bb0562ba
--- /dev/null
+++ b/public/assets/img/faq/Icono/impulsores@1x.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/img/faq/Icono/matchers@1x.svg b/public/assets/img/faq/Icono/matchers@1x.svg
new file mode 100644
index 0000000000..f61b9e5450
--- /dev/null
+++ b/public/assets/img/faq/Icono/matchers@1x.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/public/assets/img/faq/Icono/sobre-goteo@1x.svg b/public/assets/img/faq/Icono/sobre-goteo@1x.svg
new file mode 100644
index 0000000000..94ec375159
--- /dev/null
+++ b/public/assets/img/faq/Icono/sobre-goteo@1x.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/public/assets/img/faq/icones/arrow-right-primary.svg b/public/assets/img/faq/icones/arrow-right-primary.svg
new file mode 100644
index 0000000000..1aa14f3bd3
--- /dev/null
+++ b/public/assets/img/faq/icones/arrow-right-primary.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/assets/img/faq/icones/caret-down.svg b/public/assets/img/faq/icones/caret-down.svg
new file mode 100644
index 0000000000..a313efe210
--- /dev/null
+++ b/public/assets/img/faq/icones/caret-down.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/public/assets/img/faq/icones/caret-up.svg b/public/assets/img/faq/icones/caret-up.svg
new file mode 100644
index 0000000000..ae4071d40e
--- /dev/null
+++ b/public/assets/img/faq/icones/caret-up.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/public/assets/img/faq/icones/search.svg b/public/assets/img/faq/icones/search.svg
new file mode 100644
index 0000000000..fef0ae22a2
--- /dev/null
+++ b/public/assets/img/faq/icones/search.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/assets/img/faq/pattern/donantes@1x.svg b/public/assets/img/faq/pattern/donantes@1x.svg
new file mode 100644
index 0000000000..cf904ac290
--- /dev/null
+++ b/public/assets/img/faq/pattern/donantes@1x.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/img/faq/pattern/impulsores@1x.svg b/public/assets/img/faq/pattern/impulsores@1x.svg
new file mode 100644
index 0000000000..dfdcc6f316
--- /dev/null
+++ b/public/assets/img/faq/pattern/impulsores@1x.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/img/faq/pattern/matchers@1x.svg b/public/assets/img/faq/pattern/matchers@1x.svg
new file mode 100644
index 0000000000..ec1090fd13
--- /dev/null
+++ b/public/assets/img/faq/pattern/matchers@1x.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/img/faq/pattern/sobre_goteo@1x.svg b/public/assets/img/faq/pattern/sobre_goteo@1x.svg
new file mode 100644
index 0000000000..fe5d0825f3
--- /dev/null
+++ b/public/assets/img/faq/pattern/sobre_goteo@1x.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/js/admin/faqs.js b/public/assets/js/admin/faqs.js
new file mode 100644
index 0000000000..0410e8ed1f
--- /dev/null
+++ b/public/assets/js/admin/faqs.js
@@ -0,0 +1,41 @@
+/*
+@licstart The following is the entire license notice for the
+JavaScript code in this page.
+
+Copyright (C) 2010 Goteo Foundation
+
+The JavaScript code in this page is free software: you can
+redistribute it and/or modify it under the terms of the GNU
+General Public License (GNU GPL) as published by the Free Software
+Foundation, either version 3 of the License, or (at your option)
+any later version. The code is distributed WITHOUT ANY WARRANTY;
+without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
+
+As additional permission under GNU GPL version 3 section 7, you
+may distribute non-source (e.g., minimized or compacted) forms of
+that code without the copy of the GNU GPL normally required by
+section 4, provided you include this license notice and a URL
+through which recipients can access the Corresponding Source.
+
+
+@licend The above is the entire license notice
+for the JavaScript code in this page.
+*/
+
+$(function () {
+
+ // Allow drag&drop reorder by the column `order`
+ const settings = {
+ apiUrl: function (row) {
+ // '/faq/{id}/sort',
+ return '/api/faq/' + $(row).find('[data-key="id"]').data('value') + '/sort';
+ }
+ };
+ adminOrderColumn('table.model-faq', settings);
+
+ $(window).on("pronto.render", function (e) {
+ adminOrderColumn('table.model-faq', settings);
+ });
+
+});
diff --git a/public/assets/sass/common.scss b/public/assets/sass/common.scss
index eab29722c8..682f02448f 100644
--- a/public/assets/sass/common.scss
+++ b/public/assets/sass/common.scss
@@ -55,4 +55,4 @@
@import 'layouts/map';
@import 'layouts/questionnaire';
@import 'layouts/impact_discover';
-
+@import "layouts/faq";
diff --git a/public/assets/sass/layouts/_faq.scss b/public/assets/sass/layouts/_faq.scss
new file mode 100644
index 0000000000..e608bd55c6
--- /dev/null
+++ b/public/assets/sass/layouts/_faq.scss
@@ -0,0 +1,559 @@
+@import "compass/css3";
+
+/* faq styles */
+
+body.faq {
+ font-family: 'Roboto', sans-serif;
+ font-weight: 400;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ color: #3A3A3A;
+
+ article[class^='col-'] {
+ display: flex;
+ flex-flow: row wrap;
+ }
+ .row {
+ &:before {
+ display: flex;
+ }
+ &:after {
+ display: flex;
+ }
+ }
+
+ #breadcrumb {
+ font-weight: 400;
+ font-size: 13px;
+ color: #19B4B2;
+ letter-spacing: 0.24px;
+ line-height: 22px;
+ background-color: #E1F1F1;
+ padding: 12px 0;
+ a {
+ color: #1A1A1A;
+ font-weight: 500;
+ text-decoration: underline;
+ &:hover {
+ color: #19B4B2;
+ }
+ }
+ .slash {
+ color: #19B4B2;
+ }
+ }
+ #header_faqs {
+ position: relative;
+ min-height: 264px;
+ background-color: #DDF4F4;
+ display: flex;
+ flex-flow: wrap column;
+ justify-content: flex-start;
+ align-content: center;
+ background-size: 96px;
+ margin-bottom: 72px;
+ padding-top: 24px;
+ padding-bottom: 48px;
+ h1 {
+ font-size: 50px;
+ font-weight: 700;
+ color: #ffffff;
+ letter-spacing: 1.25px;
+ text-align: center;
+ margin: 16px auto 0 auto;
+ }
+ h3 {
+ font-size: 24px;
+ color: #ffffff;
+ letter-spacing: 0.24px;
+ text-align: center;
+ margin-bottom: 0px;
+ >a {
+ color: #FFFFFF;
+ text-decoration: underline;
+ &:hover {
+ color: #19B4B2;
+ }
+ }
+ }
+ .upper_bar {
+ margin-bottom: 8px;
+ .faq_search {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: flex-start;
+ justify-content: flex-start;
+ label {
+ width: 211px;
+ input[type=search] {
+ width: 100%;
+ height: 44px;
+ background: #FFFFFF;
+ border: 1px solid #D1D1D1;
+ font-family: Roboto-Regular;
+ font-weight: 400;
+ font-size: 15px;
+ color: #3A3A3A;
+ letter-spacing: 0.16px;
+ padding: 5px 15px;
+ }
+ }
+ >button.search-submit {
+ position: relative;
+ width: 44px;
+ height: 44px;
+ border: none;
+ &:after {
+ position: absolute;
+ content: '';
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: url('../img/faq/icones/search.svg') center center/24px no-repeat;
+ background-color: #FFFFFF;
+ border: 1px solid #D1D1D1;
+ }
+ }
+ }
+ .create-project {
+ float: right;
+ text-align: right;
+ }
+ }
+ }
+ #header_faqs.home_faqs {
+ min-height: 292px;
+ margin-bottom: 0;
+ h1 {
+ color: #3A3A3A;
+ }
+ }
+ #header_faqs.about {
+ background: url('../img/faq/pattern/sobre_goteo@1x.svg') center center/96px repeat #FFEC61;
+ h1 {
+ color: #3A3A3A;
+ }
+ h3 {
+ color: #3A3A3A;
+ >a {
+ color: #3A3A3A;
+ &:hover {
+ color: #19B4B2;
+ }
+ }
+ }
+ }
+
+ #header_faqs.sponsors {
+ background: url('../img/faq/pattern/impulsores@1x.svg') center center/96px repeat #A65387;
+ }
+
+ #header_faqs.donors {
+ background: url('../img/faq/pattern/donantes@1x.svg') center center/96px repeat #18508C;
+ }
+
+ #header_faqs.matchers {
+ background: url('../img/faq/pattern/matchers@1x.svg') center center/96px repeat #582470;
+ }
+
+ #header_faqs.search {
+ h1, h3 {
+ color: #3A3A3A;
+ }
+ }
+
+ .a-hidden {
+ border: 0;
+ clip: rect(0,0,0,0);
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ }
+ .step1 {
+ margin-top: -48px;
+ margin-bottom: 88px;
+ .row {
+ display: flex;
+ flex-flow: row wrap;
+ .col-lg-3 {
+ display: flex;
+ }
+ }
+ }
+
+ .faqs_module {
+ border-left: 1px solid #E3E3E3;
+ >header {
+ display: flex;
+ flex-flow: wrap column;
+ align-content: flex-start;
+ padding: 0 30px;
+ }
+ header {
+ >h2 {
+ font-size: 23px;
+ font-weight: 700;
+ color: #1A1A1A;
+ line-height: 22px;
+ letter-spacing: 0.3px;
+ margin: 4px 0 0;
+ &:after {
+ position: absolute;
+ content: '';
+ width: 48px;
+ height: 48px;
+ background-size: 48px 48px;
+ top: -56px;
+ left: calc(50% - 24px);
+ }
+ }
+ }
+ header.about {
+ >h2 {
+ &:after {
+ background: url('../img/faq/Icono/sobre-goteo@1x.svg') center center no-repeat;
+ }
+ }
+ }
+ header.sponsors {
+ >h2 {
+ &:after {
+ background: url('../img/faq/Icono/impulsores@1x.svg') center center no-repeat;
+ }
+ }
+ }
+ header.donors {
+ >h2 {
+ &:after {
+ background: url('../img/faq/Icono/donantes@1x.svg') center center no-repeat;
+ }
+ }
+ }
+ header.matchers {
+ >h2 {
+ &:after {
+ background: url('../img/faq/Icono/matchers@1x.svg') center center no-repeat;
+ }
+ }
+ }
+ >ul {
+ list-style: none;
+ padding: 32px 30px;
+ >li {
+ font-weight: 500;
+ line-height: 1.375em;
+ letter-spacing: 0.01875em;
+ color: #3A3A3A;
+ position: relative;
+ margin-bottom: 1em;
+ padding-left: 1em;
+ >a {
+ color: #3A3A3A;
+ text-decoration: none;
+ &:hover {
+ color: #19b4b2;
+ text-decoration: none;
+ }
+ }
+ &:after {
+ position: absolute;
+ content: '';
+ width: 8px;
+ height: 2px;
+ top: 10px;
+ left: 0;
+ background-color: #19B4B2;
+ }
+ }
+ }
+ }
+ .faqs_module.card {
+ position: relative;
+ border: none;
+ background: #FFFFFF;
+ box-shadow: 0 2px 5px 0 rgba(0,0,0,0.15);
+ border-radius: 5px;
+ padding-bottom: 24px;
+ >header {
+ border-radius: 5px 5px 0px 0px;
+ align-content: center;
+ }
+ header.about {
+ background: #FFEC61;
+ h2 {
+ color: #3A3A3A;
+ }
+ }
+ header.sponsors {
+ background: #A65387;
+ }
+ header.donors {
+ background: #18508C;
+ }
+ header.matchers {
+ background: #582470;
+ }
+ header {
+ >h2 {
+ font-size: 26px;
+ font-weight: 900;
+ color: #FFFFFF;
+ letter-spacing: 0.26px;
+ text-align: center;
+ position: relative;
+ margin: 80px auto 24px;
+ }
+ }
+ >ul {
+ list-style: none;
+ padding: 32px 20px;
+ }
+ }
+ hr {
+ margin: 0;
+ border-color: #E3E3E3;
+ width: 100%;
+ }
+ .see_everything {
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ padding: 0 20px;
+ >a {
+ position: relative;
+ display: inline-block;
+ font-weight: 700;
+ font-size: 14px;
+ color: #3A3A3A;
+ text-transform: uppercase;
+ margin: 1em 1.5em 1.250em;
+ margin: 1em 4px 1.250em;
+ &:hover {
+ color: #19b4b2;
+ }
+ &:after {
+ position: absolute;
+ content: '';
+ width: 24px;
+ height: 24px;
+ top: -2px;
+ right: -24px;
+ background: url('../img/faq/icones/arrow-right-primary.svg') 0 0/24px no-repeat;
+ }
+ }
+ }
+ .step2 {
+ .row {
+ display: flex;
+ flex-flow: row wrap;
+ .col-lg-4 {
+ display: flex;
+ }
+ }
+ }
+ .search {
+ margin-bottom: 2em;
+ }
+ footer.unsolved_faq {
+ width: 100%;
+ margin-top: 64px;
+ padding-top: 32px;
+ border-top: 1px solid #E3E3E3;
+ text-align: center;
+ margin-bottom: 88px;
+ a.btn {
+ background: #19B4B2;
+ border-radius: 3px;
+ padding: 14px;
+ font-size: 14px;
+ color: #FFFFFF;
+ text-transform: uppercase;
+ font-weight: 700;
+ }
+ a {
+ &:hover.btn {
+ background: #118887;
+ color: #FFFFFF;
+ }
+ }
+ }
+ p {
+ font-size: 16px;
+ color: #3A3A3A;
+ letter-spacing: 0.3px;
+ line-height: 22px;
+ }
+ aside {
+ margin-bottom: 88px;
+ >h2.otras_preguntas {
+ display: none;
+ }
+ details {
+ margin-bottom: 24px;
+ >ul {
+ list-style: none;
+ padding: 0 4px 8px;
+ >li {
+ font-size: 15px;
+ font-weight: 500;
+ line-height: 1.375em;
+ letter-spacing: 0.01875em;
+ color: #3A3A3A;
+ position: relative;
+ margin-bottom: 1em;
+ padding-left: 1em;
+ >a {
+ color: #3A3A3A;
+ text-decoration: none;
+ &:hover {
+ color: #19b4b2;
+ text-decoration: none;
+ }
+ }
+ &:after {
+ position: absolute;
+ content: '';
+ width: 8px;
+ height: 2px;
+ top: 10px;
+ left: 0;
+ background-color: #19B4B2;
+ }
+ }
+ >li.select {
+ color: #19b4b2;
+ text-decoration: none;
+ }
+ }
+ summary h3 {
+ position: relative;
+ font-size: 17px;
+ font-weight: 700;
+ color: #3A3A3A;
+ letter-spacing: 0.23px;
+ line-height: 22px;
+ padding: 0 0 6px 4px;
+ margin-top: 0;
+ margin-bottom: 16px;
+ border-bottom: 1px solid #3A3A3A;
+ &:before {
+ position: absolute;
+ content: '';
+ width: 24px;
+ height: 24px;
+ top: 0;
+ right: 4px;
+ background: url('../img/faq/icones/caret-down.svg') center center/18px no-repeat;
+ }
+ }
+ >h3.open {
+ &:before {
+ background: url('../img/faq/icones/caret-up.svg') center center/18px no-repeat;
+ }
+ }
+ }
+ }
+ section.search {
+ h2.no-results {
+ text-align: center;
+ }
+ article {
+ h2 a, h3 a {
+ color: #19b4b2;
+ }
+ }
+ }
+ @media only screen and (max-width: 767px) {
+ #header_faqs {
+ min-height: 192px;
+ margin-bottom: 32px;
+ .upper_bar {
+ .faq_search {
+ justify-content: center;
+ >label {
+ >input[type=search] {
+ height: 36px;
+ }
+ }
+ >button.search-submit {
+ width: 36px;
+ height: 36px;
+ }
+ }
+ }
+ h1 {
+ font-size: 32px;
+ letter-spacing: 0.3px;
+ margin-top: 8px;
+ }
+ h3 {
+ font-size: 18px;
+ letter-spacing: 0.2px;
+ }
+ }
+ #header_faqs.home_faqs {
+ min-height: 192px;
+ }
+ .step1 {
+ margin-bottom: 48px;
+ }
+ .faqs_module {
+ border-left: none;
+ &:not(.card) {
+ >header {
+ padding: 0 4px 6px;
+ border-bottom: 1px solid #3A3A3A;
+ }
+ header {
+ >h2 {
+ font-size: 21px;
+ letter-spacing: 0.3px;
+ line-height: 26px;
+ }
+ }
+ }
+ >ul {
+ padding: 18px 8px 8px;
+ }
+ }
+ footer.unsolved_faq {
+ margin-top: 32px;
+ margin-bottom: 48px;
+ a.btn {
+ width: 100%;
+ }
+ }
+ aside {
+ >h2.otras_preguntas {
+ display: inline-block;
+ font-weight: 700;
+ font-size: 23px;
+ color: #1A1A1A;
+ letter-spacing: 0.3px;
+ line-height: 26px;
+ max-width: 60%;
+ margin-bottom: 24px;
+ }
+ margin-bottom: 48px;
+ }
+ }
+ @media only screen and (max-width: 1199px) {
+ .faqs_module.card {
+ margin-bottom: 24px;
+ }
+ }
+ @media only screen and (min-width: 992px) {
+ #header_faqs {
+ h1 {
+ max-width: 75%;
+ }
+ }
+ }
+// End body
+}
diff --git a/public/robots.txt b/public/robots.txt
index 1f1ac45103..d099214a56 100644
--- a/public/robots.txt
+++ b/public/robots.txt
@@ -1,4 +1,20 @@
+User-agent: GPTBot
+Disallow: /
+
+User-agent: Amazonbot
+Disallow: /
+
+User-agent: SemrushBot
+Disallow: /
+
+User-agent: AhrefsBot
+Disallow: /
+
+User-Agent: ImagesiftBot
+Crawl-delay: 60
+
User-agent: *
Disallow: /admin
Disallow: /admin/
Disallow: /dashboard/
+
diff --git a/src/Goteo/Application/Config.php b/src/Goteo/Application/Config.php
index 453c0c786c..3b72dd7b3e 100644
--- a/src/Goteo/Application/Config.php
+++ b/src/Goteo/Application/Config.php
@@ -29,7 +29,9 @@
use Goteo\Controller\Admin\CommonsSubController;
use Goteo\Controller\Admin\CommunicationAdminController;
use Goteo\Controller\Admin\CriteriaSubController;
-use Goteo\Controller\Admin\FaqSubController;
+use Goteo\Controller\Admin\FaqAdminController;
+use Goteo\Controller\Admin\FaqSectionAdminController;
+use Goteo\Controller\Admin\FaqSubsectionAdminController;
use Goteo\Controller\Admin\FilterAdminController;
use Goteo\Controller\Admin\GlossarySubController;
use Goteo\Controller\Admin\HomeSubController;
@@ -72,7 +74,7 @@
use Symfony\Component\Routing\Route;
class Config {
- static public $trans_groups = [
+ static public array $trans_groups = [
'home', 'roles', 'public_profile', 'project', 'labels', 'form', 'profile', 'personal', 'overview', 'costs',
'rewards', 'subscriptions', 'supports', 'preview', 'dashboard', 'register', 'login', 'discover', 'community', 'general', 'blog',
'faq', 'contact', 'widget', 'invest', 'matcher', 'types', 'banners', 'footer', 'social', 'review', 'translate',
@@ -346,6 +348,9 @@ static public function addAdminControllers()
AdminController::addSubController(ChannelProjectsAdminController::class);
AdminController::addSubController(ImpactDataAdminController::class);
AdminController::addSubController(SubscriptionsAdminController::class);
+ AdminController::addSubController(FaqAdminController::class);
+ AdminController::addSubController(FaqSectionAdminController::class);
+ AdminController::addSubController(FaqSubsectionAdminController::class);
}
static public function addLegacyAdminControllers()
@@ -356,7 +361,6 @@ static public function addLegacyAdminControllers()
AdminController::addSubController(BannersSubController::class);
AdminController::addSubController(CommonsSubController::class);
AdminController::addSubController(CriteriaSubController::class);
- AdminController::addSubController(FaqSubController::class);
AdminController::addSubController(HomeSubController::class);
AdminController::addSubController(GlossarySubController::class);
AdminController::addSubController(IconsSubController::class);
diff --git a/src/Goteo/Controller/Admin/AccountsSubController.php b/src/Goteo/Controller/Admin/AccountsSubController.php
index 903af27e1c..4b6e45edfb 100644
--- a/src/Goteo/Controller/Admin/AccountsSubController.php
+++ b/src/Goteo/Controller/Admin/AccountsSubController.php
@@ -77,6 +77,8 @@ class AccountsSubController extends AbstractSubController {
static public function isAllowed(User $user, $node): bool {
// Only central node allowed here
if( ! Config::isMasterNode($node) ) return false;
+ if ($user->hasPerm('admin-module-account')) return true;
+
return parent::isAllowed($user, $node);
}
diff --git a/src/Goteo/Controller/Admin/FaqAdminController.php b/src/Goteo/Controller/Admin/FaqAdminController.php
new file mode 100644
index 0000000000..bea1be26db
--- /dev/null
+++ b/src/Goteo/Controller/Admin/FaqAdminController.php
@@ -0,0 +1,150 @@
+
+ *
+ * For the full copyright and license information, please view the README.md
+ * and LICENSE files that was distributed with this source code.
+ */
+
+namespace Goteo\Controller\Admin;
+
+use Goteo\Application\Exception\ModelNotFoundException;
+use Goteo\Application\Exception\ControllerAccessDeniedException;
+use Goteo\Application\Message;
+use Goteo\Model\Faq;
+use Goteo\Model\Faq\FaqSection;
+use Goteo\Model\Faq\FaqSubsection;
+use Goteo\Library\Forms\Admin\AdminFaqForm;
+use Goteo\Library\Forms\FormModelException;
+use Goteo\Library\Text;
+use PDOException;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Route;
+
+class FaqAdminController extends AbstractAdminController
+{
+ protected static string $icon = '
';
+
+ public static function getGroup(): string
+ {
+ return 'contents';
+ }
+
+ public static function getRoutes(): array
+ {
+ return [
+ new Route(
+ '/',
+ ['_controller' => __CLASS__ . "::listAction"]
+ ),
+ new Route(
+ '/subsection/{subsection}',
+ ['_controller' => __CLASS__ . "::listAction"]
+ ),
+ new Route(
+ '/add',
+ ['_controller' => __CLASS__ . "::editAction"]
+ ),
+ new Route(
+ '/edit/{id}',
+ ['_controller' => __CLASS__ . "::editAction"]
+ ),
+ new Route(
+ '/delete/{id}',
+ ['_controller' => __CLASS__ . "::deleteAction"]
+ ),
+ new Route(
+ '/{subsection}',
+ ['_controller' => __CLASS__ . "::listAction"]
+ ),
+ ];
+ }
+
+ /**
+ * @throws ControllerAccessDeniedException
+ */
+ private function validateFaq(int $id = null): Faq
+ {
+
+ if (!$this->user)
+ throw new ControllerAccessDeniedException(Text::get('user-login-required-access'));
+
+ if (!$this->user->hasPerm('admin-module-faqs'))
+ throw new ControllerAccessDeniedException();
+
+ return $id ? Faq::getById($id) : new Faq();
+ }
+
+ public function listAction(Request $request, int $subsection = null): Response
+ {
+ $this->validateFaq();
+
+ $filters = [];
+ if ($subsection)
+ $filters['subsection'] = $subsection;
+
+ $page = $request->query->getDigits('pag', 0);
+ $limit = $request->query->getDigits('limit', 25);
+
+ $subsectionCount = FaqSubsection::getList([], 0, 0, true);
+ $faq_subsections = [];
+ foreach (FaqSubsection::getList([], 0, $subsectionCount) as $s) {
+ $faq_subsections[FaqSection::getById($s->section_id)->name][$s->id] = $s->name;
+ }
+
+ $total = Faq::getListCount($filters);
+ $list = Faq::getList($filters, $page * $limit, $limit);
+ return $this->viewResponse('admin/faq/list', [
+ 'list' => $list,
+ 'total' => $total,
+ 'limit' => $limit,
+ 'faq_subsections' => $faq_subsections,
+ 'current_subsection' => $subsection
+ ]);
+ }
+
+ public function editAction(Request $request, $id = null): Response
+ {
+ $faq = $this->validateFaq($id);
+
+ $processor = $this->getModelForm(AdminFaqForm::class, $faq, (array) $faq, Array(), $request);
+ $processor->createForm();
+ $form = $processor->getForm();
+ $form->handleRequest($request);
+ if ($form->isSubmitted() && $request->isMethod('post')) {
+ try {
+ $processor->save($form);
+ Message::info(Text::get('admin-' . ($id ? 'edit' : 'add') . '-entry-ok'));
+ return $this->redirect("/admin/faq/" . $faq->subsection_id);
+ } catch (FormModelException $e) {
+ Message::error($e->getMessage());
+ }
+ }
+
+ return $this->viewResponse('admin/faq/edit', [
+ 'form' => $form->createView()
+ ]);
+ }
+
+ public function deleteAction(Request $request, $id): Response
+ {
+ try {
+ $faq = $this->validateFaq($id);
+ } catch (ModelNotFoundException $exception) {
+ Message::error($exception->getMessage());
+ return $this->redirect('/admin/faq/');
+ }
+
+ try {
+ $faq->dbDelete();
+ Message::info(Text::get('admin-remove-entry-ok'));
+ } catch (PDOException $e) {
+ Message::error($e->getMessage());
+ }
+
+ return $this->redirect('/admin/faq/' . $faq->section);
+ }
+}
diff --git a/src/Goteo/Controller/Admin/FaqSectionAdminController.php b/src/Goteo/Controller/Admin/FaqSectionAdminController.php
new file mode 100644
index 0000000000..cc189acbcd
--- /dev/null
+++ b/src/Goteo/Controller/Admin/FaqSectionAdminController.php
@@ -0,0 +1,128 @@
+ __CLASS__ . "::listAction"]
+ ),
+ new Route(
+ '/add',
+ ['_controller' => __CLASS__ . "::addAction"]
+ ),
+ new Route(
+ '/{id}/edit',
+ ['_controller' => __CLASS__ . "::editAction"]
+ ),
+ new Route(
+ '/{id}/delete',
+ ['_controller' => __CLASS__ . "::deleteAction"]
+ ),
+ ];
+ }
+
+
+ /**
+ * @throws ControllerAccessDeniedException
+ */
+ private function validateFaqSection(int $id = null): FaqSection
+ {
+
+ if (!$this->user)
+ throw new ControllerAccessDeniedException(Text::get('user-login-required-access'));
+
+ if (!$this->user->hasPerm('admin-module-faqs'))
+ throw new ControllerAccessDeniedException();
+
+ return $id ? FaqSection::getById($id) : new FaqSection();
+ }
+
+ public function listAction(Request $request): Response
+ {
+ $page = $request->query->getDigits('pag', 0);
+ $limit = $request->query->getDigits('limit', 10);
+
+ $total = FaqSection::getListCount([]);
+ $list = FaqSection::getList([], $limit * $page, $limit);
+
+ return $this->viewResponse('admin/faq/section/list',[
+ 'list' => $list,
+ 'total' => $total,
+ 'limit' => $limit
+ ]);
+ }
+
+ public function addAction(Request $request): Response
+ {
+ $faqSection = $this->validateFaqSection(null);
+
+ return $this->generateSectionFormView($request, $faqSection);
+ }
+
+ public function editAction(Request $request, int $id): Response
+ {
+ $faqSection = $this->validateFaqSection($id);
+
+ return $this->generateSectionFormView($request, $faqSection);
+ }
+
+ private function generateSectionFormView(Request $request, FaqSection $faqSection): Response
+ {
+ $processor = $this->getModelForm(AdminFaqSectionForm::class, $faqSection, (array) $faqSection, [], $request);
+ $processor->createForm();
+ $form = $processor->getForm();
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $request->isMethod(Request::METHOD_POST)) {
+ try {
+ $processor->save($form);
+ return $this->redirect("/admin/faqsection");
+ } catch (FormModelException $e) {
+ Message::error($e->getMessage());
+ }
+ }
+
+ return $this->viewResponse('admin/faq/section/edit', [
+ 'form' => $form->createView(),
+ 'faqSection' => $faqSection
+ ]);
+ }
+
+ public function deleteAction(Request $request, int $id): Response
+ {
+ try {
+ $faqSection = $this->validateFaqSection($id);
+ } catch (ModelNotFoundException $e) {
+ Message::error($e->getMessage());
+ return $this->redirect('/admin/faqsection');
+ }
+
+ try {
+ $faqSection->dbDelete();
+ Message::info(Text::get('admin-edit-entry-ok'));
+ } catch (\PDOException $e) {
+ Message::error($e->getMessage());
+ }
+
+ return $this->redirect('/admin/faqsection');
+ }
+}
diff --git a/src/Goteo/Controller/Admin/FaqSubsectionAdminController.php b/src/Goteo/Controller/Admin/FaqSubsectionAdminController.php
new file mode 100644
index 0000000000..4fba66cacd
--- /dev/null
+++ b/src/Goteo/Controller/Admin/FaqSubsectionAdminController.php
@@ -0,0 +1,127 @@
+ __CLASS__ . "::listAction"]
+ ),
+ new Route(
+ '/section/{section}',
+ ['_controller' => __CLASS__ . "::listAction"]
+ ),
+ new Route(
+ '/add',
+ ['_controller' => __CLASS__ . "::addAction"]
+ ),
+ new Route(
+ '/{id}/edit',
+ ['_controller' => __CLASS__ . "::editAction"]
+ ),
+ new Route(
+ '/{id}/delete',
+ ['_controller' => __CLASS__ . "::deleteAction"]
+ ),
+ ];
+ }
+
+ public function listAction(Request $request, int $section = null): Response
+ {
+ $page = $request->query->getDigits('pag', 0);
+ $limit = $request->query->getDigits('limit', 10);
+ $filters = [];
+
+ if ($section) {
+ $filters['section'] = $section;
+ }
+
+ $sectionsCount = FaqSection::getListCount([]);
+ $sections = FaqSection::getList([], 0, $sectionsCount);
+
+ $total = FaqSubsection::getListCount($filters);
+ $list = FaqSubsection::getList($filters, $limit * $page, $limit);
+
+ return $this->viewResponse('admin/faq/subsection/list',[
+ 'list' => $list,
+ 'total' => $total,
+ 'limit' => $limit,
+ 'faq_sections' => $sections,
+ 'current_section' => $section
+ ]);
+ }
+
+ public function addAction(Request $request): Response
+ {
+ $faqSubsection = new FaqSubsection();
+
+ return $this->generateSubsectionFormView($request, $faqSubsection);
+ }
+
+ public function editAction(Request $request, int $id): Response
+ {
+ $faqSubsection = FaqSubsection::get($id);
+
+ return $this->generateSubsectionFormView($request, $faqSubsection);
+ }
+
+ private function generateSubsectionFormView(Request $request, FaqSubsection $faqSubsection): Response
+ {
+ $processor = $this->getModelForm(AdminFaqSubsectionForm::class, $faqSubsection, (array) $faqSubsection, [], $request);
+ $processor->createForm();
+ $form = $processor->getForm();
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $request->isMethod(Request::METHOD_POST)) {
+ try {
+ $processor->save($form);
+ return $this->redirect("/admin/faqsubsection");
+ } catch (FormModelException $e) {
+ Message::error($e->getMessage());
+ }
+ }
+
+ return $this->viewResponse('admin/faq/subsection/edit', [
+ 'form' => $form->createView(),
+ 'faqSubsection' => $faqSubsection
+ ]);
+ }
+
+ public function deleteAction(Request $request, int $id): Response
+ {
+ try {
+ $faqSubsection = FaqSubsection::get($id);
+ } catch (ModelNotFoundException $e) {
+ Message::error($e->getMessage());
+ return $this->redirect('/admin/faqsubsection');
+ }
+
+ try {
+ $faqSubsection->dbDelete();
+ Message::info(Text::get('admin-edit-entry-ok'));
+ } catch (\PDOException $e) {
+ Message::error($e->getMessage());
+ }
+
+ return $this->redirect('/admin/faqsubsection');
+ }
+}
diff --git a/src/Goteo/Controller/Admin/ProjectsSubController.php b/src/Goteo/Controller/Admin/ProjectsSubController.php
index 1cbd08b242..8aa5cf6885 100644
--- a/src/Goteo/Controller/Admin/ProjectsSubController.php
+++ b/src/Goteo/Controller/Admin/ProjectsSubController.php
@@ -206,11 +206,15 @@ public function reportAction($id) {
// Datos para el informe de transacciones correctas
$data = Model\Invest::getReportData($project->id, $project->status, $project->round, $project->passed);
$account = Model\Project\Account::get($project->id);
+ $contract = Model\Contract::get($project->id);
+ $invests = Model\Invest::getAll($project->id);
return array(
'template' => 'admin/projects/report',
'project' => $project,
'account' => $account,
+ 'contract' => $contract,
+ 'invests' => $invests,
'data' => $data
);
}
diff --git a/src/Goteo/Controller/Admin/UsersSubController.php b/src/Goteo/Controller/Admin/UsersSubController.php
index 3fe2efc02d..0a4d35ec3b 100644
--- a/src/Goteo/Controller/Admin/UsersSubController.php
+++ b/src/Goteo/Controller/Admin/UsersSubController.php
@@ -69,6 +69,7 @@ public function __construct($node, User $user, Request $request) {
static public function isAllowed(User $user, $node): bool {
// Only central node or superadmins allowed here
if( ! (Config::isMasterNode($node) || $user->hasRoleInNode($node, ['superadmin', 'root'])) ) return false;
+ if ($user->hasPerm('admin-module-users')) return true;
return parent::isAllowed($user, $node);
}
diff --git a/src/Goteo/Controller/Api/FaqApiController.php b/src/Goteo/Controller/Api/FaqApiController.php
new file mode 100644
index 0000000000..e7974ccdf9
--- /dev/null
+++ b/src/Goteo/Controller/Api/FaqApiController.php
@@ -0,0 +1,60 @@
+
+ *
+ * For the full copyright and license information, please view the README.md
+ * and LICENSE files that was distributed with this source code.
+ */
+
+namespace Goteo\Controller\Api;
+
+use Symfony\Component\HttpFoundation\Request;
+use Goteo\Application\Exception\ControllerAccessDeniedException;
+use Goteo\Application\Exception\ModelNotFoundException;
+use Goteo\Application\Message;
+use Goteo\Application\AppEvents;
+use Goteo\Application\Config;
+
+use Goteo\Model\Faq;
+use Goteo\Library\Text;
+use Goteo\Library\Check;
+
+class FaqApiController extends AbstractApiController {
+
+ protected function validateFaq($id) {
+
+ if(!$this->user)
+ throw new ControllerAccessDeniedException();
+
+ $faq = $id ? Faq::get($id) : new Faq();
+
+ if($this->user->hasPerm('admin-module-faqs') ) {
+ return $faq;
+ }
+
+ throw new ControllerAccessDeniedException(Text::get('admin-faq-not-active-yet'));
+ }
+
+ public function faqSortAction($id, Request $request) {
+ $faq = $this->validateFaq($id);
+
+ $result = ['value' => (int)$faq->order, 'error' => false];
+
+ if($request->isMethod('put') && $request->request->has('value')) {
+
+ $res = Check::reorder($id, $request->request->get('value'), 'faq', 'id', 'order', ['subsection_id' => $faq->subsection_id]);
+
+ if($res != $result['value']) {
+ $result['value'] = $res;
+ } else {
+ $result['error'] = true;
+ $result['message'] = 'Sorting failed';
+ }
+ }
+
+ return $this->jsonResponse($result);
+ }
+
+}
\ No newline at end of file
diff --git a/src/Goteo/Controller/ChannelController.php b/src/Goteo/Controller/ChannelController.php
index c6e6ee2b58..e507b181af 100644
--- a/src/Goteo/Controller/ChannelController.php
+++ b/src/Goteo/Controller/ChannelController.php
@@ -190,6 +190,9 @@ public function listProjectsAction(Request $request, $id, $type = 'available', $
$view= $channel->type=='normal' ? 'channel/list_projects' : 'channel/'.$channel->type.'/list_projects';
+ $dataSetsRepository = new DataSetRepository();
+ $dataSets = $dataSetsRepository->getListByChannel([$id]);
+
return $this->viewResponse(
$view,
[
@@ -198,7 +201,8 @@ public function listProjectsAction(Request $request, $id, $type = 'available', $
'title_text' => $title_text,
'type' => $type,
'total' => $total,
- 'limit' => $limit
+ 'limit' => $limit,
+ 'dataSets' => $dataSets
]
);
}
diff --git a/src/Goteo/Controller/ContactController.php b/src/Goteo/Controller/ContactController.php
index 016d623713..c8117a3c2a 100644
--- a/src/Goteo/Controller/ContactController.php
+++ b/src/Goteo/Controller/ContactController.php
@@ -16,6 +16,7 @@
use Goteo\Core\Controller;
use Goteo\Library;
use Goteo\Library\Text;
+use Goteo\Model\FormHoneypot;
use Goteo\Model\Mail;
use Goteo\Model\Page;
use Goteo\Model\Template;
@@ -84,6 +85,22 @@ public function indexAction (Request $request) {
}
}
+ // check honeypot trap
+ $trap = Session::get('form-honeypot');
+ Session::del('form-honeypot');
+ if (FormHoneypot::checkTrap($trap, $request)) {
+ $honeypot = new FormHoneypot;
+ $honeypot->trap = $trap;
+ $honeypot->prey = $request->request->get($trap);
+
+ $honeypot->validate($honeypotErrors);
+ $honeypot->save($honeypotErrors);
+
+ // Make robot makers think they have succeeded
+ Message::info('Mensaje de contacto enviado correctamente.');
+ return $this->redirect('/contact');
+ }
+
$data = array(
'tag' => $tag,
'subject' => $subject,
@@ -104,19 +121,19 @@ public function indexAction (Request $request) {
$user_template=Template::CONTACT_AUTO_REPLY_NEW_PROJECT;
break;
case 'contact-form-project-form-tag-name':
- $to_admin = Config::get('mail.contact');
- $user_template=Template::CONTACT_AUTO_REPLY_PROJECT_FORM;
+ $to_admin = Config::get('mail.contact');
+ $user_template=Template::CONTACT_AUTO_REPLY_PROJECT_FORM;
break;
case 'contact-form-dev-tag-name':
- $to_admin = Config::get('mail.fail');
- $user_template=Template::CONTACT_AUTO_REPLY_DEV;
+ $to_admin = Config::get('mail.fail');
+ $user_template=Template::CONTACT_AUTO_REPLY_DEV;
break;
case 'contact-form-relief-tag-name':
$to_admin = Config::get('mail.donor');
$user_template=Template::CONTACT_AUTO_REPLY_RELIEF;
break;
case 'contact-form-service-tag-name':
- $to_admin = Config::get('mail.management');
+ $to_admin = Config::get('mail.management');
break;
default:
$to_admin = Config::get('mail.contact');
@@ -170,10 +187,15 @@ public function indexAction (Request $request) {
$captcha->build();
Session::store('captcha-phrase', $captcha->getPhrase());
}
+
// Generate a new form token
$token = sha1(uniqid(mt_rand(), true));
Session::store('form-token', $token);
+ // Generate honeypot fields
+ $honeypot = FormHoneypot::layTrap();
+ Session::store('form-honeypot', $honeypot->trap);
+
return $this->viewResponse('about/contact',
array(
'data' => $data,
@@ -181,6 +203,7 @@ public function indexAction (Request $request) {
'token' => $token,
'page' => Page::get('contact'),
'captcha' => $captcha,
+ 'honeypot' => $honeypot,
'errors' => $errors
)
);
diff --git a/src/Goteo/Controller/Dashboard/ProjectDashboardController.php b/src/Goteo/Controller/Dashboard/ProjectDashboardController.php
index 990cc7cbce..46b7aa479f 100644
--- a/src/Goteo/Controller/Dashboard/ProjectDashboardController.php
+++ b/src/Goteo/Controller/Dashboard/ProjectDashboardController.php
@@ -49,6 +49,7 @@
use Goteo\Model\Project\Support;
use Goteo\Model\Stories;
use Goteo\Model\User;
+use Goteo\Payment\Method\StripeSubscriptionPaymentMethod;
use Goteo\Util\Form\Type\SubmitType;
use Goteo\Util\Form\Type\TextareaType;
use Goteo\Util\Form\Type\TextType;
@@ -900,10 +901,16 @@ public static function getInvestFilters(Project $project, $filter = []): array
foreach($project->getIndividualRewards() as $reward) {
$filters['reward'][$reward->id] = $reward->getTitle();
}
+
if($project->getCall()) {
$filters['others']['drop'] = Text::Get('dashboard-project-filter-by-drop');
$filters['others']['nondrop'] = Text::Get('dashboard-project-filter-by-nondrop');
}
+
+ if ($project->isPermanent()) {
+ $filters['others']['from_subscription'] = Text::get('dashboard-project-filter-by-subscription');
+ }
+
$status = [
Invest::STATUS_CHARGED,
Invest::STATUS_PAID,
@@ -919,6 +926,12 @@ public static function getInvestFilters(Project $project, $filter = []): array
}
if(array_key_exists($filter['others'], $filters['others'])) {
$filter_by['types'] = $filter['others'];
+
+ if($filter['others']['from_subscription']) {
+ $filter_by['methods'] = [
+ StripeSubscriptionPaymentMethod::PAYMENT_METHOD_ID
+ ];
+ }
}
if($filter['query']) {
$filter_by['name'] = $filter['query'];
diff --git a/src/Goteo/Controller/FaqController.php b/src/Goteo/Controller/FaqController.php
new file mode 100644
index 0000000000..2d0a79d00b
--- /dev/null
+++ b/src/Goteo/Controller/FaqController.php
@@ -0,0 +1,100 @@
+
+ *
+ * For the full copyright and license information, please view the README.md
+ * and LICENSE files that was distributed with this source code.
+ */
+
+namespace Goteo\Controller;
+
+use Goteo\Application\Lang;
+use Goteo\Application\View;
+use Goteo\Core\Controller;
+use Goteo\Core\Traits\LoggerTrait;
+use Goteo\Library\Text;
+use Goteo\Model\Faq;
+use Goteo\Model\Faq\FaqSection;
+use Goteo\Model\Faq\FaqSubsection;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+class FaqController extends Controller {
+ use LoggerTrait;
+
+ public function __construct() {
+ // Cache & replica read activated in this controller
+ $this->dbReplica(true);
+ $this->dbCache(true);
+ View::setTheme('responsive');
+ }
+
+ public function indexAction (Request $request, string $section='', string $tag='' ): Response
+ {
+ $faq_sections = FaqSection::getList();
+
+ return $this->viewResponse('faq/index', [
+ 'meta_title' => Text::get('faq-meta-title'),
+ 'meta_description' => Text::get('faq-meta-description'),
+ 'faq_sections' => $faq_sections
+ ]
+ );
+ }
+
+ public function searchAction(Request $request): Response
+ {
+ $search = htmlspecialchars($request->query->get('search'));
+
+ $faqSections=FaqSection::getList();
+ $faqs = [];
+ $totalFaqs = 0;
+ foreach($faqSections as $section) {
+ $faqsCount = Faq::getListCount(['search' => $search, 'section' => $section->id]);
+ $totalFaqs += $faqsCount;
+ $faqs[$section->id] = Faq::getList(['search' => $search, 'section' => $section->id], 0, $faqsCount, false, Lang::current());
+ }
+
+ return $this->viewResponse('faq/search', [
+ 'search' => $search,
+ 'faqs' => $faqs,
+ 'faqSections' => $faqSections,
+ 'totalFaqs' => $totalFaqs
+ ]);
+ }
+
+ public function sectionAction(Request $request, string $section): Response
+ {
+ $faq_section = FaqSection::getBySlug($section);
+ $subsections = FaqSubsection::getList(['section' => $faq_section->id]);
+
+ return $this->viewResponse('faq/section', [
+ 'meta_title' => $faq_section->name.' :: Faq',
+ 'meta_description' => Text::get('faq-meta-description'),
+ 'faq_section' => $faq_section,
+ 'subsections' => $subsections
+ ]);
+ }
+
+ public function individualAction(Request $request, string $faq): Response
+ {
+ $faq = Faq::getBySlug($faq);
+ $faq_subsection = FaqSubsection::get($faq->subsection_id);
+ $faq_section = FaqSection::getById($faq_subsection->section_id);
+
+ // Sidebar menu
+ $subsections = FaqSubsection::getList(['section' => $faq_section->id]);
+
+ return $this->viewResponse('faq/individual', [
+ 'meta_title' => $faq->title.' :: Faq',
+ 'meta_description' => Text::get('faq-meta-description'),
+ 'faq' => $faq,
+ 'faq_section' => $faq_section,
+ 'subsections' => $subsections
+ ]);
+ }
+
+}
+
+
diff --git a/src/Goteo/Controller/StripeSubscriptionController.php b/src/Goteo/Controller/StripeSubscriptionController.php
index 1b459cdbcd..9f578a9a6c 100644
--- a/src/Goteo/Controller/StripeSubscriptionController.php
+++ b/src/Goteo/Controller/StripeSubscriptionController.php
@@ -18,8 +18,9 @@
use Goteo\Model\User;
use Goteo\Payment\Method\StripeSubscriptionPaymentMethod;
use Goteo\Repository\InvestRepository;
+use Stripe\Charge as StripeCharge;
use Stripe\Event;
-use Stripe\Invoice;
+use Stripe\Invoice as StripeInvoice;
use Stripe\StripeClient;
use Stripe\Webhook;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -48,9 +49,9 @@ public function subscriptionsWebhook(Request $request)
switch ($event->type) {
case Event::TYPE_INVOICE_PAYMENT_SUCCEEDED:
- return $this->processInvoice($event->data->object->id);
+ return $this->processInvoice($event->data->object);
case Event::CHARGE_REFUNDED:
- return $this->processRefund($event);
+ return $this->processRefund($event->data->object);
default:
return new JsonResponse(
['data' => sprintf("The event %s is not supported.", $event->type)],
@@ -60,17 +61,11 @@ public function subscriptionsWebhook(Request $request)
}
}
- private function processRefund(Event $event): JsonResponse
+ private function processRefund(StripeCharge $charge): JsonResponse
{
- $object = $event->data->object;
- if (!$object || !$object->invoice) {
- return [];
- }
-
- $invoice = $this->stripe->invoices->retrieve($object->invoice);
- $subscription = $this->stripe->subscriptions->retrieve($invoice->subscription);
+ $invoice = $this->stripe->invoices->retrieve($charge->invoice);
- $invests = $this->investRepository->getListByPayment($subscription->id);
+ $invests = $this->investRepository->getListByTransaction($invoice->id);
foreach ($invests as $key => $invest) {
$invest->setStatus(Invest::STATUS_CANCELLED);
$invest->save();
@@ -79,21 +74,22 @@ private function processRefund(Event $event): JsonResponse
return new JsonResponse(['data' => $invests], Response::HTTP_OK);
}
- private function processInvoice(string $invoiceId): JsonResponse
+ private function processInvoice(StripeInvoice $invoice): JsonResponse
{
- $invoice = $this->stripe->invoices->retrieve($invoiceId);
- if ($invoice->billing_reason === Invoice::BILLING_REASON_SUBSCRIPTION_CREATE) {
- return new JsonResponse([
- 'data' => Invest::get($invoice->lines->data[0]->price->metadata->invest),
- Response::HTTP_OK
- ]);
- }
-
/** @var User */
$user = User::getByEmail($invoice->customer_email);
-
$subscription = $this->stripe->subscriptions->retrieve($invoice->subscription);
+ if ($invoice->billing_reason === StripeInvoice::BILLING_REASON_SUBSCRIPTION_CREATE) {
+ /** @var Invest */
+ $invest = Invest::get($invoice->lines->data[0]->price->metadata->invest);
+
+ $invest->setPayment($subscription->id);
+ $invest->setTransaction($invoice->id);
+
+ return new JsonResponse(['data' => $invest], Response::HTTP_OK);
+ }
+
$invest = new Invest([
'amount' => $invoice->amount_paid / 100,
'donate_amount' => 0,
@@ -104,7 +100,8 @@ private function processInvoice(string $invoiceId): JsonResponse
'method' => StripeSubscriptionPaymentMethod::PAYMENT_METHOD_ID,
'status' => Invest::STATUS_CHARGED,
'invested' => date('Y-m-d'),
- 'payment' => $subscription->id
+ 'payment' => $subscription->id,
+ 'transaction' => $invoice->id
]);
$errors = array();
diff --git a/src/Goteo/Library/Buzz.php b/src/Goteo/Library/Buzz.php
index d7c5283604..82edca2b1d 100644
--- a/src/Goteo/Library/Buzz.php
+++ b/src/Goteo/Library/Buzz.php
@@ -55,7 +55,7 @@ public static function getTweets( $query , $matchusers = false) {
if ($doReq) {
// autenticación (application-only)
if (empty(self::$twitter_id) || empty(self::$twitter_secret)) {
- throw new Exception("Faltan credenciales para twitter, OAUTH_TWITTER_ID y OAUTH_TWITTER_SECRET en config.php");
+ throw new \Exception("Faltan credenciales para twitter, OAUTH_TWITTER_ID y OAUTH_TWITTER_SECRET en config.php");
}
$credentials = base64_encode(rawurlencode(self::$twitter_id).':'.rawurlencode(self::$twitter_secret));
$grantstr = "grant_type=client_credentials";
diff --git a/src/Goteo/Library/Forms/Admin/AdminFaqForm.php b/src/Goteo/Library/Forms/Admin/AdminFaqForm.php
new file mode 100644
index 0000000000..30a431aed1
--- /dev/null
+++ b/src/Goteo/Library/Forms/Admin/AdminFaqForm.php
@@ -0,0 +1,104 @@
+
+ *
+ * For the full copyright and license information, please view the README.md
+ * and LICENSE files that was distributed with this source code.
+ */
+
+namespace Goteo\Library\Forms\Admin;
+
+use Goteo\Application\Config;
+use Goteo\Library\Forms\AbstractFormProcessor;
+use Goteo\Library\Forms\FormModelException;
+use Goteo\Library\Text;
+use Goteo\Model\Faq;
+use Goteo\Model\Faq\FaqSection;
+use Goteo\Model\Faq\FaqSubsection;
+use Goteo\Util\Form\Type\BooleanType;
+use Goteo\Util\Form\Type\ChoiceType;
+use Goteo\Util\Form\Type\MarkdownType;
+use Goteo\Util\Form\Type\SubmitType;
+use Goteo\Util\Form\Type\TextType;
+use Symfony\Component\Form\FormInterface;
+use Symfony\Component\Validator\Constraints;
+
+class AdminFaqForm extends AbstractFormProcessor {
+
+ public function getConstraints($field): array
+ {
+ return [new Constraints\NotBlank()];
+ }
+
+ public function createForm() {
+
+ $builder = $this->getBuilder();
+ $options = $builder->getOptions();
+
+ $subsectionCount = FaqSubsection::getListCount();
+ $subsections = [];
+ foreach(FaqSubsection::getList([], 0, $subsectionCount) as $s) {
+ $subsections[FaqSection::getById($s->section_id)->name][$s->name] = $s->id;
+ }
+
+ $builder
+ ->add('title', TextType::class, [
+ 'disabled' => $this->getReadonly(),
+ 'required' => true,
+ 'constraints' => $this->getConstraints('name'),
+ 'label' => 'regular-title'
+ ])
+ ->add('description', MarkdownType::class, [
+ 'disabled' => $this->getReadonly(),
+ 'required' => true,
+ 'label' => 'regular-description',
+ 'attr' => [
+ 'data-image-upload' => '/api/faq/images',
+ ]
+ ])
+ ->add('subsection_id', ChoiceType::class, [
+ 'disabled' => $this->getReadonly(),
+ 'required' => true,
+ 'label' => 'regular-subsection',
+ 'choices' => $subsections
+ ])
+ ->add('pending', BooleanType::class, array(
+ 'label' => 'admin-faq-pending',
+ 'required' => false
+ ))
+ ->add('submit', SubmitType::class, [
+ 'label' => 'regular-submit',
+ 'attr' => ['class' => 'btn btn-cyan'],
+ 'icon_class' => 'fa fa-save'
+ ])
+ ;
+
+ return $this;
+ }
+
+
+ public function save(FormInterface $form = null, $force_save = false) {
+ if(!$form) $form = $this->getBuilder()->getForm();
+ if(!$form->isValid() && !$force_save) throw new FormModelException(Text::get('form-has-errors'));
+
+ $data = $form->getData();
+ $model = $this->getModel();
+ $model->rebuildData($data, array_keys($form->all()));
+ $model->node = Config::get('node');
+ if ($data['pendign']) {
+ $model->setPending($model->id, 'post');
+ }
+ $model->order = Faq::getList([], 0, 0, true) + 1;
+
+ $errors = [];
+ if (!$model->save($errors)) {
+ throw new FormModelException(Text::get('form-sent-error', implode(', ',$errors)));
+ }
+ if(!$form->isValid()) throw new FormModelException(Text::get('form-has-errors'));
+
+ return $this;
+ }
+}
diff --git a/src/Goteo/Library/Forms/Admin/AdminFaqSectionForm.php b/src/Goteo/Library/Forms/Admin/AdminFaqSectionForm.php
new file mode 100644
index 0000000000..fb6b056a38
--- /dev/null
+++ b/src/Goteo/Library/Forms/Admin/AdminFaqSectionForm.php
@@ -0,0 +1,93 @@
+
+ *
+ * For the full copyright and license information, please view the README.md
+ * and LICENSE files that was distributed with this source code.
+ */
+
+namespace Goteo\Library\Forms\Admin;
+
+use Goteo\Application\Config;
+use Goteo\Application\Lang;
+use Goteo\Library\Forms\AbstractFormProcessor;
+use Goteo\Library\Forms\FormModelException;
+use Goteo\Library\Text;
+use Goteo\Model\Faq;
+use Goteo\Model\Faq\FaqSection;
+use Goteo\Util\Form\Type\ChoiceType;
+use Goteo\Util\Form\Type\DropfilesType;
+use Goteo\Util\Form\Type\SubmitType;
+use Goteo\Util\Form\Type\TextType;
+use Goteo\Util\Form\Type\UrlType;
+use Symfony\Component\Form\FormInterface;
+
+class AdminFaqSectionForm extends AbstractFormProcessor
+{
+ public function createForm(): AdminFaqSectionForm
+ {
+ $model = $this->getModel();
+ $builder = $this->getBuilder();
+
+ $builder
+ ->add('name', TextType::class, [
+ 'required' => true
+ ])
+ ->add('slug', TextType::class, [
+ 'required' => true
+ ])
+ ->add('icon', DropfilesType::class, [])
+ ->add('banner_header', DropfilesType::class, [])
+ ->add('button_action', TextType::class, [
+ 'required' => true
+ ])
+ ->add('button_url', UrlType::class, [
+ 'required' => true
+ ])
+ ->add('lang', ChoiceType::class, [
+ 'choices' => $this->getLanguagesAsChoices()
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => 'regular-submit',
+ 'attr' => ['class' => 'btn btn-cyan'],
+ 'icon_class' => 'fa fa-save'
+ ]);
+
+ return $this;
+ }
+
+ public function save(FormInterface $form = null, $force_save = false): AdminFaqSectionForm
+ {
+ if(!$form) $form = $this->getBuilder()->getForm();
+ if(!$form->isValid() && !$force_save) throw new FormModelException(Text::get('form-has-errors'));
+
+ $data = $form->getData();
+ $model = $this->getModel();
+ $model->rebuildData($data, array_keys($form->all()));
+ $model->order = FaqSection::getListCount([]) + 1;
+
+ $errors = [];
+ if (!$model->save($errors)) {
+ throw new FormModelException(Text::get('form-sent-error', implode(', ',$errors)));
+ }
+ if(!$form->isValid()) throw new FormModelException(Text::get('form-has-errors'));
+
+ return $this;
+ }
+
+
+ private function getLanguagesAsChoices(): array
+ {
+ $choices = [];
+ $languages = Lang::listAll('name', false);
+
+ foreach ($languages as $key => $value) {
+ $choices[$value] = $key;
+ }
+
+ return $choices;
+ }
+}
diff --git a/src/Goteo/Library/Forms/Admin/AdminFaqSubsectionForm.php b/src/Goteo/Library/Forms/Admin/AdminFaqSubsectionForm.php
new file mode 100644
index 0000000000..d637e4d0f5
--- /dev/null
+++ b/src/Goteo/Library/Forms/Admin/AdminFaqSubsectionForm.php
@@ -0,0 +1,91 @@
+
+ *
+ * For the full copyright and license information, please view the README.md
+ * and LICENSE files that was distributed with this source code.
+ */
+
+namespace Goteo\Library\Forms\Admin;
+
+use Goteo\Application\Lang;
+use Goteo\Library\Forms\AbstractFormProcessor;
+use Goteo\Library\Forms\FormModelException;
+use Goteo\Library\Text;
+use Goteo\Model\Faq\FaqSection;
+use Goteo\Model\Faq\FaqSubsection;
+use Goteo\Util\Form\Type\ChoiceType;
+use Goteo\Util\Form\Type\SubmitType;
+use Goteo\Util\Form\Type\TextType;
+use Symfony\Component\Form\FormInterface;
+
+class AdminFaqSubsectionForm extends AbstractFormProcessor
+{
+ public function createForm(): AdminFaqSubsectionForm
+ {
+ $builder = $this->getBuilder();
+
+ $builder
+ ->add('name', TextType::class, [
+ 'required' => true
+ ])
+ ->add('section_id', ChoiceType::class, [
+ 'label' => Text::get('admin-title-section'),
+ 'choices' => $this->getSections()
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => 'regular-submit',
+ 'attr' => ['class' => 'btn btn-cyan'],
+ 'icon_class' => 'fa fa-save'
+ ]);
+
+ return $this;
+ }
+
+ public function save(FormInterface $form = null, $force_save = false): AdminFaqSubsectionForm
+ {
+ if(!$form) $form = $this->getBuilder()->getForm();
+ if(!$form->isValid() && !$force_save) throw new FormModelException(Text::get('form-has-errors'));
+
+ $data = $form->getData();
+ $model = $this->getModel();
+ $model->rebuildData($data, array_keys($form->all()));
+ $model->order = FaqSubsection::getListCount([]) + 1;
+
+ $errors = [];
+ if (!$model->save($errors)) {
+ throw new FormModelException(Text::get('form-sent-error', implode(', ',$errors)));
+ }
+ if(!$form->isValid()) throw new FormModelException(Text::get('form-has-errors'));
+
+ return $this;
+ }
+
+ private function getLanguagesAsChoices(): array
+ {
+ $choices = [];
+ $languages = Lang::listAll('name', false);
+
+ foreach ($languages as $key => $value) {
+ $choices[$value] = $key;
+ }
+
+ return $choices;
+ }
+
+ private function getSections(): array
+ {
+ $choices = [];
+ $sectionsCount = FaqSection::getListCount([]);
+ $sections = FaqSection::getList([], 0, $sectionsCount);
+
+ foreach ($sections as $section) {
+ $choices[$section->name] = $section->id;
+ }
+
+ return $choices;
+ }
+}
diff --git a/src/Goteo/Model/Faq.php b/src/Goteo/Model/Faq.php
index 3c83dfece9..789e9ac36c 100644
--- a/src/Goteo/Model/Faq.php
+++ b/src/Goteo/Model/Faq.php
@@ -10,8 +10,10 @@
namespace Goteo\Model;
+use Goteo\Application\Exception\ModelNotFoundException;
use Goteo\Application\Lang;
use Goteo\Application\Config;
+use Goteo\Model\Faq\FaqSubsection;
use Goteo\Library\Check;
use Goteo\Library\Text;
@@ -19,9 +21,10 @@ class Faq extends \Goteo\Core\Model {
public
$id,
+ $slug,
$node,
- $section,
$title,
+ $subsection_id,
$description,
$order;
@@ -29,48 +32,76 @@ static public function getLangFields() {
return ['title', 'description'];
}
+ // fallbacks to getbyid
+ public static function getBySlug($slug, $lang = null) {
+ $faq = self::get((string)$slug, $lang);
+ if(!$faq) {
+ $faq = self::get((int)$slug, $lang);
+ }
+ return $faq;
+ }
+
+ public static function getById($id, $lang = null) {
+ return self::get((int)$id, $lang);
+ }
+
/*
- * Devuelve datos de un destacado
+ * Faq data
*/
public static function get ($id, $lang = null) {
-
if(!$lang) $lang = Lang::current();
list($fields, $joins) = self::getLangsSQLJoins($lang, Config::get('sql_lang'));
- $query = static::query("
+ $sql = "
SELECT
faq.id as id,
+ faq.slug as slug,
+ faq.subsection_id as subsection_id,
faq.node as node,
- faq.section as section,
$fields,
faq.order as `order`
FROM faq
$joins
- WHERE faq.id = :id
- ", array(':id' => $id));
+ ";
+
+
+ if(is_string($id)) {
+ $sql .= "WHERE faq.slug = :slug";
+ $values = [':slug' => $id];
+ } else {
+ $sql .= "WHERE faq.id = :id";
+ $values = [':id' => $id];
+ }
+
+ $query = static::query($sql, $values);
+
$faq = $query->fetchObject(__CLASS__);
+ if (!$faq)
+ throw new ModelNotFoundException();
+
return $faq;
}
/*
- * Lista de proyectos destacados
+ * @returns Faq[]
*/
- public static function getAll ($section = 'node', $lang = null) {
+ public static function getAll (int $subsection, $lang = null): array
+ {
if(!$lang) $lang = Lang::current();
- $values = array(':section' => $section);
+ $values = array(':subsection' => $subsection);
list($fields, $joins) = self::getLangsSQLJoins($lang, Config::get('sql_lang'));
$sql="SELECT
faq.id as id,
faq.node as node,
- faq.section as section,
+ faq.subsection_id,
$fields,
faq.order as `order`
FROM faq
$joins
- WHERE faq.section = :section
+ WHERE faq.subsection_id = :subsection
ORDER BY `order` ASC";
$query = static::query($sql, $values);
@@ -78,37 +109,157 @@ public static function getAll ($section = 'node', $lang = null) {
return $query->fetchAll(\PDO::FETCH_CLASS, __CLASS__);
}
+ static public function getList(array $filters = [], int $offset = 0, int $limit = 10, bool $count = false, string $lang = null)
+ {
+ if(!$lang) $lang = Lang::current();
+ list($fields, $joins) = self::getLangsSQLJoins($lang, Config::get('sql_lang'));
+
+ $filter = [];
+ $values = [];
+
+ if ($filters['search']) {
+ $search = $filters['search'];
+ $filter[] = "faq.title like :search";
+ $values[':search'] = "%$search%";
+ }
+
+ if ($filters['section']) {
+
+ // get subsections from a section
+ $subsections= FaqSubsection::getList(['section'=>$filters['section']]);
+
+ foreach ($subsections as $subsection)
+ $subsections_id[]=$subsection->id;
+
+ $filter[] = "faq.subsection_id in ('".implode("','", $subsections_id)."')";
+
+ }
+
+ if ($filters['subsection']) {
+ $filter[] = "faq.subsection_id = :subsection_id";
+ $values[':subsection_id'] = $filters['subsection'];
+ }
+
+ if($filter) {
+ $sql = " WHERE " . implode(' AND ', $filter);
+ }
+
+ $sql="SELECT
+ faq.id as id,
+ faq.slug as slug,
+ faq.node as node,
+ $fields,
+ faq.subsection_id as subsection,
+ faq.order
+ FROM faq
+ $joins
+ $sql
+ ORDER BY faq.order ASC
+ LIMIT $offset, $limit";
+
+ $query = static::query($sql, $values);
+ return $query->fetchAll(\PDO::FETCH_CLASS, __CLASS__);
+ }
+
+ static public function getListCount(array $filters = [], string $lang = null): int
+ {
+ $filter = [];
+ $values = [];
+
+ if ($filters['search']) {
+ $search = $filters['search'];
+ $filter[] = "faq.title like :search";
+ $values[':search'] = "%$search%";
+ }
+
+ if ($filters['section']) {
+ $subsections= FaqSubsection::getList(['section'=>$filters['section']]);
+ $subsections_id = [];
+
+ foreach ($subsections as $subsection)
+ $subsections_id[]=$subsection->id;
+
+ if (!empty($subsections_id))
+ $filter[] = "faq.subsection_id in ('".implode("','", $subsections_id)."')";
+
+ }
+
+ if ($filters['subsection']) {
+ $filter[] = "faq.subsection_id = :subsection_id";
+ $values[':subsection_id'] = $filters['subsection'];
+ }
+
+ if($filter) {
+ $sql = " WHERE " . implode(' AND ', $filter);
+ }
+
+ $sql = "
+ SELECT
+ count(faq.id)
+ FROM faq
+ $sql
+ ";
+ $query = static::query($sql, $values);
+ return $query->fetchColumn();
+ }
+
+ public function getSubsection(): ?FaqSubsection
+ {
+
+ try {
+ return FaqSubsection::get($this->subsection_id);
+ } catch (ModelNotFoundException $e) {
+ return null;
+ }
+ }
+
public function validate (&$errors = array()) {
if (empty($this->node))
$errors[] = 'Missing node';
- //Text::get('mandatory-faq-node');
- if (empty($this->section))
- $errors[] = 'Missing section';
- //Text::get('mandatory-faq-section');
+ if (empty($this->subsection_id))
+ $errors[] = 'Missing subsection';
if (empty($this->title))
$errors[] = 'Missing title';
- //Text::get('mandatory-faq-title');
return empty($errors);
}
+ public function slugExists($slug) {
+ $values = [':slug' => $slug];
+ $sql = 'SELECT COUNT(*) FROM faq WHERE slug=:slug';
+ if($this->id) {
+ $values[':id'] = $this->id;
+ $sql .= ' AND id!=:id';
+ }
+
+ return self::query($sql, $values)->fetchColumn() > 0;
+ }
+
public function save (&$errors = array()) {
if (!$this->validate($errors)) return false;
+ // Attempt to create slug if not exists
+ if(!$this->slug) {
+ $this->slug = self::idealiza($this->title, false, false, 150);
+ if($this->slug && $this->slugExists($this->slug)) {
+ $this->slug = $this->slug .'-' . ($this->id ? $this->id : time());
+ }
+ }
+
try {
$this->dbInsertUpdate([
'id',
+ 'slug',
'node',
- 'section',
'title',
+ 'subsection_id',
'description',
'order'
]);
$extra = array(
- 'section' => $this->section,
'node' => $this->node
);
Check::reorder($this->id, $this->move, 'faq', 'id', 'order', $extra);
@@ -118,6 +269,8 @@ public function save (&$errors = array()) {
catch(\PDOException $e) {
$errors[] = 'Error saving faq: ' . $e->getMessage();
}
+
+ return false;
}
/*
diff --git a/src/Goteo/Model/Faq/FaqSection.php b/src/Goteo/Model/Faq/FaqSection.php
new file mode 100644
index 0000000000..2264b9aae3
--- /dev/null
+++ b/src/Goteo/Model/Faq/FaqSection.php
@@ -0,0 +1,178 @@
+ $id];
+ } else {
+ $sql .= "WHERE faq_section.id = :id";
+ $values = [':id' => $id];
+ }
+
+ $query = static::query($sql, $values);
+
+ $item = $query->fetchObject(__CLASS__);
+
+ if(!$item)
+ throw new ModelNotFoundException("Faq section not found for ID [$id]");
+
+ return $item;
+ }
+
+
+ /**
+ * @return array [] FaqSection
+ */
+ static public function getList(array $filters = [], int $offset = 0, int $limit = 10, $lang = null)
+ {
+
+ if(!$lang) $lang = Lang::current();
+ list($fields, $joins) = self::getLangsSQLJoins($lang, Config::get('sql_lang'));
+
+ $filter = [];
+ $values = [];
+
+ if($filter) {
+ $sql = " WHERE " . implode(' AND ', $filter);
+ }
+
+ $sql="SELECT
+ faq_section.id as id,
+ $fields,
+ faq_section.slug as slug,
+ faq_section.icon as icon,
+ faq_section.banner_header as banner_header,
+ faq_section.order
+ FROM faq_section
+ $joins
+ $sql
+ ORDER BY faq_section.order ASC
+ LIMIT $offset, $limit";
+ $query = static::query($sql, $values);
+ return $query->fetchAll(\PDO::FETCH_CLASS, __CLASS__);
+ }
+
+ static public function getListCount(array $filters = [], $lang = null): int
+ {
+ if(!$lang) $lang = Lang::current();
+ list($fields, $joins) = self::getLangsSQLJoins($lang, Config::get('sql_lang'));
+
+ $filter = [];
+ $values = [];
+
+ if($filter) {
+ $sql = " WHERE " . implode(' AND ', $filter);
+ }
+
+ $sql = "SELECT
+ count(faq_section.id)
+ FROM faq_section
+ $joins
+ $sql";
+ $query = static::query($sql, $values);
+ return $query->fetchColumn();
+ }
+
+ /**
+ * @return Faq[]
+ */
+ public function getFaqs(int $amount = 5): array
+ {
+ return Faq::getList(['section'=>$this->id], 0, $amount);
+ }
+
+
+ public function getBannerHeaderImage(): Image {
+ if(!$this->bannerHeaderImageInstance instanceOf Image) {
+ $this->bannerHeaderImageInstance = new Image($this->banner_header_image);
+ }
+
+ return $this->bannerHeaderImageInstance;
+ }
+
+ public function save(&$errors = array()): bool
+ {
+ if (!$this->validate($errors))
+ return false;
+
+ $fields = [
+ 'id',
+ 'name',
+ 'slug',
+ 'icon',
+ 'banner_header',
+ 'button_action',
+ 'button_url',
+ 'lang',
+ 'order'
+ ];
+
+ try {
+ //automatic $this->id assignation
+ $this->dbInsertUpdate($fields);
+
+ return true;
+ } catch(\PDOException $e) {
+ $errors[] = "Faq section save error: " . $e->getMessage();
+ return false;
+ }
+ }
+
+ public function validate(&$errors = array()): bool
+ {
+ if (empty($this->name))
+ $errors[] = "The faq section has no name";
+
+ return empty($errors);
+ }
+ }
diff --git a/src/Goteo/Model/Faq/FaqSubsection.php b/src/Goteo/Model/Faq/FaqSubsection.php
new file mode 100644
index 0000000000..685d40feca
--- /dev/null
+++ b/src/Goteo/Model/Faq/FaqSubsection.php
@@ -0,0 +1,167 @@
+fetchObject(__CLASS__);
+
+ if($item) {
+ return $item;
+ }
+
+ throw new ModelNotFoundException("Faq subsection not found for ID [$id]");
+ }
+
+ /**
+ * @return FaqSubsection [] | int
+ */
+ static public function getList(array $filters = [], int $offset = 0, int $limit = 10, bool $count = false, string $lang = null)
+ {
+ if(!$lang) $lang = Lang::current();
+ list($fields, $joins) = self::getLangsSQLJoins($lang, Config::get('sql_lang'));
+
+ $filter = [];
+ $values = [];
+
+ if ($filters['section']) {
+ $filter[] = "faq_subsection.section_id = :section_id";
+ $values[':section_id'] = $filters['section'];
+ }
+
+ if($filter) {
+ $sql = " WHERE " . implode(' AND ', $filter);
+ }
+
+ if ($count) {
+ $sql = "SELECT
+ count(faq_subsection.id)
+ FROM faq_subsection
+ $joins
+ $sql";
+
+ $query = static::query($sql, $values);
+ return $query->fetchColumn();
+ }
+
+ $sql="SELECT
+ faq_subsection.id,
+ faq_subsection.section_id,
+ $fields,
+ faq_subsection.order
+ FROM faq_subsection
+ $joins
+ $sql
+ ORDER BY faq_subsection.order ASC
+ LIMIT $offset, $limit";
+
+ $query = static::query($sql, $values);
+ return $query->fetchAll(\PDO::FETCH_CLASS, __CLASS__);
+ }
+
+ static public function getListCount(array $filters = [], $lang = null): int
+ {
+ if(!$lang) $lang = Lang::current();
+ list($fields, $joins) = self::getLangsSQLJoins($lang, Config::get('sql_lang'));
+
+ $filter = [];
+ $values = [];
+
+ if ($filters['section']) {
+ $filter[] = "faq_subsection.section_id = :section_id";
+ $values[':section_id'] = $filters['section'];
+ }
+
+ if($filter) {
+ $sql = " WHERE " . implode(' AND ', $filter);
+ }
+
+ $sql = "SELECT
+ count(faq_subsection.id)
+ FROM faq_subsection
+ $joins
+ $sql";
+
+ $query = static::query($sql, $values);
+ return $query->fetchColumn();
+ }
+
+ /**
+ * @return Faq []
+ */
+ public function getFaqs(): array
+ {
+ $total = Faq::getListCount(['subsection' => $this->id]);
+ return Faq::getList(['subsection'=>$this->id], 0, $total);
+ }
+
+ public function save(&$errors = array()): bool
+ {
+
+ if (!$this->validate($errors))
+ return false;
+
+ $fields = [
+ 'id',
+ 'section_id',
+ 'name',
+ 'lang',
+ 'order'
+ ];
+
+ try {
+ //automatic $this->id assignation
+ $this->dbInsertUpdate($fields);
+
+ return true;
+ } catch(\PDOException $e) {
+ $errors[] = "Faq subsection save error: " . $e->getMessage();
+ return false;
+ }
+ }
+
+ public function validate(&$errors = array()): bool
+ {
+ if (empty($this->name))
+ $errors[] = "The faq subsection has no name";
+
+ return empty($errors);
+ }
+ }
diff --git a/src/Goteo/Model/FormHoneypot.php b/src/Goteo/Model/FormHoneypot.php
new file mode 100644
index 0000000000..9dcf6b39fe
--- /dev/null
+++ b/src/Goteo/Model/FormHoneypot.php
@@ -0,0 +1,81 @@
+validate($errors)) return false;
+
+ $this->dbInsertUpdate(['id', 'trap', 'prey', 'template', 'datetime']);
+ }
+
+ public function validate(&$errors = array())
+ {
+ if (empty($errors))
+ return true;
+ else
+ return false;
+ }
+
+ /**
+ * Get a trapped form field that is invisible to humans and juicy for robots to fill
+ */
+ public static function layTrap()
+ {
+ $honeypot = new FormHoneypot;
+ $honeypot->trap = "email_addr_confirm";
+ $honeypot->prey = "";
+ $honeypot->datetime = new \DateTime();
+ $honeypot->params = [
+ 'trap' => $honeypot->trap,
+ 'prey' => $honeypot->prey
+ ];
+
+ return $honeypot;
+ }
+
+ /**
+ * Checks if something got caught in the trap
+ * @return bool `true` if caught something, `false` if not
+ */
+ public static function checkTrap(string $trap, $data): bool
+ {
+ if ($data instanceof Request) {
+ return $data->request->get($trap) !== "";
+ }
+
+ return false;
+ }
+}
diff --git a/src/Goteo/Model/Invest.php b/src/Goteo/Model/Invest.php
index 92666314eb..3049a10c49 100644
--- a/src/Goteo/Model/Invest.php
+++ b/src/Goteo/Model/Invest.php
@@ -19,6 +19,7 @@
use Goteo\Library\Text;
use Goteo\Model\Invest\InvestLocation;
use Goteo\Model\Project\Reward;
+use Goteo\Payment\Method\StripeSubscriptionPaymentMethod;
use Goteo\Payment\Payment;
use Goteo\Repository\InvestOriginRepository;
@@ -1289,7 +1290,8 @@ public static function investors ($project, $projNum = false, $showall = false,
invest.call as `call`,
invest.matcher as `matcher`,
invest.anonymous as anonymous,
- invest_msg.msg as msg
+ invest_msg.msg as msg,
+ invest.method
FROM invest
LEFT JOIN invest_msg
ON invest_msg.invest=invest.id
@@ -1306,6 +1308,10 @@ public static function investors ($project, $projNum = false, $showall = false,
$investor->avatar = Image::get($investor->user_avatar);
+ $invest = new Invest();
+ $invest->method = $investor->method;
+ $invest->user = $investor->user;
+
// si el usuario es hide o el aporte es anonymo, lo ponemos como el usuario anonymous (avatar 1)
if (!$showall && ($investor->hide == 1 || $investor->anonymous == 1)) {
@@ -1324,7 +1330,9 @@ public static function investors ($project, $projNum = false, $showall = false,
'droped' => $investor->droped,
'campaign' => $investor->campaign,
'call' => $investor->call,
- 'msg' => $investor->msg
+ 'msg' => $investor->msg,
+ 'method' => $investor->method,
+ 'invest' => $invest,
);
} else {
@@ -1341,7 +1349,9 @@ public static function investors ($project, $projNum = false, $showall = false,
'campaign' => $investor->campaign,
'call' => $investor->call,
'matcher' => $investor->matcher,
- 'msg' => $investor->msg
+ 'msg' => $investor->msg,
+ 'method' => $investor->method,
+ 'invest' => $invest
);
}
diff --git a/src/Goteo/Model/Node/NodeProject.php b/src/Goteo/Model/Node/NodeProject.php
index 50357d12de..8c9224da3c 100644
--- a/src/Goteo/Model/Node/NodeProject.php
+++ b/src/Goteo/Model/Node/NodeProject.php
@@ -25,9 +25,9 @@ static public function get($id): NodeProject
}
/**
- * @return NodeProject[]
+ * @return NodeProject[] | int
*/
- static public function getList(array $filters = [], int $offset = 0, int $limit = 10, bool $count = false, string $lang = null): array
+ static public function getList(array $filters = [], int $offset = 0, int $limit = 10, bool $count = false, string $lang = null)
{
$filter = [];
$values = [];
diff --git a/src/Goteo/Payment/Method/AbstractPaymentMethod.php b/src/Goteo/Payment/Method/AbstractPaymentMethod.php
index 5a39dcec38..4e60fb7037 100644
--- a/src/Goteo/Payment/Method/AbstractPaymentMethod.php
+++ b/src/Goteo/Payment/Method/AbstractPaymentMethod.php
@@ -346,4 +346,9 @@ public function isInternal(): bool
{
return false;
}
+
+ public function isSubscription(): bool
+ {
+ return false;
+ }
}
diff --git a/src/Goteo/Payment/Method/PaymentMethodInterface.php b/src/Goteo/Payment/Method/PaymentMethodInterface.php
index 2c5fc08524..e6564d5e60 100644
--- a/src/Goteo/Payment/Method/PaymentMethodInterface.php
+++ b/src/Goteo/Payment/Method/PaymentMethodInterface.php
@@ -127,4 +127,9 @@ public function calculateCommission($total_invests, $total_amount, $returned_inv
* (pool)
*/
public function isInternal(): bool;
+
+ /**
+ * Subscription payments are charged recurrently
+ */
+ public function isSubscription(): bool;
}
diff --git a/src/Goteo/Payment/Method/PaypalPaymentMethod.php b/src/Goteo/Payment/Method/PaypalPaymentMethod.php
index 84a8157a03..f9312afbb6 100644
--- a/src/Goteo/Payment/Method/PaypalPaymentMethod.php
+++ b/src/Goteo/Payment/Method/PaypalPaymentMethod.php
@@ -11,9 +11,12 @@
namespace Goteo\Payment\Method;
use Goteo\Application\Currency;
+use Goteo\Model\Project;
use Omnipay\Common\Message\ResponseInterface;
+use Omnipay\PayPal\ExpressGateway;
-class PaypalPaymentMethod extends AbstractPaymentMethod {
+class PaypalPaymentMethod extends AbstractPaymentMethod
+{
public function getGatewayName(): string
{
@@ -22,19 +25,43 @@ public function getGatewayName(): string
public function purchase(): ResponseInterface
{
+ /** @var ExpressGateway */
$gateway = $this->getGateway();
+ $invest = $this->getInvest();
+
+ $transactionId = sprintf("0000000000-%s", $invest->id);
+ if ($invest->project) {
+ $project = Project::get($invest->project);
+ $transactionId = sprintf("%s-%s", $project->getNumericId(), $invest->id);
+ }
+
+ $invest->setPreapproval($transactionId);
// You can specify your paypal gateway details in config/settings.yml
- if(!$gateway->getLogoImageUrl()) $gateway->setLogoImageUrl(SRC_URL . '/goteo_logo.png');
+ if (!$gateway->getLogoImageUrl()) $gateway->setLogoImageUrl(SRC_URL . '/goteo_logo.png');
+
+ $gateway->setCurrency(Currency::getDefault('id'));
+
+ $request = $gateway->purchase([
+ 'amount' => (float) $this->getTotalAmount(),
+ 'currency' => $gateway->getCurrency(),
+ 'description' => $this->getInvestDescription(),
+ 'returnUrl' => $this->getCompleteUrl(),
+ 'cancelUrl' => $this->getCompleteUrl(),
+ 'transactionId' => $transactionId,
+ ]);
- return parent::purchase();
+ return $request->send();
}
public function completePurchase(): ResponseInterface
{
+ /** @var ExpressGateway */
$gateway = $this->getGateway();
$invest = $this->getInvest();
+
$gateway->setCurrency(Currency::getDefault('id'));
+
$payment = $gateway->completePurchase([
'amount' => (float) $this->getTotalAmount(),
'description' => $this->getInvestDescription(),
@@ -49,5 +76,4 @@ public function completePurchase(): ResponseInterface
return $payment->send();
}
-
}
diff --git a/src/Goteo/Payment/Method/StripeSubscriptionPaymentMethod.php b/src/Goteo/Payment/Method/StripeSubscriptionPaymentMethod.php
index 0aa92d75ad..283a095df4 100644
--- a/src/Goteo/Payment/Method/StripeSubscriptionPaymentMethod.php
+++ b/src/Goteo/Payment/Method/StripeSubscriptionPaymentMethod.php
@@ -68,27 +68,24 @@ public function getGateway(): SubscriptionGateway
public function purchase(): ResponseInterface
{
- $response = $this->getGateway()->purchase([
+ return $this->getGateway()->purchase([
'invest' => $this->invest,
'user' => $this->user
])->send();
+ }
+
+ public function completePurchase(): ResponseInterface
+ {
+ $response = $this->getGateway()->completePurchase();
/** @var Subscription */
- $subscription = $response->getData();
+ $subscription = $response->getData()['subscription'];
$this->invest->setPayment($subscription->id);
return $response;
}
- public function completePurchase(): ResponseInterface
- {
- /** @var SubscriptionGateway */
- $gateway = $this->getGateway();
-
- return $gateway->completePurchase();
- }
-
public function refundable(): bool
{
return false;
@@ -103,4 +100,9 @@ public function isInternal(): bool
{
return false;
}
+
+ public function isSubscription(): bool
+ {
+ return true;
+ }
}
diff --git a/src/Goteo/Payment/Payment.php b/src/Goteo/Payment/Payment.php
index d3bb7c1277..c511cf13d1 100644
--- a/src/Goteo/Payment/Payment.php
+++ b/src/Goteo/Payment/Payment.php
@@ -15,6 +15,7 @@
use Goteo\Model\user;
use Goteo\Payment\Method\PaymentMethodInterface;
+use Goteo\Payment\Method\StripeSubscriptionPaymentMethod;
/**
* A statically defined class to manage payments
@@ -124,4 +125,30 @@ static public function defaultMethod($method = null) {
}
return self::$default_method;
}
+
+ /**
+ * @return PaymentMethodInterface[]
+ */
+ static public function getSubscriptionMethods(): array
+ {
+ return array_filter(self::$methods, function($method) {
+ switch (\get_class($method)) {
+ case StripeSubscriptionPaymentMethod::class:
+ return true;
+ default:
+ return false;
+ break;
+ }
+ });
+ }
+
+ static public function isSubscriptionMethod(string $method): bool
+ {
+ if (!self::getMethod($method)) return false;
+
+ $name = $method;
+ return 0 < count(array_filter(self::getSubscriptionMethods(), function ($method) use ($name) {
+ return $method::getId() === $name;
+ }));
+ }
}
diff --git a/src/Goteo/Repository/InvestRepository.php b/src/Goteo/Repository/InvestRepository.php
index 1797d87fc8..2521630170 100644
--- a/src/Goteo/Repository/InvestRepository.php
+++ b/src/Goteo/Repository/InvestRepository.php
@@ -34,4 +34,13 @@ public function getListByPayment(string $payment): array
return $this->query($sql, [$payment])->fetchAll(\PDO::FETCH_CLASS, Invest::class);
}
+
+ public function getListByTransaction(string $transaction): array
+ {
+ $sql = "SELECT *
+ FROM invest
+ WHERE invest.transaction = ?";
+
+ return $this->query($sql, [$transaction])->fetchAll(\PDO::FETCH_CLASS, Invest::class);
+ }
}
diff --git a/src/Goteo/Util/ModelNormalizer/ModelNormalizer.php b/src/Goteo/Util/ModelNormalizer/ModelNormalizer.php
index 812c2f501c..a40ffed0b1 100644
--- a/src/Goteo/Util/ModelNormalizer/ModelNormalizer.php
+++ b/src/Goteo/Util/ModelNormalizer/ModelNormalizer.php
@@ -9,10 +9,12 @@
*/
namespace Goteo\Util\ModelNormalizer;
+use Goteo\Application\Session;
use Goteo\Core\Model as CoreModel;
use Goteo\Model;
use Goteo\Util\ModelNormalizer\Transformer;
-use Goteo\Application\Session;
+use Goteo\Util\ModelNormalizer\Transformer\TransformerInterface;
+
/**
* This class allows to get an object standarized for its use in views
*/
@@ -25,11 +27,7 @@ public function __construct(CoreModel $model,array $keys = null) {
$this->keys = $keys;
}
- /**
- * Returns the normalized object
- * @return Goteo\Util\ModelNormalizer\TransformerInterface
- */
- public function get() {
+ public function get(): TransformerInterface {
if($this->model instanceOf Model\User) {
$ob = new Transformer\UserTransformer($this->model, $this->keys);
}
@@ -94,6 +92,15 @@ public function get() {
elseif ($this->model instanceOf Model\ImpactItem\ImpactProjectItemCost) {
$ob = new Transformer\ImpactProjectItemCostTransformer($this->model, $this->keys);
}
+ elseif ($this->model instanceOf Model\Faq) {
+ $ob = new Transformer\FaqTransformer($this->model, $this->keys);
+ }
+ elseif ($this->model instanceOf Model\Faq\FaqSection) {
+ $ob = new Transformer\FaqSectionTransformer($this->model, $this->keys);
+ }
+ elseif ($this->model instanceOf Model\Faq\FaqSubsection) {
+ $ob = new Transformer\FaqSubsectionTransformer($this->model, $this->keys);
+ }
else $ob = new Transformer\GenericTransformer($this->model, $this->keys);
$ob->setUser(Session::getUser())->rebuild();
diff --git a/src/Goteo/Util/ModelNormalizer/Transformer/FaqSectionTransformer.php b/src/Goteo/Util/ModelNormalizer/Transformer/FaqSectionTransformer.php
new file mode 100644
index 0000000000..df3ef58420
--- /dev/null
+++ b/src/Goteo/Util/ModelNormalizer/Transformer/FaqSectionTransformer.php
@@ -0,0 +1,23 @@
+model->slug;
+ }
+
+ public function getActions(): array
+ {
+ if(!$this->getUser()) return [];
+
+ return [
+ 'edit' => '/admin/faqsection/' . $this->model->id . '/edit',
+ 'delete' => '/admin/faqsection/' . $this->model->id . '/delete'
+ ];
+ }
+}
diff --git a/src/Goteo/Util/ModelNormalizer/Transformer/FaqSubsectionTransformer.php b/src/Goteo/Util/ModelNormalizer/Transformer/FaqSubsectionTransformer.php
new file mode 100644
index 0000000000..c2ba2ce4b7
--- /dev/null
+++ b/src/Goteo/Util/ModelNormalizer/Transformer/FaqSubsectionTransformer.php
@@ -0,0 +1,32 @@
+model->section_id);
+ } catch (ModelNotFoundException $e) {
+ return '';
+ }
+
+ return $faqSection->name;
+ }
+
+ public function getActions(): array
+ {
+ if(!$this->getUser()) return [];
+
+ return [
+ 'edit' => '/admin/faqsubsection/' . $this->model->id . '/edit',
+ 'delete' => '/admin/faqsubsection/' . $this->model->id . '/delete'
+ ];
+ }
+}
diff --git a/src/Goteo/Util/ModelNormalizer/Transformer/FaqTransformer.php b/src/Goteo/Util/ModelNormalizer/Transformer/FaqTransformer.php
new file mode 100644
index 0000000000..f7a219bc02
--- /dev/null
+++ b/src/Goteo/Util/ModelNormalizer/Transformer/FaqTransformer.php
@@ -0,0 +1,50 @@
+
+ *
+ * For the full copyright and license information, please view the README.md
+ * and LICENSE files that was distributed with this source code.
+ */
+namespace Goteo\Util\ModelNormalizer\Transformer;
+
+use Goteo\Application\Exception\ModelNotFoundException;
+use Goteo\Core\Model;
+use Goteo\Model\Faq\FaqSubsection;
+use Goteo\Library\Text;
+
+/**
+ * Transform a Model
+ */
+class FaqTransformer extends AbstractTransformer {
+
+ protected $keys = ['id', 'title', 'subsection_id','order'];
+
+
+ public function getSection() {
+ return $this->model->section;
+ }
+
+ public function getSubsection(): string {
+ $name = '';
+
+ try {
+ $name = FaqSubsection::get($this->model->subsection)->name;
+ } catch (ModelNotFoundException $e) {
+ //
+ }
+
+ return $name;
+ }
+
+ public function getActions(): array {
+ if(!$this->getUser()) return [];
+
+ return [
+ 'edit' => '/admin/faq/edit/' . $this->model->id,
+ 'delete' => '/admin/faq/delete/' . $this->model->id
+ ];
+ }
+
+}
diff --git a/src/Omnipay/Stripe/Subscription/Message/DonationResponse.php b/src/Omnipay/Stripe/Subscription/Message/DonationResponse.php
index e964fb8d4b..e434f06270 100644
--- a/src/Omnipay/Stripe/Subscription/Message/DonationResponse.php
+++ b/src/Omnipay/Stripe/Subscription/Message/DonationResponse.php
@@ -2,25 +2,20 @@
namespace Omnipay\Stripe\Subscription\Message;
-use Goteo\Application\Config;
use Omnipay\Common\Message\AbstractResponse;
use Omnipay\Common\Message\RedirectResponseInterface;
use Omnipay\Common\Message\RequestInterface;
use Stripe\Checkout\Session as StripeSession;
-use Stripe\StripeClient;
class DonationResponse extends AbstractResponse implements RedirectResponseInterface
{
- private StripeClient $stripe;
-
private StripeSession $checkout;
- public function __construct(RequestInterface $request, string $checkoutSessionId)
+ public function __construct(RequestInterface $request, StripeSession $checkout)
{
- parent::__construct($request, $checkoutSessionId);
+ parent::__construct($request, ['checkout' => $checkout]);
- $this->stripe = new StripeClient(Config::get('payments.stripe.secretKey'));
- $this->checkout = $this->stripe->checkout->sessions->retrieve($checkoutSessionId);
+ $this->checkout = $checkout;
}
public function isSuccessful()
diff --git a/src/Omnipay/Stripe/Subscription/Message/SubscriptionRequest.php b/src/Omnipay/Stripe/Subscription/Message/SubscriptionRequest.php
index 9e3ee57a17..a4c1cefaba 100644
--- a/src/Omnipay/Stripe/Subscription/Message/SubscriptionRequest.php
+++ b/src/Omnipay/Stripe/Subscription/Message/SubscriptionRequest.php
@@ -38,29 +38,34 @@ public function sendData($data)
$user = $data['user'];
$invest = $data['invest'];
- /** @var Project */
- $project = $invest->getProject();
-
$customer = $this->getStripeCustomer($user)->id;
- $metadata = $this->getMetadata($project, $invest, $user);
+ $metadata = $this->getMetadata($invest);
- $successUrl = sprintf('%s?session_id={CHECKOUT_SESSION_ID}', $this->getRedirectUrl(
- 'invest',
- $project->id,
- $invest->id,
- 'complete'
- ));
+ $successUrl = $this->getRedirectUrl('pool', $invest->id, 'complete');
+ if ($invest->getProject()) {
+ $successUrl = $this->getRedirectUrl(
+ 'invest',
+ $metadata['project'],
+ $invest->id,
+ 'complete'
+ );
+ }
+
+ $redirectUrl = $this->getRedirectUrl('dashboard', 'wallet');
+ if ($invest->getProject()) {
+ $redirectUrl = $this->getRedirectUrl('project', $metadata['project']);
+ }
- $session = $this->stripe->checkout->sessions->create([
+ $checkout = $this->stripe->checkout->sessions->create([
'customer' => $customer,
- 'success_url' => $successUrl,
- 'cancel_url' => $this->getRedirectUrl('project', $project->id),
+ 'success_url' => sprintf('%s?session_id={CHECKOUT_SESSION_ID}', $successUrl),
+ 'cancel_url' => $redirectUrl,
'mode' => CheckoutSession::MODE_SUBSCRIPTION,
'line_items' => [
[
'price' => $this->stripe->prices->create([
'unit_amount' => $invest->amount * 100,
- 'currency' => $project->currency,
+ 'currency' => $this->getStripeCurrency($invest, $user),
'recurring' => ['interval' => 'month'],
'product' => $this->getStripeProduct($invest)->id,
'metadata' => $metadata
@@ -71,59 +76,68 @@ public function sendData($data)
'metadata' => $metadata
]);
- return new SubscriptionResponse($this, $session->id);
+ return new SubscriptionResponse($this, $checkout);
}
public function completePurchase(array $options = [])
{
// Dirty sanitization because something is double concatenating the ?session_id query param
$sessionId = explode('?', $_REQUEST['session_id'])[0];
- $session = $this->stripe->checkout->sessions->retrieve($sessionId);
- $metadata = $session->metadata->toArray();
+ $checkout = $this->stripe->checkout->sessions->retrieve($sessionId);
+ $metadata = $checkout->metadata->toArray();
- if ($session->subscription) {
- $this->stripe->subscriptions->update(
- $session->subscription,
- [
- 'metadata' => $metadata
- ]
- );
-
- if ($metadata['donate_amount'] < 1) {
- return new SubscriptionResponse($this, $session->id);
- }
+ if (!$checkout->subscription) {
+ throw new \Exception("Could not retrieve Subscription from Stripe after checkout");
+ }
- $donation = $this->stripe->checkout->sessions->create([
- 'customer' => $this->getStripeCustomer(User::get($metadata['user']))->id,
- 'success_url' => sprintf('%s?session_id={CHECKOUT_SESSION_ID}', $this->getRedirectUrl(
- 'invest',
- $metadata['project'],
- $metadata['invest'],
- 'complete'
- )),
- 'cancel_url' => $this->getRedirectUrl('project', $metadata['project']->id),
- 'mode' => CheckoutSession::MODE_PAYMENT,
- 'line_items' => [
- [
- 'price' => $this->stripe->prices->create([
- 'unit_amount' => $metadata['donate_amount'] * 100,
- 'currency' => Config::get('currency'),
- 'product_data' => [
- 'name' => Text::get('donate-meta-description')
- ]
- ])->id,
- 'quantity' => 1
- ]
- ],
+ $subscription = $this->stripe->subscriptions->retrieve($checkout->subscription);
+ $this->stripe->subscriptions->update(
+ $checkout->subscription,
+ [
'metadata' => $metadata
- ]);
-
- return new DonationResponse($this, $donation->id);
+ ]
+ );
+
+ if ($metadata['donate_amount'] < 1) {
+ return new SubscriptionResponse($this, $checkout, $subscription);
}
- if ($session->payment_intent) {
- return new SubscriptionResponse($this, $session->id);
+ $successUrl = $this->getRedirectUrl('pool', $metadata['invest']);
+ if ($metadata['project'] !== '') {
+ $successUrl = $this->getRedirectUrl(
+ 'invest',
+ $metadata['project'],
+ $metadata['invest'],
+ 'complete'
+ );
}
+
+ $cancelUrl = $this->getRedirectUrl('dashboard', 'wallet');
+ if ($metadata['project'] !== '') {
+ $cancelUrl = $this->getRedirectUrl('project', $metadata['project']);
+ }
+
+ $donationCheckout = $this->stripe->checkout->sessions->create([
+ 'customer' => $this->getStripeCustomer(User::get($metadata['user']))->id,
+ 'success_url' => sprintf('%s?session_id={CHECKOUT_SESSION_ID}', $successUrl),
+ 'cancel_url' => $cancelUrl,
+ 'mode' => CheckoutSession::MODE_PAYMENT,
+ 'line_items' => [
+ [
+ 'price' => $this->stripe->prices->create([
+ 'unit_amount' => $metadata['donate_amount'] * 100,
+ 'currency' => Config::get('currency'),
+ 'product_data' => [
+ 'name' => Text::get('donate-meta-description')
+ ]
+ ])->id,
+ 'quantity' => 1
+ ]
+ ],
+ 'metadata' => $metadata
+ ]);
+
+ return new DonationResponse($this, $donationCheckout);
}
private function getRedirectUrl(...$args): string
@@ -167,27 +181,21 @@ private function getStripeCustomer(User $user): Customer
private function getStripeProduct(Invest $invest): Product
{
- /** @var User */
- $user = $invest->getUser();
-
- /** @var Project */
- $project = $invest->getProject();
-
- $productId = sprintf(
- '%s_%s_%s',
- $project->id,
- $this->getInvestReward($invest, 'noreward'),
- $user->id,
- );
+ $productId = $this->getProductId($invest);
try {
return $this->stripe->products->retrieve($productId);
} catch (\Stripe\Exception\InvalidRequestException $e) {
- $productDescription = sprintf(
- '%s - %s',
- $project->name,
- $this->getInvestReward($invest, Text::get('invest-resign'))
- );
+ if ($project = $invest->getProject()) {
+ $productDescription = sprintf(
+ '%s - %s',
+ $project->name,
+ $this->getInvestReward($invest, Text::get('invest-resign'))
+ );
+ } else {
+ $productDescription = Text::get('invest-pool-method');
+ }
+
return $this->stripe->products->create([
'id' => $productId,
@@ -197,14 +205,70 @@ private function getStripeProduct(Invest $invest): Product
}
}
- private function getMetadata(Project $project, Invest $invest, User $user): array
+ private function getMetadata(Invest $invest): array
{
+ /** @var Project */
+ $project = $invest->getProject();
+ /** @var User */
+ $user = $invest->getUser();
+
+ $projectId = ($project) ? $project->id : null;
+
return [
'donate_amount' => $invest->donate_amount,
- 'project' => $project->id,
+ 'project' => $projectId,
'invest' => $invest->id,
'reward' => $this->getInvestReward($invest, ''),
'user' => $user->id,
];
}
+
+ private function getProductId(Invest $invest): string
+ {
+ if ($project = $invest->getProject())
+ return $this->getProductWithProjectId($invest, $project);
+
+ return $this->getProductWithoutProjectId($invest);
+ }
+
+ private function getProductWithProjectId(Invest $invest, Project $project): string
+ {
+ /** @var User */
+ $user = $invest->getUser();
+
+ return sprintf(
+ '%s_%s_%s',
+ $project->id,
+ $this->getInvestReward($invest, 'noreward'),
+ $user->id,
+ );
+ }
+
+ private function getProductWithoutProjectId(Invest $invest): string
+ {
+ /** @var User */
+ $user = $invest->getUser();
+
+ return sprintf(
+ '%s_%s',
+ $invest->id,
+ $user->id
+ );
+ }
+
+ private function getStripeCurrency(Invest $invest, User $user): string
+ {
+ if ($project = $invest->getProject()) {
+ return $project->currency;
+ }
+
+ /** @var stdClass */
+ $preferences = User::getPreferences($user);
+
+ if (\property_exists($preferences, 'currency')) {
+ return $preferences->currency;
+ }
+
+ return Config::get('currency');
+ }
}
diff --git a/src/Omnipay/Stripe/Subscription/Message/SubscriptionResponse.php b/src/Omnipay/Stripe/Subscription/Message/SubscriptionResponse.php
index fd4c2f9f2f..3d9cb0d6ab 100644
--- a/src/Omnipay/Stripe/Subscription/Message/SubscriptionResponse.php
+++ b/src/Omnipay/Stripe/Subscription/Message/SubscriptionResponse.php
@@ -2,25 +2,24 @@
namespace Omnipay\Stripe\Subscription\Message;
-use Goteo\Application\Config;
use Omnipay\Common\Message\AbstractResponse;
use Omnipay\Common\Message\RedirectResponseInterface;
use Omnipay\Common\Message\RequestInterface;
use Stripe\Checkout\Session as StripeSession;
-use Stripe\StripeClient;
+use Stripe\Subscription;
class SubscriptionResponse extends AbstractResponse implements RedirectResponseInterface
{
- private StripeClient $stripe;
-
private StripeSession $checkout;
- public function __construct(RequestInterface $request, string $checkoutSessionId)
- {
- parent::__construct($request, $checkoutSessionId);
+ public function __construct(
+ RequestInterface $request,
+ StripeSession $checkout,
+ ?Subscription $subscription = null
+ ) {
+ parent::__construct($request, ['checkout' => $checkout, 'subscription' => $subscription]);
- $this->stripe = new StripeClient(Config::get('payments.stripe.secretKey'));
- $this->checkout = $this->stripe->checkout->sessions->retrieve($checkoutSessionId);
+ $this->checkout = $checkout;
}
public function isSuccessful()
@@ -37,4 +36,9 @@ public function getRedirectUrl()
{
return $this->checkout->url;
}
+
+ public function getTransactionReference()
+ {
+ return $this->checkout->invoice;
+ }
}
diff --git a/src/Routes/api_routes.php b/src/Routes/api_routes.php
index c79027355c..f3da5fadb3 100644
--- a/src/Routes/api_routes.php
+++ b/src/Routes/api_routes.php
@@ -483,6 +483,13 @@
array('_controller' => 'Goteo\Controller\Dashboard\AjaxDashboardController::sdgFootprintAction')
));
+// Stories sort up/down arbitrarily (use the PUT method to sort)
+$api->add('api-faq-sort', new Route(
+ '/faq/{id}/sort',
+ array('_controller' => 'Goteo\Controller\Api\FaqApiController::faqSortAction'
+ )
+));
+
// ImpactData images upload (POST method only)
$api->add('api-impact-data-images-upload', new Route(
'/impactdata/images',
@@ -499,4 +506,15 @@
array('_controller' => 'Goteo\Controller\Api\DataSetApiController::dataSetsAction')
));
+// ImpactData images upload (POST method only)
+$api->add('api-faq-images-upload', new Route(
+ '/faq/images',
+ ['_controller' => 'Goteo\Controller\Api\FaqApiController::uploadImagesAction'],
+ [], // requirements
+ [], // options
+ '', // host
+ [], // schemes
+ ['POST'] // methods
+));
+
return $api;
diff --git a/src/Routes/faq_routes.php b/src/Routes/faq_routes.php
new file mode 100644
index 0000000000..0e6932573e
--- /dev/null
+++ b/src/Routes/faq_routes.php
@@ -0,0 +1,48 @@
+
+ *
+ * For the full copyright and license information, please view the README.md
+ * and LICENSE files that was distributed with this source code.
+ */
+
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Routing\RouteCollection;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Request;
+
+
+$faq = new RouteCollection();
+
+//// FAQ /////
+
+
+
+// New all-in-one controller
+
+$faq->add('faq-search', new Route(
+ '/search',
+ [
+ '_controller' => 'Goteo\Controller\FaqController::searchAction'
+ ]
+));
+
+$faq->add('faq-section', new Route(
+ '/{section}',
+ [
+ '_controller' => 'Goteo\Controller\FaqController::sectionAction'
+ ]
+));
+
+$faq->add('faq-individual', new Route(
+ '/{section}/{faq}',
+ [
+ '_controller' => 'Goteo\Controller\FaqController::individualAction'
+ ]
+));
+
+
+
+return $faq;
diff --git a/src/routes.php b/src/routes.php
index 97c162b4eb..a1726bef1f 100644
--- a/src/routes.php
+++ b/src/routes.php
@@ -44,6 +44,15 @@
$invest_routes->addPrefix('/invest');
$routes->addCollection($invest_routes);
+// Faq routes
+$routes->add('faq', new Route(
+ '/faq',
+ array('_controller' => 'Goteo\Controller\FaqController::indexAction')
+));
+$faq_routes = include __DIR__ . '/Routes/faq_routes.php';
+$faq_routes->addPrefix('/faq');
+$routes->addCollection($faq_routes);
+
// Pool routes
// Pool invest main route
// Notify URL for gateways that need it
@@ -61,7 +70,7 @@
if(Config::get('donate.landing')) {
$routes->add('donate-landing', new Route(
'/donate',
- array('_controller' => 'Goteo\Controller\DonateLandingController::indexAction')
+ array('_controller' => 'Goteo\Controller\DonateController::donateLandingAction')
));
}
diff --git a/tests/Goteo/Controller/Admin/FaqAdminControllerTest.php b/tests/Goteo/Controller/Admin/FaqAdminControllerTest.php
new file mode 100644
index 0000000000..3072ac4b92
--- /dev/null
+++ b/tests/Goteo/Controller/Admin/FaqAdminControllerTest.php
@@ -0,0 +1,18 @@
+assertInstanceOf(FaqAdminController::class, $controller);
+
+ return $controller;
+ }
+}
diff --git a/tests/Goteo/Controller/FaqControllerTest.php b/tests/Goteo/Controller/FaqControllerTest.php
new file mode 100644
index 0000000000..d9babbc94e
--- /dev/null
+++ b/tests/Goteo/Controller/FaqControllerTest.php
@@ -0,0 +1,20 @@
+assertInstanceOf(FaqController::class, $controller);
+
+ return $controller;
+ }
+}
diff --git a/tests/Goteo/Model/Faq/FaqSectionTest.php b/tests/Goteo/Model/Faq/FaqSectionTest.php
new file mode 100644
index 0000000000..d5096322a3
--- /dev/null
+++ b/tests/Goteo/Model/Faq/FaqSectionTest.php
@@ -0,0 +1,106 @@
+ 'test-section',
+ 'slug' => 'test-section-slug',
+ 'button_action' => '',
+ 'button_url' => ''
+ ];
+
+ public function testInstance(): FaqSection
+ {
+ $faqSection = new FaqSection();
+
+ $this->assertInstanceOf(FaqSection::class, $faqSection);
+
+ return $faqSection;
+ }
+
+ /**
+ * @depends testInstance
+ */
+ public function testValidate(FaqSection $faqSection)
+ {
+ $this->assertFalse($faqSection->validate());
+ $this->assertFalse($faqSection->save());
+ }
+
+ public function testCreate(): FaqSection
+ {
+ $faqSection = new FaqSection(self::$data);
+ $errors = [];
+
+ $this->assertTrue($faqSection->validate($errors), implode(',', $errors));
+ $this->assertTrue($faqSection->save($errors), implode(',', $errors));
+
+ return $faqSection;
+ }
+
+ /**
+ * @depends testCreate
+ */
+ public function testGetById(FaqSection $faqSection): void
+ {
+ $ob = FaqSection::getById($faqSection->id);
+
+ $this->assertEquals($faqSection->id, $ob->id);
+ }
+
+ /**
+ * @depends testCreate
+ */
+ public function testGetBySlug(FaqSection $faqSection): void
+ {
+ $ob = FaqSection::getBySlug($faqSection->slug);
+
+ $this->assertEquals($faqSection->id, $ob->id);
+ }
+
+ /**
+ * @depends testCreate
+ */
+ public function testGetListNotEmpty()
+ {
+ $list = FaqSection::getList();
+ $this->assertIsArray($list);
+ $this->assertNotEmpty($list);
+ }
+
+ /**
+ * @depends testCreate
+ */
+ public function testDelete(FaqSection $faqSection): FaqSection
+ {
+ $this->assertTrue($faqSection->dbDelete());
+
+ return $faqSection;
+ }
+
+ /**
+ * @depends testDelete
+ */
+ public function testNonExisting(FaqSection $faqSection)
+ {
+ $this->expectException(ModelNotFoundException::class);
+
+ FaqSection::get($faqSection->id);
+ }
+
+ /**
+ * @depends testDelete
+ */
+ public function getListEmpty()
+ {
+ $list = FaqSection::getList();
+ $this->assertIsArray($list);
+ $this->assertEmpty($list);
+ }
+}
diff --git a/tests/Goteo/Model/Faq/FaqSubsectionTest.php b/tests/Goteo/Model/Faq/FaqSubsectionTest.php
new file mode 100644
index 0000000000..8dfaf071b1
--- /dev/null
+++ b/tests/Goteo/Model/Faq/FaqSubsectionTest.php
@@ -0,0 +1,120 @@
+ 'test-subsection'
+ ];
+
+ private static array $sectionData = [
+ 'name' => 'test-section',
+ 'slug' => 'test-section-slug',
+ 'button_action' => '',
+ 'button_url' => ''
+ ];
+
+ public function testInstance(): FaqSubsection
+ {
+ $faqSubsection = new FaqSubsection();
+
+ $this->assertInstanceOf(FaqSubsection::class, $faqSubsection);
+
+ return $faqSubsection;
+ }
+
+ /**
+ * @depends testInstance
+ */
+ public function testValidate(FaqSubsection $faqSubsection)
+ {
+ $this->assertFalse($faqSubsection->validate());
+ $this->assertFalse($faqSubsection->save());
+ }
+
+ public function testCreate(): FaqSubsection
+ {
+
+ $faqSection = new FaqSection(self::$sectionData);
+ $faqSection->save();
+
+ $faqSubsection = new FaqSubsection(self::$data);
+ $faqSubsection->section_id = $faqSection->id;
+ $errors = [];
+
+ $this->assertTrue($faqSubsection->validate($errors), implode(',', $errors));
+ $this->assertTrue($faqSubsection->save($errors), implode(',', $errors));
+
+ return $faqSubsection;
+ }
+
+ /**
+ * @depends testCreate
+ */
+ public function testGet(FaqSubsection $faqSubsection): void
+ {
+ $ob = FaqSubsection::get($faqSubsection->id);
+
+ $this->assertInstanceOf(FaqSubsection::class, $faqSubsection);
+ $this->assertEquals($faqSubsection->id, $ob->id);
+ }
+
+ /**
+ * @depends testCreate
+ */
+ public function testGetListNotEmpty()
+ {
+ $list = FaqSubsection::getList();
+ $this->assertIsArray($list);
+ $this->assertNotEmpty($list);
+ }
+
+ /**
+ * @depends testCreate
+ */
+ public function testDelete(FaqSubsection $faqSubsection): FaqSubsection
+ {
+ $this->assertTrue($faqSubsection->dbDelete());
+
+ return $faqSubsection;
+ }
+
+ /**
+ * @depends testDelete
+ */
+ public function testNonExisting(FaqSubsection $faqSubsection)
+ {
+ $this->expectException(ModelNotFoundException::class);
+
+ FaqSubsection::get($faqSubsection->id);
+ }
+
+ /**
+ * @depends testDelete
+ */
+ public function getListEmpty()
+ {
+ $list = FaqSubsection::getList();
+ $this->assertIsArray($list);
+ $this->assertEmpty($list);
+ }
+
+ /**
+ * Some cleanup
+ */
+ static function tearDownAfterClass(): void {
+ self::delete_faq_section();
+ }
+
+ private static function delete_faq_section()
+ {
+ $faqSection = FaqSection::getBySlug(self::$sectionData['slug']);
+ $faqSection->dbDelete();
+ }
+}
diff --git a/tests/Goteo/Model/FaqTest.php b/tests/Goteo/Model/FaqTest.php
index a87d5a1f48..abf8efda4d 100644
--- a/tests/Goteo/Model/FaqTest.php
+++ b/tests/Goteo/Model/FaqTest.php
@@ -1,19 +1,34 @@
'test-section', 'description' => 'test description', 'title' => 'Test title', 'order' => 1);
- private static $trans_data = array('description' => 'Descripció test', 'title' => 'Test títol');
+ private static array $sectionData = [
+ 'name' => 'test-section',
+ 'slug' => 'test-section-slug',
+ 'button_action' => '',
+ 'button_url' => ''
+ ];
+
+ private static array $subsectionData = [
+ 'name' => 'test-subsection'
+ ];
+
+ private static array $data = ['description' => 'test description', 'title' => 'Test title', 'order' => 1];
+ private static array $trans_data = ['description' => 'Descripció test', 'title' => 'Test títol'];
- public static function setUpBeforeClass(): void {
+ public static function setUpBeforeClass(): void
+ {
Config::set('lang', 'es');
Lang::setDefault('es');
Lang::set('es');
@@ -21,11 +36,11 @@ public static function setUpBeforeClass(): void {
public function testInstance(): Faq
{
- \Goteo\Core\DB::cache(false);
+ DB::cache(false);
$ob = new Faq();
- $this->assertInstanceOf('\Goteo\Model\Faq', $ob);
+ $this->assertInstanceOf(Faq::class, $ob);
return $ob;
}
@@ -38,17 +53,27 @@ public function testValidate(Faq $ob) {
$this->assertFalse($ob->save());
}
- public function testCreate() {
- self::$data['node'] = get_test_node()->id;
+ public function testCreate(): Faq {
+
$errors = [];
+ $faqSection = new FaqSection(self::$sectionData);
+ $this->assertTrue($faqSection->save($errors), implode(',', $errors));
+
+ $faqSubsection = new FaqSubsection(self::$subsectionData);
+ $faqSubsection->section_id = $faqSection->id;
+ $this->assertTrue($faqSubsection->save($errors), implode(',', $errors));
+
+ self::$data['node'] = get_test_node()->id;
+ self::$data['subsection_id'] = $faqSubsection->id;
$ob = new Faq(self::$data);
$this->assertTrue($ob->validate($errors), implode(',',$errors));
- $this->assertTrue($ob->save());
- $ob = Faq::get($ob->id);
- $this->assertInstanceOf('\Goteo\Model\Faq', $ob);
+ $this->assertTrue($ob->save($errors), implode(',', $errors));
+
+ $ob = Faq::getById($ob->id);
+ $this->assertInstanceOf(Faq::class, $ob);
foreach(self::$data as $key => $val) {
- $this->assertEquals($ob->{$key}, $val);
+ $this->assertEquals($val, $ob->{$key});
}
return $ob;
}
@@ -56,7 +81,7 @@ public function testCreate() {
/**
* @depends testCreate
*/
- public function testSaveLanguages(Faq $ob) {
+ public function testSaveLanguages(Faq $ob): Faq {
$errors = [];
$this->assertTrue($ob->setLang('ca', self::$trans_data, $errors), print_r($errors, 1));
return $ob;
@@ -66,12 +91,12 @@ public function testSaveLanguages(Faq $ob) {
* @depends testSaveLanguages
*/
public function testCheckLanguages(Faq $ob) {
- $faq = Faq::get($ob->id);
- $this->assertInstanceOf('Goteo\Model\Faq', $faq);
+ $faq = Faq::getById($ob->id);
+ $this->assertInstanceOf(Faq::class, $faq);
$this->assertEquals(self::$data['title'], $faq->title);
$this->assertEquals(self::$data['description'], $faq->description);
Lang::set('ca');
- $faq2 = Faq::get($ob->id);
+ $faq2 = Faq::getById($ob->id);
$this->assertEquals(self::$trans_data['title'], $faq2->title);
$this->assertEquals(self::$trans_data['description'], $faq2->description);
Lang::set('es');
@@ -81,24 +106,26 @@ public function testCheckLanguages(Faq $ob) {
/**
* @depends testCreate
*/
- public function testListing() {
- $list = Faq::getAll('test-section');
+ public function testListing(Faq $faq) {
+ $list = Faq::getAll(self::$data['subsection_id']);
$this->assertIsArray($list);
- $faq = end($list);
- $this->assertInstanceOf('Goteo\Model\Faq', $faq);
- $this->assertEquals(self::$data['title'], $faq->title);
- $this->assertEquals(self::$data['description'], $faq->description);
+
+ $list = Faq::getAll($faq->subsection_id);
+ $this->assertCount(1, $list);
+ $listed_faq = current($list);
+ $this->assertInstanceOf(Faq::class, $listed_faq);
+ $this->assertEquals($faq->title, $listed_faq->title);
+ $this->assertEquals($faq->description, $listed_faq->description);
Lang::set('ca');
- $list = Faq::getAll('test-section');
+ $list = Faq::getAll(self::$data['subsection_id']);
$this->assertIsArray($list);
- $faq2 = end($list);
- $this->assertEquals(self::$trans_data['title'], $faq2->title);
- $this->assertEquals(self::$trans_data['description'], $faq2->description);
+ $listed_faq2 = current($list);
+ $this->assertEquals(self::$trans_data['title'], $listed_faq2->title);
+ $this->assertEquals(self::$trans_data['description'], $listed_faq2->description);
Lang::set('es');
}
-
/**
* @depends testCreate
*/
@@ -106,8 +133,11 @@ public function testDelete(Faq $ob): Faq
{
$this->assertTrue($ob->dbDelete());
+ $subsection = $ob->subsection_id;
//save and delete statically
$ob = new Faq(self::$data);
+ $ob->subsection_id = $subsection;
+
$errors = [];
$this->assertTrue($ob->save($errors), implode("\n", $errors));
$this->assertTrue(Faq::remove($ob->id, self::$data['node']));
@@ -119,9 +149,8 @@ public function testDelete(Faq $ob): Faq
* @depends testDelete
*/
public function testNonExisting(Faq $ob) {
+ $this->expectException(ModelNotFoundException::class);
$ob = Faq::get($ob->id);
- $this->assertFalse($ob);
- $this->assertFalse(Faq::remove($ob->id));
}
/**
@@ -129,5 +158,13 @@ public function testNonExisting(Faq $ob) {
*/
static function tearDownAfterClass(): void {
delete_test_node();
+ self::delete_faq_section();
+ }
+
+ private static function delete_faq_section(): void
+ {
+ $faqSection = FaqSection::getBySlug(self::$sectionData['slug']);
+ $faqSection->dbDelete();
}
+
}
diff --git a/translations/ca/admin.yml b/translations/ca/admin.yml
index 03d9231dd1..c30e870aef 100644
--- a/translations/ca/admin.yml
+++ b/translations/ca/admin.yml
@@ -314,4 +314,13 @@ admin-impact-data-source-channel: "Canal"
admin-impact-data-result_msg-help: "Aquest camp permet afegir un missatge al càlcul del impacte d'aquesta Data d'Impacte. Per utilitzar-lo haurem de fer servir les variables
%amount i
%result , on
%amount fa referència a la quantitat estimada per l'usuari i
%result al resultat d'impacte calculat."
admin-impact-data-type-estimation: "Estimació"
admin-impact-data-type-real: "Real"
-admin-subscriptions: "Subscripcions"
\ No newline at end of file
+admin-subscriptions: "Subscripcions"
+admin-faqs: Faqs
+admin-faq-add: Afegir Faq
+admin-faq-pending: "Marcar com pendent de traduïr"
+admin-faq-subsections: "Subseccions"
+admin-faq-subsections-all: "Totes les subseccions"
+admin-faq-subsections-add: "Afegir subsecció"
+admin-faq-sections: "Seccions de les FAQ"
+admin-faq-sections-all: "Totes les seccions"
+admin-faq-sections-add: "Afegir secció"
diff --git a/translations/ca/dashboard.yml b/translations/ca/dashboard.yml
index 592f68ad50..7618719fb2 100644
--- a/translations/ca/dashboard.yml
+++ b/translations/ca/dashboard.yml
@@ -73,7 +73,7 @@ dashboard-menu-profile-personal: 'Dades personals'
dashboard-menu-profile-preferences: 'Preferències'
dashboard-menu-profile-profile: 'Edita el Perfil'
dashboard-menu-profile-public: 'Perfil públic'
-dashboard-menu-project-nid: 'Identificador numèric de projecte'
+dashboard-menu-project-nid: 'Número de seguiment'
dashboard-menu-projects: 'Projectes'
dashboard-menu-projects-analytics: Analítica
dashboard-menu-projects-commons: Retorns
@@ -242,6 +242,7 @@ dashboard-project-filter-by-drop: 'Veure només les aportacions de matchfunding'
dashboard-project-filter-by-nondrop: 'Amaga les aportacions de matchfunding'
dashboard-project-filter-by-pending: 'Veure només les pendents'
dashboard-project-filter-by-fulfilled: 'Veure només les completades'
+dashboard-project-filter-by-subscription: 'Veure només les aportacions des de suscripcions'
dashboard-project-no-invests: 'No hi ha aportacions per a aquest criteri de cerca'
dashboard-new-message-to-donors: 'Nou missatge als cofinançadors'
dashboard-message-donors-reward: 'Cofinançadors amb la recompensa %s'
diff --git a/translations/ca/faq.yml b/translations/ca/faq.yml
index 724eac731a..66c9bead09 100644
--- a/translations/ca/faq.yml
+++ b/translations/ca/faq.yml
@@ -1,6 +1,14 @@
---
+faq-meta-title: 'FAQ :: Goteo'
+faq-meta-description: "Preguntas freqüents dirigides a resoldre dubtes d'impulsors, donants i usuaris Goteo en general."
+faq-title: 'FAQs'
+faq-search: 'Què estas cercant?'
+faq-ask-question: "No has pogut resoldre el teu dubte? Envía un missatge amb la teva pregunta."
faq-investors-section-header: 'Per a cofinançadors/es'
faq-main-section-header: 'Una aproximació a Goteo'
faq-nodes-section-header: 'Sobre nodes locals'
faq-project-section-header: 'Sobre els projectes'
faq-sponsor-section-header: 'Per a impulsors/es'
+faq-unsolved-footer: "NO HEM RESOLT EL TEU DUBTE?"
+faq-searched-word: 'La teva cerca és "%s"'
+faq-search-empty: "No hem trobat cap FAQ que compleixi amb la cerca"
diff --git a/translations/ca/general.yml b/translations/ca/general.yml
index a026193b5e..584014c846 100644
--- a/translations/ca/general.yml
+++ b/translations/ca/general.yml
@@ -221,6 +221,7 @@ regular-remember-me: 'Recorda''m'
regular-next: Següent
regular-write-description: 'Escriu un text descriptiu...'
regular-choice-placeholder: 'Selecciona una opció'
+regular-subsection: 'Subsecció'
session-about-to-expire: 'La teva sessió expirarà en 10 minuts. ¿Vols recarregar la pàgina per renovar-la?'
session-expired: 'La teva sessió ha expirat!'
stories-user-project: '%s, del projecte: %s'
diff --git a/translations/ca/labels.yml b/translations/ca/labels.yml
index 00de53abe4..8479c5cfc0 100644
--- a/translations/ca/labels.yml
+++ b/translations/ca/labels.yml
@@ -43,7 +43,9 @@ criteria-lb-edit: 'Editant Criteri'
criteria-lb-translate: 'Traduint Criteri'
faq-lb-add: 'Nova Pregunta'
faq-lb-edit: 'Editant Pregunta'
-faq-lb-translate: 'Tradunt Pregunta'
+faq-lb-translate: 'Traduint Pregunta'
+faqsection-lb: "Seccions de les FAQs"
+faqsubsection-lb: "Subseccions de les FAQs"
filter-lb: 'Filtres'
glossary-lb: Glossari
impactdata-lb: "Dades d'impacte"
diff --git a/translations/ca/project.yml b/translations/ca/project.yml
index 8d6cdeeb36..34edbf2eb6 100644
--- a/translations/ca/project.yml
+++ b/translations/ca/project.yml
@@ -23,6 +23,7 @@ project-help-license: 'Necessito ajuda per definir els retorns col·lectius i le
project-hide-donors: 'Amagar cofinançadors'
project-hide-needs: 'Amagar llistat de necessitats'
project-invest: Aportació
+project-invest-from-subscription: Subscripció
project-invest-msg: 'Missatge de suport:'
project-langs-header: 'Projecte en:'
project-media-play_video: 'Veure vídeo'
diff --git a/translations/ca/roles.yml b/translations/ca/roles.yml
index 9f03171c85..b099ea618c 100644
--- a/translations/ca/roles.yml
+++ b/translations/ca/roles.yml
@@ -6,6 +6,7 @@ role-name-translator: 'Traductor de continguts'
role-name-checker: 'Revisor de projectes'
role-name-stats: 'Accés a stats'
role-name-manager: 'Gestor de contractes'
+role-name-helper: 'Ajudant'
role-name-consultant: 'Assessor de projectes'
role-name-admin: 'Administrador d''assessors'
role-name-superadmin: 'Super-Administrador'
diff --git a/translations/en/admin.yml b/translations/en/admin.yml
index e77db39fc7..bdf93ed014 100644
--- a/translations/en/admin.yml
+++ b/translations/en/admin.yml
@@ -553,3 +553,12 @@ admin-impact-data-result_msg-help: "This field allows you to add a message to th
admin-impact-data-type-estimation: "Estimation"
admin-impact-data-type-real: "Real"
admin-subscriptions: "Subscriptions"
+admin-faqs: Faqs
+admin-faq-add: Add Faq
+admin-faq-pending: Check as translation is pending
+admin-faq-subsections: "Subsections"
+admin-faq-subsections-all: "All subsections"
+admin-faq-subsections-add: "Add subsection"
+admin-faq-sections: "FAQ's sections"
+admin-faq-sections-all: "All sections"
+admin-faq-sections-add: "Add section"
diff --git a/translations/en/dashboard.yml b/translations/en/dashboard.yml
index 43d50d5737..01b0ea75a9 100644
--- a/translations/en/dashboard.yml
+++ b/translations/en/dashboard.yml
@@ -241,6 +241,7 @@ dashboard-project-filter-by-drop: 'Show only matchfunding donations'
dashboard-project-filter-by-nondrop: 'Hide matchfunding donations'
dashboard-project-filter-by-pending: 'Show only pending'
dashboard-project-filter-by-fulfilled: 'Show only completed'
+dashboard-project-filter-by-subscription: 'Show only donations from subscriptions'
dashboard-project-no-invests: 'No entries for the current search criteria'
dashboard-new-message-to-donors: 'New message to donors'
dashboard-message-donors-reward: 'Donors with the reward %s'
diff --git a/translations/en/faq.yml b/translations/en/faq.yml
index 6bb80a60bd..67e380b0a2 100644
--- a/translations/en/faq.yml
+++ b/translations/en/faq.yml
@@ -5,3 +5,7 @@ faq-main-section-header: 'An approach to Goteo'
faq-nodes-section-header: 'About local nodes'
faq-project-section-header: 'About the projects'
faq-sponsor-section-header: 'For promoters'
+faq-unsolved-footer: "HAVEN'T WE SOLVED YOUR DOUBT?"
+faq-searched-word: 'You searched for "%s"'
+faq-search: 'What are you looking for?'
+faq-search-empty: "We couldn't find any FAQ related to your search term"
diff --git a/translations/en/general.yml b/translations/en/general.yml
index 4aba3380aa..bed5fe8363 100644
--- a/translations/en/general.yml
+++ b/translations/en/general.yml
@@ -227,6 +227,7 @@ regular-next: Next
regular-previous: Previous
regular-write-description: 'Write a descriptive text...'
regular-choice-placeholder: 'Choose an option'
+regular-subsection: 'Subsection'
session-about-to-expire: 'Your session will expire in 10 Minutes, do you want to reload?'
session-expired: 'Your session has expired'
stories-user-project: '%s, from the project: %s'
diff --git a/translations/en/labels.yml b/translations/en/labels.yml
index 3a3b4492db..37b2e637ac 100644
--- a/translations/en/labels.yml
+++ b/translations/en/labels.yml
@@ -53,6 +53,8 @@ faq-lb-add: 'New Question'
faq-lb-edit: 'Editing Question'
faq-lb-list: Listing
faq-lb-translate: 'Translating Question'
+faqsection-lb: "FAQ's sections"
+faqsubsection-lb: "FAQ's subsections"
filter-lb: 'Filters'
glossary-lb: Glossary
glossary-lb-edit: 'Editing Term'
diff --git a/translations/en/project.yml b/translations/en/project.yml
index 9989910526..47dbed1da8 100644
--- a/translations/en/project.yml
+++ b/translations/en/project.yml
@@ -20,6 +20,7 @@ project-help-license: 'I need help with collective returns and licenses'
project-hide-donors: 'Hide donors'
project-hide-needs: 'Hide list of needs'
project-invest: Donation
+project-invest-from-subscription: Subscription
project-invest-msg: 'Support message:'
project-langs-header: 'Project in:'
project-media-play_video: 'Watch video'
diff --git a/translations/es/admin.yml b/translations/es/admin.yml
index c0f6ee5b70..9bd6b4336e 100644
--- a/translations/es/admin.yml
+++ b/translations/es/admin.yml
@@ -296,6 +296,7 @@ admin-title-resource-category: 'Categoría de recurso'
admin-title-year: Año
admin-title-nif: NIF
admin-title-country: País
+admin-title-subsection: 'Subsección'
admin-blog-not-published-yet: 'La entrada de blog no es pública todavía'
admin-blog-initialized: 'Se ha inicializado el espacio de blog'
@@ -592,13 +593,10 @@ mandatory-filter-description: Falta descripción
mandatory-filter-role: Falta un rol
admin-node-iframe: Iframe
-
-
admin-channel-resource: 'Recursos Canal'
admin-channel-resource-add: 'Crear recurso'
admin-channel-projects-add: "Añadir proyecto"
admin-channel-projects-not-exists: "No existe esta relación"
-
admin-posts: 'Posts'
admin-impact_data: "Datos de impacto"
admin-user-manage-tip-donation: "de donación propina"
@@ -622,3 +620,12 @@ admin-impact-data-type-estimation: "Estimación"
admin-impact-data-type-real: "Real"
admin-subscriptions: "Suscripciones"
+admin-faqs: Faqs
+admin-faq-add: Añadir Faq
+admin-faq-pending: "Marcar com pendiente de traducir"
+admin-faq-subsections: "Subsecciones"
+admin-faq-subsections-all: "Todas las subsecciones"
+admin-faq-subsections-add: "Añadir subsección"
+admin-faq-sections: "Secciones de las FAQ"
+admin-faq-sections-all: "Todas las secciones"
+admin-faq-sections-add: "Añadir sección"
diff --git a/translations/es/dashboard.yml b/translations/es/dashboard.yml
index e433de0543..1fcaf3bf47 100644
--- a/translations/es/dashboard.yml
+++ b/translations/es/dashboard.yml
@@ -73,7 +73,7 @@ dashboard-menu-profile-personal: 'Datos personales'
dashboard-menu-profile-preferences: 'Mis preferencias'
dashboard-menu-profile-profile: 'Editar perfil'
dashboard-menu-profile-public: 'Perfil público'
-dashboard-menu-project-nid: 'Identificador numérico de proyecto'
+dashboard-menu-project-nid: 'Número de seguimiento'
dashboard-menu-projects: 'Mis proyectos'
dashboard-menu-projects-analytics: Analítica
dashboard-menu-projects-commons: Retornos
@@ -243,6 +243,7 @@ dashboard-project-filter-by-drop: 'Ver sólo aportes de matchfunding'
dashboard-project-filter-by-nondrop: 'Esconder aportes de matchfunding'
dashboard-project-filter-by-pending: 'Ver sólo las pendientes'
dashboard-project-filter-by-fulfilled: 'Ver sólo las completadas'
+dashboard-project-filter-by-subscription: 'Ver sólo aportes desde subscripciones'
dashboard-project-no-invests: 'No hay aportes para este criterio de búsqueda'
dashboard-new-message-to-donors: 'Nuevo mensaje a cofinanciadores'
dashboard-message-donors-reward: 'Los cofinanciadores con la recompensa %s'
diff --git a/translations/es/faq.yml b/translations/es/faq.yml
index 0276df6f87..897011748a 100644
--- a/translations/es/faq.yml
+++ b/translations/es/faq.yml
@@ -1,7 +1,14 @@
---
+faq-meta-title: 'FAQ :: Goteo'
+faq-meta-description: 'Preguntas frecuentes dirigidas a resolver dudas de impulsores, donantes, y usuarios Goteo en general.'
+faq-title: 'FAQs'
+faq-search: '¿Qué estás buscando?'
faq-ask-question: "¿No has podido resolver tu duda? Envía un mensaje con tu pregunta."
faq-investors-section-header: 'Para cofinanciador@s'
faq-main-section-header: 'Sobre Goteo'
faq-nodes-section-header: 'Sobre nodos locales'
faq-project-section-header: 'Sobre los proyectos'
faq-sponsor-section-header: 'Para impulsor@s'
+faq-unsolved-footer: "¿NO HEMOS RESUELTO TU DUDA?"
+faq-searched-word: 'Tu búsqueda es "%s"'
+faq-search-empty: "No hemos encontrado ninguna FAQ que cumpla con la búsqueda"
diff --git a/translations/es/general.yml b/translations/es/general.yml
index d62f6fbb84..54727e7717 100644
--- a/translations/es/general.yml
+++ b/translations/es/general.yml
@@ -238,6 +238,7 @@ regular-next: Siguiente
regular-previous: Anterior
regular-next-workshops: 'Agenda de Fundlabs'
regular-write-description: 'Escribe un texto descriptivo...'
+regular-subsection: 'Subsección'
regular-choice-placeholder: 'Selecciona una opción'
session-about-to-expire: 'Tu sesión expirará en 10 minutos. ¿Quieres recargar la página para renovarla?'
session-expired: 'Tu sesión en goteo ha expirado!'
diff --git a/translations/es/labels.yml b/translations/es/labels.yml
index ba67fd1097..062dd4488a 100644
--- a/translations/es/labels.yml
+++ b/translations/es/labels.yml
@@ -53,6 +53,8 @@ faq-lb-add: 'Nueva Pregunta'
faq-lb-edit: 'Editando Pregunta'
faq-lb-list: Listando
faq-lb-translate: 'Traduciendo Pregunta'
+faqsection-lb: "Secciones de las FAQs"
+faqsubsection-lb: "Subsecciones de las FAQs"
filter-lb: 'Filtros'
glossary-lb: Glosario
glossary-lb-edit: 'Editando Término'
diff --git a/translations/es/project.yml b/translations/es/project.yml
index 46cac0be01..12408136dd 100644
--- a/translations/es/project.yml
+++ b/translations/es/project.yml
@@ -23,6 +23,7 @@ project-help-license: 'Necesito ayuda para definir los retornos colectivos y las
project-hide-donors: 'Ocultar cofinanciadores'
project-hide-needs: 'Ocultar listado de necesidades'
project-invest: Aportación
+project-invest-from-subscription: Suscripción
project-invest-msg: 'Mensaje de apoyo:'
project-langs-header: 'Proyecto en'
project-media-play_video: 'Ver video'
diff --git a/translations/es/roles.yml b/translations/es/roles.yml
index 616b3612e0..b409e3b988 100644
--- a/translations/es/roles.yml
+++ b/translations/es/roles.yml
@@ -14,6 +14,7 @@ role-name-superadmin: 'Super Administrador'
role-name-owner: 'Impulsor'
role-name-root: 'Root'
role-name-matcher: 'Matcher'
+role-name-helper: 'Ayudante'
role-perm-name-create-project: 'Puede crear proyectos'
role-perm-name-edit-project: 'Puede editar sus proyectos'