From 32ddac6cfe7b2e447bba285fe1729f06de4fd73e Mon Sep 17 00:00:00 2001 From: David Date: Mon, 18 Nov 2024 12:38:42 +0100 Subject: [PATCH] Feat/new faqs (#626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Call to Exception from php global namespace (#601) * Add role "helper" with access to users and accounts (#602) * Allow accounts and users admin sub controller to be accessed via module perm * Add helper role with admin-module-users and admin-module-account perms * Fix/stripe refunds (#596) * Update stripe gateway responses data * Simplify stripe gateway sub request * Fix setting invest payment * Codefixes on stripe webhook controller * Cancel invest by transaction ref instead of payment ref * Fix participate invests from subscriptions (#595) * Generic logic to list and tell subscription payment methods * Change invest text on participate based on payment method subscription * Add translations * Add isSubscription method to payment method interface * Use invest method isSubscription in participate view * Fix typo * Avoid db call with shallow invest object on return array * Allow to filter backer invests by subscription based payment methods (#594) * Allow to filter backer invests by subscription based payment methods * Remove 'subscription_methods' filter * [feat] Antispam forms (#609) * Add FormHoneypot DB model * Add form honeypot in templates * Check for form honeypots in contact controller * Add timestamp to honeypot catches * Add datetime in model * [FEAT] Add project numeric ids to invests paid via Paypal (#608) * Project-based transaction ids in PayPal (#604) * Add transactionId same as CECA to PayPal * Call to Exception from php global namespace (#601) * Add role "helper" with access to users and accounts (#602) * Allow accounts and users admin sub controller to be accessed via module perm * Add helper role with admin-module-users and admin-module-account perms * Fix/stripe refunds (#596) * Update stripe gateway responses data * Simplify stripe gateway sub request * Fix setting invest payment * Codefixes on stripe webhook controller * Cancel invest by transaction ref instead of payment ref * Fix participate invests from subscriptions (#595) * Generic logic to list and tell subscription payment methods * Change invest text on participate based on payment method subscription * Add translations * Add isSubscription method to payment method interface * Use invest method isSubscription in participate view * Fix typo * Avoid db call with shallow invest object on return array * Allow to filter backer invests by subscription based payment methods (#594) * Allow to filter backer invests by subscription based payment methods * Remove 'subscription_methods' filter * Specify currency for PayPal in purchase step * Specify currency to purchase array data * Store generated transaction id with project num id + invest id in invest preapproval * Fix transactions id in payments to pool (#613) * fix use of project variable in Paypal payment method * WIP: refactor use of project in stripe metadata * change product description in stripe payment when there's no project * Left pad transaction ids with 0 --------- Co-authored-by: Daniel Subiabre * [FIX] Product references on Stripe for wallet payments (#614) * fix use of project variable in Paypal payment method * WIP: refactor use of project in stripe metadata * change product description in stripe payment when there's no project * Left pad transaction ids with 0 * Fix references to project * Fix rediretions --------- Co-authored-by: David Igón * Fix style of honeypot input field to be of size true 0 (#611) * Fix node project get list return type (#615) * make nodeproject get list return not only arrays * change php doc definition of getList function * Move ajax loader to expected route (#618) * [fix] $dataSets variable naming (#617) * Fix $dataSets variable naming * Revert "Fix $dataSets variable naming" This reverts commit a06b2f0ca1b631a18d377d62e47db13424019b2c. * Add data sets to channel list projects route * add entries in robots.txt for new bots (#619) * [feature] project report updates (#621) * Fix code formatting * Add incomes table to project report * Add relevant associated ids to project report * Rename project num id to tracking number * [fix] report table raw invests (#622) * Pass raw invests data to report * Show full invests calcs on project report table * add migration and models to faq sections and subsections from feature/new-faq * fix faq test * create new admin modules for new faq models * change type of subsections and sections ids * make improvements based on review * refactor faqsection getfaqs function * create new admin modules for new faq models (cherry picked from commit 2caa3268cc0d8be2f8c4deeacd9f65a3552c2354) * add missing copies * fixes on faq admin controller * remove unnecessary template * fix bug on faqsection admin controller * use of new methods from models and fixes * files from #400 related to the views of the new faqs * fix integration * add missing copies * refactor in partials of faq header and classes * change accordion to details tag * fix pagination in faq admin controller * add faq search action and views * not show faq subsections if empty * fixes in faq search * fixes on faq individuals styles and home colors * change date of new faqs migration * fixes on title in individual faq * fixes in styles for faqs * rename faq migration * update migration date * add drag and drop to faq forms * update migration date --------- Co-authored-by: Daniel Subiabre García Co-authored-by: Daniel Subiabre --- Resources/permissions.yml | 6 +- Resources/roles.yml | 8 + Resources/templates/default/about/contact.php | 1 + .../default/admin/projects/report.php | 9 +- .../default/partials/form/honeypot.php | 9 + .../legacy/project/invests_table.html.php | 127 ++++ .../templates/legacy/project/report.html.php | 305 +++++----- .../templates/responsive/admin/faq/detail.php | 51 ++ .../templates/responsive/admin/faq/edit.php | 20 + .../templates/responsive/admin/faq/layout.php | 20 + .../templates/responsive/admin/faq/list.php | 38 ++ .../responsive/admin/faq/section/edit.php | 20 + .../responsive/admin/faq/section/layout.php | 20 + .../responsive/admin/faq/section/list.php | 26 + .../responsive/admin/faq/subsection/edit.php | 20 + .../admin/faq/subsection/layout.php | 21 + .../responsive/admin/faq/subsection/list.php | 37 ++ Resources/templates/responsive/faq/index.php | 31 + .../templates/responsive/faq/individual.php | 47 ++ Resources/templates/responsive/faq/layout.php | 19 + .../responsive/faq/partials/header.php | 33 ++ Resources/templates/responsive/faq/search.php | 30 + .../templates/responsive/faq/section.php | 41 ++ .../responsive/project/participate.php | 4 +- .../20240620092323_goteo_form_honeypot.php | 56 ++ .../20241118104717_goteo_new_faq.php | 118 ++++ public/assets/css/ajax-loader.gif | Bin 0 -> 4178 bytes public/assets/img/faq/Icono/donantes@1x.svg | 6 + public/assets/img/faq/Icono/impulsores@1x.svg | 10 + public/assets/img/faq/Icono/matchers@1x.svg | 6 + .../assets/img/faq/Icono/sobre-goteo@1x.svg | 6 + .../img/faq/icones/arrow-right-primary.svg | 3 + public/assets/img/faq/icones/caret-down.svg | 6 + public/assets/img/faq/icones/caret-up.svg | 6 + public/assets/img/faq/icones/search.svg | 3 + public/assets/img/faq/pattern/donantes@1x.svg | 11 + .../assets/img/faq/pattern/impulsores@1x.svg | 11 + public/assets/img/faq/pattern/matchers@1x.svg | 11 + .../assets/img/faq/pattern/sobre_goteo@1x.svg | 11 + public/assets/js/admin/faqs.js | 41 ++ public/assets/sass/common.scss | 2 +- public/assets/sass/layouts/_faq.scss | 559 ++++++++++++++++++ public/robots.txt | 16 + src/Goteo/Application/Config.php | 10 +- .../Admin/AccountsSubController.php | 2 + .../Controller/Admin/FaqAdminController.php | 150 +++++ .../Admin/FaqSectionAdminController.php | 128 ++++ .../Admin/FaqSubsectionAdminController.php | 127 ++++ .../Admin/ProjectsSubController.php | 4 + .../Controller/Admin/UsersSubController.php | 1 + src/Goteo/Controller/Api/FaqApiController.php | 60 ++ src/Goteo/Controller/ChannelController.php | 6 +- src/Goteo/Controller/ContactController.php | 33 +- .../Dashboard/ProjectDashboardController.php | 13 + src/Goteo/Controller/FaqController.php | 100 ++++ .../StripeSubscriptionController.php | 43 +- src/Goteo/Library/Buzz.php | 2 +- .../Library/Forms/Admin/AdminFaqForm.php | 104 ++++ .../Forms/Admin/AdminFaqSectionForm.php | 93 +++ .../Forms/Admin/AdminFaqSubsectionForm.php | 91 +++ src/Goteo/Model/Faq.php | 191 +++++- src/Goteo/Model/Faq/FaqSection.php | 178 ++++++ src/Goteo/Model/Faq/FaqSubsection.php | 167 ++++++ src/Goteo/Model/FormHoneypot.php | 81 +++ src/Goteo/Model/Invest.php | 16 +- src/Goteo/Model/Node/NodeProject.php | 4 +- .../Payment/Method/AbstractPaymentMethod.php | 5 + .../Payment/Method/PaymentMethodInterface.php | 5 + .../Payment/Method/PaypalPaymentMethod.php | 34 +- .../StripeSubscriptionPaymentMethod.php | 22 +- src/Goteo/Payment/Payment.php | 27 + src/Goteo/Repository/InvestRepository.php | 9 + .../Util/ModelNormalizer/ModelNormalizer.php | 19 +- .../Transformer/FaqSectionTransformer.php | 23 + .../Transformer/FaqSubsectionTransformer.php | 32 + .../Transformer/FaqTransformer.php | 50 ++ .../Subscription/Message/DonationResponse.php | 11 +- .../Message/SubscriptionRequest.php | 212 ++++--- .../Message/SubscriptionResponse.php | 22 +- src/Routes/api_routes.php | 18 + src/Routes/faq_routes.php | 48 ++ src/routes.php | 11 +- .../Admin/FaqAdminControllerTest.php | 18 + tests/Goteo/Controller/FaqControllerTest.php | 20 + tests/Goteo/Model/Faq/FaqSectionTest.php | 106 ++++ tests/Goteo/Model/Faq/FaqSubsectionTest.php | 120 ++++ tests/Goteo/Model/FaqTest.php | 99 +++- translations/ca/admin.yml | 11 +- translations/ca/dashboard.yml | 3 +- translations/ca/faq.yml | 8 + translations/ca/general.yml | 1 + translations/ca/labels.yml | 4 +- translations/ca/project.yml | 1 + translations/ca/roles.yml | 1 + translations/en/admin.yml | 9 + translations/en/dashboard.yml | 1 + translations/en/faq.yml | 4 + translations/en/general.yml | 1 + translations/en/labels.yml | 2 + translations/en/project.yml | 1 + translations/es/admin.yml | 13 +- translations/es/dashboard.yml | 3 +- translations/es/faq.yml | 7 + translations/es/general.yml | 1 + translations/es/labels.yml | 2 + translations/es/project.yml | 1 + translations/es/roles.yml | 1 + 107 files changed, 4054 insertions(+), 361 deletions(-) create mode 100644 Resources/templates/default/partials/form/honeypot.php create mode 100644 Resources/templates/legacy/project/invests_table.html.php create mode 100644 Resources/templates/responsive/admin/faq/detail.php create mode 100644 Resources/templates/responsive/admin/faq/edit.php create mode 100644 Resources/templates/responsive/admin/faq/layout.php create mode 100644 Resources/templates/responsive/admin/faq/list.php create mode 100644 Resources/templates/responsive/admin/faq/section/edit.php create mode 100644 Resources/templates/responsive/admin/faq/section/layout.php create mode 100644 Resources/templates/responsive/admin/faq/section/list.php create mode 100644 Resources/templates/responsive/admin/faq/subsection/edit.php create mode 100644 Resources/templates/responsive/admin/faq/subsection/layout.php create mode 100644 Resources/templates/responsive/admin/faq/subsection/list.php create mode 100644 Resources/templates/responsive/faq/index.php create mode 100644 Resources/templates/responsive/faq/individual.php create mode 100644 Resources/templates/responsive/faq/layout.php create mode 100644 Resources/templates/responsive/faq/partials/header.php create mode 100644 Resources/templates/responsive/faq/search.php create mode 100644 Resources/templates/responsive/faq/section.php create mode 100755 db/migrations/20240620092323_goteo_form_honeypot.php create mode 100644 db/migrations/20241118104717_goteo_new_faq.php create mode 100644 public/assets/css/ajax-loader.gif create mode 100644 public/assets/img/faq/Icono/donantes@1x.svg create mode 100644 public/assets/img/faq/Icono/impulsores@1x.svg create mode 100644 public/assets/img/faq/Icono/matchers@1x.svg create mode 100644 public/assets/img/faq/Icono/sobre-goteo@1x.svg create mode 100644 public/assets/img/faq/icones/arrow-right-primary.svg create mode 100644 public/assets/img/faq/icones/caret-down.svg create mode 100644 public/assets/img/faq/icones/caret-up.svg create mode 100644 public/assets/img/faq/icones/search.svg create mode 100644 public/assets/img/faq/pattern/donantes@1x.svg create mode 100644 public/assets/img/faq/pattern/impulsores@1x.svg create mode 100644 public/assets/img/faq/pattern/matchers@1x.svg create mode 100644 public/assets/img/faq/pattern/sobre_goteo@1x.svg create mode 100644 public/assets/js/admin/faqs.js create mode 100644 public/assets/sass/layouts/_faq.scss create mode 100644 src/Goteo/Controller/Admin/FaqAdminController.php create mode 100644 src/Goteo/Controller/Admin/FaqSectionAdminController.php create mode 100644 src/Goteo/Controller/Admin/FaqSubsectionAdminController.php create mode 100644 src/Goteo/Controller/Api/FaqApiController.php create mode 100644 src/Goteo/Controller/FaqController.php create mode 100644 src/Goteo/Library/Forms/Admin/AdminFaqForm.php create mode 100644 src/Goteo/Library/Forms/Admin/AdminFaqSectionForm.php create mode 100644 src/Goteo/Library/Forms/Admin/AdminFaqSubsectionForm.php create mode 100644 src/Goteo/Model/Faq/FaqSection.php create mode 100644 src/Goteo/Model/Faq/FaqSubsection.php create mode 100644 src/Goteo/Model/FormHoneypot.php create mode 100644 src/Goteo/Util/ModelNormalizer/Transformer/FaqSectionTransformer.php create mode 100644 src/Goteo/Util/ModelNormalizer/Transformer/FaqSubsectionTransformer.php create mode 100644 src/Goteo/Util/ModelNormalizer/Transformer/FaqTransformer.php create mode 100644 src/Routes/faq_routes.php create mode 100644 tests/Goteo/Controller/Admin/FaqAdminControllerTest.php create mode 100644 tests/Goteo/Controller/FaqControllerTest.php create mode 100644 tests/Goteo/Model/Faq/FaqSectionTest.php create mode 100644 tests/Goteo/Model/Faq/FaqSubsectionTest.php diff --git a/Resources/permissions.yml b/Resources/permissions.yml index 4f953ef170..59f0579644 100644 --- a/Resources/permissions.yml +++ b/Resources/permissions.yml @@ -1,7 +1,7 @@ --- # Full list of Permissions in Goteo # -# Some permission require to check if the user and the model.id is present in +# Some permission require checking if the user and the model.id is present in # a table. # # Example: @@ -252,6 +252,10 @@ admin-module-communication: # Can access to module CommunicationAdminController model: null relational: null +admin-module-faqs: # Can access to module FaqAdminController + model: null + relational: null + receive-test-communications: # Can receive test communications model: null relational: null diff --git a/Resources/roles.yml b/Resources/roles.yml index ef49feeb26..a0e5ca4a7d 100644 --- a/Resources/roles.yml +++ b/Resources/roles.yml @@ -72,6 +72,13 @@ stats: - admin-module-stats # Can access to stats level: 40 +helper: + extends: user + perms: + - admin-module-users + - admin-module-account + level: 40 + manager: extends: user perms: @@ -81,6 +88,7 @@ manager: - edit-published-calls # Edit any published call - admin-module-blog # create/edit/delete non-project posts - admin-module-stories # create/edit/delete non-project posts + - admin-module-faqs # create/edit/delete faqs - full-html-edit # Affects if the user is allowed dangerous tags (iframe,...) in posts - admin-module-stats # Can access to stats level: 50 diff --git a/Resources/templates/default/about/contact.php b/Resources/templates/default/about/contact.php index 513595e89b..8786ac0d02 100644 --- a/Resources/templates/default/about/contact.php +++ b/Resources/templates/default/about/contact.php @@ -52,6 +52,7 @@

+ insert($this->honeypot->template, $this->honeypot->params) ?>
diff --git a/Resources/templates/default/admin/projects/report.php b/Resources/templates/default/admin/projects/report.php index 52e23f1984..78d9b46a9f 100644 --- a/Resources/templates/default/admin/projects/report.php +++ b/Resources/templates/default/admin/projects/report.php @@ -2,7 +2,14 @@ section('admin-project-content') ?> - $this->project, 'account' => $this->account, 'Data' => $this->data, 'admin'=>true)) ?> + $this->project, + 'account' => $this->account, + 'contract' => $this->contract, + 'invests' => $this->invests, + 'Data' => $this->data, + 'admin' => true +]) ?> replace() ?> diff --git a/Resources/templates/default/partials/form/honeypot.php b/Resources/templates/default/partials/form/honeypot.php new file mode 100644 index 0000000000..00e76954c4 --- /dev/null +++ b/Resources/templates/default/partials/form/honeypot.php @@ -0,0 +1,9 @@ + + + +
+ \ No newline at end of file diff --git a/Resources/templates/legacy/project/invests_table.html.php b/Resources/templates/legacy/project/invests_table.html.php new file mode 100644 index 0000000000..9662e94e16 --- /dev/null +++ b/Resources/templates/legacy/project/invests_table.html.php @@ -0,0 +1,127 @@ +vat) { + $account->vat = 21; +} + +$projectFee = round($account->fee / 100, 2); + +function countTotal($invests, $method) +{ + $count = 0; + + foreach ($invests as $invest) { + if ($invest->status < 1) continue; + if ($invest->method !== $method) continue; + + $total = $count + 1; + } + + return $total; +} + +function calcTotal($invests, $method) +{ + $total = 0; + + foreach ($invests as $invest) { + if ($invest->status < 1) continue; + if ($invest->method !== $method) continue; + + $total = $total + $invest->amount; + } + + return $total; +} + +$tpvTotal = calcTotal($vars['invests'], 'tpv'); +$tpvProjectFee = $tpvTotal * $projectFee; +$tpvProjectVat = $tpvProjectFee * 0.21; +$tpvGatewayFee = $tpvTotal * 0.008; +$tpvGatewayVat = 0; + +$paypalTotal = calcTotal($vars['invests'], 'paypal'); +$paypalProjectFee = $paypalTotal * $projectFee; +$paypalProjectVat = $paypalProjectFee * 0.21; +$paypalGatewayFee = ($paypalTotal * 0.034) + (countTotal($vars['invests'], 'paypal') * 0.35); +$paypalGatewayVat = 0; + +$poolTotal = calcTotal($vars['invests'], 'pool'); +$poolProjectFee = $poolTotal * $projectFee; +$poolProjectVat = $poolProjectFee * 0.21; +$poolGatewayFee = $poolTotal * 0.02; +$poolGatewayVat = $poolGatewayFee * 0.21; + +$cashTotal = calcTotal($vars['invests'], 'cash'); +$cashProjectFee = $cashTotal * $projectFee; +$cashProjectVat = $cashProjectFee * 0.21; +$cashGatewayFee = $cashTotal * 0.02; +$cashGatewayVat = $cashGatewayFee * 0.21; + +$totalTotal = $cashTotal + $poolTotal + $paypalTotal + $tpvTotal; +$totalProjectFee = $cashProjectFee + $poolProjectFee + $paypalProjectFee + $tpvProjectFee; +$totalProjectVat = $cashProjectVat + $poolProjectVat + $paypalProjectVat + $tpvProjectVat; +$totalGatewayFee = $cashGatewayFee + $poolGatewayFee + $paypalGatewayFee + $tpvGatewayFee; +$totalGatewayVat = $cashGatewayVat + $poolGatewayVat + $paypalGatewayVat + $tpvGatewayVat; + +$reportData = [ + 'TPV' => [ + 'base' => \amount_format($tpvTotal, 2), + 'project_fee' => sprintf("%s (%s%%)", \amount_format($tpvProjectFee, 2), $account->fee,), + 'project_vat' => sprintf("%s (21%%)", \amount_format($tpvProjectVat, 2),), + 'gateway_fee' => sprintf("%s (0,8%%)", \amount_format($tpvGatewayFee, 2)), + 'gateway_vat' => sprintf("%s (21%%)", \amount_format($tpvGatewayVat, 2)) + ], + 'PAYPAL' => [ + 'base' => \amount_format($paypalTotal, 2), + 'project_fee' => sprintf("%s (%s%%)", \amount_format($paypalProjectFee, 2), $account->fee), + 'project_vat' => sprintf("%s (21%%)", \amount_format($paypalProjectVat, 2)), + 'gateway_fee' => sprintf("%s (3,4%% + 0,35 * trxs)", \amount_format($paypalGatewayFee, 2)), + 'gateway_vat' => sprintf("%s (21%%)", \amount_format($paypalGatewayVat, 2)) + ], + 'MONEDERO' => [ + 'base' => \amount_format($poolTotal, 2), + 'project_fee' => sprintf("%s (%s%%)", \amount_format($poolProjectFee, 2), $account->fee), + 'project_vat' => sprintf("%s (21%%)", \amount_format($poolProjectVat, 2)), + 'gateway_fee' => sprintf("%s (2%%)", \amount_format($poolGatewayFee, 2)), + 'gateway_vat' => sprintf("%s (21%%)", \amount_format($poolGatewayVat, 2)) + ], + 'MANUAL' => [ + 'base' => \amount_format($cashTotal, 2), + 'project_fee' => sprintf("%s (%s%%)", \amount_format($cashProjectFee, 2), $account->fee), + 'project_vat' => sprintf("%s (21%%)", \amount_format($cashProjectVat, 2)), + 'gateway_fee' => sprintf("%s (2%%)", \amount_format($cashGatewayFee, 2)), + 'gateway_vat' => sprintf("%s (21%%)", \amount_format($cashGatewayVat, 2)), + ], + 'TOTAL' => [ + 'base' => \amount_format($totalTotal, 2), + 'project_fee' => sprintf("%s", \amount_format($totalProjectFee, 2)), + 'project_vat' => sprintf("%s", \amount_format($totalProjectVat, 2)), + 'gateway_fee' => sprintf("%s", \amount_format($totalGatewayFee, 2)), + 'gateway_vat' => sprintf("%s", \amount_format($totalGatewayVat, 2)) + ] +]; + +?> + + + + + + + + + + $value): ?> + + + + + + + + + +
RECAUDACIÓNCOMISIÓN DE GOTEOIVACOMISIONES COBRADAS A GOTEOIVA
\ No newline at end of file diff --git a/Resources/templates/legacy/project/report.html.php b/Resources/templates/legacy/project/report.html.php index 78a50aef92..7070f4274f 100644 --- a/Resources/templates/legacy/project/report.html.php +++ b/Resources/templates/legacy/project/report.html.php @@ -1,33 +1,33 @@ vat) -{ - $account->vat=21; +if (!$account->vat) { + $account->vat = 21; } -$matchers=$project->getMatchers('active'); +$matchers = $project->getMatchers('active'); // prepare base to apply the vat -$var_percentage_applied=$account->tax_base_percentage ? $account->tax_base_percentage/100 : '0.5'; +$var_percentage_applied = $account->tax_base_percentage ? $account->tax_base_percentage / 100 : '0.5'; -$sumData['match_goteo']=0; -$matchfunding_invest=0; +$sumData['match_goteo'] = 0; +$matchfunding_invest = 0; $matchers_amounts = []; if ($matchers) { - foreach($matchers as $matcher){ + foreach ($matchers as $matcher) { $matchers_amounts[$matcher->id] = Invest::getList([ 'projects' => $project->id, 'users' => $matcher->owner, @@ -35,13 +35,13 @@ 'status' => Invest::$ACTIVE_STATUSES ], null, 0, 0, 'money'); - $matchfunding_invest+= $matchers_amounts[$matcher->id]; - $matcher_fee=round($matcher->fee / 100, 2); + $matchfunding_invest += $matchers_amounts[$matcher->id]; + $matcher_fee = round($matcher->fee / 100, 2); $matched_fee = ($matchers_amounts[$matcher->id] * $matcher_fee); - $sumData['match_goteo']+= $matched_fee; + $sumData['match_goteo'] += $matched_fee; } //Aplicamos el IVA al 50% de la comision de Goteo - $sumData['match_goteo']+=(( $sumData['match_goteo']*$var_percentage_applied)*($account->vat/100)); + $sumData['match_goteo'] += (($sumData['match_goteo'] * $var_percentage_applied) * ($account->vat / 100)); } $called = $project->called; @@ -56,7 +56,7 @@ $admin = (isset($vars['admin']) && $vars['admin'] === true) ? true : false; //restamos a los aportes recibios de monedero lo correspondiente a matchers -$Data['pool']['total']['amount']=$Data['pool']['total']['amount']-$matchfunding_invest; +$Data['pool']['total']['amount'] = $Data['pool']['total']['amount'] - $matchfunding_invest; $total_issues = 0; foreach ($Data['issues'] as $issue) { @@ -68,7 +68,9 @@ $cName = "P-{$cNum}-{$cDate}"; ?>

Informe de financiación del proyecto
name) ?>

@@ -81,44 +83,45 @@ $sumData['fail'] = $total_issues; // $sumData['shown'] = $sumData['total'] + $sumData['fail'] + $sumData['drop'] + $sumData['pool'] + $sumData['ghost']; $sumData['shown'] = Invest::getList(['projects' => $project->id, 'status' => - [Invest::STATUS_PENDING, - Invest::STATUS_CHARGED, - Invest::STATUS_CANCELLED, - Invest::STATUS_TO_POOL, - Invest::STATUS_PAID - ]], null, 0, 0, 'money'); + [ + Invest::STATUS_PENDING, + Invest::STATUS_CHARGED, + Invest::STATUS_CANCELLED, + Invest::STATUS_TO_POOL, + Invest::STATUS_PAID + ]], null, 0, 0, 'money'); $sumData['cancelled'] = Invest::getList(['projects' => $project->id, 'status' => [Invest::STATUS_CANCELLED, Invest::STATUS_TO_POOL]], null, 0, 0, 'money'); $sumData['tpv_fee_goteo'] = $Data['tpv']['total']['amount'] * 0.008; $sumData['cash_goteo'] = $Data['cash']['total']['amount'] * $GOTEO_FEE; //Aplicamos el IVA al 50% de la comision de Goteo - $sumData['cash_goteo']=(($sumData['cash_goteo']*$var_percentage_applied)*($account->vat/100))+$sumData['cash_goteo']; + $sumData['cash_goteo'] = (($sumData['cash_goteo'] * $var_percentage_applied) * ($account->vat / 100)) + $sumData['cash_goteo']; $sumData['tpv_goteo'] = $Data['tpv']['total']['amount'] * $GOTEO_FEE; //Aplicamos el IVA al 50% de la comision de Goteo - $sumData['tpv_goteo']=(($sumData['tpv_goteo']*$var_percentage_applied)*($account->vat/100))+$sumData['tpv_goteo']; + $sumData['tpv_goteo'] = (($sumData['tpv_goteo'] * $var_percentage_applied) * ($account->vat / 100)) + $sumData['tpv_goteo']; $sumData['pp_goteo'] = $Data['paypal']['total']['amount'] * $GOTEO_FEE; //Aplicamos el IVA al 50% de la comision de Goteo - $sumData['pp_goteo']=(($sumData['pp_goteo']*$var_percentage_applied)*($account->vat/100))+$sumData['pp_goteo']; + $sumData['pp_goteo'] = (($sumData['pp_goteo'] * $var_percentage_applied) * ($account->vat / 100)) + $sumData['pp_goteo']; $sumData['drop_goteo'] = $Data['drop']['total']['amount'] * $CALL_FEE; //Aplicamos el IVA al 50% de la comision de Goteo - $sumData['drop_goteo']=(($sumData['drop_goteo']*$var_percentage_applied)*($account->vat/100))+$sumData['drop_goteo']; + $sumData['drop_goteo'] = (($sumData['drop_goteo'] * $var_percentage_applied) * ($account->vat / 100)) + $sumData['drop_goteo']; $sumData['pool_goteo'] = $Data['pool']['total']['amount'] * $GOTEO_FEE; //Aplicamos el IVA al 50% de la comision de Goteo - $sumData['pool_goteo']=(($sumData['pool_goteo']*$var_percentage_applied)*($account->vat/100))+$sumData['pool_goteo']; + $sumData['pool_goteo'] = (($sumData['pool_goteo'] * $var_percentage_applied) * ($account->vat / 100)) + $sumData['pool_goteo']; $sumData['ghost_goteo'] = $Data['ghost']['total']['amount'] * $GOTEO_FEE; //Aplicamos el IVA - $sumData['ghost_goteo']=(($sumData['ghost_goteo']*$var_percentage_applied)*($account->vat/100))+$sumData['ghost_goteo']; + $sumData['ghost_goteo'] = (($sumData['ghost_goteo'] * $var_percentage_applied) * ($account->vat / 100)) + $sumData['ghost_goteo']; $sumData['pp_project'] = $Data['paypal']['total']['amount'] - $sumData['pp_goteo']; $sumData['pp_fee_goteo'] = ($Data['paypal']['total']['invests'] * 0.35) + ($Data['paypal']['total']['amount'] * 0.034); @@ -126,36 +129,36 @@ $sumData['pp_fee_project'] = ($Data['paypal']['total']['invests'] * 0.35) + ($sumData['pp_project'] * 0.034); $sumData['pp_net_project'] = $sumData['pp_project'] - $sumData['pp_fee_project']; $sumData['fee_goteo'] = $sumData['tpv_fee_goteo'] + $sumData['pp_fee_goteo']; - $sumData['goteo'] = $sumData['cash_goteo'] + $sumData['tpv_goteo'] + $sumData['pp_goteo'] + $sumData['drop_goteo'] + $sumData['pool_goteo'] + $sumData['ghost_goteo'] +$sumData['match_goteo']; // si que se descuenta la comisión sobre capital riego + $sumData['goteo'] = $sumData['cash_goteo'] + $sumData['tpv_goteo'] + $sumData['pp_goteo'] + $sumData['drop_goteo'] + $sumData['pool_goteo'] + $sumData['ghost_goteo'] + $sumData['match_goteo']; // si que se descuenta la comisión sobre capital riego // round to 2 decimal - $sumData['fee_goteo']=round($sumData['fee_goteo'],2); - $sumData['goteo']=round($sumData['goteo'],2); + $sumData['fee_goteo'] = round($sumData['fee_goteo'], 2); + $sumData['goteo'] = round($sumData['goteo'], 2); $sumData['total_fee_project'] = $sumData['fee_goteo'] + $sumData['goteo']; // este es el importe de la factura $sumData['project'] = $sumData['total'] - $sumData['fee_goteo'] - $sumData['goteo']; ?> -

- one_round) ? ' (y única)' : ''; - if (!empty($project->passed)) { - echo 'El proyecto terminó la primera'.$unique.' ronda el día '.date('d/m/Y', strtotime($project->passed)).'.
'; - } else { - echo 'El proyecto terminará la primera'.$unique.' ronda el día '.date('d/m/Y', strtotime($project->willpass)).'.
'; - } ?> - - one_round && !empty($project->success)) { - echo 'El proyecto terminó la segunda ronda el día '.date('d/m/Y', strtotime($project->success)).'.'; - } elseif (empty($project->success)) { - echo 'El proyecto terminará la segunda ronda el día '.date('d/m/Y', strtotime($project->willfinish)).'.
'; - } ?> -
-
- Envío correo electrónico user->email; ?> -

-
+

+ one_round) ? ' (y única)' : ''; + if (!empty($project->passed)) { + echo 'El proyecto terminó la primera' . $unique . ' ronda el día ' . date('d/m/Y', strtotime($project->passed)) . '.
'; + } else { + echo 'El proyecto terminará la primera' . $unique . ' ronda el día ' . date('d/m/Y', strtotime($project->willpass)) . '.
'; + } ?> + + one_round && !empty($project->success)) { + echo 'El proyecto terminó la segunda ronda el día ' . date('d/m/Y', strtotime($project->success)) . '.'; + } elseif (empty($project->success)) { + echo 'El proyecto terminará la segunda ronda el día ' . date('d/m/Y', strtotime($project->willfinish)) . '.
'; + } ?> +
+
+ Envío correo electrónico user->email; ?> +

+
@@ -163,45 +166,46 @@ +
     (Puede que no se haya llegado nunca a esta cifra si han devuelto aportes antes del cierre de campaña) + - - - + + + - - - + + + - - + + - - - + + +
-    Máximo mostrado en el termómetro de Goteo.org al cerrar la campaña (success) ? date('d/m/Y', strtotime($project->success)) : 'fecha'; ?>): -
     (Puede que no se haya llegado nunca a esta cifra si han devuelto aportes antes del cierre de campaña)
-    Aportes cancelados manualmente desde el admin o devueltos al monedero del usuario: -
-    Incidencias (Usuarios/as que no tienen fondos en su cuenta, tarjetas desactualizadas, cancelaciones, reembolsos...) : (* ver listado más abajo)
-    Incidencias (Usuarios/as que no tienen fondos en su cuenta, tarjetas desactualizadas, cancelaciones, reembolsos...) : (* ver listado más abajo)
-    Total recaudado: (importe de las ayudas monetarias recibidas)
-    Total Capital Riego: (Transferencia del convocador 'called->user->name ?>' directamente al impulsor)
-    Total Capital Riego: (Transferencia del convocador 'called->user->name ?>' directamente al impulsor)
-    Riego obtenido del Canal name ?>: id]) ?> (A transferir de forma directa)
- Comisión matcher: fee.'%' ?> + Comisión matcher: fee . '%' ?>
-    Otro recibido: (Aporte manual sin ingreso bancario)
-    Otro recibido: (Aporte manual sin ingreso bancario)
-
+
@@ -211,34 +215,37 @@ - - - - + - +
-    Comisiones cobradas a Goteo por cada transferencia de tarjeta (0,8%) y PayPal (3,4% + 0,35 por transacción/usuario/a): total
-    Comisión del fee; ?>% de Goteo.org vat ? '(Incluye un '.$account->vat.'% de IVA para el '. $account->tax_base_percentage .'% de la cantidad)' : '' ?>: - called): ?> -
     (fee_projects_drop .'%)' ?> +
-    Comisión del fee; ?>% de Goteo.org vat ? '(Incluye un ' . $account->vat . '% de IVA para el ' . $account->tax_base_percentage . '% de la cantidad)' : '' ?>: + called): ?> +
     (fee_projects_drop . '%)' ?> - -
     () + +
     ()
Por el total de estas comisiones la Fundación Goteo ha emitido la factura [Número de factura] por importe de , a nombre de la persona o entidad que firma el contratoPor el total de estas comisiones la Fundación Goteo ha emitido la factura [Número de factura] por importe de , a nombre de la persona o entidad que firma el contrato
-
+
- + @@ -247,7 +254,7 @@
3) Transferencias de la Fundación Goteo (Goteo.org) a los/as impulsores/as3) Transferencias de la Fundación Goteo (Goteo.org) a los/as impulsores/as
-    Envío a través de cuenta bancaria: ()En estas cantidades ya se ha descontado el importe de la factura [Número de factura] por importe de
-
+
@@ -255,74 +262,74 @@
- - - - - - - - - - - - - - -
Desglose informativo de lo pagado mediante PayPal
- Cantidad transferida:
- Comisión aproximada cobrada al impulor:
- Cantidad aproximada recibida por el impulsor:
-
- - - -
- - - - - - - -
* Listado de usuarios/as con incidencias en su cuenta PayPal.
Estos son los aportes con problemas en payPal que no se han conseguido cobrar y se han cancelado.
- -
- - status == 1) ? ' style="color: red !important;"' : ''; + +
+
+ + + + + + + + + +
Números de seguimiento relevantes
- Número de contrato: Pnumber ?>
- Número de seguimiento: getNumericId() ?>
+
+ +
+ + + +
+ + + + + + + +
* Listado de usuarios/as con incidencias en su cuenta PayPal.
Estos son los aportes con problemas en payPal que no se han conseguido cobrar y se han cancelado.
+ +
+ + status == 1) ? ' style="color: red !important;"' : ''; ?> - - - - - >Usuario/a userName; ?>, statusName; ?>, amount . ' ss.'; if (!empty($warst)) echo ' (Aporte: '.$issue->invest.')';?> - - - -
invest.'" target="_blank"'.$warst.'>[Ir al aporte] Usuario ' . $issue->userName . ' ['.$issue->userEmail.'], ' . $issue->statusName . ', ' . $issue->amount . ' euros.'; if (!empty($warst)) echo ' (Aporte: '.$issue->invest.')'; ?>
- -
- - - - -
TOTAL (no cobrado):
- - - - - - - - - - - - - - - -
Notas para el admin al generar los datos del informe.
La mayoría harán referencia a las incidencias (o a aportes que no están en el estado que deberían en este punto de la campaña)
-
- - - -
+ + + invest . '" target="_blank"' . $warst . '>[Ir al aporte] Usuario ' . $issue->userName . ' [' . $issue->userEmail . '], ' . $issue->statusName . ', ' . $issue->amount . ' euros.'; + if (!empty($warst)) echo ' (Aporte: ' . $issue->invest . ')'; ?> + + >Usuario/a userName; ?>, statusName; ?>, amount . ' ss.'; + if (!empty($warst)) echo ' (Aporte: ' . $issue->invest . ')'; ?> + + + + + +
+ + + + +
TOTAL (no cobrado):
+ + + + + + + + + + + + + + + +
Notas para el admin al generar los datos del informe.
La mayoría harán referencia a las incidencias (o a aportes que no están en el estado que deberían en este punto de la campaña)
+
+ + \ No newline at end of file diff --git a/Resources/templates/responsive/admin/faq/detail.php b/Resources/templates/responsive/admin/faq/detail.php new file mode 100644 index 0000000000..ea1926e1fb --- /dev/null +++ b/Resources/templates/responsive/admin/faq/detail.php @@ -0,0 +1,51 @@ +layout('admin/communication/layout'); + +$this->section('admin-container-head'); + +?> + +append() ?> + + +section('admin-container-body') ?> + + +
+
+ +
text('admin-communications-id') ?>
+

communication->id ?>

+ +
text('admin-mailing-subject') ?>
+

communication->subject ?>

+ +
text('regular-date') ?>
+

communication->date ?>

+ + insert('admin/partials/material_table', ['list' => $this->model_list_entries($this->mails, ['id', 'subject', 'lang', 'receivers', 'sent', 'failed', 'pending', 'success', 'status', 'percent'])]) ?> + + +
+

+ communication->isSent() && $this->communication->isActive()): ?> + text('admin-communications-cancel') ?> + communication->isActive() && !$this->communication->isSent()): ?> + text('admin-communications-send') ?> + + text('regular-preview') ?> + communication->isActive()) : ?> + text('regular-edit') ?> + +

+ +
+ +
+
+ + + + +replace() ?> \ No newline at end of file diff --git a/Resources/templates/responsive/admin/faq/edit.php b/Resources/templates/responsive/admin/faq/edit.php new file mode 100644 index 0000000000..8362f02a10 --- /dev/null +++ b/Resources/templates/responsive/admin/faq/edit.php @@ -0,0 +1,20 @@ +layout('admin/faq/layout'); + +$this->section('admin-container-head'); + +?> + +append() ?> + + +section('admin-container-body') ?> + + form_form($this->raw('form')) ?> + +replace() ?> + +section('footer') ?> + +append() ?> diff --git a/Resources/templates/responsive/admin/faq/layout.php b/Resources/templates/responsive/admin/faq/layout.php new file mode 100644 index 0000000000..d5e3fd45d4 --- /dev/null +++ b/Resources/templates/responsive/admin/faq/layout.php @@ -0,0 +1,20 @@ +layout('admin/container'); + +$this->section('admin-container-head'); + +?> +

text('admin-faqs') ?>

+ +insert('admin/partials/search_box') ?> + +supply('admin-faq-head') ?> + +replace() ?> + +section('footer') ?> + + + +append() ?> diff --git a/Resources/templates/responsive/admin/faq/list.php b/Resources/templates/responsive/admin/faq/list.php new file mode 100644 index 0000000000..d56f8491e5 --- /dev/null +++ b/Resources/templates/responsive/admin/faq/list.php @@ -0,0 +1,38 @@ +layout('admin/faq/layout'); + +$this->section('admin-search-box-addons'); + +if ($this->current_subsection) + $keys = ['id', 'title', 'order', 'subsection', 'actions']; +else + $keys = ['id', 'title', 'subsection', 'actions']; +?> + + +
+ +
+ + text('admin-faq-add') ?> + +replace() ?> + +section('admin-container-body') ?> + +
text('admin-list-total', $this->total) ?>
+ insert('admin/partials/material_table', ['list' => $this->model_list_entries($this->list, $keys)]) ?> + +replace() ?> diff --git a/Resources/templates/responsive/admin/faq/section/edit.php b/Resources/templates/responsive/admin/faq/section/edit.php new file mode 100644 index 0000000000..85d845594e --- /dev/null +++ b/Resources/templates/responsive/admin/faq/section/edit.php @@ -0,0 +1,20 @@ +layout('admin/faq/section/layout'); + +$this->section('admin-container-head'); + +?> + +append() ?> + + +section('admin-container-body') ?> + + form_form($this->raw('form')) ?> + +replace() ?> + +section('footer') ?> + +append() ?> diff --git a/Resources/templates/responsive/admin/faq/section/layout.php b/Resources/templates/responsive/admin/faq/section/layout.php new file mode 100644 index 0000000000..d5e3fd45d4 --- /dev/null +++ b/Resources/templates/responsive/admin/faq/section/layout.php @@ -0,0 +1,20 @@ +layout('admin/container'); + +$this->section('admin-container-head'); + +?> +

text('admin-faqs') ?>

+ +insert('admin/partials/search_box') ?> + +supply('admin-faq-head') ?> + +replace() ?> + +section('footer') ?> + + + +append() ?> diff --git a/Resources/templates/responsive/admin/faq/section/list.php b/Resources/templates/responsive/admin/faq/section/list.php new file mode 100644 index 0000000000..122760fad2 --- /dev/null +++ b/Resources/templates/responsive/admin/faq/section/list.php @@ -0,0 +1,26 @@ +layout('admin/faq/section/layout'); + +$this->section('admin-search-box-addons'); + +$keys = [ + 'id', + 'name', + 'slug', + 'actions' +] +?> + +

t('admin-faq-sections') ?>

+ + text('admin-faq-sections-add') ?> + +replace() ?> + +section('admin-container-body') ?> + +
text('admin-list-total', $this->total) ?>
+ insert('admin/partials/material_table', ['list' => $this->model_list_entries($this->list, $keys)]) ?> + +replace() ?> diff --git a/Resources/templates/responsive/admin/faq/subsection/edit.php b/Resources/templates/responsive/admin/faq/subsection/edit.php new file mode 100644 index 0000000000..67a76a04c5 --- /dev/null +++ b/Resources/templates/responsive/admin/faq/subsection/edit.php @@ -0,0 +1,20 @@ +layout('admin/faq/subsection/layout'); + +$this->section('admin-container-head'); + +?> + +append() ?> + + +section('admin-container-body') ?> + + form_form($this->raw('form')) ?> + +replace() ?> + +section('footer') ?> + +append() ?> diff --git a/Resources/templates/responsive/admin/faq/subsection/layout.php b/Resources/templates/responsive/admin/faq/subsection/layout.php new file mode 100644 index 0000000000..99dadf6a6d --- /dev/null +++ b/Resources/templates/responsive/admin/faq/subsection/layout.php @@ -0,0 +1,21 @@ +layout('admin/container'); + +$this->section('admin-container-head'); + +?> + +

text('admin-faq-subsections') ?>

+ +insert('admin/partials/search_box') ?> + +supply('admin-faq-head') ?> + +replace() ?> + +section('footer') ?> + + + +append() ?> diff --git a/Resources/templates/responsive/admin/faq/subsection/list.php b/Resources/templates/responsive/admin/faq/subsection/list.php new file mode 100644 index 0000000000..6c82e83209 --- /dev/null +++ b/Resources/templates/responsive/admin/faq/subsection/list.php @@ -0,0 +1,37 @@ +layout('admin/faq/subsection/layout'); + +$this->section('admin-search-box-addons'); + +$keys = [ + 'id', + 'name', + 'subsection', + 'order', + 'actions' +]; +?> + + +
+ +
+ + text('admin-faq-subsections-add') ?> + +replace() ?> + +section('admin-container-body') ?> + +
text('admin-list-total', $this->total) ?>
+ insert('admin/partials/material_table', ['list' => $this->model_list_entries($this->list, $keys)]) ?> + +replace() ?> diff --git a/Resources/templates/responsive/faq/index.php b/Resources/templates/responsive/faq/index.php new file mode 100644 index 0000000000..a5ae083ca1 --- /dev/null +++ b/Resources/templates/responsive/faq/index.php @@ -0,0 +1,31 @@ +layout('faq/layout') ?> + +section('faq-content') ?> + + + insert('faq/partials/header', ['header' => 'home_faqs']) ?> + +
+
+ faq_sections as $section): ?> + getFaqs()): ?> + + + + +replace() ?> diff --git a/Resources/templates/responsive/faq/individual.php b/Resources/templates/responsive/faq/individual.php new file mode 100644 index 0000000000..9668c32de8 --- /dev/null +++ b/Resources/templates/responsive/faq/individual.php @@ -0,0 +1,47 @@ +layout('faq/layout') ?> + +section('faq-content') ?> + + + + insert('faq/partials/header', ['header' => $this->faq_section->slug]) ?> + +
+
+ + +
+
+ +replace() ?> diff --git a/Resources/templates/responsive/faq/layout.php b/Resources/templates/responsive/faq/layout.php new file mode 100644 index 0000000000..6aaa3eb81a --- /dev/null +++ b/Resources/templates/responsive/faq/layout.php @@ -0,0 +1,19 @@ +layout('layout', [ + 'bodyClass' => 'faq', + 'title' => $this->meta_title, + 'meta_description' => $this->meta_description + ]); + +$this->section('content'); + +?> + +
+ + supply('faq-content') ?> + +
+ +replace() ?> diff --git a/Resources/templates/responsive/faq/partials/header.php b/Resources/templates/responsive/faq/partials/header.php new file mode 100644 index 0000000000..137e2de110 --- /dev/null +++ b/Resources/templates/responsive/faq/partials/header.php @@ -0,0 +1,33 @@ +
+
+
+ + +
+ faq): ?> +
+

faq->title ?>

+
+ faq_section): ?> +
+

t('faq-title') ?>

+

faq_section->name ?>

+
+ view == 'search'): ?> +
+

t('faq-search') ?>

+

text('faq-searched-word', $this->get_query('search')) ?>

+
+ +

t('faq-title') ?>

+ +
+
diff --git a/Resources/templates/responsive/faq/search.php b/Resources/templates/responsive/faq/search.php new file mode 100644 index 0000000000..895cedac0d --- /dev/null +++ b/Resources/templates/responsive/faq/search.php @@ -0,0 +1,30 @@ +layout('faq/layout') ?> + +section('faq-content') ?> + + insert('faq/partials/header', ['view' => 'search', 'search' => $this->search, 'header' => 'search']) ?> + + + +replace() ?> diff --git a/Resources/templates/responsive/faq/section.php b/Resources/templates/responsive/faq/section.php new file mode 100644 index 0000000000..e23a357d78 --- /dev/null +++ b/Resources/templates/responsive/faq/section.php @@ -0,0 +1,41 @@ +layout('faq/layout', ['title' => $this->faq_section->name]) ?> + +section('faq-content') ?> + + + + + insert('faq/partials/header', ['header' => $this->faq_section->slug]) ?> + +
+
+ + subsections as $subsection): ?> + + getFaqs()): ?> +
+
+

name ?>

+
    + + + slug ?? $faq->id; ?> + +
  • title ?>
  • + + +
+
+
+ + + + +
+ + +replace() ?> diff --git a/Resources/templates/responsive/project/participate.php b/Resources/templates/responsive/project/participate.php index c40df55fea..5a3c683cec 100644 --- a/Resources/templates/responsive/project/participate.php +++ b/Resources/templates/responsive/project/participate.php @@ -53,7 +53,7 @@
- text('project-invest') ?> + invest->getMethod()->isSubscription() ? $this->text('project-invest-from-subscription') : $this->text('project-invest') ?>
amount) ?> @@ -93,7 +93,7 @@
- text('project-invest') ?> + invest->getMethod()->isSubscription() ? $this->text('project-invest-from-subscription') : $this->text('project-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 0000000000000000000000000000000000000000..e0e6e9760bc04861cc4771e327f22ed7962e0858 GIT binary patch literal 4178 zcmd7VX;c#jy9e;etjSCgCNLmNf(amEL|#O+KwAx2Si>T+wW0=%qLdv}L}cG_0mZ6t zsSyDqzOuN1ueAmP3doKiE>%QC+(FxFTYbIpz4m_Tx%X2)bUx0UGc)IR{?GrJarbm{ zat`MMeBfsQ`0(Ka006)J_FG$9+tk$5^z?Lpe}7t9T6uZ-FTeaUIXU_6-MhVe_vYp0 zjgODFw6v&Hs%Ouh)z{bGxpQaf(xt({!3u>UH8oYOR=;@h!pqAmDk_S}Wa{qO+uPgG z(J?wYYHMq|di82^b91>|-q_fvyYv?xf`6Mz64r+&tyl85Zc5t7504B_j*1Oe+HH#2 z5DN%?g#ldmG{FbLR~EQJ;_5GRu(O9~x>L3vU*aPIfPN5V#Ccd5IdR?NJFR^6+gy(= zvcb#cjCTFX;Cuw3yi@&c_8cj5p=>B5p-DWj^TrxcsNf%_y-abkIA-k**{lc{$Od9L z2`DOqBg}TL1{kp+QpP#}#xSOrgp4piAP6C1d$ZA zKAh@4u05q$bs_#zTjo%;g6}MOx?x_1)m-hD`P!l#`y|g+qnj(t7yRyFXDlcrbMIU* zdiCQmq+utE(dpOWZL#nH^{-Rd#9}+^?UBy|kMp%+aqJc5`q621+mipv`vPgEM8o1` zO)U%Yv-6A_+%K$UdgmCm@IR^2{!D1?Xe!nb>cdhfcdZS(yt|La(GgblqAMM_>@^u> zF5Dy+i-gknjiTYZ;cD%?jzV^Xp7@(JWGt30Gmc2h1rRRJG6D9IA`xVA6c!ue#*i=| zXm(n31k6BD20NCLf*c$t#DsYbgl+|m+1{w&GC&~baJ2;f9%8qocb?;Hl@SKt^M|^s zlpqRqhZ5HY)9TL)TMWoD)N zz;Az-oVazE*~MqO*8Vd?9Ce*iW=u8SI$P=uD@%e0MwZ38MJ)&|;kU|HPIw9F?Y-a4 zUQ(zhxM}h09>(G@(aX^;O&q;H*3*m~jjKj{1{`Hn z%YEVGCra?ol(^}xkCfI%(yRB!Y)s4L?HU6eB@2gES~1ZaT^b$zZCD92iFx42nvC2k z&yGSQ!a$cty;w3`#*YBE>OyEr876?c-6BGANnIY9>%;_(a}MT2FhDjNgk#O5b1Zqh z!+=Q*j%*?LUNQrtO+d^!9wh@W;A!cIMTcdfoN=L5X?2c^-INmut`0?c7TXfGq_@b3 z1Jehdrq4`Q_gt7zcE5e!)A!T6dC4JunlBvSr#$YdMo+Evjh_~VqhxUgy~g6*K#>s`{S$-(Yf}dSkd_j06DIl^n3-)`lLvmcxY4Dka3vD_Pex;OI^N%nM z1BBTYmNnlk*mZs8IJxu7Tse|4{A8qI`C*4d7v+2)-n>2dY@K>?=#N-Jf3~zkA=mg$ zM`(g3TIm{n~;6%o)MJJfr806p_=7ABM#y0}8`N?R?I=rve=YI6DwI0sCQmG>?mMFb+*U&yY^GNXVeG21h7E~`Ikkn`xo6{D(rB6;2 z-7FebFwlErD182WmmH-YKcP$~j$hb1_4Kjn=&jqC0DOO}*85Hn@bJRg`i=}XR=a>R z?dF>Exxgi9Ebi%=Mee|UCl$X*Qb3a}asFbT>!A#$FS>K8C9~smiwYxKj}lR>r3L2X zk@MB*P9I0-S+fkCG^!q%cqPE?+#B0x=L{?{R1Vtv1^_{?U7sffF~66Fo^di5j1o4S z@VU+GngxG?ME+mMcW=+3b6-E@6?fiqeseID*u#kg zH|PX6rG>0_!zFn*C#Psfz@AMKli3io4T#V81(2Lv6JG{e0iogUn-d9s-E3H1gdb3x zs$d`SCRSB@Ga&wD`45Up8Ij$a-5CV3uMe|V)!)48&BpHD!&o9F3E$5Gx>8+$fZD-jK1cktX2M7y929Ko<4i z`h2Y`LlEP+6!1Y;sI}0#g6ncxChejb2t53=PxgJg805O-#66nyFkc3+t8+vYps6a( z**T?gH8-wyJPI0@ygF)b^OZ`!s{e>|DEMtJ`~Cwv`X@>Bua=ZCwgI0gOE$$sc}V`( zkyw?lQ%pHlS|usM4=PUXme&?X<{^jwm9nQf`*QY0MJ>|NsjRDOkR#B*;6QhGuXq2@ zAfdh79t3ud-?-Oz2?)6%Wn<8jb>*3nbPQvm%_qN4M97~pI@dm6PT|me$cRpl*NokR zEb5|`uidJl(QwL?H0f8Fm%3fFqZ#)f(EZiGOI-Ifc6PVeAwRc_@-Z;Q@qF*=oBZ=7G$1h9U zR@ZqxQQ6h2BkbuSuC`qo9%+}{9@M!F$PkGAqo2;r9C{Ax*t*f@kojqG(_S$mfV|kG zLO6ZoF05mVp6YJ}XmpZJImM}94)$|_=bHvW=KL05@opQBU8 zSVakqsYlQB)YkwGMPH`xn$pk=`UFh2BY6x4C3MMdJYF=TZP4e5$xW3 z%0yW&e}ZYBVo3knGqOi7As83xKA|9Wd)+dz@|sN7kUR=aY;iZKGJ9n?N6avKVmNOs zvk35c2vk3aQy4)wWlb5|^C=lAUCRk?JaU@^$y0db%}lm{@t<%fRdnwM2d}`>6-IoCyRQ+oPE+bE~gx{CdvBcPM?gIoC-f z%78G?j#DU;g4szDJgO{M5n8^Y%Jg_<<4n!9WuYaE_{LI!dVU2!T?DmbB1pIZ>mJPM z*0?2$_x4_XO|;SAunf0{#}?I%)Hmm`R_XsS%=lmAN0PGtSt}pQ5Y?pxlIk`~9{#Zp zb@Nurvtmn-4HCk{SJ#O$l3RsUMAqXRb*)*IRbumQIh*2@>6+0u5lsQQWH357gu*=$ z;LTfrDRuWArPrf$e~9b$%6Q7eBtCF`a3qDe^-Et^&)XmnV%0>d;B{*=S~DT$WE?L@w=g+x-fK=9^U~FC^PfFjtSoNI5484Rrdie*9EjS%Z+fz46M%R$jA7=FjprxCwjWT2O=jsA#5^-w1BXpsV<^I@C+h$q)W{)CS zN-5djgaPiH7(G21TS?__0vH7nMkZjO3kxd6lqzrq;U2w%m+1_S5@oBFz`>W}o>=e2PDmwF2+%2^2|Na~3O|4!?c8*kNDAYR`98T{oXI wRm;kR;ccgj<_0bfst{IIqdo5VxUb7Dui~hoCd)pD@Zkk?;Pa1v(EmC98@j*+jsO4v literal 0 HcmV?d00001 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'