diff --git a/.github/workflows/ci-auto-lint.yml b/.github/workflows/ci-auto-lint.yml index a54a0904..f99f9f5d 100644 --- a/.github/workflows/ci-auto-lint.yml +++ b/.github/workflows/ci-auto-lint.yml @@ -24,4 +24,4 @@ jobs: ruby-version: "4.0.1" bundler-cache: true - name: Run RuboCop - run: bundle exec rubocop --format github --force-exclusion + run: bin/rubocop --format github --force-exclusion diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml new file mode 100644 index 00000000..06aa8060 --- /dev/null +++ b/.github/workflows/ci-dev.yml @@ -0,0 +1,81 @@ +# CI unique pour la branche dev : lint + sécurité + tests en parallèle (un seul run GitHub Actions). +# Permissions minimales (contents: read) — pas d’écriture dépôt ; artefact tests seulement. +name: ci-dev + +on: + push: + branches: [dev] + pull_request: + branches: [dev] + +permissions: + contents: read + +concurrency: + group: ci-dev-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "4.0.1" + bundler-cache: true + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: RuboCop + run: bin/rubocop --format github --force-exclusion + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "4.0.1" + bundler-cache: true + - name: Brakeman + run: bin/brakeman --no-pager + - name: Bundler Audit + run: bundle exec bundle-audit check --update + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "4.0.1" + bundler-cache: true + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Build Tailwind CSS + run: bundle exec rails tailwindcss:build + - name: Prepare test databases + run: RAILS_ENV=test bin/rails db:prepare + - name: Run full test suite with coverage + env: + CI: true + run: | + mkdir -p tmp + bin/rspec --format progress --format json --out tmp/rspec.json + - name: Verify coverage threshold + run: | + COVERAGE=$(ruby -rjson -e 'f="coverage/.last_run.json"; puts(File.exist?(f) ? JSON.parse(File.read(f)).dig("result","line").to_f : 0)') + echo "Coverage: ${COVERAGE}%" + awk "BEGIN {exit !(${COVERAGE} >= 58)}" + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results + path: | + tmp/rspec.json + coverage + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/ci-docker-build.yml b/.github/workflows/ci-docker-build.yml index ebe5c32d..e710ea5e 100644 --- a/.github/workflows/ci-docker-build.yml +++ b/.github/workflows/ci-docker-build.yml @@ -16,12 +16,10 @@ concurrency: jobs: docker-build-check: runs-on: ubuntu-latest - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - uses: actions/checkout@v6 - - uses: docker/setup-buildx-action@v3 - - uses: docker/build-push-action@v6 + - uses: docker/setup-buildx-action@v4 + - uses: docker/build-push-action@v7 with: context: . push: false diff --git a/.github/workflows/ci-lint-audit.yml b/.github/workflows/ci-lint-audit.yml deleted file mode 100644 index 09a0817e..00000000 --- a/.github/workflows/ci-lint-audit.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: ci-lint-audit - -on: - push: - branches: [dev] - pull_request: - branches: [dev] - -permissions: - contents: read - -concurrency: - group: ci-lint-audit-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: "4.0.1" - bundler-cache: true - - uses: actions/setup-node@v6 - with: - node-version: "22" - - name: Run RuboCop - run: bundle exec rubocop --format github --force-exclusion - - security: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: "4.0.1" - bundler-cache: true - - name: Run Brakeman - run: bundle exec brakeman --no-pager - - name: Run Bundler Audit - run: bundle exec bundle-audit check --update diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml deleted file mode 100644 index 1fc86ade..00000000 --- a/.github/workflows/ci-tests.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: ci-tests - -on: - push: - branches: [dev] - pull_request: - branches: [dev] - -permissions: - contents: read - -concurrency: - group: ci-tests-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: "4.0.1" - bundler-cache: true - - uses: actions/setup-node@v6 - with: - node-version: "22" - - name: Build Tailwind CSS - run: bundle exec rails tailwindcss:build - - name: Run full test suite with coverage - run: bundle exec rspec --format json --out tmp/rspec.json - - name: Verify coverage threshold - run: | - COVERAGE=$(ruby -rjson -e 'f="coverage/.last_run.json"; puts(File.exist?(f) ? JSON.parse(File.read(f)).dig("result","line").to_f : 0)') - echo "Coverage: ${COVERAGE}%" - awk "BEGIN {exit !(${COVERAGE} >= 52)}" - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-results - path: | - tmp/rspec.json - coverage - if-no-files-found: warn - retention-days: 7 diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 3ae0b278..0fe58b35 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -15,17 +15,15 @@ concurrency: jobs: build-push: runs-on: ubuntu-latest - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - uses: actions/checkout@v6 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 + - uses: docker/setup-buildx-action@v4 + - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@v6 + - uses: docker/build-push-action@v7 with: context: . push: true @@ -45,7 +43,7 @@ jobs: with: ruby-version: "4.0.1" bundler-cache: true - - uses: webfactory/ssh-agent@v0.9.0 + - uses: webfactory/ssh-agent@v0.10.0 with: ssh-private-key: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }} - name: Deploy production with Kamal @@ -63,7 +61,7 @@ jobs: with: ruby-version: "4.0.1" bundler-cache: true - - uses: webfactory/ssh-agent@v0.9.0 + - uses: webfactory/ssh-agent@v0.10.0 with: ssh-private-key: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }} - name: Check production health diff --git a/.github/workflows/deploy-promote-staging.yml b/.github/workflows/deploy-promote-staging.yml index 9a184cbc..ce33d920 100644 --- a/.github/workflows/deploy-promote-staging.yml +++ b/.github/workflows/deploy-promote-staging.yml @@ -28,7 +28,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - for required in "ci-lint-audit.yml" "ci-tests.yml" "ci-docker-build.yml"; do + for required in "ci-dev.yml" "ci-docker-build.yml"; do status=$(gh run list \ --workflow="$required" \ --branch=dev \ diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 07010c6b..2e5454a8 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -15,17 +15,15 @@ concurrency: jobs: build-push: runs-on: ubuntu-latest - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - uses: actions/checkout@v6 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 + - uses: docker/setup-buildx-action@v4 + - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@v6 + - uses: docker/build-push-action@v7 with: context: . push: true @@ -45,7 +43,7 @@ jobs: with: ruby-version: "4.0.1" bundler-cache: true - - uses: webfactory/ssh-agent@v0.9.0 + - uses: webfactory/ssh-agent@v0.10.0 with: ssh-private-key: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} - name: Deploy staging with Kamal @@ -63,7 +61,7 @@ jobs: with: ruby-version: "4.0.1" bundler-cache: true - - uses: webfactory/ssh-agent@v0.9.0 + - uses: webfactory/ssh-agent@v0.10.0 with: ssh-private-key: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} - name: Check staging health diff --git a/.gitignore b/.gitignore index c3810244..21a7753d 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ db/schema_clean.rb # CI / linter outputs (never commit these) rspec.xml +spec/examples.txt rubocop.json identifier.sqlite @@ -95,3 +96,4 @@ SCRATCH.md # Watchman (not used - we use Tailwind watcher instead) /watchman +docs/internal/ux_audit_2025_01.md diff --git a/.rubocop.yml b/.rubocop.yml index 0b50b51a..0ad9c398 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -69,4 +69,51 @@ Performance/MapMethodChain: Performance/Sum: Enabled: true Performance/Detect: - Enabled: true \ No newline at end of file + Enabled: true + +# Phase audit metrics (conservative thresholds to avoid CI churn) +Metrics/ClassLength: + Enabled: true + Max: 320 + Include: + - 'app/models/person.rb' + - 'app/controllers/admin/users_controller.rb' + - 'app/controllers/admin/payments_controller.rb' + - 'app/services/member_management_service.rb' + Exclude: + - 'app/views/**/*' +Metrics/ModuleLength: + Enabled: true + Max: 220 + Include: + - 'app/models/person.rb' + - 'app/controllers/admin/users_controller.rb' + - 'app/controllers/admin/payments_controller.rb' + - 'app/services/member_management_service.rb' +Metrics/MethodLength: + Enabled: true + Max: 45 + Include: + - 'app/models/person.rb' + - 'app/controllers/admin/users_controller.rb' + - 'app/controllers/admin/payments_controller.rb' + - 'app/services/member_management_service.rb' +Metrics/BlockLength: + Enabled: true + Max: 60 + Include: + - 'app/models/person.rb' + - 'app/controllers/admin/users_controller.rb' + - 'app/controllers/admin/payments_controller.rb' + - 'app/services/member_management_service.rb' + Exclude: + - 'config/routes.rb' + - 'spec/**/*' +Metrics/AbcSize: + Enabled: true + Max: 60 + Include: + - 'app/models/person.rb' + - 'app/controllers/admin/users_controller.rb' + - 'app/controllers/admin/payments_controller.rb' + - 'app/services/member_management_service.rb' \ No newline at end of file diff --git a/Gemfile b/Gemfile index 823d1e65..c74883bc 100644 --- a/Gemfile +++ b/Gemfile @@ -57,6 +57,8 @@ group :development, :test do # RSpec testing framework - ESSENTIELS gem "factory_bot_rails" + gem "guard", require: false + gem "guard-rspec", require: false gem "rspec-rails", "~> 8.0.2" gem "shoulda-matchers" # Pour tester les validations Rails # gem "database_cleaner-active_record" # Nettoyage de DB entre tests diff --git a/Gemfile.lock b/Gemfile.lock index b86563c7..47f38fac 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -102,6 +102,7 @@ GEM xpath (~> 3.2) childprocess (5.1.0) logger (~> 1.5) + coderay (1.1.3) concurrent-ruby (1.3.6) connection_pool (3.0.2) crass (1.0.6) @@ -141,11 +142,28 @@ GEM ffi (1.17.4-arm-linux-musl) ffi (1.17.4-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-musl) + formatador (1.2.3) + reline fugit (1.12.1) et-orbi (~> 1.4) raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) + guard (2.20.1) + formatador (>= 0.2.4) + listen (>= 2.7, < 4.0) + logger (~> 1.6) + lumberjack (>= 1.0.12, < 2.0) + nenv (~> 0.1) + notiffany (~> 0.0) + pry (>= 0.13.0) + shellany (~> 0.0) + thor (>= 0.18.1) + guard-compat (1.2.1) + guard-rspec (4.7.3) + guard (~> 2.1) + guard-compat (~> 1.1) + rspec (>= 2.99.0, < 4.0) i18n (1.14.8) concurrent-ruby (~> 1.0) image_processing (1.14.0) @@ -189,10 +207,15 @@ GEM railties (>= 6.1) rexml lint_roller (1.1.0) + listen (3.10.0) + logger + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) logger (1.7.0) loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) + lumberjack (1.4.2) mail (2.9.0) logger mini_mime (>= 0.1.1) @@ -206,6 +229,7 @@ GEM yajl-ruby marcel (1.1.0) matrix (0.4.3) + method_source (1.1.0) mini_magick (5.3.1) logger mini_mime (1.1.5) @@ -213,6 +237,7 @@ GEM drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) + nenv (0.3.0) net-http (0.9.1) uri (>= 0.11.1) net-imap (0.6.3) @@ -242,6 +267,9 @@ GEM racc (~> 1.4) nokogiri (1.19.2-x86_64-linux-musl) racc (~> 1.4) + notiffany (0.1.3) + nenv (~> 0.1) + shellany (~> 0.0) ostruct (0.6.3) pagy (6.5.0) parallel (2.0.1) @@ -256,6 +284,10 @@ GEM actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack + pry (0.16.0) + coderay (~> 1.1) + method_source (~> 1.0) + reline (>= 0.6.0) psych (5.3.1) date stringio @@ -314,6 +346,9 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.4.2) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) rdoc (7.2.0) erb psych (>= 4.0.0) @@ -322,6 +357,10 @@ GEM reline (0.6.3) io-console (~> 0.5) rexml (3.4.4) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) @@ -384,6 +423,7 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) + shellany (0.0.1) shoulda-matchers (7.0.1) activesupport (>= 7.1) simplecov (0.22.0) @@ -489,6 +529,8 @@ DEPENDENCIES dotenv-rails factory_bot_rails faker + guard + guard-rspec image_processing (~> 1.2) importmap-rails jbuilder @@ -551,6 +593,7 @@ CHECKSUMS bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef childprocess (5.1.0) sha256=9a8d484be2fd4096a0e90a0cd3e449a05bc3aa33f8ac9e4d6dcef6ac1455b6ec + coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d @@ -577,8 +620,12 @@ CHECKSUMS ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + formatador (1.2.3) sha256=19fa898133c2c26cdbb5d09f6998c1e137ad9427a046663e55adfe18b950d894 fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 + guard (2.20.1) sha256=ab9cd7873854e6b76080c0589f781ff3e390e441bdda20165804df54f977015a + guard-compat (1.2.1) sha256=3ad21ab0070107f92edfd82610b5cdc2fb8e368851e72362ada9703443d646fe + guard-rspec (4.7.3) sha256=a47ba03cbd1e3c71e6ae8645cea97e203098a248aede507461a43e906e2f75ca i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a @@ -592,16 +639,20 @@ CHECKSUMS letter_opener (1.10.0) sha256=2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2 letter_opener_web (3.0.0) sha256=3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 + lumberjack (1.4.2) sha256=40de5ae46321380c835031bcc1370f13bba304d29f2b5f5bb152061a5a191b95 mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 mailjet (1.8.3) sha256=50980a5664e38f379d191887834a4834af3c3cf7a2a177864c052482346db77a marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.5) sha256=f007d7246bf4feea549502842cd7c6aba8851cdc9c90ba06de9c476c0d01155c msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 + nenv (0.3.0) sha256=d9de6d8fb7072228463bf61843159419c969edb34b3cef51832b516ae7972765 net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 net-imap (0.6.3) sha256=9bab75f876596d09ee7bf911a291da478e0cd6badc54dfb82874855ccc82f2ad net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 @@ -617,6 +668,7 @@ CHECKSUMS nokogiri (1.19.2-arm-linux-musl) sha256=61114d44f6742ff72194a1b3020967201e2eb982814778d130f6471c11f9828c nokogiri (1.19.2-x86_64-linux-gnu) sha256=fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8 + notiffany (0.1.3) sha256=d37669605b7f8dcb04e004e6373e2a780b98c776f8eb503ac9578557d7808738 ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 pagy (6.5.0) sha256=3b2418e79bd67abbac820dddaf6fd76665bac4170b2fef0f4a601a6400ef1a07 parallel (2.0.1) sha256=337782d3e39f4121e67563bf91dd8ece67f48923d90698614773a0ec9a5b2c7d @@ -625,6 +677,7 @@ CHECKSUMS prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 puma (8.0.0) sha256=1681050b8b60fab1d3033255ab58b6aec64cd063e43fc6f8204bcb8bf9364b88 @@ -643,10 +696,13 @@ CHECKSUMS railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe + rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 @@ -664,6 +720,7 @@ CHECKSUMS rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-webdriver (4.43.0) sha256=a634377b964b701c6ac0a009ce3a08fa34ec1e1e7fe9a6d57e3088d14529a65c + shellany (0.0.1) sha256=0e127a9132698766d7e752e82cdac8250b6adbd09e6c0a7fbbb6f61964fedee7 shoulda-matchers (7.0.1) sha256=b4bfd8744c10e0a36c8ac1a687f921ee7e25ed529e50488d61b79a8688749c77 simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 diff --git a/Guardfile b/Guardfile new file mode 100644 index 00000000..8b7fad7a --- /dev/null +++ b/Guardfile @@ -0,0 +1,70 @@ +# A sample Guardfile +# More info at https://github.com/guard/guard#readme + +## Uncomment and set this to only include directories you want to watch +# directories %w(app lib config test spec features) \ +# .select{|d| Dir.exist?(d) ? d : UI.warning("Directory #{d} does not exist")} + +## Note: if you are using the `directories` clause above and you are not +## watching the project directory ('.'), then you will want to move +## the Guardfile to a watched dir and symlink it back, e.g. +# +# $ mkdir config +# $ mv Guardfile config/ +# $ ln -s config/Guardfile . +# +# and, you'll have to watch "config/Guardfile" instead of "Guardfile" + +# Note: The cmd option is now required due to the increasing number of ways +# rspec may be run, below are examples of the most common uses. +# * bundler: 'bundle exec rspec' +# * bundler binstubs: 'bin/rspec' +# * spring: 'bin/rspec' (This will use spring if running and you have +# installed the spring binstubs per the docs) +# * zeus: 'zeus rspec' (requires the server to be started separately) +# * 'just' rspec: 'rspec' + +guard :rspec, cmd: "bin/rspec" do + require "guard/rspec/dsl" + dsl = Guard::RSpec::Dsl.new(self) + + # Feel free to open issues for suggestions and improvements + + # RSpec files + rspec = dsl.rspec + watch(rspec.spec_helper) { rspec.spec_dir } + watch(rspec.spec_support) { rspec.spec_dir } + watch(rspec.spec_files) + + # Ruby files + ruby = dsl.ruby + dsl.watch_spec_files_for(ruby.lib_files) + + # Rails files + rails = dsl.rails(view_extensions: %w[erb haml slim]) + dsl.watch_spec_files_for(rails.app_files) + dsl.watch_spec_files_for(rails.views) + + watch(rails.controllers) do |m| + [ + rspec.spec.call("routing/#{m[1]}_routing"), + rspec.spec.call("controllers/#{m[1]}_controller"), + rspec.spec.call("acceptance/#{m[1]}") + ] + end + + # Rails config changes + watch(rails.spec_helper) { rspec.spec_dir } + watch(rails.routes) { "#{rspec.spec_dir}/routing" } + watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" } + + # Capybara features specs + watch(rails.view_dirs) { |m| rspec.spec.call("features/#{m[1]}") } + watch(rails.layouts) { |m| rspec.spec.call("features/#{m[1]}") } + + # Turnip features and steps + watch(%r{^spec/acceptance/(.+)\.feature$}) + watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) do |m| + Dir[File.join("**/#{m[1]}.feature")][0] || "spec/acceptance" + end +end diff --git a/README.md b/README.md index d7301549..fd37aacf 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,17 @@ Le projet utilise **RSpec uniquement**. Le legacy Minitest (`test/`) a ete retir La stack d'authentification reste **native Rails 8** (pas Devise). ```bash +bin/rspec spec/services # RSpec sérialisé pour SQLite bin/test # suite complète + couverture bin/test_fast # models + services (rapide) bin/test --no-coverage # sans SimpleCov -bundle exec rspec # commande RSpec canonique +bin/test_watch # watch mode via Guard bundle exec rubocop --force-exclusion ``` +Avec SQLite, n'exécutez pas plusieurs processus RSpec en parallèle sur la même base `tmp/test.sqlite3`. +Utilisez `bin/rspec`, `bin/test`, `bin/test_fast` et `bin/test_watch` : ils sérialisent l'accès via un lockfile. + --- ## Déploiement diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 59fa3663..b7739add 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1 +1,5 @@ -/* (legacy placeholder file intentionally left minimal) */ \ No newline at end of file +/* + *= require opening_hours_editor + */ + +/* Legacy stylesheet kept intentionally minimal. */ \ No newline at end of file diff --git a/app/assets/stylesheets/opening_hours_editor.css b/app/assets/stylesheets/opening_hours_editor.css new file mode 100644 index 00000000..b89dec67 --- /dev/null +++ b/app/assets/stylesheets/opening_hours_editor.css @@ -0,0 +1,23 @@ +/* Opening hours editor native select styling */ +.hour-select, +.minute-select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 8px center; + background-repeat: no-repeat; + background-size: 16px; + padding-right: 32px; +} + +.hour-select:focus, +.minute-select:focus { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%231F5C55' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); +} + +.hour-select:hover, +.minute-select:hover { + border-color: #1f5c55; + box-shadow: 0 0 0 1px rgba(31, 92, 85, 0.1); +} diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 62d6cf85..8ba4e1e2 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -4,6 +4,29 @@ @import "tailwindcss"; +/* + * Leaflet CSS is loaded via in layouts/application.html.erb (before tailwind.css). + * Embedding @import url(leaflet) here breaks Tailwind v4 output: "@import must precede @layer". + * + * Preflight img { max-width: 100% } breaks tiles — reset inside maps only. + */ +.leaflet-container img { + max-width: none !important; +} + +.leaflet-map-shell { + position: relative; +} + +/* Force full bleed inside the rounded shell (Leaflet defaults + % heights are fragile). */ +.leaflet-map-shell-inner.leaflet-container { + position: absolute !important; + inset: 0 !important; + width: 100% !important; + height: 100% !important; + outline: none; +} + @import "./components/buttons.css"; @import "./components/forms.css"; @import "./components/hero.css"; @@ -76,6 +99,8 @@ html { @apply h-full; overflow-y: auto; + /* Réserve l’espace scrollbar + évite le collage au bord droit (nav, etc.) */ + scrollbar-gutter: stable; } html::-webkit-scrollbar { @@ -117,8 +142,11 @@ .text-brand-accent { color: #5836A5; } .bg-brand-accent { background-color: #5836A5; } .bg-brand-accent\/10 { background-color: rgba(88, 54, 165, 0.1); } + /* Opacity variants used in markup; not always emitted by Tailwind v4 scan */ + .bg-brand-accent\/72 { background-color: rgba(88, 54, 165, 0.72); } .border-brand-accent { border-color: #5836A5; } .border-brand-accent\/20 { border-color: rgba(88, 54, 165, 0.2); } + .border-brand-accent\/45 { border-color: rgba(88, 54, 165, 0.45); } .focus-visible\:outline-brand-primary:focus-visible { outline-color: #1F5C55; } .focus-visible\:outline-brand-accent:focus-visible { outline-color: #5836A5; } diff --git a/app/assets/tailwind/components/buttons.css b/app/assets/tailwind/components/buttons.css index 550b0f39..f86e9a26 100644 --- a/app/assets/tailwind/components/buttons.css +++ b/app/assets/tailwind/components/buttons.css @@ -116,5 +116,71 @@ color: var(--brand-primary); border-color: var(--brand-primary); } -} + .admin-btn-primary { + @apply inline-flex items-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2; + background-color: var(--brand-primary); + } + + .admin-btn-primary:hover { + background-color: #194A45; + } + + .admin-btn-primary:focus-visible { + --tw-ring-color: var(--brand-primary); + } + + .admin-btn-accent { + @apply inline-flex items-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2; + background-color: var(--brand-accent); + } + + .admin-btn-accent:hover { + background-color: #4c2d8a; + } + + .admin-btn-accent:focus-visible { + --tw-ring-color: var(--brand-accent); + } + + .public-cta { + @apply inline-flex items-center justify-center rounded-lg border-2 px-8 py-4 text-lg font-bold tracking-wide shadow-lg transition-all duration-300; + background-color: var(--brand-primary); + border-color: var(--brand-primary); + color: #fff; + } + + .public-cta:hover { + background-color: #f3f4f6; + color: var(--brand-primary); + border-color: var(--brand-primary); + } + + .public-cta:hover, + .public-cta:focus-visible { + transform: translateY(-0.25rem); + } + + .public-cta-pill { + @apply inline-flex items-center justify-center rounded-full border px-6 py-3 text-sm font-semibold shadow-md transition-colors duration-300; + background-color: var(--brand-primary); + border-color: var(--brand-primary); + color: #fff; + } + + .public-cta-pill:hover { + background-color: #fff; + color: var(--brand-primary); + } + + .public-cta-ghost { + @apply inline-flex items-center justify-center rounded-full border px-5 py-2.5 text-sm font-semibold transition-colors duration-300; + border-color: rgba(31, 92, 85, 0.4); + color: var(--brand-primary); + background-color: #fff; + } + + .public-cta-ghost:hover { + background-color: rgba(31, 92, 85, 0.1); + } +} diff --git a/app/assets/tailwind/components/forms.css b/app/assets/tailwind/components/forms.css index 778348b4..6a1f6cc8 100644 --- a/app/assets/tailwind/components/forms.css +++ b/app/assets/tailwind/components/forms.css @@ -10,5 +10,8 @@ .form-control { @apply block w-full px-4 py-2 text-gray-800 placeholder-gray-400 bg-white border border-gray-200 rounded-lg transition focus:outline-none focus:ring-1 focus:ring-[#5836A5] focus:border-[#5836A5]; } -} + .auth-field { + @apply block w-full px-4 py-2 text-gray-800 placeholder-gray-400 bg-white border border-gray-200 rounded-lg transition focus:outline-none focus:ring-1 focus:ring-[#5836A5] focus:border-[#5836A5]; + } +} diff --git a/app/assets/tailwind/components/layout.css b/app/assets/tailwind/components/layout.css index 940b7cc3..bb77a6ea 100644 --- a/app/assets/tailwind/components/layout.css +++ b/app/assets/tailwind/components/layout.css @@ -4,6 +4,46 @@ margin-bottom: 2rem !important; } + .page-container { + @apply mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8; + } + + .page-container-wide { + @apply mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8; + } + + .surface-card { + @apply overflow-hidden rounded-lg bg-white shadow-lg; + } + + .admin-page-header { + @apply mb-6 rounded-lg bg-gradient-to-r from-[#1F5C55] to-[#194A45] shadow-lg; + } + + .admin-metric-card { + @apply rounded-lg border border-gray-200 bg-white p-4 shadow-lg transition-shadow duration-200 hover:shadow-xl; + } + + .admin-filter-panel { + @apply mb-6 rounded-lg bg-gray-50 p-4; + } + + .public-hero-shell { + @apply relative overflow-hidden rounded-[32px] shadow-2xl; + } + + .public-surface { + @apply rounded-[28px] bg-white/95 shadow-lg; + } + + .public-surface-bordered { + @apply rounded-[28px] border border-white/80 bg-white/95 shadow-lg; + } + + .public-soft-card { + @apply rounded-2xl border border-gray-100 bg-white shadow-md; + } + .outline-contrast-primary { outline-color: rgba(31, 92, 85, 0.85); } @@ -13,4 +53,3 @@ font-weight: 600; } } - diff --git a/app/assets/tailwind/components/swiper_overrides.css b/app/assets/tailwind/components/swiper_overrides.css index 06bb8f2e..b50f36f4 100644 --- a/app/assets/tailwind/components/swiper_overrides.css +++ b/app/assets/tailwind/components/swiper_overrides.css @@ -23,10 +23,6 @@ display: block; } - .swiper-horizontal { - touch-action: pan-y; - } - .swiper-button-next, .swiper-button-prev { position: absolute; diff --git a/app/components/admin/payments/payment_display_component.rb b/app/components/admin/payments/payment_display_component.rb index 35929731..09fc3ba0 100644 --- a/app/components/admin/payments/payment_display_component.rb +++ b/app/components/admin/payments/payment_display_component.rb @@ -46,7 +46,7 @@ def display_payment_lines if payment.payment_lines.any? payment.payment_lines.map do |line| content_tag :div, class: "text-xs" do - "#{line.description || line.item_type}: #{number_to_currency(line.amount_cents / 100.0, unit: '€', separator: ',', delimiter: ' ')}" + "#{line.history_description}: #{number_to_currency(line.amount_cents / 100.0, unit: '€', separator: ',', delimiter: ' ')}" end end.join.html_safe else diff --git a/app/components/admin/users/action_buttons_component.rb b/app/components/admin/users/action_buttons_component.rb deleted file mode 100644 index fd164a67..00000000 --- a/app/components/admin/users/action_buttons_component.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -module Admin - module Users - class ActionButtonsComponent < ViewComponent::Base - # LEGACY: kept for reference; not currently used in admin user views. - def initialize(person:) - @person = person - end - - private - - attr_reader :person - - def membership_action_button - current_membership = person.current_membership - - if current_membership.nil? - # Pas d'adhésion active - link_to "Ajouter une adhésion", - new_admin_membership_path(person_id: person.id), - class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" - elsif current_membership.membership_type.basic? - # Adhésion basique -> peut upgrader vers cirque - link_to "Upgrader vers Cirque", - new_admin_membership_path(person_id: person.id, upgrade: true), - class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" - elsif current_membership.membership_type.circus? - # Adhésion cirque -> peut renouveler - if current_membership.ended_at < 30.days.from_now - link_to "Renouveler adhésion", - new_admin_membership_path(person_id: person.id, renew: true), - class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" - else - # Adhésion cirque valide -> pas de bouton - content_tag :span, "Adhésion Cirque active", - class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-500 bg-gray-100" - end - else - # Autres cas -> renouveler - link_to "Renouveler adhésion", - new_admin_membership_path(person_id: person.id, renew: true), - class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" - end - end - - def subscription_action_button - current_membership = person.current_membership - - if current_membership.nil? - # Pas d'adhésion -> pas de cotisation possible - content_tag :span, "Adhésion requise", - class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-500 bg-gray-100" - elsif current_membership.membership_type.basic? - # Adhésion basique -> pas de cotisation - content_tag :span, "Adhésion Basique", - class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-500 bg-gray-100" - elsif current_membership.membership_type.circus? - # Adhésion cirque -> peut ajouter des cotisations - active_contribution = person.contributions.active.first - - buttons = [] - if active_contribution - label = "Voir cotisation" - if active_contribution.contribution_formula.duration == "pack10" - remaining_entries = active_contribution.remaining_entries.to_i - label = "Voir cotisation (#{remaining_entries} restantes)" if remaining_entries.positive? - end - buttons << link_to( - label, - admin_user_path(person.user ? person.user.id : "person_#{person.id}"), - class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55] mr-2" - ) - end - - buttons << link_to( - "Ajouter une cotisation", - new_admin_subscription_plan_path(person_id: person.id), - class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" - ) - - safe_join(buttons) - else - # Autres cas - content_tag :span, "Non applicable", - class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-500 bg-gray-100" - end - end - end - end -end diff --git a/app/components/admin/users/editable_member_number_component.html.erb b/app/components/admin/users/editable_member_number_component.html.erb index 113740fd..bcd99cec 100644 --- a/app/components/admin/users/editable_member_number_component.html.erb +++ b/app/components/admin/users/editable_member_number_component.html.erb @@ -3,8 +3,8 @@
# - <%= form_with model: person, url: admin_user_path("person_#{person.id}"), method: :patch, - local: false, id: form_id, class: "inline-flex items-center", + <%= form_with model: person, url: admin_user_path(Admin::Users::PersonRouteKey.call(person)), method: :patch, + local: false, id: form_id, class: "inline-flex items-center", data: { "editable-member-number-target": "form" } do |form| %> <%= form.text_field :member_number, value: member_number, diff --git a/app/components/admin/users/member_number_change_component.html.erb b/app/components/admin/users/member_number_change_component.html.erb index aad8b6f8..4641474e 100644 --- a/app/components/admin/users/member_number_change_component.html.erb +++ b/app/components/admin/users/member_number_change_component.html.erb @@ -21,8 +21,8 @@
- <%= form_with model: person, url: admin_user_path("person_#{person.id}"), method: :patch, - local: false, id: "member-number-change-form", + <%= form_with model: person, url: admin_user_path(Admin::Users::PersonRouteKey.call(person)), method: :patch, + local: false, id: "member-number-change-form", data: { "member-number-change-target": "form" } do |form| %>
diff --git a/app/components/admin/users/membership_display_component.html.erb b/app/components/admin/users/membership_display_component.html.erb index 3892b830..66693c75 100644 --- a/app/components/admin/users/membership_display_component.html.erb +++ b/app/components/admin/users/membership_display_component.html.erb @@ -52,11 +52,11 @@ <% end %>
- <% elsif can_purchase_subscriptions? %> + <% elsif can_purchase_contributions? %>

Aucune cotisation active

- <%= link_to "Ajouter une cotisation", new_admin_subscription_plan_path(person_id: person.id), + <%= link_to "Ajouter une cotisation", new_admin_contribution_formula_path(person_id: person.id), class: "inline-flex items-center px-3 py-1 text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] rounded-md" %>
diff --git a/app/components/admin/users/membership_display_component.rb b/app/components/admin/users/membership_display_component.rb index 82a119df..76d74635 100644 --- a/app/components/admin/users/membership_display_component.rb +++ b/app/components/admin/users/membership_display_component.rb @@ -39,7 +39,7 @@ def is_circus_member? current_membership&.membership_type&.circus? end - def can_purchase_subscriptions? + def can_purchase_contributions? is_circus_member? end diff --git a/app/components/admin/users/user_actions_component.rb b/app/components/admin/users/user_actions_component.rb index 87f6d01d..44e702ff 100644 --- a/app/components/admin/users/user_actions_component.rb +++ b/app/components/admin/users/user_actions_component.rb @@ -23,8 +23,8 @@ def primary_actions # Membership action actions << membership_action - # Subscription action - actions << subscription_action + # Contribution action + actions << contribution_action # Create web account action - moved to header # if is_person_without_user @@ -52,27 +52,27 @@ def membership_action new_admin_membership_path(person_id: person.id), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" elsif current_membership.membership_type.basic? - link_to "Upgrader vers Cirque", + link_to "Passer en adhésion Cirque", new_admin_membership_path(person_id: person.id, upgrade: true), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" elsif current_membership.membership_type.circus? if current_membership.ended_at < 30.days.from_now - link_to "Renouveler adhésion", + link_to "Renouveler l'adhésion", new_admin_membership_path(person_id: person.id, renew: true), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" else link_to "Voir adhésion", - "#{admin_user_path(user ? user.id : "person_#{person.id}")}#membership", + "#{admin_user_path(user || person_route_key)}#membership", class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" end else - link_to "Renouveler adhésion", + link_to "Renouveler l'adhésion", new_admin_membership_path(person_id: person.id, renew: true), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" end end - def subscription_action + def contribution_action current_membership = person.current_membership if current_membership.nil? @@ -83,7 +83,6 @@ def subscription_action class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-500 bg-gray-100" elsif current_membership.membership_type.circus? active_contribution = person.contributions.active.first - links = [] if active_contribution label = "Voir cotisation" @@ -93,18 +92,16 @@ def subscription_action end links << link_to( label, - "#{admin_user_path(user ? user.id : "person_#{person.id}")}#payments", + "#{admin_user_path(person_route_key)}#payments", class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55] mr-2" ) end - links << link_to( - "Ajouter une cotisation", - new_admin_subscription_plan_path(person_id: person.id), + contribution_action_label, + new_admin_contribution_formula_path(person_id: person.id), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" ) - - safe_join(links) + helpers.safe_join(links) else content_tag :span, "Non applicable", class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-500 bg-gray-100" @@ -119,18 +116,18 @@ def create_web_account_action def edit_information_action link_to "Modifier les informations", - edit_person_admin_user_path("person_#{person.id}"), + edit_person_admin_user_path(person_route_key), class: "text-[#1F5C55] hover:text-[#194A45] hover:underline" end def payment_history_action - link_to "Historique des paiements", + link_to "Voir les paiements", admin_payments_path(person_id: person.id), class: "text-[#1F5C55] hover:text-[#194A45] hover:underline" end def make_donation_action - link_to "Faire un don", + link_to "Enregistrer un don", new_admin_donation_path(person_id: person.id), class: "text-[#1F5C55] hover:text-[#194A45] hover:underline" end @@ -161,11 +158,27 @@ def delete_action end button_to "Supprimer", - admin_user_path(user || "person_#{person.id}"), + admin_user_path(user || person_route_key), method: :delete, form: { data: { turbo_confirm: "Êtes-vous sûr de vouloir supprimer cet utilisateur/cette personne ?" } }, class: "text-red-600 hover:text-red-800 hover:underline bg-transparent border-none cursor-pointer" end + + def person_route_key + Admin::Users::PersonRouteKey.call(person) + end + + def contribution_action_label + active_contribution = person.contributions.active.first + return "Acheter une cotisation" unless active_contribution + + return "Gérer les cotisations" unless active_contribution.contribution_formula.duration == "pack10" + + remaining_entries = active_contribution.remaining_entries.to_i + return "Gérer les cotisations" unless remaining_entries.positive? + + "Gérer les cotisations (#{remaining_entries} restantes)" + end end end end diff --git a/app/components/contextual_actions_component.html.erb b/app/components/contextual_actions_component.html.erb index 06137642..2078c20b 100644 --- a/app/components/contextual_actions_component.html.erb +++ b/app/components/contextual_actions_component.html.erb @@ -4,7 +4,7 @@ # Construire l'URL selon le type d'action url = case action[:type] when :view - admin_user_path("person_#{person.id}") + admin_user_path(Admin::Users::PersonRouteKey.call(person)) when :create_user new_admin_user_path(person_id: person.id) when :add_membership diff --git a/app/components/contextual_actions_component.rb b/app/components/contextual_actions_component.rb index 47516fca..8f03d702 100644 --- a/app/components/contextual_actions_component.rb +++ b/app/components/contextual_actions_component.rb @@ -17,7 +17,7 @@ def build_actions actions = [] # Action "Voir" (toujours disponible) - actions << { type: :view, icon: :view_icon, url: "person_#{person.id}", + actions << { type: :view, icon: :view_icon, url: Admin::Users::PersonRouteKey.call(person), class: "action-icon text-gray-600 hover:text-[#1F5C55] mr-2", title: "Voir la fiche", data: { turbo: false } } @@ -33,7 +33,7 @@ def build_actions # Actions de cotisation (si adhésion Cirque) if person.has_active_membership? && person.current_membership.membership_type.name.downcase.include?("cirque") - actions << { type: :subscription, icon: :subscription_icon, url: "#", + actions << { type: :contribution, icon: :contribution_icon, url: "#", class: "action-icon text-indigo-600 hover:text-indigo-800 mr-2", title: "Ajouter une cotisation" } end @@ -138,7 +138,7 @@ def add_icon viewBox: "0 0 24 24") end - def subscription_icon + def contribution_icon content_tag(:svg, content_tag(:path, "", stroke_linecap: "round", diff --git a/app/controllers/admin/attendances_controller.rb b/app/controllers/admin/attendances_controller.rb index 17e3ecfc..add22839 100644 --- a/app/controllers/admin/attendances_controller.rb +++ b/app/controllers/admin/attendances_controller.rb @@ -79,10 +79,7 @@ def set_attendance end def attendance_params - params.expect(attendance: %i[person_id event_id date contribution_id attendance_list_id notes]).tap do |permitted| - legacy = params[:attendance][:book_of_entry_id] - permitted[:contribution_id] ||= legacy if legacy.present? - end + params.expect(attendance: %i[person_id event_id date contribution_id attendance_list_id notes]) end end end diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index 8b1c3d12..aecff1ee 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -2,14 +2,44 @@ module Admin class BaseController < ApplicationController - before_action :require_admin_or_super_admin + # On évite le before_action global `require_authentication` ici : il ne distingue pas + # « pas connecté » et « connecté sans rôle staff », ce qui renvoie parfois vers /session/new + # alors qu’une session cookie existe déjà (compte public). On centralise la logique admin. + skip_before_action :require_authentication + before_action :require_admin_zone_access private - def require_admin_or_super_admin + def admin_person_path(person) + admin_user_path(Admin::Users::PersonRouteKey.call(person)) + end + + def add_person_context_breadcrumbs(person, current_label = nil) + add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path + add_breadcrumb person.full_name, admin_person_path(person) + add_breadcrumb current_label, nil if current_label.present? + end + + def require_admin_zone_access + resume_session + + unless Current.session + store_return_location_for_admin_request + redirect_to new_session_path, alert: I18n.t("admin.base.sign_in_required_alert") + return + end + return if Current.user&.has_privileges? - redirect_to root_path, alert: I18n.t("admin.base.unauthorized_alert") + redirect_to root_path, alert: I18n.t("admin.base.staff_only_alert") + end + + def store_return_location_for_admin_request + uri = URI.parse(request.url) + path = uri.path.to_s.presence || "/" + session[:return_to_after_authenticating] = request.url unless non_gettable_redirect_path?(path) + rescue URI::InvalidURIError + session[:return_to_after_authenticating] = request.url end end end diff --git a/app/controllers/admin/subscription_plans_controller.rb b/app/controllers/admin/contribution_formulas_controller.rb similarity index 56% rename from app/controllers/admin/subscription_plans_controller.rb rename to app/controllers/admin/contribution_formulas_controller.rb index 0c13477c..0aa0d6eb 100644 --- a/app/controllers/admin/subscription_plans_controller.rb +++ b/app/controllers/admin/contribution_formulas_controller.rb @@ -1,7 +1,19 @@ # frozen_string_literal: true module Admin - class SubscriptionPlansController < BaseController + class ContributionFormulasController < BaseController + FORMULA_ATTRS = %i[name duration rate_kind price_cents description membership_type_id sessions_count validity_days].freeze + PURCHASE_ATTRS = %i[ + person_id + contribution_formula_id + payment_method + record_attendance + attendance_date + custom_amount_cents + offer_reason + donation_amount + ].freeze + before_action :set_contribution_formula, only: %i[show edit update destroy] before_action :set_person, only: %i[new create] before_action :set_breadcrumbs @@ -9,12 +21,12 @@ class SubscriptionPlansController < BaseController def index @contribution_formulas = ContributionFormula.includes(:membership_type).order(:duration, :price_cents) - add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.plans"), nil + add_breadcrumb I18n.t("breadcrumbs.admin.contribution_formulas.catalog"), nil end def show @contributions = @contribution_formula.contributions.includes(:person) - add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.plan_named", name: @contribution_formula.name), nil + add_breadcrumb I18n.t("breadcrumbs.admin.contribution_formulas.formula_named", name: @contribution_formula.name), nil end def new @@ -22,17 +34,17 @@ def new unless @person&.can_buy_contribution_formulas? flash[:alert] = t(".needs_circus_membership_alert") - redirect_to admin_users_path + redirect_to admin_person_path(@person) return end @contribution_formulas = ContributionFormula.available_for(@person) - add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.new_contribution"), nil + add_breadcrumb I18n.t("breadcrumbs.admin.contribution_formulas.new_contribution"), nil end def edit - add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.edit_named", name: @contribution_formula.name), nil + add_breadcrumb I18n.t("breadcrumbs.admin.contribution_formulas.edit_named", name: @contribution_formula.name), nil end def create @@ -43,7 +55,7 @@ def create result = People::ContributionCreator.new( person: @person, - contribution_formula_id: contribution_purchase_params[:contribution_formula_id] || contribution_purchase_params[:subscription_plan_id], + contribution_formula_id: contribution_formula_id_from_purchase_params, payment_method: contribution_purchase_params[:payment_method].presence || "cash", recorded_by_id: Current.user&.id, record_attendance: false, @@ -53,19 +65,19 @@ def create ).call if result.success? - redirect_to admin_user_path("person_#{@person.id}"), notice: t(".purchased") + redirect_to admin_user_path(Admin::Users::PersonRouteKey.call(@person)), notice: t(".purchased") else - redirect_to new_admin_subscription_plan_path(person_id: @person.id), + redirect_to new_admin_contribution_formula_path(person_id: @person.id), alert: t(".purchase_failed_alert", message: result.message) end rescue StandardError => e flash[:alert] = t(".purchase_failed_alert", message: e.message) - redirect_to new_admin_subscription_plan_path(person_id: @person.id) + redirect_to new_admin_contribution_formula_path(person_id: @person.id) end def update if @contribution_formula.update(contribution_formula_params) - redirect_to admin_subscription_plans_path, notice: t(".updated") + redirect_to admin_contribution_formulas_path, notice: t(".updated") else flash.now[:alert] = @contribution_formula.errors.full_messages.to_sentence render :edit, status: :unprocessable_content @@ -74,18 +86,22 @@ def update def destroy if @contribution_formula.destroy - redirect_to admin_subscription_plans_path, notice: t(".destroyed") + redirect_to admin_contribution_formulas_path, notice: t(".destroyed") else - redirect_to admin_subscription_plans_path, alert: @contribution_formula.errors.full_messages.to_sentence + redirect_to admin_contribution_formulas_path, alert: @contribution_formula.errors.full_messages.to_sentence end end private + def contribution_formula_id_from_purchase_params + contribution_purchase_params[:contribution_formula_id] + end + def require_super_admin return if Current.user&.super_admin? - redirect_to admin_subscription_plans_path, alert: I18n.t("admin.subscription_plans.require_super_admin.forbidden") + redirect_to admin_contribution_formulas_path, alert: I18n.t("admin.contribution_formulas.require_super_admin.forbidden") end def set_contribution_formula @@ -98,23 +114,19 @@ def set_person def set_breadcrumbs add_breadcrumb I18n.t("breadcrumbs.admin.common.administration"), admin_dashboard_index_path - add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.plans"), admin_subscription_plans_path + if @person.present? + add_person_context_breadcrumbs(@person, I18n.t("breadcrumbs.admin.contribution_formulas.new_contribution")) + else + add_breadcrumb I18n.t("breadcrumbs.admin.contribution_formulas.catalog"), admin_contribution_formulas_path + end end def contribution_formula_params - params.expect(contribution_formula: %i[name duration price_cents description membership_type_id sessions_count validity_days]).tap do |permitted| - legacy = params[:subscription_plan] - permitted.merge!(legacy.permit(:name, :duration, :price_cents, :description, :membership_type_id, :sessions_count, :validity_days)) if legacy.respond_to?(:permit) - end - rescue ActionController::ParameterMissing - params.expect(subscription_plan: %i[name duration price_cents description membership_type_id sessions_count validity_days]) + params.expect(contribution_formula: FORMULA_ATTRS) end def contribution_purchase_params - key = params.key?(:contribution_formula) ? :contribution_formula : :subscription_plan - params.expect(key => %i[person_id contribution_formula_id subscription_plan_id payment_method record_attendance attendance_date custom_amount_cents offer_reason donation_amount]).merge( - recorded_by_id: Current.user.id - ) + params.expect(contribution_formula: PURCHASE_ATTRS).merge(recorded_by_id: Current.user.id) end def donation_cents_from(params_hash) diff --git a/app/controllers/admin/contributions_controller.rb b/app/controllers/admin/contributions_controller.rb new file mode 100644 index 00000000..77872cb4 --- /dev/null +++ b/app/controllers/admin/contributions_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Admin + class ContributionsController < BaseController + before_action :set_person + + def upgrade + result = build_contribution_upgrader.call + + if result.success? + redirect_to admin_person_path(@person), notice: upgrade_notice_for(result) + else + redirect_to admin_person_path(@person), alert: upgrade_failure_message(result.message) + end + rescue StandardError => e + redirect_to admin_person_path(@person), alert: upgrade_failure_message(e.message) + end + + private + + def set_person + @person = Person.find(params[:person_id]) + end + + def admin_person_path(person) + admin_user_path(Admin::Users::PersonRouteKey.call(person)) + end + + def build_contribution_upgrader + People::ContributionUpgrader.new( + person: @person, + from_contribution_id: source_contribution_id, + to_formula_id: target_formula_id, + payment_method: params[:payment_method].presence || "cash", + recorded_by_id: Current.user.id, + offer_reason: params[:offer_reason] + ) + end + + def source_contribution_id + params[:from_contribution_id].presence || params[:from_book_id] + end + + def target_formula_id + params[:to_formula_id].presence || params[:to_plan_id] + end + + def upgrade_notice_for(result) + return t(".success_notice") unless result.credit_applied.positive? + + t(".success_notice") + t(".credit_applied_suffix", amount: (result.credit_applied / 100.0).round(2)) + end + + def upgrade_failure_message(message) + t(".failure_alert", message: message) + end + end +end diff --git a/app/controllers/admin/donations_controller.rb b/app/controllers/admin/donations_controller.rb index b2716b82..dfe8819c 100644 --- a/app/controllers/admin/donations_controller.rb +++ b/app/controllers/admin/donations_controller.rb @@ -2,49 +2,31 @@ module Admin class DonationsController < BaseController - before_action :set_breadcrumbs - def new return unless assign_person_context! - add_breadcrumb I18n.t("admin.donations.new.title"), nil + set_breadcrumbs end def create return unless assign_person_context! - begin - amount_cents = (payment_params[:payment_amount].to_f * 100).to_i - - result = People::PaymentCreator.new( - person: @person, - amount_cents: amount_cents, - payment_method: "cash", - recorded_by_id: Current.user&.id, - item_type: "Donation", - item_id: @person.id, - description: "Donation", - notes: "Donation" - ).call + result = build_payment_recorder.call - if result.success? - redirect_to admin_payment_path(result.payment), notice: t(".recorded") - else - flash[:alert] = "Erreur lors de la création de la donation: #{result.message}" - add_breadcrumb I18n.t("admin.donations.new.title"), nil - render :new, status: :unprocessable_content - end - rescue StandardError => e - flash[:alert] = "Erreur lors de la création de la donation: #{e.message}" - add_breadcrumb I18n.t("admin.donations.new.title"), nil - render :new, status: :unprocessable_content + if result.success? + redirect_to admin_payments_path(person_id: @person.id), notice: t(".recorded") + else + render_creation_error(result.message) end + rescue StandardError => e + render_creation_error(e.message) end private def set_breadcrumbs add_breadcrumb I18n.t("breadcrumbs.admin.common.administration"), admin_dashboard_index_path + add_person_context_breadcrumbs(@person, I18n.t("admin.donations.new.title")) end def assign_person_context! @@ -65,5 +47,33 @@ def assign_person_context! def payment_params params.expect(payment: %i[payment_amount payment_date payment_type status donation total_payment user_id person_id]) end + + def build_payment_recorder + People::PaymentRecorder.new( + person: @person, + payment_method: "cash", + recorded_by: Current.user, + status: "success", + notes: "Donation", + total_cents: payment_amount_cents, + payment_lines: [ + { + item_type: "Donation", + amount_cents: payment_amount_cents, + description: "Donation" + } + ] + ) + end + + def payment_amount_cents + (payment_params[:payment_amount].to_f * 100).to_i + end + + def render_creation_error(message) + flash[:alert] = "Erreur lors de la création de la donation: #{message}" + set_breadcrumbs + render :new, status: :unprocessable_content + end end end diff --git a/app/controllers/admin/exports_controller.rb b/app/controllers/admin/exports_controller.rb index 2ef6f953..791bd89b 100644 --- a/app/controllers/admin/exports_controller.rb +++ b/app/controllers/admin/exports_controller.rb @@ -9,44 +9,50 @@ def index end def newsletter_subscribed - users = User.where(newsletter_subscribed: true).select(:first_name, :last_name, :email_address) - csv_data = users_to_csv_newsletter(users) + subscribers = NewsletterSubscriber.subscribed.includes(:person).order(:email) + csv_data = users_to_csv_newsletter(subscribers) send_data csv_data, filename: "utilisateur_newsletter.csv", type: "text/csv", disposition: "attachment" end def all_users - users = User.where(deleted: false).select( - :id, :first_name, :last_name, :email_address, :birthdate, - :address, :zip_code, :town, :phone_number, :occupation, - :specialty, :newsletter_subscribed, :created_at - ) - csv_data = users_to_csv(users) + people = Person.active.includes(:newsletter_subscriber, :user).order(:last_name, :first_name) + csv_data = users_to_csv(people) send_data csv_data, filename: "tous_les_utilisateurs.csv", type: "text/csv", disposition: "attachment" end - def users_to_csv(users) - return "" if users.empty? + def users_to_csv(people) + return "" if people.empty? CSV.generate(headers: true) do |csv| - # Utiliser les colonnes sélectionnées dans la requête - selected_columns = %i[id first_name last_name email_address birthdate - address zip_code town phone_number occupation - specialty newsletter_subscribed created_at] - - csv << selected_columns - users.each do |user| - csv << selected_columns.map { |col| user.send(col) } + csv << %i[id first_name last_name email email_address birth_date address zip_code town phone occupation specialty newsletter_subscribed created_at] + people.each do |person| + csv << [ + person.id, + person.first_name, + person.last_name, + person.email, + person.user&.email_address, + person.birth_date, + person.address, + person.zip_code, + person.town, + person.phone, + person.occupation, + person.specialty, + person.newsletter_subscribed?, + person.created_at + ] end end end - def users_to_csv_newsletter(users) - return "" if users.empty? + def users_to_csv_newsletter(subscribers) + return "" if subscribers.empty? CSV.generate(headers: true) do |csv| - csv << %i[first_name last_name email_address] - users.each do |user| - csv << [ user.first_name, user.last_name, user.email_address ] + csv << %i[first_name last_name email] + subscribers.each do |subscriber| + csv << [ subscriber.person&.first_name, subscriber.person&.last_name, subscriber.email ] end end end diff --git a/app/controllers/admin/health_reports_controller.rb b/app/controllers/admin/health_reports_controller.rb index b174d57c..c820381a 100644 --- a/app/controllers/admin/health_reports_controller.rb +++ b/app/controllers/admin/health_reports_controller.rb @@ -13,6 +13,14 @@ def index @people_without_user_count = report.people_without_user_count @payments_without_person = report.payments_without_person @payments_without_person_count = report.payments_without_person_count + @payments_without_lines = report.payments_without_lines + @payments_without_lines_count = report.payments_without_lines_count + @payments_with_mismatched_totals = report.payments_with_mismatched_totals + @payments_with_mismatched_totals_count = report.payments_with_mismatched_totals_count + @legacy_donation_lines = report.legacy_donation_lines + @legacy_donation_lines_count = report.legacy_donation_lines_count + @contribution_invariant_issues = report.contribution_invariant_issues + @contribution_invariant_issues_count = report.contribution_invariant_issues_count @duplicate_people_by_email_count = report.duplicate_people_by_email_count @duplicate_people_by_phone_count = report.duplicate_people_by_phone_count @duplicate_people_by_email_groups = report.duplicate_people_by_email.group_by { |person| person.email.to_s.downcase } diff --git a/app/controllers/admin/membership_types_controller.rb b/app/controllers/admin/membership_types_controller.rb index 7078a115..f4bc6f7d 100644 --- a/app/controllers/admin/membership_types_controller.rb +++ b/app/controllers/admin/membership_types_controller.rb @@ -18,6 +18,7 @@ def show def new @membership_type = MembershipType.new( effective_from: Date.current, + rate_kind: "standard", version: 1, created_by_user: Current.user ) @@ -74,7 +75,7 @@ def set_breadcrumbs end def membership_type_params - params.expect(membership_type: %i[name category price_cents description effective_from version created_by_user_id]) + params.expect(membership_type: %i[name category rate_kind price_cents description effective_from version created_by_user_id]) end end end diff --git a/app/controllers/admin/memberships_controller.rb b/app/controllers/admin/memberships_controller.rb index d16b06b6..a67bc188 100644 --- a/app/controllers/admin/memberships_controller.rb +++ b/app/controllers/admin/memberships_controller.rb @@ -16,43 +16,39 @@ def show @membership_types = MembershipType.all @contribution_formulas = ContributionFormula.all - add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path - add_breadcrumb @person.full_name, admin_user_path("person_#{@person.id}") - add_breadcrumb I18n.t("breadcrumbs.admin.memberships.membership"), nil + add_person_context_breadcrumbs(@person, I18n.t("breadcrumbs.admin.memberships.membership")) end def new @person = Person.find(params[:person_id]) if params[:person_id] - # Gérer l'upgrade d'adhésion if params[:upgrade] == "true" && @person&.current_membership&.basic? - # Pour l'upgrade, on ne propose que les types Circus - @membership_types = MembershipType.circus_types.current_versions.order(:price_cents) + @membership_types = MembershipType.circus_types.available_for(@person).order(:price_cents) @is_upgrade = true @current_membership = @person.current_membership - add_breadcrumb I18n.t("breadcrumbs.admin.memberships.upgrade_to_circus"), nil + add_person_context_breadcrumbs(@person, I18n.t("breadcrumbs.admin.memberships.upgrade_to_circus")) else - # Pour une nouvelle adhésion, on propose tous les types @membership_types = MembershipType.current_versions.order(:price_cents) @is_upgrade = false - add_breadcrumb I18n.t("breadcrumbs.admin.memberships.new_membership"), nil + add_person_context_breadcrumbs(@person, I18n.t("breadcrumbs.admin.memberships.new_membership")) if @person.present? + add_breadcrumb I18n.t("breadcrumbs.admin.memberships.new_membership"), nil unless @person.present? end end def edit @membership = @person.current_membership @membership_types = MembershipType.all - add_breadcrumb I18n.t("breadcrumbs.admin.memberships.edit_membership"), nil + add_person_context_breadcrumbs(@person, I18n.t("breadcrumbs.admin.memberships.edit_membership")) end def create - @person = Person.find(membership_purchase_params[:person_id]) - membership_type = MembershipType.find(membership_purchase_params[:membership_type_id]) + params_hash = membership_purchase_params + membership_type = MembershipType.find(params_hash[:membership_type_id]) - if membership_purchase_params[:upgrade] == "true" && @person.current_membership&.basic? - handle_upgrade_flow(@person, membership_type) + if upgrade_request_for?(@person, params_hash) + handle_upgrade_flow(@person, membership_type, params_hash) else - handle_creation_flow(@person, membership_type) + handle_creation_flow(@person, membership_type, params_hash) end end @@ -68,7 +64,7 @@ def update ).call if result.success? - redirect_to admin_user_path("person_#{@person.id}"), notice: t(".success") + redirect_to admin_person_path(@person), notice: t(".success") else flash[:alert] = result.message redirect_to edit_admin_membership_path(@membership) @@ -130,8 +126,7 @@ def donation_cents_from(params_hash) cents.positive? ? cents : nil end - def handle_upgrade_flow(person, membership_type) - params_hash = membership_purchase_params + def handle_upgrade_flow(person, membership_type, params_hash) result = People::MembershipUpgrader.new( person: person, new_membership_type_id: membership_type.id, @@ -143,15 +138,14 @@ def handle_upgrade_flow(person, membership_type) ).call if result.success? - redirect_to admin_user_path("person_#{person.id}"), notice: build_upgrade_notice(membership_type, result, payment_method_from(params_hash)) + redirect_to admin_person_path(person), notice: build_upgrade_notice(membership_type, result, payment_method_from(params_hash)) else redirect_to new_admin_membership_path(person_id: person.id, upgrade: params_hash[:upgrade]), alert: t("admin.memberships.create.upgrade_failed_alert", message: result.message) end end - def handle_creation_flow(person, membership_type) - params_hash = membership_purchase_params + def handle_creation_flow(person, membership_type, params_hash) result = People::MembershipCreator.new( person: person, membership_type_id: membership_type.id, @@ -167,7 +161,7 @@ def handle_creation_flow(person, membership_type) redirect_to new_admin_membership_path(person_id: person.id), alert: t(".duplicate_active") else - redirect_to admin_user_path("person_#{person.id}"), + redirect_to admin_person_path(person), notice: t(".success_with_contribution_hint") end else @@ -176,6 +170,10 @@ def handle_creation_flow(person, membership_type) end end + def upgrade_request_for?(person, params_hash) + params_hash[:upgrade] == "true" && person.current_membership&.basic? + end + def build_upgrade_notice(membership_type, result, payment_method) message = if payment_method == "offered" diff --git a/app/controllers/admin/payments_controller.rb b/app/controllers/admin/payments_controller.rb index 6eac58f2..6fc3bf94 100644 --- a/app/controllers/admin/payments_controller.rb +++ b/app/controllers/admin/payments_controller.rb @@ -50,66 +50,13 @@ def edit end def create - # Convertir le montant en centimes si fourni en euros - total_cents = payment_params[:total_cents] - total_cents = (total_cents.to_f * 100).to_i if total_cents.present? - - person = Person.find(payment_params[:person_id]) - - result = People::PaymentCreator.new( - person: person, - amount_cents: total_cents, - payment_method: payment_params[:payment_method] || "cash", - recorded_by_id: Current.user&.id, - item_type: "Donation", - item_id: person.id, - description: "Paiement direct", - notes: payment_params[:notes] - ).call - - respond_to do |format| - if result.success? - created_msg = t(".created_notice") - format.html { redirect_to admin_payments_path, notice: created_msg } - format.turbo_stream do - filter_locals = payments_index_filter_params.to_unsafe_h - fresh = payment_for_ui_row(result.payment) - render turbo_stream: [ - turbo_stream.append("payments", partial: "payment_row", - locals: { payment: fresh, list_filter_params: filter_locals }), - turbo_stream.replace("payment-summary", partial: "payment_summary", - locals: payment_summary_locals(payments_index_filter_params)), - turbo_stream.replace("flash", partial: "shared/flash", locals: { notice: created_msg }) - ] - end - else - fail_msg = t(".failure_alert", message: result.message) - err_detail = I18n.t("flash.generic.error_detail", message: result.message) - format.html { redirect_to admin_payments_path, alert: fail_msg } - format.turbo_stream do - render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail }) - end - end - end + result = build_payment_create_result + result.success? ? respond_to_created_payment(result) : respond_to_failed_payment_create(result.message) rescue ActiveRecord::RecordNotFound => e - respond_to do |format| - fail_msg = t("admin.payments.create.failure_alert", message: e.message) - err_detail = I18n.t("flash.generic.error_detail", message: e.message) - format.html { redirect_to admin_payments_path, alert: fail_msg } - format.turbo_stream do - render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail }) - end - end + respond_to_failed_payment_create(e.message) rescue StandardError => e Rails.logger.error("[Admin::PaymentsController#create] #{e.class}: #{e.message}") - respond_to do |format| - fail_msg = t("admin.payments.create.failure_alert", message: e.message) - err_detail = I18n.t("flash.generic.error_detail", message: e.message) - format.html { redirect_to admin_payments_path, alert: fail_msg } - format.turbo_stream do - render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail }) - end - end + respond_to_failed_payment_create(e.message) end # OLD: logique directe (commentée pour rollback) @@ -138,6 +85,7 @@ def update payment_method: payment_params[:payment_method], status: payment_params[:status], notes: payment_params[:notes], + offer_reason: payment_params[:offer_reason], updated_by_id: Current.user.id ).call @@ -155,7 +103,7 @@ def update locals: { payment: fresh, list_filter_params: filter_locals }), turbo_stream.replace("payment-summary", partial: "payment_summary", locals: payment_summary_locals(payments_index_filter_params)), - turbo_stream.replace("flash", partial: "shared/flash", locals: { notice: updated_msg }) + turbo_flash_replace(:notice, updated_msg) ] end else @@ -163,7 +111,7 @@ def update err_detail = I18n.t("flash.generic.error_detail", message: result.message) format.html { redirect_to admin_payment_path(params[:id]), alert: fail_msg } format.turbo_stream do - render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail }) + render turbo_stream: turbo_flash_replace(:alert, err_detail) end end end @@ -194,7 +142,7 @@ def destroy turbo_stream.remove("payment_row_#{result.payment.id}"), turbo_stream.replace("payment-summary", partial: "payment_summary", locals: payment_summary_locals(payments_index_filter_params)), - turbo_stream.replace("flash", partial: "shared/flash", locals: { notice: cancelled_msg }) + turbo_flash_replace(:notice, cancelled_msg) ] end else @@ -202,7 +150,7 @@ def destroy err_detail = I18n.t("flash.generic.error_detail", message: result.message) format.html { redirect_to admin_payments_path, alert: fail_msg } format.turbo_stream do - render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail }) + render turbo_stream: turbo_flash_replace(:alert, err_detail) end end end @@ -239,6 +187,71 @@ def restore FILTER_PARAM_KEYS = Admin::PaymentsHelper::PAYMENTS_INDEX_QUERY_KEYS + def build_payment_create_result + person = Person.find(payment_create_params[:person_id]) + total_cents = normalized_total_cents(payment_create_params[:total_cents]) + + People::PaymentRecorder.new( + person: person, + payment_method: payment_create_params[:payment_method] || "cash", + recorded_by: Current.user, + status: "success", + notes: payment_create_params[:notes], + offer_reason: payment_create_params[:offer_reason], + total_cents: total_cents, + payment_lines: direct_payment_lines(total_cents) + ).call + end + + def direct_payment_lines(total_cents) + [ + { + item_type: "Donation", + amount_cents: total_cents, + description: "Paiement direct" + } + ] + end + + def respond_to_created_payment(result) + created_msg = t(".created_notice") + + respond_to do |format| + format.html { redirect_to admin_payments_path, notice: created_msg } + format.turbo_stream do + filter_locals = payments_index_filter_params.to_unsafe_h + fresh = payment_for_ui_row(result.payment) + render turbo_stream: [ + turbo_stream.append("payments", partial: "payment_row", + locals: { payment: fresh, list_filter_params: filter_locals }), + turbo_stream.replace("payment-summary", partial: "payment_summary", + locals: payment_summary_locals(payments_index_filter_params)), + turbo_flash_replace(:notice, created_msg) + ] + end + end + end + + def respond_to_failed_payment_create(message) + fail_msg = t("admin.payments.create.failure_alert", message: message) + err_detail = I18n.t("flash.generic.error_detail", message: message) + + respond_to do |format| + format.html { redirect_to admin_payments_path, alert: fail_msg } + format.turbo_stream { render turbo_stream: turbo_flash_replace(:alert, err_detail) } + end + end + + def payment_create_params + @payment_create_params ||= payment_params.to_h.symbolize_keys + end + + def normalized_total_cents(total_cents) + return total_cents unless total_cents.present? + + (total_cents.to_f * 100).to_i + end + # Filtres liste : uniquement la query string (pas le corps form PATCH), pour éviter les logs # Strong Parameters « Unpermitted » sur :payment, :authenticity_token, :controller, etc. # Ordre : query courante → GET index → Referer vers la liste. @@ -302,7 +315,7 @@ def payment_summary_locals(filter_params) def payment_params params.expect( - payment: [ :person_id, :recorded_by_id, :total_cents, :payment_method, :status, :notes, + payment: [ :person_id, :recorded_by_id, :total_cents, :payment_method, :status, :notes, :offer_reason, # Compatibilité avec l'ancien modèle :payment_id, :payment_date, :payment_amount, :payment_type, :order_id, :donation, :total_payment ] ) @@ -313,18 +326,7 @@ def set_payments_breadcrumbs person = Person.find_by(id: params[:person_id]) if person add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path - add_breadcrumb person.full_name, admin_user_path("person_#{person.id}") - add_breadcrumb I18n.t("breadcrumbs.admin.payments.history"), nil - return - end - end - - if params[:user_id].present? - user = User.find_by(id: params[:user_id]) - if user - add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path - label = user.full_name.presence || "Utilisateur ##{user.id}" - add_breadcrumb label, admin_user_path(user) + add_breadcrumb person.full_name, admin_user_path(Admin::Users::PersonRouteKey.call(person)) add_breadcrumb I18n.t("breadcrumbs.admin.payments.history"), nil return end @@ -332,5 +334,9 @@ def set_payments_breadcrumbs add_breadcrumb I18n.t("breadcrumbs.admin.payments.history"), nil end + + def turbo_flash_replace(type, message) + turbo_stream.replace("flash", partial: "shared/flash", locals: { type => message }) + end end end diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb index 64aa28f4..1d48c776 100644 --- a/app/controllers/admin/sessions_controller.rb +++ b/app/controllers/admin/sessions_controller.rb @@ -2,22 +2,24 @@ module Admin class SessionsController < BaseController - allow_unauthenticated_access only: %i[new create] - rate_limit to: 10, within: 3.minutes, only: :create, - with: -> { redirect_to new_session_url, alert: I18n.t("admin.sessions.rate_limited_alert") } + skip_before_action :require_admin_zone_access, only: %i[new create] + + rate_limit to: 10, within: 3.minutes, only: :create, + with: -> { redirect_to new_session_url, alert: I18n.t("admin.sessions.rate_limited_alert") } def new - return unless authenticated? + return redirect_to(root_path, status: :see_other) if authenticated? - redirect_to root_path + # Pas de vue dédiée : même formulaire que l’espace public. + redirect_to new_session_path, status: :see_other end def create if (user = User.authenticate_by(params.permit(:email_address, :password))) - start_new_session_for user - redirect_to after_authentication_url, notice: t(".success") + start_new_session_for(user) + redirect_to after_authentication_url, notice: t(".success"), status: :see_other else - redirect_to new_session_path, alert: t(".invalid_credentials") + redirect_to new_session_path, alert: t(".invalid_credentials"), status: :see_other end end diff --git a/app/controllers/admin/subscriptions_controller.rb b/app/controllers/admin/subscriptions_controller.rb deleted file mode 100644 index f273f80f..00000000 --- a/app/controllers/admin/subscriptions_controller.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module Admin - class SubscriptionsController < BaseController - before_action :set_person - - def upgrade - result = People::ContributionUpgrader.new( - person: @person, - from_contribution_id: params[:from_contribution_id] || params[:from_book_id], - to_formula_id: params[:to_formula_id] || params[:to_plan_id], - payment_method: params[:payment_method] || "cash", - recorded_by_id: Current.user.id - ).call - - if result.success? - notice = t(".success_notice") - if result.credit_applied.positive? - notice += t(".credit_applied_suffix", amount: (result.credit_applied / 100.0).round(2)) - end - redirect_to admin_user_path("person_#{@person.id}"), notice: notice - else - redirect_to admin_user_path("person_#{@person.id}"), - alert: t(".failure_alert", message: result.message) - end - rescue StandardError => e - redirect_to admin_user_path("person_#{@person.id}"), - alert: t(".failure_alert", message: e.message) - end - - private - - def set_person - @person = Person.find(params[:person_id]) - end - end -end diff --git a/app/controllers/admin/users/payments_controller.rb b/app/controllers/admin/users/payments_controller.rb index 8035ed8f..84c30de8 100644 --- a/app/controllers/admin/users/payments_controller.rb +++ b/app/controllers/admin/users/payments_controller.rb @@ -9,71 +9,45 @@ module Users # - Managing payment-related operations class PaymentsController < BaseController before_action :set_person - before_action :set_breadcrumbs # GET /admin/users/person_1/payments def index - @payments = @person.payments.includes(:payment_lines, :recorded_by) - .order(created_at: :desc) - .page(params[:page]) + redirect_to filtered_payments_path end # GET /admin/users/person_1/payments/1 def show - @payment = @person.payments.find(params[:id]) + redirect_to filtered_payments_path end # GET /admin/users/person_1/payments/new def new - @payment = @person.payments.build - @payment.recorded_by = Current.user - @membership_types = MembershipType.all - @contribution_formulas = ContributionFormula.all + redirect_to filtered_payments_path end # POST /admin/users/person_1/payments def create normalized_lines = normalize_payment_lines(params[:payment_lines]) + lines = normalized_lines.presence || direct_payment_lines + total_cents = lines.sum { |line| line[:amount_cents].to_i } - total_cents = if normalized_lines.any? - normalized_lines.sum { |line| line[:amount_cents].to_i } - else - payment_params[:total_cents].to_i - end - - service_params = { + result = People::PaymentRecorder.new( person: @person, payment_method: payment_params[:payment_method] || "cash", - recorded_by_id: Current.user&.id, - notes: payment_params[:notes] - } - - if normalized_lines.any? - service_params[:payment_lines] = normalized_lines - service_params[:total_cents] = total_cents - else - first_line = normalized_lines.first - service_params[:amount_cents] = total_cents - service_params[:item_type] = first_line ? first_line[:item_type] : "Donation" - service_params[:item_id] = first_line ? first_line[:item_id] : @person.id - service_params[:description] = first_line ? first_line[:description] : "Paiement" - end - - result = People::PaymentCreator.new(service_params).call + recorded_by: Current.user, + status: "success", + notes: payment_params[:notes], + total_cents: total_cents, + payment_lines: lines + ).call if result.success? - redirect_to admin_user_path("person_#{@person.id}"), notice: t(".success") + redirect_to filtered_payments_path, notice: t(".success") else - @membership_types = MembershipType.all - @contribution_formulas = ContributionFormula.all - flash.now[:alert] = t(".failure_alert", message: result.message) - render :new, status: :unprocessable_content + redirect_to filtered_payments_path, alert: t(".failure_alert", message: result.message) end rescue StandardError => e - @membership_types = MembershipType.all - @contribution_formulas = ContributionFormula.all - flash.now[:alert] = t(".failure_alert", message: e.message) - render :new, status: :unprocessable_content + redirect_to filtered_payments_path, alert: t(".failure_alert", message: e.message) end # PATCH /admin/users/person_1/payments/1 @@ -93,9 +67,9 @@ def update ).call if result.success? - redirect_to admin_user_path("person_#{@person.id}"), notice: t(".success") + redirect_to filtered_payments_path, notice: t(".success") else - redirect_to admin_user_path("person_#{@person.id}"), alert: t(".failure_alert", message: result.message) + redirect_to filtered_payments_path, alert: t(".failure_alert", message: result.message) end end @@ -110,9 +84,9 @@ def destroy ).call if result.success? - redirect_to admin_user_path("person_#{@person.id}"), notice: t(".destroyed") + redirect_to filtered_payments_path, notice: t(".destroyed") else - redirect_to admin_user_path("person_#{@person.id}"), alert: t(".failure_alert", message: result.message) + redirect_to filtered_payments_path, alert: t(".failure_alert", message: result.message) end end @@ -129,15 +103,15 @@ def process_payment ).call if result.success? - redirect_to admin_user_path("person_#{@person.id}"), notice: t(".processed") + redirect_to filtered_payments_path, notice: t(".processed") else - redirect_to admin_user_path("person_#{@person.id}"), alert: t(".failure_alert", message: result.message) + redirect_to filtered_payments_path, alert: t(".failure_alert", message: result.message) end else - redirect_to admin_user_path("person_#{@person.id}"), notice: t(".already_processed") + redirect_to filtered_payments_path, notice: t(".already_processed") end rescue StandardError => e - redirect_to admin_user_path("person_#{@person.id}"), alert: t(".failure_alert", message: e.message) + redirect_to filtered_payments_path, alert: t(".failure_alert", message: e.message) end end @@ -147,8 +121,8 @@ def set_person identifier = params[:person_id].presence || params[:user_id].presence raise ActiveRecord::RecordNotFound, "person identifier missing" if identifier.blank? - if identifier.to_s.start_with?("person_") - person_id = identifier.to_s.delete_prefix("person_") + if Admin::Users::PersonRouteKey.person_identifier?(identifier) + person_id = Admin::Users::PersonRouteKey.extract(identifier) @person = Person.find(person_id) else user = User.find(identifier) @@ -156,12 +130,6 @@ def set_person end end - def set_breadcrumbs - add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path - add_breadcrumb @person.full_name, admin_user_path("person_#{@person.id}") - add_breadcrumb I18n.t("breadcrumbs.admin.payments.management"), nil - end - def payment_params params.expect( payment: %i[total_cents @@ -178,6 +146,20 @@ def normalize_payment_lines(lines_param) line.symbolize_keys end end + + def direct_payment_lines + [ + { + item_type: "Donation", + amount_cents: payment_params[:total_cents].to_i, + description: "Paiement direct" + } + ] + end + + def filtered_payments_path + admin_payments_path(person_id: @person.id) + end end end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 5f2086af..9089d5ed 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -9,6 +9,9 @@ module Admin # The public UsersController in contrast only handles self-service actions # for individual users managing their own profiles. class UsersController < BaseController + include NewsletterParamParser + include Admin::Users::ParameterHandling + include Admin::Users::UpdateHandling before_action :set_user, only: %i[edit update destroy] before_action :set_breadcrumbs, except: %i[index new] before_action :check_deletion_permissions, only: [ :destroy ] @@ -16,28 +19,14 @@ class UsersController < BaseController # GET /admin/users or /admin/users.json def index - # Base query avec eager loading optimisé - ne montrer que les Person principales - @people = PersonQuery.active.main_people.includes( - :user, - memberships: :membership_type, - contributions: :contribution_formula - ) - - # Filtres - apply_person_filters - - # Recherche - apply_person_search - - # Tri - @people = @people.order(:last_name, :first_name) + people_scope = Admin::Users::IndexQuery.new(params).call # Pagination - Réduire à 15 éléments pour une meilleure lisibilité (ou paramètre items) items_per_page = params[:items]&.to_i || 15 - @pagy, @people = pagy(@people, items: items_per_page) + @pagy, @people = pagy(people_scope, items: items_per_page) # Statistiques pour le dashboard (basées sur les Person principales) - statistics_service = Admin::DashboardStatisticsService.new(base_people: @people) + statistics_service = Admin::DashboardStatisticsService.new(base_people: people_scope) statistics = statistics_service.call @total_people = statistics[:total_people] @people_with_user = statistics[:people_with_user] @@ -53,65 +42,10 @@ def index # GET /admin/users/1 or /admin/users/1.json def show - # Adapter pour accepter les ID de Person ET de User - if params[:id].to_s.start_with?("person_") - # ID de Person (format: person_123) - person_id = params[:id].gsub("person_", "") - - # Chercher d'abord dans les Person actives - @person = PersonQuery.active.includes(:user, memberships: :membership_type, contributions: :contribution_formula, payments: %i[payment_lines recorded_by]) - .find_by(id: person_id) - - # Si Person archivée (fusion), rediriger vers la liste - if @person.nil? - archived_person = Person.find_by(id: person_id) - raise ActiveRecord::RecordNotFound if archived_person&.deleted_at.blank? - - redirect_to admin_users_path, notice: t(".merged_person_notice") - return - - end - @user = @person.user # Peut être nil - - # Si pas de User, créer un User temporaire pour la vue - if @user.nil? - @user = User.new( - id: "temp_#{@person.id}", - email_address: @person.email, - system_role: nil # Pas de rôle pour une Person sans User - ) - # Établir la relation person manuellement - @user.association(:person).target = @person - @user.association(:person).loaded! - @is_person_without_user = true - else - @is_person_without_user = false - end - - # Données pour les formulaires - @membership_types = MembershipType.all - @contribution_formulas = ContributionFormula.all - @users = User.where(person: nil) # Users non liés - @recent_payments = @person.payments.includes(:payment_lines, :recorded_by).order(created_at: :desc).limit(10) - - add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path - add_breadcrumb @person.full_name, nil - + if person_identifier?(params[:id]) + return unless load_show_context_for_person else - # ID de User (format classique) - @user = User.unscoped.includes( - :person, - :memberships, - payments: { payment_lines: :item } - ).find_by(id: params[:id]) - - @person = @user.person - @array_right = available_roles_for_user(@user) - @is_person_without_user = false - @recent_payments = @person&.payments&.includes(:payment_lines, :recorded_by)&.order(created_at: :desc)&.limit(10) || [] - - add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path - add_breadcrumb @user&.person&.full_name.present? ? @user.person.full_name : "Utilisateur ##{@user.id}", nil + return unless load_show_context_for_user end respond_to do |format| @@ -132,7 +66,7 @@ def new @user.email_address = @person.email @user.system_role = "web_visitor" # Rôle par défaut add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path - add_breadcrumb @person.full_name, admin_user_path("person_#{@person.id}") + add_breadcrumb @person.full_name, admin_user_path(person_route_key(@person)) add_breadcrumb I18n.t("breadcrumbs.admin.users.create_web_account"), nil else add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path @@ -142,17 +76,17 @@ def new # GET /admin/users/person_1/edit_person def edit_person - person_id = params[:id].to_s.gsub("person_", "") + person_id = extracted_person_id(params[:id]) @person = PersonQuery.active.find(person_id) add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path - add_breadcrumb @person.full_name, admin_user_path("person_#{@person.id}") + add_breadcrumb @person.full_name, admin_user_path(person_route_key(@person)) add_breadcrumb I18n.t("breadcrumbs.admin.common.edit"), nil end # GET /admin/users/1/edit def edit # Adapter pour gérer les Person - if params[:id].to_s.start_with?("person_") + if person_identifier?(params[:id]) # Rediriger vers l'édition Person redirect_to edit_person_admin_user_path(params[:id]) return @@ -191,7 +125,7 @@ def create result = form.call if result.success? - redirect_to admin_user_path("person_#{result.person.id}"), notice: result.message + redirect_to admin_user_path(person_route_key(result.person)), notice: result.message else @user = User.new @user.person = result.person if result.person @@ -202,116 +136,16 @@ def create # PATCH/PUT /admin/users/1 or /admin/users/1.json def update - # Adapter pour gérer les Person - if params[:id].to_s.start_with?("person_") - person_id = params[:id].to_s.gsub("person_", "") - @person = PersonQuery.active.find(person_id) - - person_attributes = person_params.to_h.deep_symbolize_keys - newsletter_flag = ActiveModel::Type::Boolean.new.cast(person_attributes.delete(:newsletter_subscribed)) - - result = People::Register.new( - person_params: person_attributes.merge(allow_blank_attributes: true), - existing_person: @person, - newsletter_subscribed: newsletter_flag, - newsletter_source: "admin", - create_user_account: false, - create_membership: false - ).call - - if result.success? - updated_person = result.person || @person - # Handle AJAX requests for inline editing - if request.xhr? - render json: { - success: true, - member_number: updated_person.reload.member_number, - message: t(".ajax_success_json_message") - } - else - redirect_to admin_user_path("person_#{updated_person.id}"), notice: t(".person_saved_notice") - end - elsif request.xhr? - render json: { - success: false, - errors: result.errors - }, status: :unprocessable_content - else - flash.now[:alert] = result.message - render :edit_person, status: :unprocessable_content - end - return - end + return handle_person_update if person_identifier?(params[:id]) - respond_to do |format| - # Séparer les paramètres User des paramètres Person - user_only_params = user_params.slice(:email_address, :system_role, :created_by_admin, :create_web_account) - person_params_flat = user_params.except(:email_address, :system_role, :created_by_admin, :create_web_account, :person) - newsletter_flag = person_params_flat.delete(:newsletter_subscribed) - - # Utiliser le service UserManagement::UserUpdater - updater = UserManagement::UserUpdater.new( - user_id: @user.id, - email_address: user_only_params[:email_address], - system_role: user_only_params[:system_role], - person_attributes: person_params_flat, - newsletter_subscribed: [ "1", true, 1 ].include?(newsletter_flag), - updated_by_id: Current.user.id - ) - - result = updater.call - - if result.success? - format.html { redirect_to admin_user_path(@user), notice: t(".html_updated") } - format.json { render json: @user } - format.turbo_stream do - flash.now[:notice] = t(".turbo_notice") - render turbo_stream: [ - turbo_stream.replace(@user), - turbo_stream.replace("flash", partial: "shared/flash") - ] - end - else - format.html { render :show, status: :unprocessable_content, alert: result.message } - format.json { render json: { errors: result.errors }, status: :unprocessable_content } - format.turbo_stream do - render turbo_stream: turbo_stream.replace( - "error_explanation", - partial: "shared/error_messages", - locals: { resource: @user, errors: result.errors } - ) - end - end - end + handle_user_update end # DELETE /admin/users/1 or /admin/users/1.json def destroy # Adapter pour gérer les Person - if params[:id].to_s.start_with?("person_") - # Supprimer la Person (déjà chargée dans set_user) - person = @person - - # Debug: vérifier si @person est défini - if person.nil? - Rails.logger.error "DEBUG: @person is nil for params[:id] = #{params[:id]}" - redirect_to admin_users_path, alert: t(".person_not_found_alert") and return - end - - # Utiliser le service UserManagement::UserDeleter - deleter = UserManagement::UserDeleter.new( - person_id: person.id, - deleted_by_id: current_user.id, - reason: "Suppression via interface admin" - ) - - result = deleter.call - - if result.success? - redirect_to admin_users_path, status: :see_other, notice: t(".person_deleted_notice") - else - redirect_to admin_users_path, alert: t(".destruction_failed_alert_html", message: result.message) - end + if person_identifier?(params[:id]) + destroy_person_entity return end @@ -342,9 +176,9 @@ def restore # Use callbacks to share common setup or constraints between actions. def set_user # Adapter pour gérer les IDs de Person (format: person_123) - if params[:id].to_s.start_with?("person_") + if person_identifier?(params[:id]) # Pour les Person, charger la Person - person_id = params[:id].gsub("person_", "") + person_id = extracted_person_id(params[:id]) @person = Person.find_by(id: person_id) # If person not found, redirect to index with alert @@ -374,147 +208,6 @@ def check_deletion_permissions redirect_to admin_users_path, alert: I18n.t("admin.users.check_deletion_permissions.higher_privileges") end - # Méthodes privées pour les filtres et la recherche - def apply_person_filters - case params[:filter] - when "with_active_membership" - @people = @people.with_active_membership - when "with_expiring_membership" - @people = @people.with_expiring_membership - when "with_expired_membership" - @people = @people.with_expired_membership - when "without_membership" - @people = @people.without_membership - when "with_user_account" - @people = @people.with_user_account - when "without_user_account" - @people = @people.without_user_account - end - end - - def apply_person_search - @people = @people.search_by_contact(params[:search]) if params[:search].present? - end - - # Only allow a list of trusted parameters through. - def user_params - params.expect( - user: [ :email_address, - :system_role, - :created_by_admin, - :create_web_account, - # Attributs délégués à Person (paramètres plats) - :first_name, - :last_name, - :email, - :phone, - :birth_date, - :address, - :emergency_contact_name, - :emergency_contact_phone, - :notes, - :specialty, - :is_minor, - :image_rights, - :get_involved, - :newsletter_subscribed, - :dyslexic_font, - :zip_code, - :town, - :country, - :reduced_rate_eligible, - :reduced_rate_reason, - :reduced_rate_proof, - # Paramètres imbriqués (pour compatibilité) - { - person: %i[ - id - first_name - last_name - email - phone - birth_date - address - emergency_contact_name - emergency_contact_phone - notes - specialty - is_minor - image_rights - get_involved - newsletter_subscribed - dyslexic_font - zip_code - town - country - reduced_rate_eligible - reduced_rate_reason - reduced_rate_proof - ] - } ] - ) - end - - def user_creation_params - # Extraire les paramètres de person et les aplatir pour le formulaire - person_params = params.dig(:user, :person) || {} - - { - first_name: person_params[:first_name], - last_name: person_params[:last_name], - email: person_params[:email], - phone: person_params[:phone], - address: person_params[:address], - zip_code: person_params[:zip_code], - town: person_params[:town], - country: person_params[:country], - birth_date: person_params[:birth_date], - emergency_contact_name: person_params[:emergency_contact_name], - emergency_contact_phone: person_params[:emergency_contact_phone], - notes: person_params[:notes], - specialty: person_params[:specialty], - is_minor: person_params[:is_minor], - image_rights: person_params[:image_rights], - get_involved: person_params[:get_involved], - newsletter_subscribed: person_params[:newsletter_subscribed], - dyslexic_font: person_params[:dyslexic_font], - reduced_rate_eligible: person_params[:reduced_rate_eligible], - reduced_rate_reason: person_params[:reduced_rate_reason], - reduced_rate_proof: person_params[:reduced_rate_proof], - create_web_account: params.dig(:user, :create_web_account), - email_address: params.dig(:user, :email_address) || person_params[:email], - system_role: params.dig(:user, :system_role), - create_membership: params.dig(:user, :create_membership), - membership_type_id: params.dig(:user, :membership_type_id), - payment_method: params.dig(:user, :payment_method), - person_id: params.dig(:user, :person_id) - }.compact - end - - def person_params - params.expect( - person: %i[first_name - last_name - email - phone - address - zip_code - town - country - birth_date - emergency_contact_name - emergency_contact_phone - notes - newsletter_subscribed - get_involved - image_rights - is_minor - reduced_rate_eligible - reduced_rate_reason - reduced_rate_proof] - ) - end - def available_roles_for_user(user) return [] if user.nil? @@ -534,5 +227,106 @@ def require_super_admin redirect_to admin_users_path, alert: I18n.t("admin.users.require_super_admin.restore_denied_alert") end + + def person_identifier?(raw_id) + Admin::Users::PersonRouteKey.person_identifier?(raw_id) + end + + def extracted_person_id(raw_id) + Admin::Users::PersonRouteKey.extract(raw_id) + end + + def person_route_key(person_or_id) + Admin::Users::PersonRouteKey.call(person_or_id) + end + + def load_recent_payments(person) + return [] unless person + + PaymentQuery.with_person_and_recorded_by + .where(person_id: person.id) + .order(created_at: :desc) + .limit(10) + end + + def add_admin_user_breadcrumbs(label) + add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path + add_breadcrumb label, nil + end + + def user_label(user) + user&.person&.full_name.presence || "Utilisateur ##{user.id}" + end + + def load_show_context_for_person + person_id = extracted_person_id(params[:id]) + @person = PersonQuery.active.includes(:user, memberships: :membership_type, contributions: :contribution_formula, payments: %i[payment_lines recorded_by]) + .find_by(id: person_id) + + return handle_missing_person_in_show(person_id) if @person.nil? + + @user = @person.user + @is_person_without_user = @user.nil? + @user = Admin::Users::ViewUserAdapter.from_person(@person) if @is_person_without_user + @membership_types = MembershipType.all + @contribution_formulas = ContributionFormula.all + @users = User.where(person: nil) + @recent_payments = load_recent_payments(@person) + add_admin_user_breadcrumbs(@person.full_name) + true + end + + def handle_missing_person_in_show(person_id) + archived_person = Person.find_by(id: person_id) + raise ActiveRecord::RecordNotFound if archived_person&.deleted_at.blank? + + redirect_to admin_users_path, notice: t(".merged_person_notice") + false + end + + def load_show_context_for_user + @user = User.unscoped.includes( + :person, + :memberships, + payments: { payment_lines: :item } + ).find_by(id: params[:id]) + + if @user.nil? + respond_to do |format| + format.html do + redirect_to admin_users_path, alert: I18n.t("admin.users.set_user.person_or_user_missing_alert") + end + format.json { head :not_found } + end + return false + end + + @person = @user.person + @array_right = available_roles_for_user(@user) + @is_person_without_user = false + @recent_payments = load_recent_payments(@person) + add_admin_user_breadcrumbs(user_label(@user)) + true + end + + def destroy_person_entity + person = @person + if person.nil? + redirect_to admin_users_path, alert: t(".person_not_found_alert") + return + end + + deleter = UserManagement::UserDeleter.new( + person_id: person.id, + deleted_by_id: current_user.id, + reason: "Suppression via interface admin" + ) + result = deleter.call + if result.success? + redirect_to admin_users_path, status: :see_other, notice: t(".person_deleted_notice") + else + redirect_to admin_users_path, alert: t(".destruction_failed_alert_html", message: result.message) + end + end end end diff --git a/app/controllers/blogs_controller.rb b/app/controllers/blogs_controller.rb index 64021e97..0c7a4c7d 100644 --- a/app/controllers/blogs_controller.rb +++ b/app/controllers/blogs_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class BlogsController < ApplicationController + skip_before_action :require_authentication, only: %i[latest] before_action :set_blog, only: %i[show] # def article diff --git a/app/controllers/concerns/admin/users/parameter_handling.rb b/app/controllers/concerns/admin/users/parameter_handling.rb new file mode 100644 index 00000000..5106cfe2 --- /dev/null +++ b/app/controllers/concerns/admin/users/parameter_handling.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Admin + module Users + module ParameterHandling + extend ActiveSupport::Concern + + PERSON_FORM_KEYS = %i[ + first_name + last_name + email + phone + birth_date + address + emergency_contact_name + emergency_contact_phone + notes + specialty + is_minor + image_rights + get_involved + newsletter_subscribed + dyslexic_font + zip_code + town + country + reduced_rate_eligible + reduced_rate_reason + reduced_rate_proof + ].freeze + + USER_CONTROL_KEYS = %i[ + email_address + system_role + created_by_admin + create_web_account + ].freeze + + PERSON_EDIT_KEYS = %i[ + first_name + last_name + email + phone + address + zip_code + town + country + birth_date + emergency_contact_name + emergency_contact_phone + notes + newsletter_subscribed + get_involved + image_rights + is_minor + reduced_rate_eligible + reduced_rate_reason + reduced_rate_proof + ].freeze + + USER_EXPECTED_KEYS = [ + *USER_CONTROL_KEYS, + *PERSON_FORM_KEYS, + { person: %i[id] + PERSON_FORM_KEYS } + ].freeze + + private + + def user_params + params.expect(user: USER_EXPECTED_KEYS) + end + + def user_creation_params + person_attributes = nested_user_person_params + + person_attributes.slice(*PERSON_FORM_KEYS).merge( + create_web_account: params.dig(:user, :create_web_account), + email_address: params.dig(:user, :email_address) || person_attributes[:email], + system_role: params.dig(:user, :system_role), + create_membership: params.dig(:user, :create_membership), + membership_type_id: params.dig(:user, :membership_type_id), + payment_method: params.dig(:user, :payment_method), + person_id: params.dig(:user, :person_id) + ).compact + end + + def person_params + params.expect(person: PERSON_EDIT_KEYS) + end + + def nested_user_person_params + raw_person_params = params.dig(:user, :person) + return {} unless raw_person_params.respond_to?(:permit) + + raw_person_params.permit(*PERSON_FORM_KEYS).to_h.symbolize_keys + end + end + end +end diff --git a/app/controllers/concerns/admin/users/update_handling.rb b/app/controllers/concerns/admin/users/update_handling.rb new file mode 100644 index 00000000..cf6ac900 --- /dev/null +++ b/app/controllers/concerns/admin/users/update_handling.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Admin + module Users + module UpdateHandling + extend ActiveSupport::Concern + + USER_UPDATE_CONTROL_KEYS = %i[email_address system_role created_by_admin create_web_account].freeze + + private + + def handle_person_update + @person = PersonQuery.active.find(extracted_person_id(params[:id])) + result = build_person_update_result(@person) + + return respond_to_successful_person_update(result.person || @person) if result.success? + return respond_to_failed_person_update(result) unless request.xhr? + + render json: { success: false, errors: result.errors }, status: :unprocessable_content + end + + def handle_user_update + respond_to do |format| + result = build_user_update_result + result.success? ? respond_to_successful_user_update(format) : respond_to_failed_user_update(format, result) + end + end + + def build_person_update_result(person) + person_attributes = person_params.to_h.deep_symbolize_keys + newsletter_flag = ActiveModel::Type::Boolean.new.cast(person_attributes.delete(:newsletter_subscribed)) + + People::Register.new( + person_params: person_attributes.merge(allow_blank_attributes: true), + existing_person: person, + newsletter_subscribed: newsletter_flag, + newsletter_source: "admin", + create_user_account: false, + create_membership: false + ).call + end + + def respond_to_successful_person_update(person) + if request.xhr? + render json: { + success: true, + member_number: person.reload.member_number, + message: t(".ajax_success_json_message") + } + else + redirect_to admin_user_path(person_route_key(person)), notice: t(".person_saved_notice") + end + end + + def respond_to_failed_person_update(result) + flash.now[:alert] = result.message + render :edit_person, status: :unprocessable_content + end + + def build_user_update_result + user_only_params, person_params_flat, newsletter_subscribed_value = extracted_user_update_attributes + + UserManagement::UserUpdater.new( + user_id: @user.id, + email_address: user_only_params[:email_address], + system_role: user_only_params[:system_role], + person_attributes: person_params_flat, + newsletter_subscribed: newsletter_subscribed_value, + updated_by_id: Current.user.id + ).call + end + + def extracted_user_update_attributes + permitted_params = user_params + user_only_params = permitted_params.slice(*USER_UPDATE_CONTROL_KEYS) + person_params_flat = permitted_params.except(*USER_UPDATE_CONTROL_KEYS, :person) + newsletter_subscribed_value = extract_newsletter_subscribed!( + source_params: permitted_params, + person_params: person_params_flat + ) + + [ user_only_params, person_params_flat, newsletter_subscribed_value ] + end + + def respond_to_successful_user_update(format) + format.html { redirect_to admin_user_path(@user), notice: t(".html_updated") } + format.json { render json: @user } + format.turbo_stream do + flash.now[:notice] = t(".turbo_notice") + render turbo_stream: [ + turbo_stream.replace(@user), + turbo_stream.replace("flash", partial: "shared/flash") + ] + end + end + + def respond_to_failed_user_update(format, result) + format.html { render :show, status: :unprocessable_content, alert: result.message } + format.json { render json: { errors: result.errors }, status: :unprocessable_content } + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "error_explanation", + partial: "shared/error_messages", + locals: { resource: @user, errors: result.errors } + ) + end + end + end + end +end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 2b3604ec..339ac88a 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -33,27 +33,90 @@ def resume_session end def find_session_by_cookie - Session.find_by(id: cookies.signed[:session_id]) + session_id = cookies.signed[:session_id] + return nil if session_id.blank? + + found = Session.find_by(id: session_id) + if found.nil? + cookies.delete(:session_id, **SESSION_COOKIE_OPTS) + Rails.logger.warn( + "[auth.audit] stale_session_cookie cookie_session_id=#{session_id} path=#{request.path} method=#{request.request_method}" + ) + return nil + end + + user = User.find_by(id: found.user_id) + if user.nil? || user.deleted? + reason = user.nil? ? "missing_user" : "user_deleted" + db_session_id = found.id + db_user_id = found.user_id + found.destroy + cookies.delete(:session_id, **SESSION_COOKIE_OPTS) + Rails.logger.warn( + "[auth.audit] invalid_session_cookie reason=#{reason} cookie_session_id=#{session_id} " \ + "db_session_id=#{db_session_id} user_id=#{db_user_id} path=#{request.path} method=#{request.request_method}" + ) + return nil + end + + found end def request_authentication - session[:return_to_after_authenticating] = request.url + begin + uri = URI.parse(request.url) + path = uri.path.to_s.presence || "/" + session[:return_to_after_authenticating] = request.url unless non_gettable_redirect_path?(path) + rescue URI::InvalidURIError + session[:return_to_after_authenticating] = request.url + end + redirect_to new_session_path end + # Cible après login : ne jamais rediriger vers une URL en GET qui n'existe pas + # (ex. /session et /registration sont POST-only → 303 après POST créait GET /session → RoutingError). def after_authentication_url - session.delete(:return_to_after_authenticating) || root_url + stored = session.delete(:return_to_after_authenticating) + safe_url_after_authentication(stored) end + def safe_url_after_authentication(stored) + return root_path if stored.blank? + + uri = URI.parse(stored.to_s.strip) + + if uri.host.present? + return root_path unless uri.host == request.host && uri.port == request.port + end + + path = uri.path.to_s + path = "/" if path.blank? + return root_path if non_gettable_redirect_path?(path) + + stored.to_s + rescue URI::InvalidURIError + root_path + end + + def non_gettable_redirect_path?(path) + path == "/session" || path == "/registration" + end + + # Même jeu d’options à la pose et à la suppression du cookie (évite un cookie « mort »). + SESSION_COOKIE_OPTS = { httponly: true, same_site: :lax, path: "/" }.freeze + def start_new_session_for(user) user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| Current.session = session - cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } + cookies.signed.permanent[:session_id] = { value: session.id, **SESSION_COOKIE_OPTS } end end def terminate_session - Current.session.destroy - cookies.delete(:session_id) + Current.session&.destroy + ensure + Current.session = nil + cookies.delete(:session_id, **SESSION_COOKIE_OPTS) end end diff --git a/app/controllers/concerns/newsletter_param_parser.rb b/app/controllers/concerns/newsletter_param_parser.rb new file mode 100644 index 00000000..e49fdc38 --- /dev/null +++ b/app/controllers/concerns/newsletter_param_parser.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module NewsletterParamParser + private + + # Returns nil when newsletter key is absent, to avoid implicit unsubscribe. + def extract_newsletter_subscribed!(source_params:, person_params:) + newsletter_explicit = source_params.key?(:newsletter_subscribed) + newsletter_flag = person_params.delete(:newsletter_subscribed) + return nil unless newsletter_explicit + + [ "1", true, 1 ].include?(newsletter_flag) + end +end diff --git a/app/controllers/concerns/profile_section_turbo_streams.rb b/app/controllers/concerns/profile_section_turbo_streams.rb new file mode 100644 index 00000000..e75e0061 --- /dev/null +++ b/app/controllers/concerns/profile_section_turbo_streams.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# Builds Turbo Stream responses for embedded profile sections (contact + account). +module ProfileSectionTurboStreams + extend ActiveSupport::Concern + + private + + def profile_contact_section_update_streams(user) + [ + profile_contact_section_replace_action(user), + profile_flash_replace_action + ] + end + + def profile_account_section_update_streams(user, embedded:, compact:) + [ + profile_account_section_replace_action(user, embedded: embedded, compact: compact), + profile_flash_replace_action + ] + end + + def profile_contact_section_replace_action(user) + turbo_stream.replace( + ProfileSectionDomIds::CONTACT_SECTION, + render_to_string( + partial: "users/contact_section", + locals: { + user: user, + frame_id: ProfileSectionDomIds::CONTACT_SECTION + } + ) + ) + end + + def profile_account_section_replace_action(user, embedded:, compact:) + turbo_stream.replace( + ProfileSectionDomIds::ACCOUNT_SECTION, + render_to_string( + partial: "settings/account_section", + locals: { + user: user, + frame_id: ProfileSectionDomIds::ACCOUNT_SECTION, + embedded: embedded, + compact: compact + } + ) + ) + end + + def profile_flash_replace_action + turbo_stream.replace( + ProfileSectionDomIds::FLASH_FRAME, + render_to_string(partial: "shared/flash") + ) + end +end diff --git a/app/controllers/contacts_controller.rb b/app/controllers/contacts_controller.rb index 0d46473f..6cf53629 100644 --- a/app/controllers/contacts_controller.rb +++ b/app/controllers/contacts_controller.rb @@ -4,6 +4,9 @@ class ContactsController < ApplicationController allow_unauthenticated_access only: :create CONTACT_SUBMISSION_KEYS = %i[name email message category].freeze + LEGACY_CONTACT_CATEGORY_TO_CANONICAL = { + "residence" => "creative_hosting" + }.freeze def create @contact = contact_submission_params @@ -13,23 +16,25 @@ def create return end - recipient_email = case @contact[:category] - when "technical" - ENV.fetch("CONTACT_EMAIL_TECHNICAL", nil) - when "residence" - ENV.fetch("CONTACT_EMAIL_RESIDENCE", nil) - when "partnership" - ENV.fetch("CONTACT_EMAIL_PARTNERSHIP", nil) - else - ENV.fetch("CONTACT_EMAIL_GENERAL", nil) - end + category = canonical_contact_category(@contact[:category]) + recipient_email = + case category + when "technical" + ENV.fetch("CONTACT_EMAIL_TECHNICAL", nil) + when "creative_hosting" + ENV.fetch("CONTACT_EMAIL_CREATIVE_HOSTING", nil) || ENV.fetch("CONTACT_EMAIL_RESIDENCE", nil) + when "partnership" + ENV.fetch("CONTACT_EMAIL_PARTNERSHIP", nil) + else + ENV.fetch("CONTACT_EMAIL_GENERAL", nil) + end begin UserMailer.contact_email( @contact[:name], @contact[:email], @contact[:message], - @contact[:category], + category, recipient_email ).deliver_later @@ -50,6 +55,11 @@ def create private + def canonical_contact_category(raw) + key = raw.to_s + LEGACY_CONTACT_CATEGORY_TO_CANONICAL.fetch(key, key) + end + # Formulaire public : champs à la racine (`name`, `email`, …), pas `contact[...]`. # Toujours inclure les clés requises : `permit` seul omet les paramètres absents, ce qui faisait # passer des requêtes incomplètes jusqu’au mailer. diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 89d38411..3c2ed544 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class EventsController < ApplicationController - skip_before_action :require_authentication, only: %i[index show upcoming] + skip_before_action :require_authentication, only: %i[index show upcoming past] def index redirect_to page_path("news", anchor: "evenements") diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 3124e615..4495f79f 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -35,7 +35,6 @@ def show if params[:id] == "contact_us" @contact = {} - @faqs = contact_faq_entries end @adhesion_faqs = adhesion_faq_entries if params[:id] == "become_member" @@ -60,29 +59,78 @@ def show private def contact_faq_entries + cf = page_path("contact_us", anchor: "contact-form") + lc = "text-[#5836A5] underline hover:text-[#412886]" [ - { question: "Comment adhérer au Circographe ?", answer: "Passe sur un créneau d'ouverture : on remplit la fiche ensemble et on t'explique le fonctionnement." }, - { question: "Puis-je réserver un créneau de résidence ?", answer: "Oui, écris-nous via la catégorie 'Résidence'. Nous te recontacterons avec les disponibilités et modalités." }, - { question: "Le lieu est-il accessible aux débutant·es ?", answer: "Les entraînements libres sont destinés aux personnes autonomes. Pour débuter, on recommande une école partenaire : contacte-nous pour des conseils." }, - { question: "Proposez-vous des prestations ou des partenariats ?", answer: "Oui, nous travaillons avec des structures culturelles, établissements scolaires et entreprises. Sélectionne la catégorie 'Partenariat' pour en discuter." } + { + question: "Demander un temps d’accueil en création", + answer_html: helpers.safe_join([ + "Écris-nous avec le ", + helpers.link_to("formulaire Contact", cf, class: lc), + ", catégorie « Temps d’accueil en création » — on te répond sur les dispo et le cadre." + ]) + }, + { + question: "Partenariat, atelier, événement ou projet avec le lieu", + answer_html: helpers.safe_join([ + "Le plus simple : passer lors d’un créneau d’ouverture avec ton idée. Tu peux aussi utiliser le ", + helpers.link_to("formulaire Contact", cf, class: lc), + " (Partenariat ou Question générale). Les bénévoles t’orientent." + ]) + } ] end def adhesion_faq_entries + cf = page_path("contact_us", anchor: "contact-form") + lc = "text-[#5836A5] underline hover:text-[#412886]" [ - { question: "Puis-je adhérer en ligne ?", answer: "L'inscription se fait uniquement sur place afin de te présenter le lieu et les règles d'autogestion." }, - { question: "Quels moyens de paiement acceptez-vous ?", answer: "Carte bancaire et espèces. Une adhésion de soutien peut également être effectuée par virement sur demande." }, - { question: "Faut-il être autonome pour les entraînements libres ?", answer: "Oui, les créneaux libres s'adressent aux pratiquant·es autonomes. Pour débuter, on peut te recommander des écoles partenaires." }, - { question: "Puis-je proposer un atelier ou un événement ?", answer: "Tout est possible ! Passe nous voir avec ton idée, on regardera ensemble comment l'inscrire dans la programmation." } + { + question: "Comment adhérer ?", + answer: "Sur place, lors d’un créneau d’accueil : visite du lieu, fiche d’adhésion et explication de l’autogestion. Pas d’inscription en ligne — on fait ça ensemble au lieu pour que chacun·e parte avec les mêmes repères." + }, + { + question: "Paiement adhésion ou cotisation", + answer: "Carte bancaire ou espèces à l’accueil, avec les bénévoles." + }, + { + question: "Je débute : les entraînements libres me concernent ?", + answer_html: helpers.safe_join([ + "Les créneaux libres cirque sont pour des pratiquant·es autonomes en sécurité. Pour apprendre les bases, une école partenaire ; pour une orientation, ", + helpers.link_to("écris-nous", cf, class: lc), + " en Question générale." + ]) + } ] end def general_faq_entries + cf = page_path("contact_us", anchor: "contact-form") + bm = page_path("become_member", anchor: "tarifs") + lc = "text-[#5836A5] underline hover:text-[#412886]" [ - { question: "Où se situe le Circographe ?", answer: "Au 27 bis allée Maurice Sarraut, Toulouse — dans le quartier de la Cartoucherie. Consulte la page Contact pour la carte et l'accès." }, - { question: "Quels sont les horaires d'ouverture ?", answer: "Les créneaux publics évoluent chaque saison ; on les met à jour sur les pages Accueil, Adhérer et Contact. Pense à vérifier avant de te déplacer." }, - { question: "Comment soutenir financièrement le projet ?", answer: "En adhérant, en souscrivant à l'adhésion soutien ou en faisant un don ponctuel. Écris-nous si tu souhaites devenir partenaire." }, - { question: "J'ai une question administrative, qui contacter ?", answer: "Utilise le formulaire de contact (catégorie 'Question générale') ou écris à contact@circographe.fr ; l'équipe bénévole te répondra rapidement." } + { + question: "Adresse et accès", + answer_html: helpers.safe_join([ + "97 bis boulevard de Suisse, 31200 Toulouse. ", + helpers.link_to("Plan et bus (ligne 15)", page_path("contact_us", anchor: "map"), class: lc), + "." + ]) + }, + { + question: "Horaires", + answer: "Les créneaux publics bougent selon la saison et les bénévoles. À jour sur l’accueil, la page Adhérer et la page Contact — jette un œil avant de venir." + }, + { + question: "Soutenir le lieu ou une question administrative", + answer_html: helpers.safe_join([ + "Adhérer, cotisation, don : ", + helpers.link_to("page Adhérer", bm, class: lc), + ". Pour un partenariat, un don hors cadre ou une demande administrative : ", + helpers.link_to("formulaire Contact", cf, class: lc), + " (Question générale ou Partenariat)." + ]) + } ] end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 5027d65f..63af7a18 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -6,24 +6,28 @@ class SessionsController < ApplicationController with: -> { redirect_to new_session_url, alert: I18n.t("sessions.rate_limited_alert") } def new - return unless authenticated? - - redirect_to root_path + # Déjà connecté·e : ne pas afficher le formulaire (évite la confusion après un POST échoué avec cookie valide). + redirect_to(root_path, status: :see_other) if authenticated? end def create - respond_to do |format| - if (user = User.authenticate_by(params.permit(:email_address, :password))) - start_new_session_for user - flash[:notice] = t(".login_success_notice") - format.turbo_stream { redirect_to after_authentication_url } - format.html { redirect_to after_authentication_url, notice: t(".login_success_notice") } - else + # Même logique que #new : si une session cookie est valide, inutile de traiter un « second » login ici. + return redirect_to(root_path, status: :see_other, notice: t("sessions.already_signed_in_notice")) if authenticated? + + email_address, password = params.expect(:email_address, :password) + + if (user = User.authenticate_by(email_address:, password:)) + start_new_session_for(user) + # 303 + Turbo : évite les redirections POST ignorées ou sans cookie ; même comportement qu’un POST HTML classique. + redirect_to after_authentication_url, notice: t(".login_success_notice"), status: :see_other + else + log_system_account_login_incident(email_address) + respond_to do |format| format.turbo_stream do flash.now[:alert] = t(".invalid_credentials_flash") render turbo_stream: turbo_stream.replace("flash", render_to_string(partial: "shared/flash")), status: :unprocessable_content end - format.html { redirect_to new_session_path, alert: t(".invalid_credentials_redirect") } + format.html { redirect_to new_session_path, alert: t(".invalid_credentials_redirect"), status: :see_other } end end end @@ -36,4 +40,23 @@ def destroy format.html { redirect_to root_path, notice: t(".signed_out_notice") } end end + + private + + def log_system_account_login_incident(email_address) + return unless Rails.env.development? + + tracked_accounts = %w[super-admin@rails.com admin@rails.com volunteer@rails.com] + normalized_email = email_address.to_s.strip.downcase + return unless tracked_accounts.include?(normalized_email) + + DevIncidentLogger.log!( + type: "system_account_login_failed", + details: { + email_address: normalized_email, + user_exists: User.exists?(email_address: normalized_email), + users_count: User.count + } + ) + end end diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index e8dc446b..2ad5a708 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -1,37 +1,43 @@ # frozen_string_literal: true class SettingsController < ApplicationController + include ProfileSectionTurboStreams + include NewsletterParamParser + before_action :require_authentication + def show @user = current_user end def update @user = current_user + profile_context = params[:ui_context] == "profile" + + if email_change_flow? + return handle_email_change(profile_context) + end - # Séparer les paramètres User des paramètres Person user_only_params = user_params.slice(:email_address) person_params = user_params.except(:email_address) - newsletter_flag = person_params.delete(:newsletter_subscribed) + newsletter_subscribed_value = extract_newsletter_subscribed!( + source_params: user_params, + person_params: person_params + ) - # Utiliser le service UserManagement::UserUpdater updater = UserManagement::UserUpdater.new( user_id: @user.id, email_address: user_only_params[:email_address], person_attributes: person_params, - newsletter_subscribed: [ "1", true, 1 ].include?(newsletter_flag), + newsletter_subscribed: newsletter_subscribed_value, updated_by_id: @user.id ) result = updater.call - if result.success? - flash[:notice] = t(".saved_notice") - redirect_to user_path(@user), status: :see_other - else - flash.now[:alert] = result.message - render :show, status: :unprocessable_content - end + return render_profile_section_success(t(".saved_notice"), profile_context) if result.success? + + render_profile_section_error(result.message, profile_context) end private @@ -43,4 +49,98 @@ def user_params dyslexic_font] ) end + + def email_change_flow? + params.key?(:email_confirm) || params.key?(:email_verification_code) + end + + def handle_email_change(profile_context) + new_email = params.dig(:user, :email_address).to_s.strip.downcase + email_confirm = params[:email_confirm].to_s.strip.downcase + verification_code = params[:email_verification_code].to_s.strip + + if verification_code.present? + confirm_email_change!(new_email:, email_confirm:, verification_code:, profile_context:) + else + request_email_change_code!(new_email:, email_confirm:, profile_context:) + end + end + + def request_email_change_code!(new_email:, email_confirm:, profile_context:) + return render_email_change_error(t("settings.update.email_change_error_blank"), profile_context) if new_email.blank? || email_confirm.blank? + return render_email_change_error(t("settings.update.email_change_error_mismatch"), profile_context) if new_email != email_confirm + return render_email_change_error(t("settings.update.email_change_error_unchanged"), profile_context) if new_email == @user.email_address.to_s.downcase + return render_email_change_error(t("settings.update.email_change_error_taken"), profile_context) if User.where.not(id: @user.id).exists?(email_address: new_email) + + code = format("%06d", SecureRandom.random_number(1_000_000)) + @user.store_email_change_request!(new_email:, code:) + UserMailer.email_change_verification(@user, new_email, code).deliver_now + + render_email_change_success( + t("settings.update.email_change_code_sent_notice", email: new_email), + profile_context + ) + rescue StandardError => e + Rails.logger.error("[SettingsController] email change code request failed: #{e.message}") + render_email_change_error(t("settings.update.email_change_error_send_failed"), profile_context) + end + + def confirm_email_change!(new_email:, email_confirm:, verification_code:, profile_context:) + return render_email_change_error(t("settings.update.email_change_error_no_request"), profile_context) if @user.pending_email_address.blank? + return render_email_change_error(t("settings.update.email_change_error_expired"), profile_context) if @user.email_change_code_expired? + return render_email_change_error(t("settings.update.email_change_error_mismatch"), profile_context) if new_email.blank? || email_confirm.blank? || new_email != email_confirm + return render_email_change_error(t("settings.update.email_change_error_pending_mismatch"), profile_context) if @user.pending_email_address != new_email + return render_email_change_error(t("settings.update.email_change_error_invalid_code"), profile_context) unless @user.email_change_code_valid?(verification_code) + + @user.update!(email_address: @user.pending_email_address) + @user.clear_email_change_request! + + render_email_change_success(t("settings.update.email_change_success_notice"), profile_context) + rescue ActiveRecord::RecordInvalid => e + render_email_change_error(e.record.errors.full_messages.to_sentence, profile_context) + rescue StandardError => e + Rails.logger.error("[SettingsController] email change confirmation failed: #{e.message}") + render_email_change_error(t("settings.update.email_change_error_confirm_failed"), profile_context) + end + + def render_email_change_success(message, profile_context) + render_profile_section_success(message, profile_context, reload_user: true) + end + + def render_email_change_error(message, profile_context) + render_profile_section_error(message, profile_context) + end + + def render_profile_section_success(message, profile_context, reload_user: false) + account_user = reload_user ? @user.reload : @user + + respond_to do |format| + format.turbo_stream do + flash.now[:notice] = message + render turbo_stream: profile_account_section_update_streams( + account_user, + embedded: profile_context, + compact: profile_context + ) + end + format.html { redirect_to settings_path, notice: message, status: :see_other } + end + end + + def render_profile_section_error(message, profile_context) + respond_to do |format| + format.turbo_stream do + flash.now[:alert] = message + render turbo_stream: profile_account_section_update_streams( + @user, + embedded: profile_context, + compact: profile_context + ), status: :unprocessable_content + end + format.html do + flash.now[:alert] = message + render :show, status: :unprocessable_content + end + end + end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 47ef9d2d..00b6d475 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class UsersController < ApplicationController + include ProfileSectionTurboStreams + # This controller handles user profile management for authenticated users. # It allows users to view and edit their profile information and # delete their account (GDPR compliance). @@ -16,25 +18,41 @@ class UsersController < ApplicationController # User profile view def show; end - # User profile edit form - def edit; end + # User profile edit form — unified on users#show (#contact) + def edit + redirect_to user_path(@user, anchor: "contact"), status: :see_other + end - # User profile update + # User profile update (postal / phone coordinates only; account email & prefs → Settings) def update - # Utiliser le service UserManagement::UserUpdater updater = UserManagement::UserUpdater.new( user_id: @user.id, - person_attributes: person_params, + person_attributes: coordinate_params, updated_by_id: @user.id ) result = updater.call if result.success? - redirect_to @user, notice: t(".profile_updated") + respond_to do |format| + format.turbo_stream do + flash.now[:notice] = t(".coordinates_updated") + render turbo_stream: profile_contact_section_update_streams(@user) + end + format.html { redirect_to user_path(@user, anchor: "contact"), notice: t(".coordinates_updated") } + end else - flash.now[:alert] = result.message - render :edit, status: :unprocessable_content + respond_to do |format| + format.turbo_stream do + flash.now[:alert] = result.message + render turbo_stream: profile_contact_section_update_streams(@user), + status: :unprocessable_content + end + format.html do + flash.now[:alert] = result.message + render :show, status: :unprocessable_content + end + end end end @@ -46,13 +64,13 @@ def destroy reset_session redirect_to root_path, notice: t(".deleted_notice") else - redirect_to edit_user_path(@user), alert: t(".destroy_failed_alert") + redirect_to user_path(@user, anchor: "account"), alert: t(".destroy_failed_alert") end end # Newsletter subscription management (legacy, redirect to settings) def change_newsletter_status - redirect_to settings_path, alert: t(".redirect_manage_in_settings_alert") + redirect_to user_path(Current.user, anchor: "account"), alert: t(".redirect_manage_in_settings_alert") end # Handle newsletter signup from footer @@ -75,7 +93,7 @@ def newsletter_signup # If authenticated, redirect to settings (no form for connected users) if authenticated? && Current.user.present? - redirect_to settings_path, notice: t(".manage_newsletter_notice") + redirect_to user_path(Current.user, anchor: "account"), notice: t(".manage_newsletter_notice") return end @@ -110,18 +128,10 @@ def set_user @user = Current.user end - # Permitted parameters for person data (migrated from user) - def person_params + # Postal / phone fields only (email & preference toggles use SettingsController) + def coordinate_params params.expect( - user: %i[phone - address - zip_code - town - country - image_rights - get_involved - newsletter_subscribed - dyslexic_font] + user: %i[phone address zip_code town country] ) end end diff --git a/app/helpers/admin/memberships_helper.rb b/app/helpers/admin/memberships_helper.rb index 1bf33f5d..bbb7cbcb 100644 --- a/app/helpers/admin/memberships_helper.rb +++ b/app/helpers/admin/memberships_helper.rb @@ -2,33 +2,12 @@ module Admin module MembershipsHelper - # Calculer et formater la différence de prix pour l'upgrade - def upgrade_price_difference(new_membership_type, current_membership:) - return "0€" unless current_membership&.membership_type - - old_price = current_membership.membership_type.price_cents - new_price = new_membership_type.price_cents - difference = new_price - old_price - - if difference.positive? - "+#{difference / 100.0}€" - elsif difference.negative? - "#{difference / 100.0}€" - else - "0€" - end + def upgrade_membership_options_for(membership_types) + membership_types.map { |membership_type| [ membership_type.name_with_price, membership_type.id ] } end - # Formater le nom avec la différence de prix pour l'upgrade - def upgrade_name_with_price(membership_type, current_membership:) - base_name = membership_type.name - price_diff = upgrade_price_difference(membership_type, current_membership: current_membership) - - if price_diff == "0€" - "#{base_name} - Gratuit" - else - "#{base_name} - #{price_diff}" - end + def upgrade_membership_rule_hint + "Le montant facture correspond a l'adhesion Cirque choisie. Aucun prorata de l'adhesion actuelle n'est deduit." end end end diff --git a/app/helpers/admin/payments_helper.rb b/app/helpers/admin/payments_helper.rb index 6295253d..8c78b2b1 100644 --- a/app/helpers/admin/payments_helper.rb +++ b/app/helpers/admin/payments_helper.rb @@ -47,7 +47,7 @@ def payment_lines_display(payment) if payment.payment_lines.any? payment.payment_lines.map do |line| content_tag :div, class: "text-xs" do - "#{line.description || line.item_type}: #{number_to_currency(line.price_euros, unit: '€', separator: ',', delimiter: ' ')}" + "#{line.history_description}: #{number_to_currency(line.price_euros, unit: '€', separator: ',', delimiter: ' ')}" end end.join.html_safe else diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index af35a816..b138d6cd 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -2,6 +2,7 @@ module ApplicationHelper include Pagy::Frontend + include MembershipCardHelper def public_registration_enabled? Rails.application.config.x.public_registration_enabled @@ -90,6 +91,13 @@ def available_hero_images(except: []) fallback && exclusions.exclude?(fallback.to_s) ? [ fallback ] : [] end + # Filenames under app/assets/images/ (same pool as hero_image). Sorted for stable order. + # Swiper receives several slides but only the first image uses eager loading. + def news_carousel_image_sources(limit: 12) + max_slides = limit.to_i.clamp(1, 24) + hero_image_pool.uniq.sort.take(max_slides) + end + def asset_available?(logical_path) return false if logical_path.blank? diff --git a/app/helpers/membership_card_helper.rb b/app/helpers/membership_card_helper.rb new file mode 100644 index 00000000..853fb4ca --- /dev/null +++ b/app/helpers/membership_card_helper.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Shared presentation for `shared/_membershipcard` (role badge, avatar). +module MembershipCardHelper + MEMBERSHIP_CARD_ROLE_BADGE_CLASSES = { + "super_admin" => "bg-[#1F5C55]", + "admin" => "bg-[#1F5C55]/90", + "volunteer" => "bg-[#1F5C55]/80", + "web_visitor" => "bg-[#1F5C55]/70" + }.freeze + + MEMBERSHIP_CARD_AVATARS = { + "super_admin" => [ "super_admin.webp", "Avatar Super administrateur" ], + "admin" => [ "admin.webp", "Avatar Administrateur" ], + "volunteer" => [ "volunteer.webp", "Avatar Bénévole" ], + "web_visitor" => [ "users.png", "Avatar Visiteur web" ] + }.freeze + + DEFAULT_AVATAR = [ "users.png", "Avatar" ].freeze + + def membership_card_role_badge_classes(system_role) + MEMBERSHIP_CARD_ROLE_BADGE_CLASSES[system_role.to_s] || "bg-[#1F5C55]/70" + end + + # @return [Array(String, String)] image filename under app/assets/images, alt text + def membership_card_avatar_source_and_alt(user) + MEMBERSHIP_CARD_AVATARS[user.system_role.to_s] || DEFAULT_AVATAR + end + + def membership_card_display_name(user) + user.full_name.presence || "Membre #{user.id}" + end +end diff --git a/app/javascript/confirm_modal.js b/app/javascript/confirm_modal.js index 84eae7b7..da0f39d5 100644 --- a/app/javascript/confirm_modal.js +++ b/app/javascript/confirm_modal.js @@ -2,11 +2,14 @@ import { Turbo } from "@hotwired/turbo-rails" const HIDDEN_CLASS = "hidden" const DEFAULT_CONFIRM_MESSAGE = "Es-tu sûr·e de vouloir continuer ?" +const DEFAULT_CONFIRM_BODY_FALLBACK = "Êtes-vous sûr de vouloir poursuivre cette action ?" +const DEFAULT_CONFIRM_TITLE = "Confirmation" let modalElement let backdropElement let dialogElement let messageElement +let titleElement let confirmButton let cancelButton let activeResolve @@ -22,12 +25,28 @@ function cacheElements() { backdropElement = document.getElementById("confirm-modal-backdrop") dialogElement = modalElement.querySelector("[data-confirm-dialog='true']") || modalElement messageElement = document.getElementById("confirm-modal-message") + titleElement = document.getElementById("confirm-modal-title") confirmButton = document.getElementById("confirm-modal-confirm") cancelButton = document.getElementById("confirm-modal-cancel") return Boolean(messageElement && confirmButton && cancelButton) } +function resetConfirmModalChrome() { + if (titleElement) { + titleElement.textContent = DEFAULT_CONFIRM_TITLE + } + if (messageElement) { + messageElement.replaceChildren() + const p = document.createElement("p") + p.className = "text-base leading-relaxed" + p.textContent = DEFAULT_CONFIRM_BODY_FALLBACK + messageElement.appendChild(p) + } + if (confirmButton) confirmButton.textContent = "Confirmer" + if (cancelButton) cancelButton.textContent = "Annuler" +} + function closeModal(result) { if (!modalElement) return @@ -37,6 +56,8 @@ function closeModal(result) { document.removeEventListener("keydown", keydownHandler) keydownHandler = null + resetConfirmModalChrome() + if (previousFocus && typeof previousFocus.focus === "function") { previousFocus.focus() } @@ -54,7 +75,97 @@ function showModal(message, element) { return Promise.resolve(window.confirm(message)) } - messageElement.textContent = message || DEFAULT_CONFIRM_MESSAGE + resetConfirmModalChrome() + messageElement.replaceChildren() + const p = document.createElement("p") + p.className = "text-base leading-relaxed" + p.textContent = message || DEFAULT_CONFIRM_MESSAGE + messageElement.appendChild(p) + + return new Promise((resolve) => { + activeResolve = resolve + previousFocus = document.activeElement + + modalElement.classList.remove(HIDDEN_CLASS) + modalElement.removeAttribute("aria-hidden") + + requestAnimationFrame(() => { + if (dialogElement) { + dialogElement.focus() + } else { + confirmButton.focus() + } + }) + + const finish = (result) => { + confirmButton.removeEventListener("click", confirmHandler) + cancelButton.removeEventListener("click", cancelHandler) + if (backdropElement) { + backdropElement.removeEventListener("click", backdropHandler) + } + closeModal(result) + } + + const confirmHandler = () => finish(true) + const cancelHandler = () => finish(false) + + const backdropHandler = (event) => { + if (event.target === backdropElement) { + finish(false) + } + } + + confirmButton.addEventListener("click", confirmHandler) + cancelButton.addEventListener("click", cancelHandler) + if (backdropElement) { + backdropElement.addEventListener("click", backdropHandler) + } + + keydownHandler = (event) => { + if (event.key === "Escape") { + event.preventDefault() + finish(false) + } else if (event.key === "Enter" && document.activeElement === confirmButton) { + event.preventDefault() + finish(true) + } + } + + document.addEventListener("keydown", keydownHandler) + }) +} + +export function openRichConfirmModal(options = {}) { + const { + title = DEFAULT_CONFIRM_TITLE, + introText = "", + htmlBody = "", + confirmText = "Confirmer", + cancelText = "Annuler" + } = options + + if (!modalElement && !cacheElements()) { + return Promise.resolve(window.confirm(introText || DEFAULT_CONFIRM_MESSAGE)) + } + + if (titleElement) titleElement.textContent = title + confirmButton.textContent = confirmText + cancelButton.textContent = cancelText + + messageElement.replaceChildren() + if (introText) { + const intro = document.createElement("p") + intro.className = "text-base leading-relaxed text-gray-700" + intro.textContent = introText + messageElement.appendChild(intro) + } + if (htmlBody) { + const wrap = document.createElement("div") + wrap.className = + "mt-2 max-h-52 overflow-y-auto rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-900 border border-gray-100" + wrap.innerHTML = htmlBody + messageElement.appendChild(wrap) + } return new Promise((resolve) => { activeResolve = resolve diff --git a/app/javascript/controllers/admin_users_index_controller.js b/app/javascript/controllers/admin_users_index_controller.js new file mode 100644 index 00000000..81b16554 --- /dev/null +++ b/app/javascript/controllers/admin_users_index_controller.js @@ -0,0 +1,61 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["searchInput", "hiddenSearch", "itemsPerPage"] + static values = { debounceMs: { type: Number, default: 500 } } + + connect() { + this.boundHandleGlobalKeydown = this.handleGlobalKeydown.bind(this) + document.addEventListener("keydown", this.boundHandleGlobalKeydown) + } + + disconnect() { + document.removeEventListener("keydown", this.boundHandleGlobalKeydown) + if (this.searchTimeout) clearTimeout(this.searchTimeout) + } + + debouncedSearch(event) { + if (this.searchTimeout) clearTimeout(this.searchTimeout) + + this.searchTimeout = setTimeout(() => { + this.syncSearchValue() + const valueLength = event.target.value.length + if (valueLength >= 2 || valueLength === 0) { + event.target.form.requestSubmit() + } + }, this.debounceMsValue) + } + + clearSearch(event) { + event.preventDefault() + event.stopPropagation() + + if (this.searchTimeout) clearTimeout(this.searchTimeout) + + const url = new URL(window.location.href) + url.searchParams.delete("search") + url.searchParams.delete("page") + window.location.href = url.toString() + } + + changeItemsPerPage(event) { + const items = event.target.value + const url = new URL(window.location.href) + url.searchParams.set("items", items) + url.searchParams.delete("page") + window.location.href = url.toString() + } + + handleGlobalKeydown(event) { + if ((event.ctrlKey || event.metaKey) && event.key === "k") { + event.preventDefault() + if (this.hasSearchInputTarget) this.searchInputTarget.focus() + } + } + + syncSearchValue() { + if (!this.hasSearchInputTarget || !this.hasHiddenSearchTarget) return + + this.hiddenSearchTarget.value = this.searchInputTarget.value + } +} diff --git a/app/javascript/controllers/contribution_purchase_controller.js b/app/javascript/controllers/contribution_purchase_controller.js new file mode 100644 index 00000000..9156e132 --- /dev/null +++ b/app/javascript/controllers/contribution_purchase_controller.js @@ -0,0 +1,24 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["formula", "summaryName", "summaryPrice", "summaryConstraint", "summaryAlert"] + + connect() { + this.refresh() + } + + refresh() { + const selectedFormula = this.formulaTargets.find((input) => input.checked) + if (!selectedFormula) return + + this.summaryNameTarget.textContent = selectedFormula.dataset.formulaName || "" + this.summaryPriceTarget.textContent = selectedFormula.dataset.formulaPrice || "" + this.summaryConstraintTarget.textContent = selectedFormula.dataset.formulaConstraint || "" + + if (this.hasSummaryAlertTarget) { + const alert = selectedFormula.dataset.formulaAlert || "" + this.summaryAlertTarget.textContent = alert + this.summaryAlertTarget.classList.toggle("hidden", alert.length === 0) + } + } +} diff --git a/app/javascript/controllers/email_change_modal_controller.js b/app/javascript/controllers/email_change_modal_controller.js new file mode 100644 index 00000000..c565b419 --- /dev/null +++ b/app/javascript/controllers/email_change_modal_controller.js @@ -0,0 +1,145 @@ +import { Controller } from "@hotwired/stimulus" +import { openRichConfirmModal } from "confirm_modal" + +export default class extends Controller { + static targets = ["panel", "form", "email", "emailConfirm", "code", "error"] + static values = { + currentEmail: String, + blankMessage: String, + mismatchMessage: String, + unchangedMessage: String, + previewTitle: String, + previewIntro: String, + previewConfirmLabel: String, + previewBackLabel: String, + invalidCodeMessage: String + } + + connect() { + this.onSubmitEnd = this.handleSubmitEnd.bind(this) + if (this.hasFormTarget) { + this.formTarget.addEventListener("turbo:submit-end", this.onSubmitEnd) + } + } + + disconnect() { + if (this.hasFormTarget) { + this.formTarget.removeEventListener("turbo:submit-end", this.onSubmitEnd) + } + document.body.style.overflow = "" + } + + open(event) { + event?.preventDefault() + if (!this.hasPanelTarget) return + this.clearError() + this.panelTarget.classList.remove("hidden") + document.body.style.overflow = "hidden" + requestAnimationFrame(() => { + if (this.hasEmailTarget) this.emailTarget.focus() + }) + } + + close(event) { + event?.preventDefault() + if (!this.hasPanelTarget) return + this.panelTarget.classList.add("hidden") + document.body.style.overflow = "" + this.clearError() + if (this.hasFormTarget) this.formTarget.reset() + } + + stopPropagation(event) { + event.stopPropagation() + } + + async requestCode(event) { + event.preventDefault() + this.clearError() + + const payload = this.validatedPayload() + if (!payload) return + + const escaped = this.escapeHtml(payload.nextRaw) + const confirmed = await openRichConfirmModal({ + title: this.previewTitleValue, + introText: this.previewIntroValue, + htmlBody: `

${escaped}

`, + confirmText: this.previewConfirmLabelValue, + cancelText: this.previewBackLabelValue + }) + + if (!confirmed) return + + if (this.hasCodeTarget) this.codeTarget.value = "" + this.formTarget.requestSubmit() + } + + // Backward-compatible action name used by previous modal button wiring. + async submitWithConfirmation(event) { + await this.requestCode(event) + } + + confirmCode(event) { + event.preventDefault() + this.clearError() + + const payload = this.validatedPayload() + if (!payload) return + + const code = this.hasCodeTarget ? this.codeTarget.value.trim() : "" + if (!/^\d{6}$/.test(code)) { + this.showError(this.invalidCodeMessageValue || "Invalid code format.") + return + } + + this.formTarget.requestSubmit() + } + + handleSubmitEnd(event) { + if (event.detail.success) this.close() + } + + showError(message) { + if (!this.hasErrorTarget) return + this.errorTarget.textContent = message + this.errorTarget.classList.remove("hidden") + } + + clearError() { + if (!this.hasErrorTarget) return + this.errorTarget.textContent = "" + this.errorTarget.classList.add("hidden") + } + + escapeHtml(text) { + const div = document.createElement("div") + div.textContent = text + return div.innerHTML + } + + validatedPayload() { + const nextRaw = this.emailTarget.value.trim() + const confirmRaw = this.emailConfirmTarget.value.trim() + const next = nextRaw.toLowerCase() + const confirm = confirmRaw.toLowerCase() + const current = (this.currentEmailValue || "").trim().toLowerCase() + + if (!next || !confirm) { + this.showError(this.blankMessageValue || this.mismatchMessageValue) + return null + } + + if (next !== confirm) { + this.showError(this.mismatchMessageValue) + return null + } + + if (next === current) { + this.showError(this.unchangedMessageValue) + return null + } + + return { nextRaw } + } +} diff --git a/app/javascript/controllers/form_toggle_controller.js b/app/javascript/controllers/form_toggle_controller.js index 14ecef10..a5dd5750 100644 --- a/app/javascript/controllers/form_toggle_controller.js +++ b/app/javascript/controllers/form_toggle_controller.js @@ -5,59 +5,47 @@ export default class extends Controller { static targets = ["webAccountFields", "emailField", "newsletterNote"] connect() { - console.log('Form toggle controller connected!') - this.toggleWebAccountFields() - this.toggleNewsletterNote() + this.refresh() } toggleWebAccountFields() { - console.log('toggleWebAccountFields called') - const checkbox = this.element.querySelector('input[name="user[create_web_account]"]') - console.log('Checkbox found:', checkbox) - console.log('Checkbox checked:', checkbox?.checked) - - if (this.hasWebAccountFieldsTarget && checkbox) { - if (checkbox.checked) { - this.webAccountFieldsTarget.style.display = 'block' - console.log('Showing web account fields') - if (this.hasEmailFieldTarget) { - this.emailFieldTarget.required = true - } - } else { - this.webAccountFieldsTarget.style.display = 'none' - console.log('Hiding web account fields') - if (this.hasEmailFieldTarget) { - this.emailFieldTarget.required = false - } - } - } + this.refresh() } toggleNewsletterNote() { - const checkbox = this.element.querySelector('input[name="user[person][newsletter_subscribed]"]') - if (this.hasNewsletterNoteTarget && checkbox) { - if (checkbox.checked) { - this.newsletterNoteTarget.style.display = 'block' - if (this.hasEmailFieldTarget) { - this.emailFieldTarget.required = true - } - } else { - this.newsletterNoteTarget.style.display = 'none' - if (this.hasEmailFieldTarget) { - this.emailFieldTarget.required = false - } - } - } + this.refresh() } - // Actions createWebAccountChanged() { - console.log('createWebAccountChanged called') - this.toggleWebAccountFields() + this.refresh() } newsletterSubscribedChanged() { - console.log('newsletterSubscribedChanged called') - this.toggleNewsletterNote() + this.refresh() + } + + refresh() { + const webAccountChecked = this.webAccountCheckbox?.checked || false + const newsletterChecked = this.newsletterCheckbox?.checked || false + + if (this.hasWebAccountFieldsTarget) { + this.webAccountFieldsTarget.classList.toggle("hidden", !webAccountChecked) + } + + if (this.hasNewsletterNoteTarget) { + this.newsletterNoteTarget.classList.toggle("hidden", !newsletterChecked) + } + + if (this.hasEmailFieldTarget) { + this.emailFieldTarget.required = webAccountChecked || newsletterChecked + } + } + + get webAccountCheckbox() { + return this.element.querySelector('input[name="user[create_web_account]"]') + } + + get newsletterCheckbox() { + return this.element.querySelector('input[name="user[person][newsletter_subscribed]"]') } } diff --git a/app/javascript/controllers/map_controller.js b/app/javascript/controllers/map_controller.js new file mode 100644 index 00000000..95a7eda3 --- /dev/null +++ b/app/javascript/controllers/map_controller.js @@ -0,0 +1,113 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { + lat: Number, + lng: Number, + zoom: { type: Number, default: 17 } + } + + async connect () { + this._disconnected = false + this._lastSize = { w: 0, h: 0 } + this._resizeFrame = null + this.L = await this._loadLeaflet() + + if (this._disconnected || !this.element.isConnected) return + + // Attendre 2 frames : la grille / le turbo-frame ont souvent une hauteur encore à 0 au premier paint. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (this._disconnected || !this.element.isConnected) return + this._initMap() + }) + }) + } + + _initMap () { + if (this._disconnected || !this.element.isConnected) return + + this._resizeTimeouts = [] + + const scheduleInvalidate = (force = false) => { + if (!this.map) return + if (this._resizeFrame != null) cancelAnimationFrame(this._resizeFrame) + this._resizeFrame = requestAnimationFrame(() => { + this._resizeFrame = null + const el = this.element + const w = el.clientWidth + const h = el.clientHeight + if (w <= 0 || h <= 0) return + if (!force && w === this._lastSize.w && h === this._lastSize.h) return + this._lastSize = { w, h } + this.map.invalidateSize({ animate: false }) + }) + } + + this.boundTurboFrameLoad = (e) => { + if (e.target.contains(this.element)) scheduleInvalidate(true) + } + document.addEventListener("turbo:frame-load", this.boundTurboFrameLoad) + + this.boundWindowResize = () => scheduleInvalidate(true) + window.addEventListener("resize", this.boundWindowResize) + + this.map = this.L.map(this.element).setView([this.latValue, this.lngValue], this.zoomValue) + + // Tuiles raster via CARTO (cartocdn.com) ; rendu 100 % Leaflet côté navigateur (pas d’iframe ni SDK tiers). + this.L.tileLayer("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png", { + attribution: + '© Sources des données · CARTO', + subdomains: "abcd", + maxZoom: 19 + }).addTo(this.map) + + // Pas d’images CDN (évite icône cassée si cdnjs / tracking bloqués) — cercle aux couleurs du site. + this.L.circleMarker([this.latValue, this.lngValue], { + radius: 10, + color: "#1F5C55", + fillColor: "#5836A5", + fillOpacity: 0.9, + weight: 3 + }).addTo(this.map).bindPopup("Le Circographe
97 bis bd de Suisse, Toulouse") + + this.map.whenReady(() => { + scheduleInvalidate(true) + ;[120, 350, 700].forEach(ms => { + const id = setTimeout(() => scheduleInvalidate(true), ms) + this._resizeTimeouts.push(id) + }) + }) + + this.resizeObserver = new ResizeObserver(() => scheduleInvalidate(false)) + this.resizeObserver.observe(this.element) + } + + disconnect () { + this._disconnected = true + this._resizeTimeouts?.forEach(id => clearTimeout(id)) + this._resizeTimeouts = null + if (this.boundTurboFrameLoad) { + document.removeEventListener("turbo:frame-load", this.boundTurboFrameLoad) + this.boundTurboFrameLoad = null + } + if (this.boundWindowResize) { + window.removeEventListener("resize", this.boundWindowResize) + this.boundWindowResize = null + } + if (this._resizeFrame != null) cancelAnimationFrame(this._resizeFrame) + this._resizeFrame = null + this.resizeObserver?.disconnect() + this.resizeObserver = null + this.map?.remove() + this.map = null + } + + async _loadLeaflet () { + if (this.constructor.leafletModule) return this.constructor.leafletModule + + const module = await import("leaflet") + this.constructor.leafletModule = module + return module + } +} diff --git a/app/javascript/controllers/offer_fields_controller.js b/app/javascript/controllers/offer_fields_controller.js new file mode 100644 index 00000000..fd25d64b --- /dev/null +++ b/app/javascript/controllers/offer_fields_controller.js @@ -0,0 +1,25 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["paymentMethod", "customAmount", "offerReason", "offerReasonInput"] + + connect() { + this.refresh() + } + + refresh() { + const offered = this.paymentMethodTarget.value === "offered" + + if (this.hasCustomAmountTarget) { + this.customAmountTarget.classList.toggle("hidden", !offered) + } + + if (this.hasOfferReasonTarget) { + this.offerReasonTarget.classList.toggle("hidden", !offered) + } + + if (this.hasOfferReasonInputTarget) { + this.offerReasonInputTarget.required = offered + } + } +} diff --git a/app/javascript/controllers/opening_hours_editor_controller.js b/app/javascript/controllers/opening_hours_editor_controller.js new file mode 100644 index 00000000..5b30745e --- /dev/null +++ b/app/javascript/controllers/opening_hours_editor_controller.js @@ -0,0 +1,130 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["closedCheckbox", "timeInput", "finalInput", "previewContainer"] + + connect() { + this.initializeFromInputs() + } + + toggleClosed(event) { + const checkbox = event.currentTarget + const day = checkbox.dataset.day + const timeInput = this.timeInputFor(day) + const finalInput = this.finalInputFor(day) + + if (!timeInput || !finalInput) return + + if (checkbox.checked) { + timeInput.style.display = "none" + finalInput.value = "Fermé" + this.updatePreview(day, "Fermé") + return + } + + timeInput.style.display = "block" + this.resetDaySelectors(day) + this.updateFromSelectors(day) + } + + updateFromSelectors(eventOrDay) { + const day = typeof eventOrDay === "string" ? eventOrDay : eventOrDay.currentTarget.dataset.day + const openHour = this.inputValue(`open_hour_${day}`) + const openMinute = this.inputValue(`open_minute_${day}`) + const closeHour = this.inputValue(`close_hour_${day}`) + const closeMinute = this.inputValue(`close_minute_${day}`) + + if ([openHour, openMinute, closeHour, closeMinute].some((value) => value === null)) return + + const timeValue = `${openHour.padStart(2, "0")}:${openMinute.padStart(2, "0")} - ${closeHour.padStart(2, "0")}:${closeMinute.padStart(2, "0")}` + const finalInput = this.finalInputFor(day) + if (finalInput) finalInput.value = timeValue + this.updatePreview(day, timeValue) + } + + initializeFromInputs() { + this.closedCheckboxTargets.forEach((checkbox) => { + const day = checkbox.dataset.day + const finalInput = this.finalInputFor(day) + const timeInput = this.timeInputFor(day) + if (!finalInput || !timeInput) return + + this.updatePreview(day, finalInput.value) + if (checkbox.checked) { + timeInput.style.display = "none" + } else { + timeInput.style.display = "block" + this.initializeSelectors(day, finalInput.value) + } + }) + } + + updatePreview(day, value) { + if (!this.hasPreviewContainerTarget) return + + const rows = this.previewContainerTarget.querySelectorAll("tr") + rows.forEach((row) => { + const rowText = row.textContent.toLowerCase() + if (!rowText.includes(day.toLowerCase())) return + + const hourCell = row.querySelector("td:nth-child(2)") + if (!hourCell) return + + if (value.toLowerCase() === "fermé") { + hourCell.innerHTML = ` + + + Fermé + + ` + } else { + hourCell.innerHTML = ` + + + ${value} + + ` + } + }) + } + + initializeSelectors(day, timeValue) { + if (!timeValue || timeValue.toLowerCase() === "fermé") return + + const [openTime, closeTime] = timeValue.split(" - ") + if (!openTime || !closeTime) return + + const [openHour, openMinute] = openTime.split(":") + const [closeHour, closeMinute] = closeTime.split(":") + + this.setInputValue(`open_hour_${day}`, openHour) + this.setInputValue(`open_minute_${day}`, openMinute) + this.setInputValue(`close_hour_${day}`, closeHour) + this.setInputValue(`close_minute_${day}`, closeMinute) + } + + resetDaySelectors(day) { + this.setInputValue(`open_hour_${day}`, "14") + this.setInputValue(`open_minute_${day}`, "0") + this.setInputValue(`close_hour_${day}`, "22") + this.setInputValue(`close_minute_${day}`, "0") + } + + inputValue(name) { + const input = this.element.querySelector(`select[name="${name}"]`) + return input ? input.value : null + } + + setInputValue(name, value) { + const input = this.element.querySelector(`select[name="${name}"]`) + if (input) input.value = value + } + + timeInputFor(day) { + return this.timeInputTargets.find((element) => element.dataset.day === day) + } + + finalInputFor(day) { + return this.finalInputTargets.find((element) => element.dataset.day === day) + } +} diff --git a/app/javascript/controllers/payment_modal_controller.js b/app/javascript/controllers/payment_modal_controller.js index 143d181b..ba6dd363 100644 --- a/app/javascript/controllers/payment_modal_controller.js +++ b/app/javascript/controllers/payment_modal_controller.js @@ -3,13 +3,6 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["container"] - connect() { - // Auto-open if person_id is in params - if (new URLSearchParams(window.location.search).get("person_id")) { - this.open() - } - } - open() { if (this.hasContainerTarget) { this.containerTarget.classList.remove("hidden") diff --git a/app/javascript/controllers/reduced_rate_controller.js b/app/javascript/controllers/reduced_rate_controller.js index ebebc60e..dd1a508e 100644 --- a/app/javascript/controllers/reduced_rate_controller.js +++ b/app/javascript/controllers/reduced_rate_controller.js @@ -2,10 +2,15 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="reduced-rate" export default class extends Controller { - static targets = ["details", "checkbox"] + static targets = ["details", "checkbox", "reason", "proof"] connect() { + this.refresh() + } + + refresh() { this.toggleDetails() + this.toggleProof() } toggleDetails() { @@ -26,7 +31,17 @@ export default class extends Controller { // Action when checkbox changes checkboxChanged() { - this.toggleDetails() + this.refresh() } -} + reasonChanged() { + this.toggleProof() + } + + toggleProof() { + if (!this.hasProofTarget || !this.hasReasonTarget) return + + const shouldShowProof = this.reasonTarget.value === "Autre" + this.proofTarget.classList.toggle("hidden", !shouldShowProof) + } +} diff --git a/app/javascript/controllers/section_edit_controller.js b/app/javascript/controllers/section_edit_controller.js new file mode 100644 index 00000000..34c1d90d --- /dev/null +++ b/app/javascript/controllers/section_edit_controller.js @@ -0,0 +1,209 @@ +import { Controller } from "@hotwired/stimulus" +import { openRichConfirmModal } from "confirm_modal" + +// data-controller="section-edit" +export default class extends Controller { + static targets = ["read", "edit", "form", "save", "modifyWrap", "cancelWrap", "acceptWrap", "modifyButton", "acceptButton", "cancelButton", "field", "protectedAction"] + static values = { + editing: Boolean, + warningMessage: String, + modifyLabel: String, + acceptLabel: String, + previewTitle: String, + previewIntro: String, + previewConfirmLabel: String, + previewBackLabel: String, + yesLabel: String, + noLabel: String + } + + connect() { + this.allowNativeSubmit = false + this.initialValues = this.fieldTargets.map((field) => this.fieldValue(field)) + this.beforeUnloadHandler = (event) => { + if (!this.hasUnsavedChanges()) return + event.preventDefault() + event.returnValue = this.warningMessageValue || "Vous avez des modifications non enregistrées." + } + window.addEventListener("beforeunload", this.beforeUnloadHandler) + this.render() + } + + disconnect() { + window.removeEventListener("beforeunload", this.beforeUnloadHandler) + } + + handleModifyClick(event) { + event.preventDefault() + if (this.editingValue) return + + this.initialValues = this.fieldTargets.map((field) => this.fieldValue(field)) + this.editingValue = true + this.render() + this.updateSaveState() + } + + async handleAcceptClick(event) { + event.preventDefault() + if (!this.editingValue || !this.hasUnsavedChanges()) return + + const summaryHtml = this.buildRecapFromFields() + const confirmed = await openRichConfirmModal({ + title: this.previewTitleValue, + introText: this.previewIntroValue, + htmlBody: summaryHtml, + confirmText: this.previewConfirmLabelValue, + cancelText: this.previewBackLabelValue + }) + + if (!confirmed) return + + this.allowNativeSubmit = true + this.formTarget.requestSubmit() + } + + cancelEdit(event) { + event.preventDefault() + this.resetFields() + this.editingValue = false + this.render() + } + + handleInput() { + this.updateSaveState() + } + + handleFormSubmit(event) { + if (this.allowNativeSubmit) { + this.allowNativeSubmit = false + this.applySavingStateToSubmit() + return + } + + event.preventDefault() + event.stopPropagation() + } + + applySavingStateToSubmit() { + if (this.hasSaveTarget) { + this.saveTarget.disabled = true + } + + if (this.hasAcceptWrapTarget && !this.acceptWrapTarget.classList.contains("hidden")) { + this.acceptButtonTarget.disabled = true + const loading = this.acceptButtonTarget.dataset.loadingText + if (loading) this.acceptButtonTarget.textContent = loading + } + } + + render() { + const editing = this.editingValue + const dirty = this.hasUnsavedChanges() + + this.readTargets.forEach((element) => element.classList.toggle("hidden", editing)) + this.editTargets.forEach((element) => element.classList.toggle("hidden", !editing)) + + // Visibility on wrappers only — never combine Tailwind `hidden` with `inline-flex` + // on the same node (display utilities fight; buttons stayed visible). + if (this.hasModifyWrapTarget) { + const showModify = !editing || (editing && !dirty) + this.modifyWrapTarget.classList.toggle("hidden", !showModify) + } + + if (this.hasCancelWrapTarget) { + this.cancelWrapTarget.classList.toggle("hidden", !editing) + } + + if (this.hasAcceptWrapTarget) { + this.acceptWrapTarget.classList.toggle("hidden", !(editing && dirty)) + } + + if (this.hasModifyButtonTarget) { + if (!editing) { + this.modifyButtonTarget.disabled = false + this.modifyButtonTarget.textContent = this.modifyLabelValue + } else if (!dirty) { + this.modifyButtonTarget.disabled = true + this.modifyButtonTarget.textContent = this.modifyLabelValue + } else { + this.modifyButtonTarget.disabled = false + } + } + + if (this.hasAcceptButtonTarget && editing && dirty) { + this.acceptButtonTarget.disabled = false + this.acceptButtonTarget.textContent = this.acceptLabelValue + } + + this.protectedActionTargets.forEach((element) => { + element.classList.toggle("opacity-50", editing) + element.classList.toggle("pointer-events-none", editing) + element.setAttribute("aria-disabled", editing ? "true" : "false") + }) + } + + updateSaveState() { + this.render() + } + + resetFields() { + this.fieldTargets.forEach((field, index) => { + const initial = this.initialValues[index] + if (field.type === "checkbox") { + field.checked = initial === "true" + } else { + field.value = initial + } + }) + } + + fieldValue(field) { + return field.type === "checkbox" ? String(field.checked) : (field.value || "") + } + + hasUnsavedChanges() { + if (!this.editingValue) return false + return this.fieldTargets.some((field, index) => this.fieldValue(field) !== this.initialValues[index]) + } + + escapeHtml(text) { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + } + + fieldLabelText(field) { + if (field.labels && field.labels.length > 0) { + const lab = field.labels[0] + const span = lab.querySelector(":scope > span") + if (span) return span.textContent.replace(/\s+/g, " ").trim() + + const clone = lab.cloneNode(true) + clone.querySelectorAll("input, textarea, select, button").forEach((el) => el.remove()) + return clone.textContent.replace(/\s+/g, " ").trim() + } + return field.name || "" + } + + displayValueForField(field) { + if (field.type === "checkbox") { + return field.checked ? this.yesLabelValue : this.noLabelValue + } + const v = (field.value || "").trim() + return v.length ? v : "—" + } + + buildRecapFromFields() { + const items = this.fieldTargets.map((field) => { + const label = this.escapeHtml(this.fieldLabelText(field)) + const value = this.escapeHtml(this.displayValueForField(field)) + return `
  • ${label}${value}
  • ` + }) + + if (!items.length) return "" + + return `` + } +} diff --git a/app/javascript/controllers/slider_controller.js b/app/javascript/controllers/slider_controller.js index ee64448a..377b3ee0 100644 --- a/app/javascript/controllers/slider_controller.js +++ b/app/javascript/controllers/slider_controller.js @@ -33,6 +33,14 @@ export default class extends Controller { } const options = Object.assign({}, defaultOptions, this.optionsValue || {}) + + if (options.pagination?.el && typeof options.pagination.el === "string") { + const paginationEl = this.element.querySelector(options.pagination.el) + if (paginationEl) { + options.pagination = { ...options.pagination, el: paginationEl } + } + } + const slideCount = this.element.querySelectorAll(".swiper-slide").length const breakpointValues = Object.values(options.breakpoints || {}).map(cfg => cfg.slidesPerView || options.slidesPerView) const maxSlidesPerView = Math.max(options.slidesPerView || 1, ...breakpointValues, 1) diff --git a/app/javascript/controllers/tabs_controller.js b/app/javascript/controllers/tabs_controller.js index f62ee4d7..d8cc2499 100644 --- a/app/javascript/controllers/tabs_controller.js +++ b/app/javascript/controllers/tabs_controller.js @@ -4,7 +4,7 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["trigger", "panel"] static values = { initial: String, useHash: Boolean } - static classes = ["active"] + static classes = ["active", "inactive"] connect() { const initialName = this.initialValue || this.defaultName @@ -24,7 +24,8 @@ export default class extends Controller { show(name) { this.triggerTargets.forEach(trigger => { const selected = trigger.dataset.tabsNameValue === name - trigger.classList.toggle(this.activeClass, selected) + this.activeClassesList.forEach(className => trigger.classList.toggle(className, selected)) + this.inactiveClassesList.forEach(className => trigger.classList.toggle(className, !selected)) trigger.setAttribute("aria-selected", selected) trigger.setAttribute("tabindex", selected ? "0" : "-1") }) @@ -46,4 +47,12 @@ export default class extends Controller { const names = this.triggerTargets.map(trigger => trigger.dataset.tabsNameValue) return names.includes(hash) ? hash : null } + + get activeClassesList() { + return this.hasActiveClass ? this.activeClass.split(" ").filter(Boolean) : [] + } + + get inactiveClassesList() { + return this.hasInactiveClass ? this.inactiveClass.split(" ").filter(Boolean) : [] + } } diff --git a/app/javascript/global_animations.js b/app/javascript/global_animations.js index 4e700fe7..0b80bfea 100644 --- a/app/javascript/global_animations.js +++ b/app/javascript/global_animations.js @@ -1,21 +1,26 @@ +let fadeInObserver = null + document.addEventListener("turbo:load", function () { - initFadeInAnimations(); -}); + if (fadeInObserver) { + fadeInObserver.disconnect() + fadeInObserver = null + } + initFadeInAnimations() +}) -function initFadeInAnimations() { - const fadeElements = document.querySelectorAll(".fade-in"); +function initFadeInAnimations () { + const fadeElements = document.querySelectorAll(".fade-in") - if (fadeElements.length === 0) return; // Évite d'exécuter le script s'il n'y a aucun élément à animer + if (fadeElements.length === 0) return - const observer = new IntersectionObserver((entries, observer) => { - entries.forEach(entry => { - if (entry.isIntersecting) { - entry.target.classList.add("visible"); - observer.unobserve(entry.target); // Arrête d'observer une fois l'animation déclenchée - } - }); - }, { threshold: 0.1 }); + fadeInObserver = new IntersectionObserver((entries, observer) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.classList.add("visible") + observer.unobserve(entry.target) + } + }) + }, { threshold: 0.1 }) - fadeElements.forEach(el => observer.observe(el)); + fadeElements.forEach(el => fadeInObserver.observe(el)) } - diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 197ec2c5..70315e3b 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -25,9 +25,36 @@ def membership_expiration_reminder(user_membership) def contact_email(name, email, message, category, recipient_email) @name = name + @email = email @message = message @category = category + @category_label = contact_category_label(category) @submitted_at = Time.zone.now - mail(to: recipient_email, subject: "Nouveau message : #{category.capitalize}", reply_to: email) + mail( + to: recipient_email, + subject: I18n.t("mailers.user_mailer.contact_email.subject", category_label: @category_label), + reply_to: email + ) + end + + def email_change_verification(user, new_email, code) + @user = user + @new_email = new_email + @code = code + @ttl_minutes = (User::EMAIL_CHANGE_CODE_TTL / 60).to_i + + mail( + to: @new_email, + subject: I18n.t("mailers.user_mailer.email_change_verification.subject") + ) + end + + private + + def contact_category_label(category) + I18n.t( + "mailers.user_mailer.contact_email.category_labels.#{category}", + default: category.to_s.tr("_", " ").capitalize + ) end end diff --git a/app/models/concerns/person_payment_reporting.rb b/app/models/concerns/person_payment_reporting.rb new file mode 100644 index 00000000..2e4fd63d --- /dev/null +++ b/app/models/concerns/person_payment_reporting.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module PersonPaymentReporting + extend ActiveSupport::Concern + + class_methods do + def total_offered_payments + joins(:payments).where(payments: { payment_method: "offered" }).count + end + + def total_free_offers + joins(:payments).where(payments: { payment_method: "offered", total_cents: 0 }).count + end + + def total_paid_offers + joins(:payments).where(payments: { payment_method: "offered" }).where("payments.total_cents > 0").count + end + + def offered_payments_by_reason + joins(:payments) + .where(payments: { payment_method: "offered" }) + .group("payments.offer_reason") + .count + end + + def upgrades_today + joins(:payments) + .joins("JOIN payment_lines ON payments.id = payment_lines.payment_id") + .where(payment_lines: { item_type: "MembershipUpgrade" }) + .where(payments: { created_at: Date.current.all_day }) + .count + end + end + + def offered_payments_count + payments.where(payment_method: "offered").count + end + + def offered_payments_total + payments.where(payment_method: "offered").sum(:total_cents) + end + + def free_offers_count + payments.where(payment_method: "offered", total_cents: 0).count + end + + def paid_offers_count + payments.where(payment_method: "offered").where("total_cents > 0").count + end + + def membership_upgrades_count + payments.joins(:payment_lines) + .where(payment_lines: { item_type: "MembershipUpgrade" }) + .count + end + + def contribution_purchases_count + payments.joins(:payment_lines) + .where(payment_lines: { item_type: "Contribution" }) + .count + end + + def newsletter_subscribed? + return false if email.blank? + + subscriber = NewsletterSubscriber.find_by(email: email) + subscriber&.subscribed? || false + end + + def newsletter_subscribed + newsletter_subscribed? + end + + def newsletter_subscribed=(_value) + # Compatibility writer for legacy forms/factories. Newsletter persistence is handled by NewsletterSubscriber. + end +end diff --git a/app/models/concerns/priceable.rb b/app/models/concerns/priceable.rb index f4e3e292..2e81e284 100644 --- a/app/models/concerns/priceable.rb +++ b/app/models/concerns/priceable.rb @@ -18,6 +18,10 @@ def price_euros cents / 100.0 end + def total_euros + price_euros + end + def price_euros=(value) # Support pour différents noms de colonnes if respond_to?(:price_cents=) diff --git a/app/models/concerns/rate_kindable.rb b/app/models/concerns/rate_kindable.rb new file mode 100644 index 00000000..a551f748 --- /dev/null +++ b/app/models/concerns/rate_kindable.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module RateKindable + extend ActiveSupport::Concern + + RATE_KINDS = %w[standard reduced].freeze + + included do + validates :rate_kind, presence: true, inclusion: { in: RATE_KINDS } + scope :for_rate_kinds, ->(rate_kinds) { where(rate_kind: Array(rate_kinds)) } + end + + def standard_rate? + rate_kind == "standard" + end + + def reduced_rate? + rate_kind == "reduced" + end + + def available_for?(person) + person&.allows_rate_kind?(rate_kind) || false + end + + def rate_kind_humanized + self.class.humanize_rate_kind(rate_kind) + end + + def rate_kind_badge_class + reduced_rate? ? "bg-amber-100 text-amber-800" : "bg-slate-100 text-slate-800" + end + + class_methods do + def rate_kind_options + RATE_KINDS.map { |kind| [ humanize_rate_kind(kind), kind ] } + end + + def humanize_rate_kind(kind) + case kind.to_s + when "standard" + "Tarif standard" + when "reduced" + "Tarif réduit" + else + kind.to_s.humanize + end + end + end +end diff --git a/app/models/concerns/roleable.rb b/app/models/concerns/roleable.rb index 4884f0ba..81e3180c 100644 --- a/app/models/concerns/roleable.rb +++ b/app/models/concerns/roleable.rb @@ -6,10 +6,10 @@ module Roleable # Humanization des rôles def role_humanized case system_role - when "super_admin" then "Super Admin" - when "admin" then "Admin" + when "super_admin" then "Super administrateur" + when "admin" then "Administrateur" when "volunteer" then "Bénévole" - when "web_visitor" then "Visiteur Web" + when "web_visitor" then "Visiteur web" else system_role.humanize end end @@ -75,10 +75,10 @@ def can_manage_notepad? class_methods do def humanize_role(role) case role.to_s - when "super_admin" then "Super Admin" - when "admin" then "Admin" + when "super_admin" then "Super administrateur" + when "admin" then "Administrateur" when "volunteer" then "Bénévole" - when "web_visitor" then "Visiteur Web" + when "web_visitor" then "Visiteur web" else role.to_s.humanize end end diff --git a/app/models/contribution.rb b/app/models/contribution.rb index 88d54794..64119384 100644 --- a/app/models/contribution.rb +++ b/app/models/contribution.rb @@ -13,6 +13,8 @@ class Contribution < ApplicationRecord validates :expires_at, presence: true, unless: :is_pack10? validate :sessions_remaining_validation + validate :day_pass_single_use_validation + validate :day_pass_expiration_validation enum :status, { inactive: 0, @@ -128,6 +130,23 @@ def sessions_remaining_validation end end + def day_pass_single_use_validation + return unless contribution_formula&.duration == "day" + return if sessions_remaining.in?([ 0, 1 ]) + + errors.add(:sessions_remaining, "doit être 1 avant utilisation puis 0 après utilisation pour une cotisation journée") + end + + def day_pass_expiration_validation + return unless contribution_formula&.duration == "day" + return if expires_at.blank? || purchased_at.blank? + + expected_expiration = purchased_at.end_of_day + return if expires_at.to_i == expected_expiration.to_i + + errors.add(:expires_at, "doit être la fin du jour d'achat pour une cotisation journée") + end + def set_initial_values self.purchased_at ||= Time.current self.status ||= :active diff --git a/app/models/contribution_formula.rb b/app/models/contribution_formula.rb index 6bb70859..9b3378cc 100644 --- a/app/models/contribution_formula.rb +++ b/app/models/contribution_formula.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class ContributionFormula < ApplicationRecord + include RateKindable include Priceable include Humanizable include Versionable @@ -110,7 +111,10 @@ def price_change_percentage(from_date, to_date) def self.available_for(person) return none unless person&.current_membership&.membership_type&.circus? - for_circus_members.current_versions.order(:duration, :price_cents) + for_circus_members + .current_versions + .for_rate_kinds(person.allowed_rate_kinds) + .order(:duration, :price_cents) end def self.create_default_plans! @@ -120,6 +124,7 @@ def self.create_default_plans! find_or_create_by(name: "Journée - #{membership_type.name}", version: 1) do |sp| sp.membership_type = membership_type sp.duration = :day + sp.rate_kind = membership_type.rate_kind sp.price_cents = 800 sp.description = "Accès aux cours pour une journée" sp.version = 1 @@ -129,6 +134,7 @@ def self.create_default_plans! find_or_create_by(name: "Trimestre - #{membership_type.name}", version: 1) do |sp| sp.membership_type = membership_type sp.duration = :trimester + sp.rate_kind = membership_type.rate_kind sp.price_cents = 6000 sp.description = "Accès aux cours pendant 3 mois" sp.version = 1 @@ -138,6 +144,7 @@ def self.create_default_plans! find_or_create_by(name: "Annuel - #{membership_type.name}", version: 1) do |sp| sp.membership_type = membership_type sp.duration = :annual + sp.rate_kind = membership_type.rate_kind sp.price_cents = 20_000 sp.description = "Accès aux cours pendant 1 an" sp.version = 1 @@ -147,6 +154,7 @@ def self.create_default_plans! find_or_create_by(name: "Pack 10 séances - #{membership_type.name}", version: 1) do |sp| sp.membership_type = membership_type sp.duration = :pack10 + sp.rate_kind = membership_type.rate_kind sp.price_cents = 7000 sp.sessions_count = 10 sp.validity_days = 365 diff --git a/app/models/membership_type.rb b/app/models/membership_type.rb index 28bf1c2a..9ef7081e 100644 --- a/app/models/membership_type.rb +++ b/app/models/membership_type.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class MembershipType < ApplicationRecord + include RateKindable include Priceable include Humanizable include Versionable @@ -77,11 +78,16 @@ def price_change_percentage(from_date, to_date) scope :effective_on, ->(date) { where("effective_from <= ? AND (effective_until IS NULL OR effective_until >= ?)", date, date) } scope :price_history, -> { order(:effective_from, :version) } + def self.available_for(person) + current_versions.for_rate_kinds(person.allowed_rate_kinds) + end + # Méthodes de classe pour créer les types par défaut def self.create_default_types! find_or_create_by(name: "Adhésion Basique", version: 1) do |mt| mt.category = :basic mt.price_cents = 1500 # 15€ + mt.rate_kind = "standard" mt.description = "Adhésion de base à l'association" mt.effective_from = Date.current end @@ -89,6 +95,7 @@ def self.create_default_types! find_or_create_by(name: "Adhésion Cirque Complète", version: 1) do |mt| mt.category = :circus mt.price_cents = 2500 # 25€ + mt.rate_kind = "standard" mt.description = "Adhésion complète avec accès aux cours de cirque" mt.effective_from = Date.current end @@ -96,6 +103,7 @@ def self.create_default_types! find_or_create_by(name: "Adhésion Cirque Réduite", version: 1) do |mt| mt.category = :circus mt.price_cents = 2000 # 20€ + mt.rate_kind = "reduced" mt.description = "Adhésion cirque à tarif réduit (étudiants, chômeurs, etc.)" mt.effective_from = Date.current end diff --git a/app/models/payment.rb b/app/models/payment.rb index fc3d4b07..01d88b23 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -15,6 +15,8 @@ class Payment < ApplicationRecord enum :status, { success: 0, pending: 1, cancel: 2 }, default: :pending enum :payment_method, { cash: 0, card: 1, cheque: 2, transfer: 3, offered: 4 }, default: :cash + validates :offer_reason, presence: true, if: :is_offered? + before_create :generate_uuid after_create :create_audit_log # Callbacks legacy supprimés : la création/mise à jour cascade passe désormais par les services People::*. @@ -51,7 +53,7 @@ def payment_type # Déterminer le type de paiement basé sur les payment_lines if payment_lines.memberships.any? "Adhésion" - elsif payment_lines.contribution_formulas.any? + elsif payment_lines.contributions.any? || payment_lines.contribution_formulas.any? "Cotisation" elsif payment_lines.any? # Utiliser la description de la première ligne @@ -82,8 +84,22 @@ def membership_related? end def carnet_related? - payment_lines.joins("JOIN contribution_formulas ON payment_lines.item_type = 'ContributionFormula' AND payment_lines.item_id = contribution_formulas.id") - .exists?(contribution_formulas: { duration: ContributionFormula.durations[:pack10] }) + payment_lines.joins(<<~SQL.squish) + LEFT JOIN contribution_formulas direct_formulas + ON payment_lines.item_type = 'ContributionFormula' + AND payment_lines.item_id = direct_formulas.id + LEFT JOIN contributions + ON payment_lines.item_type = 'Contribution' + AND payment_lines.item_id = contributions.id + LEFT JOIN contribution_formulas contribution_formulas + ON contributions.contribution_formula_id = contribution_formulas.id + SQL + .where( + "direct_formulas.duration = ? OR contribution_formulas.duration = ?", + ContributionFormula.durations[:pack10], + ContributionFormula.durations[:pack10] + ) + .exists? end # Generate a UUID for the payment @@ -160,12 +176,16 @@ def destroy # Anonymization for GDPR compliance def anonymize! - return if anonymized_at.present? - - self.original_person_identifier = "ANON_#{Digest::SHA256.hexdigest("#{person_id}_#{id}_#{created_at}")}" - self.person_id = nil - self.anonymized_at = Time.current - save! + with_lock do + return if anonymized_at.present? + + # payments.person_id is NOT NULL: anonymization is a traceability mark, + # not a destructive unlink from the owning Person. + update!( + original_person_identifier: "ANON_#{Digest::SHA256.hexdigest("#{person_id}_#{id}_#{created_at}")}", + anonymized_at: Time.current + ) + end end scope :anonymized, -> { where.not(anonymized_at: nil) } diff --git a/app/models/payment_line.rb b/app/models/payment_line.rb index 955026d7..c93ca46a 100644 --- a/app/models/payment_line.rb +++ b/app/models/payment_line.rb @@ -24,7 +24,7 @@ class PaymentLine < ApplicationRecord def item_description case item_type when "Membership" - item&.membership_type&.name || I18n.t("payment_line.item_description.membership_fallback") + description.presence || item&.membership_type&.name || I18n.t("payment_line.item_description.membership_fallback") when "MembershipType" item&.name || I18n.t("payment_line.item_description.membership_type_fallback") when "Contribution" @@ -42,10 +42,26 @@ def item_description end end + def history_description + case item_type + when "Membership" + membership_history_description + when "Contribution" + "Cotisation #{item_description}" + when "ContributionFormula" + "Cotisation #{item_description}" + when "Donation" + description.presence || I18n.t("payment_line.item_description.donation_fallback") + else + item_description + end + end + # (amount_euros maintenant dans le module Priceable) # Scopes scope :memberships, -> { where(item_type: "Membership") } + scope :contributions, -> { where(item_type: "Contribution") } scope :contribution_formulas, -> { where(item_type: "ContributionFormula") } scope :membership_types, -> { where(item_type: "MembershipType") } scope :donations, -> { where(item_type: "Donation") } @@ -57,7 +73,7 @@ def self.create_for_membership!(payment, membership, amount_cents) payment: payment, item: membership, amount_cents: amount_cents, - description: I18n.t("payment_line.descriptions.membership", name: membership.membership_type.name) + description: normalize_membership_name(membership.membership_type.name) ) end @@ -77,7 +93,44 @@ def self.create_for_membership_type!(payment, membership_type, amount_cents) payment: payment, item: membership_type, amount_cents: amount_cents, - description: I18n.t("payment_line.descriptions.membership_type", name: membership_type.name) + description: normalize_membership_name(membership_type.name) ) end + + def self.normalize_membership_name(raw_name) + name = raw_name.to_s.strip + return "Adhésion" if name.blank? + + if name.match?(/\A(?:adhesion|adhésion)\b/i) + normalized = name.gsub(/\A(?:adhesion|adhésion)(?:\s+(?:adhesion|adhésion))+\s+/i, "Adhésion ") + normalized.gsub(/^adhesion\s+/i, "Adhésion ").gsub(/^adhésion\s+/i, "Adhésion ") + else + "Adhésion #{name}" + end + end + + private + + def membership_history_description + return normalized_upgrade_description if upgrade_description? + + self.class.normalize_membership_name(description.presence || item&.membership_type&.name) + end + + def upgrade_description? + description.to_s.start_with?("Upgrade d'adhésion", "Passage d'adhésion") + end + + def normalized_upgrade_description + text = description.to_s.strip + + if text.start_with?("Passage d'adhésion") + text + elsif text.match(/\AUpgrade d'adhésion de (?.+) vers (?.+?)(?:\s+\(plein tarif\))?\z/) + match = Regexp.last_match + "Passage d'adhésion : #{match[:from]} -> #{match[:to]}" + else + text + end + end end diff --git a/app/models/person.rb b/app/models/person.rb index 19f3e14b..9d5dd1e4 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -1,22 +1,18 @@ # frozen_string_literal: true class Person < ApplicationRecord - # =================================================================== - # ⚠️ DEPRECATED: newsletter_subscribed column - # =================================================================== - # This column is deprecated in favor of NewsletterSubscriber model. - # The new table provides better tracking with source ('web', 'admin', 'import') - # and supports orphaned emails (without Person). - # - # Migration plan: - # 1. Phase 1 (current): Mark as deprecated, stop writing to it - # 2. Phase 2: Migrate data from Person.newsletter_subscribed → NewsletterSubscriber - # 3. Phase 3: Remove Person.newsletter_subscribed column - # - # TODO: Replace all newsletter_subscribed references with NewsletterSubscriber - # =================================================================== + include PersonPaymentReporting + + REDUCED_RATE_REASONS = [ + "RSA", + "Mineur", + "Situation Handicap", + "Étudiant", + "Autre" + ].freeze has_one :user, dependent: :restrict_with_error + has_many :account_claims, dependent: :restrict_with_error has_many :memberships, dependent: :restrict_with_error has_many :payments, dependent: :restrict_with_error has_many :attendances, dependent: :destroy @@ -31,6 +27,8 @@ class Person < ApplicationRecord validates :email, uniqueness: true, allow_blank: true validates :phone, uniqueness: true, allow_blank: true validates :member_number, uniqueness: true, allow_blank: true + validate :email_not_used_by_other_user_account + validate :reduced_rate_consistency def full_name "#{first_name} #{last_name}" @@ -102,6 +100,11 @@ def current_membership memberships.active.current.first end + # Dernière adhésion la plus récente par date de fin (profil : distinguer « jamais adhéré » / « adhésion terminée »). + def most_recent_membership + memberships.order(ended_at: :desc, started_at: :desc).first + end + def has_active_membership? current_membership.present? end @@ -120,6 +123,16 @@ def adult? !is_minor end + def allows_rate_kind?(rate_kind) + allowed_rate_kinds.include?(rate_kind.to_s.presence || "standard") + end + + def allowed_rate_kinds + kinds = [ "standard" ] + kinds << "reduced" if reduced_rate_eligible? + kinds + end + scope :with_user_account, -> { joins(:user) } scope :without_user_account, -> { where.missing(:user) } scope :by_name, lambda { |query| @@ -192,240 +205,6 @@ def can_be_claimed_by?(email_to_check) true end - def create_membership!(membership_type, recorded_by:, payment_method: :cash, custom_amount_cents: nil, offer_reason: nil, donation_cents: nil) - ActiveRecord::Base.transaction do - validate_offer_permissions!(recorded_by, "membership", offer_reason) if payment_method.to_s == "offered" - - raise "Cette personne a déjà une adhésion active" if memberships.active.current.exists? - - membership = memberships.create!( - membership_type: membership_type, - started_at: Date.current, - ended_at: Date.current + 1.year, - status: :active - ) - - if member_number.blank? - normalized_category = case membership_type.category - when "circus" - "CIRQUE" - when "basic" - "BASIQUE" - else - "BASIQUE" - end - - MemberManagementService.assign_member_number(self, normalized_category) unless Rails.env.test? - end - - amount_cents = calculate_amount_cents(payment_method, membership_type.price_cents, custom_amount_cents) - donation_cents = donation_cents.to_i if donation_cents.present? - donation_cents = nil if donation_cents.to_i <= 0 - total_cents = amount_cents + (donation_cents || 0) - - description = generate_payment_description(payment_method, membership_type.name, "Membership") - payment = payments.create!( - total_cents: total_cents, - payment_method: payment_method, - status: :success, - recorded_by: recorded_by, - notes: "Paiement pour #{description}" - ) - - payment.payment_lines.create!( - item_type: "Membership", - item_id: membership.id, - amount_cents: amount_cents, - description: description - ) - - if donation_cents.present? - payment.payment_lines.create!( - item_type: "Donation", - item_id: payment.id, - amount_cents: donation_cents, - description: "Donation" - ) - end - - { membership: membership, payment: payment } - end - end - - def create_contribution!(contribution_formula, recorded_by:, payment_method: :cash, record_attendance: false, custom_amount_cents: nil, offer_reason: nil, donation_cents: nil) - ActiveRecord::Base.transaction do - validate_offer_permissions!(recorded_by, "contribution", offer_reason, contribution_formula) if payment_method.to_s == "offered" - - raise "Cette personne doit avoir une adhésion Cirque active pour acheter une cotisation" unless can_buy_contribution_formulas? - - sessions_remaining = case contribution_formula.duration - when "pack10" - contribution_formula.sessions_count || 10 - when "day" - 1 - when "trimester", "annual" - nil - else - contribution_formula.sessions_count || 1 - end - - expires_at = case contribution_formula.duration - when "pack10" - nil - when "day" - Date.current.end_of_day - when "trimester" - Date.current + 90.days - when "annual" - Date.current + 1.year - else - contribution_formula.validity_days ? Date.current + contribution_formula.validity_days.days : nil - end - - contribution = contributions.create!( - contribution_formula: contribution_formula, - sessions_remaining: sessions_remaining, - status: :active, - purchased_at: Time.current, - expires_at: expires_at - ) - - amount_cents = calculate_amount_cents(payment_method, contribution_formula.price_cents, custom_amount_cents) - donation_cents = donation_cents.to_i if donation_cents.present? - donation_cents = nil if donation_cents.to_i <= 0 - total_cents = amount_cents + (donation_cents || 0) - - description = generate_payment_description(payment_method, contribution_formula.name, "Contribution") - payment = payments.create!( - total_cents: total_cents, - payment_method: payment_method, - status: :success, - recorded_by: recorded_by, - notes: "Paiement pour #{description}" - ) - - payment.payment_lines.create!( - item_type: "Contribution", - item_id: contribution.id, - amount_cents: amount_cents, - description: description - ) - - if donation_cents.present? - payment.payment_lines.create!( - item_type: "Donation", - item_id: payment.id, - amount_cents: donation_cents, - description: "Donation" - ) - end - - if record_attendance - # Logique de présence — implémentation à compléter selon les besoins. - end - - { contribution: contribution, payment: payment } - end - end - - def upgrade_contribution!(from_contribution_id:, to_formula_id:, recorded_by:, payment_method: :cash) - ActiveRecord::Base.transaction do - raise "Adhésion Cirque active requise" unless can_buy_contribution_formulas? - - from_contribution = contributions.find(from_contribution_id) - to_formula = ContributionFormula.find(to_formula_id) - - validate_contribution_upgrade!(from_contribution, to_formula) - - credit_cents = calculate_contribution_credit(from_contribution) - - from_contribution.suspend!(reason: "Upgrade vers #{to_formula.name}") - - new_result = create_contribution!(to_formula, payment_method: payment_method, recorded_by: recorded_by) - - amount_to_pay = [ to_formula.price_cents - credit_cents, 0 ].max - - payment = payments.create!( - total_cents: amount_to_pay, - payment_method: payment_method, - status: :success, - recorded_by: recorded_by, - notes: "Upgrade cotisation: #{from_contribution.contribution_formula.name} → #{to_formula.name}. Crédit: #{credit_cents / 100.0}€" - ) - - payment.payment_lines.create!( - item_type: "Contribution", - item_id: new_result[:contribution].id, - amount_cents: amount_to_pay, - description: "Upgrade avec crédit prorata" - ) - - { - old_contribution: from_contribution, - new_contribution: new_result[:contribution], - payment: payment, - credit_applied: credit_cents - } - end - end - - def create_donation!(amount_cents, recorded_by:, payment_method: :cash, notes: "Donation") - ActiveRecord::Base.transaction do - payment = payments.create!( - total_cents: amount_cents, - payment_method: payment_method, - status: :success, - recorded_by: recorded_by, - notes: notes - ) - - payment.payment_lines.create!( - item_type: "Donation", - item_id: payment.id, - amount_cents: amount_cents, - description: notes - ) - - payment - end - end - - def upgrade_membership!(new_membership_type, recorded_by:, payment_method: :cash, custom_amount_cents: nil, offer_reason: nil, donation_cents: nil) - ActiveRecord::Base.transaction do - current_membership = self.current_membership - raise "Aucune adhésion active à upgrader" unless current_membership - - validate_offer_permissions!(recorded_by, "membership_upgrade", offer_reason) if payment_method.to_s == "offered" - - old_membership_type = current_membership.membership_type - - amount_to_pay = new_membership_type.price_cents - - new_membership = current_membership.upgrade_to!(new_membership_type) - - old_member_number = member_number - new_member_number = handle_member_number_change!(old_membership_type, new_membership_type, recorded_by) - - payment = create_payment_with_line!( - amount_cents: amount_to_pay, - payment_method: payment_method, - recorded_by: recorded_by, - item_type: "Membership", - item_id: new_membership.id, - description: "Upgrade d'adhésion de #{old_membership_type.name} vers #{new_membership_type.name} (plein tarif)", - donation_cents: donation_cents - ) - - { - membership: new_membership, - payment: payment, - member_number_changed: old_member_number != new_member_number, - old_member_number: old_member_number, - new_member_number: new_member_number - } - end - end - def renew_membership!(membership_type, recorded_by:, payment_method: :cash, custom_amount_cents: nil, offer_reason: nil) ActiveRecord::Base.transaction do current = current_membership @@ -433,7 +212,17 @@ def renew_membership!(membership_type, recorded_by:, payment_method: :cash, cust current&.update!(status: :expired) - result = create_membership!(membership_type, payment_method: payment_method, recorded_by: recorded_by, custom_amount_cents: custom_amount_cents, offer_reason: offer_reason) + creation_result = People::MembershipCreator.new( + person: self, + membership_type_id: membership_type.id, + payment_method: payment_method.to_s, + recorded_by_id: recorded_by.id, + custom_amount_cents: custom_amount_cents, + offer_reason: offer_reason + ).call + + raise "Cette personne a déjà une adhésion active" if creation_result.success? && creation_result.already_existed + raise creation_result.message unless creation_result.success? old_number = member_number new_number = MemberManagementService.generate_member_number(get_membership_type_code(membership_type)) @@ -448,37 +237,18 @@ def renew_membership!(membership_type, recorded_by:, payment_method: :cash, cust update!(member_number: new_number) - result.merge( + { + membership: creation_result.membership, + payment: creation_result.payment, renewed: true, old_member_number: old_number, new_member_number: new_number - ) + } end end private - def handle_member_number_change!(old_membership_type, new_membership_type, recorded_by) - old_type_code = get_membership_type_code(old_membership_type) - new_type_code = get_membership_type_code(new_membership_type) - - if old_type_code == new_type_code - member_number - else - new_member_number = MemberManagementService.generate_member_number(new_type_code) - - create_member_number_change_history!(old_member_number: member_number, - new_member_number: new_member_number, - old_type: old_type_code, - new_type: new_type_code, - recorded_by: recorded_by) - - update!(member_number: new_member_number) - - new_member_number - end - end - def get_membership_type_code(membership_type) case membership_type.category when "circus" @@ -490,17 +260,6 @@ def get_membership_type_code(membership_type) end end - def get_membership_type_name(membership_type) - case membership_type.category - when "circus" - "Cirque" - when "basic" - "Basique" - else - "Basique" - end - end - def create_member_number_change_history!(old_member_number:, new_member_number:, old_type:, new_type:, recorded_by:) if old_member_number.present? old_history = member_number_histories.where(member_number: old_member_number, replaced_at: nil).first @@ -519,183 +278,37 @@ def create_member_number_change_history!(old_member_number:, new_member_number:, ) end - def calculate_amount_cents(payment_method, base_price_cents, custom_amount_cents = nil) - case payment_method.to_s - when "offered" - custom_amount_cents || 0 - else - base_price_cents - end - end + def must_have_active_membership + return if new_record? + return if memberships.active.any? + return if skip_membership_validation - def generate_payment_description(payment_method, item_name, item_type) - case payment_method.to_s - when "offered" - case item_type - when "Membership" then "Adhésion offerte #{item_name}" - when "Contribution" then "Cotisation offerte #{item_name}" - when "MembershipUpgrade" then "Upgrade offert d'adhésion vers #{item_name}" - else "#{item_type} offert #{item_name}" - end - else - case item_type - when "Membership" then "Adhésion #{item_name}" - when "Contribution" then "Plan d'abonnement #{item_name}" - when "MembershipUpgrade" then "Upgrade d'adhésion vers #{item_name}" - else "#{item_type} #{item_name}" - end - end + errors.add(:base, "Une adhésion active est obligatoire") end - def validate_offer_permissions!(recorded_by, offer_type, offer_reason, contribution_formula = nil) - raise "Seuls les bénévoles, admins et super-admins peuvent offrir des #{offer_type}s" unless recorded_by.super_admin? || recorded_by.admin? || recorded_by.volunteer? - - raise "Une raison doit être fournie pour offrir une #{offer_type}" if offer_reason.blank? - - raise "Les bénévoles ne peuvent offrir que des cotisations 'journée'" if recorded_by.volunteer? && offer_type == "contribution" && contribution_formula&.duration != "day" + private - create_offer_audit_log!(recorded_by, offer_type, offer_reason, contribution_formula) - end + def email_not_used_by_other_user_account + return if email.blank? + return unless Identity::EmailPolicy.person_email_conflicts_with_other_user?(email: email, current_person_id: id) - def create_offer_audit_log!(recorded_by, offer_type, offer_reason, _contribution_formula = nil) - Rails.logger.info "OFFER AUDIT: #{recorded_by.email} offered #{offer_type} to #{full_name} (#{id}) - Reason: #{offer_reason}" + errors.add(:email, "est deja utilisee par un autre compte utilisateur") end - def create_payment_with_line!(amount_cents:, payment_method:, recorded_by:, item_type:, item_id:, description:, donation_cents: nil) - donation_cents = donation_cents.to_i if donation_cents.present? - donation_cents = nil if donation_cents.to_i <= 0 - total_cents = amount_cents + (donation_cents || 0) - - payment = payments.create!( - total_cents: total_cents, - payment_method: payment_method, - status: :success, - recorded_by: recorded_by, - notes: description - ) + def reduced_rate_consistency + return unless reduced_rate_eligible? - payment.payment_lines.create!( - item_type: item_type, - item_id: item_id, - amount_cents: amount_cents, - description: description - ) - - if donation_cents.present? - payment.payment_lines.create!( - item_type: "Donation", - item_id: payment.id, - amount_cents: donation_cents, - description: "Donation" - ) + if reduced_rate_reason.blank? + errors.add(:reduced_rate_reason, "doit être renseigné pour un tarif réduit") + return end - payment - end - - def validate_contribution_upgrade!(from_contribution, to_formula) - from_duration = from_contribution.contribution_formula.duration - to_duration = to_formula.duration - - valid_upgrades = { - "pack10" => %w[trimester annual], - "trimester" => [ "annual" ] - } - - allowed = valid_upgrades[from_duration] - raise "Upgrade #{from_duration} → #{to_duration} non autorisé" unless allowed&.include?(to_duration) - end - - def calculate_contribution_credit(contribution) - formula = contribution.contribution_formula - - case formula.duration - when "pack10" - 0 - when "trimester" - total_days = 90 - days_remaining = (contribution.expires_at.to_date - Date.current).to_i - (formula.price_cents * days_remaining / total_days.to_f).round - when "annual" - total_days = 365 - days_remaining = (contribution.expires_at.to_date - Date.current).to_i - (formula.price_cents * days_remaining / total_days.to_f).round - else - 0 + unless REDUCED_RATE_REASONS.include?(reduced_rate_reason) + errors.add(:reduced_rate_reason, "n'est pas une raison de tarif réduit valide") end - end - - public - def offered_payments_count - payments.where(payment_method: "offered").count - end - - def offered_payments_total - payments.where(payment_method: "offered").sum(:total_cents) - end - - def free_offers_count - payments.where(payment_method: "offered", total_cents: 0).count - end + return unless reduced_rate_reason == "Autre" && reduced_rate_proof.blank? - def paid_offers_count - payments.where(payment_method: "offered").where("total_cents > 0").count + errors.add(:reduced_rate_proof, "doit être renseigné quand la raison de tarif réduit est Autre") end - - def membership_upgrades_count - payments.joins(:payment_lines) - .where(payment_lines: { item_type: "MembershipUpgrade" }) - .count - end - - def contribution_purchases_count - payments.joins(:payment_lines) - .where(payment_lines: { item_type: "Contribution" }) - .count - end - - def self.total_offered_payments - joins(:payments).where(payments: { payment_method: "offered" }).count - end - - def self.total_free_offers - joins(:payments).where(payments: { payment_method: "offered", total_cents: 0 }).count - end - - def self.total_paid_offers - joins(:payments).where(payments: { payment_method: "offered" }).where("payments.total_cents > 0").count - end - - def self.offered_payments_by_reason - joins(:payments) - .where(payments: { payment_method: "offered" }) - .group("payments.notes") - .count - end - - def self.upgrades_today - joins(:payments) - .joins("JOIN payment_lines ON payments.id = payment_lines.payment_id") - .where(payment_lines: { item_type: "MembershipUpgrade" }) - .where(payments: { created_at: Date.current.all_day }) - .count - end - - def must_have_active_membership - return if new_record? - return if memberships.active.any? - return if skip_membership_validation - - errors.add(:base, "Une adhésion active est obligatoire") - end - - def newsletter_subscribed? - return false if email.blank? - - subscriber = NewsletterSubscriber.find_by(email: email) - subscriber&.subscribed? || false - end - - public :newsletter_subscribed? end diff --git a/app/models/user.rb b/app/models/user.rb index db732c85..281a51c2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,11 +4,12 @@ class User < ApplicationRecord include Roleable include Dateable + EMAIL_CHANGE_CODE_TTL = 15.minutes + attr_accessor :cgu, :privacy_policy before_validation :ensure_person_for_new_record, on: :create after_create :generate_password_reset_token - after_create :welcome_send # Configuration du token de réinitialisation de mot de passe generates_token_for :password_reset, expires_in: 15.minutes do @@ -54,7 +55,7 @@ def newsletter_subscribed validates :email_address, presence: true validates :person, presence: true - validate :email_uniqueness_unless_person_email + validate :email_uniqueness_and_identity_consistency validates :cgu, acceptance: { message: :must_accept }, unless: :created_by_admin? validates :privacy_policy, acceptance: { message: :must_accept }, unless: :created_by_admin? @@ -209,6 +210,7 @@ def deleted? def archive! return false if deleted? + sessions.destroy_all update!(deleted: true, deleted_at: Time.current) anonymize_personal_data true @@ -224,16 +226,12 @@ def restore! # Standard destroy method - no soft deletion - def email_uniqueness_unless_person_email + def email_uniqueness_and_identity_consistency return if email_address.blank? + errors.add(:email_address, "est deja utilise") if User.where(email_address: email_address).where.not(id: id).exists? + return unless Identity::EmailPolicy.user_email_conflicts_with_other_person?(email: email_address, current_person_id: person_id) - # Si on a une Person associée avec le même email, c'est OK - return if person&.email == email_address - - # Sinon, vérifier l'unicité normale - return unless User.where(email_address: email_address).where.not(id: id).exists? - - errors.add(:email_address, "est déjà utilisé") + errors.add(:email_address, "entre en conflit avec l'email d'une autre personne") end # Check if user is interested in an event (Person-Based Architecture) @@ -243,6 +241,36 @@ def is_interested_in?(event_id) person.attendances.exists?(event_id: event_id) end + def store_email_change_request!(new_email:, code:) + update!( + pending_email_address: new_email, + email_change_code_digest: BCrypt::Password.create(code), + email_change_code_sent_at: Time.current + ) + end + + def email_change_code_valid?(candidate_code) + return false if email_change_code_digest.blank? + + BCrypt::Password.new(email_change_code_digest).is_password?(candidate_code.to_s) + rescue BCrypt::Errors::InvalidHash + false + end + + def email_change_code_expired? + return true if email_change_code_sent_at.blank? + + email_change_code_sent_at < EMAIL_CHANGE_CODE_TTL.ago + end + + def clear_email_change_request! + update!( + pending_email_address: nil, + email_change_code_digest: nil, + email_change_code_sent_at: nil + ) + end + private def ensure_person_for_new_record diff --git a/app/queries/admin/users/index_query.rb b/app/queries/admin/users/index_query.rb new file mode 100644 index 00000000..c15614ff --- /dev/null +++ b/app/queries/admin/users/index_query.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Admin + module Users + class IndexQuery + def initialize(params) + @params = params + end + + def call + apply_sort(apply_search(apply_filter(base_query))) + end + + private + + attr_reader :params + + def base_query + PersonQuery.active.main_people.includes( + :user, + memberships: :membership_type, + contributions: :contribution_formula + ) + end + + def apply_filter(relation) + case params[:filter] + when "with_active_membership" + relation.with_active_membership + when "with_expiring_membership" + relation.with_expiring_membership + when "with_expired_membership" + relation.with_expired_membership + when "without_membership" + relation.without_membership + when "with_user_account" + relation.with_user_account + when "without_user_account" + relation.without_user_account + else + relation + end + end + + def apply_search(relation) + return relation if params[:search].blank? + + relation.search_by_contact(params[:search]) + end + + def apply_sort(relation) + relation.order(:last_name, :first_name) + end + end + end +end diff --git a/app/services/admin/health_report.rb b/app/services/admin/health_report.rb index edae0a65..97cc2dbf 100644 --- a/app/services/admin/health_report.rb +++ b/app/services/admin/health_report.rb @@ -4,6 +4,7 @@ module Admin class HealthReport MAX_LIST = 50 DUPLICATE_FIELDS = %i[email phone].freeze + ContributionIssue = Struct.new(:contribution, :message, keyword_init: true) Result = Struct.new( :users_without_person, @@ -16,6 +17,14 @@ class HealthReport :duplicate_people_by_phone_count, :payments_without_person, :payments_without_person_count, + :payments_without_lines, + :payments_without_lines_count, + :payments_with_mismatched_totals, + :payments_with_mismatched_totals_count, + :legacy_donation_lines, + :legacy_donation_lines_count, + :contribution_invariant_issues, + :contribution_invariant_issues_count, keyword_init: true ) @@ -23,6 +32,10 @@ def call users_without_person = User.left_joins(:person).where(people: { id: nil }).order(:created_at) people_without_user = PersonQuery.active.where.missing(:user).order(:created_at) payments_without_person = Payment.where(person_id: nil).order(:created_at) + payments_without_lines = Payment.includes(:person, :recorded_by).where.missing(:payment_lines).order(:created_at) + payments_with_mismatched_totals = payment_total_mismatches + legacy_donation_lines = PaymentLine.where(item_type: "Payment").includes(payment: :person).order(:created_at) + contribution_invariant_issues = build_contribution_invariant_issues email_keys = duplicate_keys(PersonQuery.active, :email, normalize: true) phone_keys = duplicate_keys(PersonQuery.active, :phone, normalize: false) @@ -37,12 +50,90 @@ def call duplicate_people_by_phone: people_by_keys(PersonQuery.active, :phone, phone_keys, normalize: false).limit(MAX_LIST), duplicate_people_by_phone_count: phone_keys.size, payments_without_person: payments_without_person.limit(MAX_LIST), - payments_without_person_count: payments_without_person.count + payments_without_person_count: payments_without_person.count, + payments_without_lines: payments_without_lines.limit(MAX_LIST), + payments_without_lines_count: payments_without_lines.count, + payments_with_mismatched_totals: payments_with_mismatched_totals.limit(MAX_LIST), + payments_with_mismatched_totals_count: grouped_count(payments_with_mismatched_totals), + legacy_donation_lines: legacy_donation_lines.limit(MAX_LIST), + legacy_donation_lines_count: legacy_donation_lines.count, + contribution_invariant_issues: contribution_invariant_issues.first(MAX_LIST), + contribution_invariant_issues_count: contribution_invariant_issues.size ) end private + def payment_total_mismatches + Payment.includes(:person, :recorded_by) + .left_joins(:payment_lines) + .group("payments.id") + .having("COUNT(payment_lines.id) > 0") + .having("COALESCE(SUM(payment_lines.amount_cents), 0) != payments.total_cents") + .select("payments.*, COALESCE(SUM(payment_lines.amount_cents), 0) AS lines_total_cents, COUNT(payment_lines.id) AS payment_lines_count") + .order(:created_at) + end + + def grouped_count(relation) + relation.except(:select, :order).count.size + end + + def build_contribution_invariant_issues + contributions = Contribution.includes(:person, :contribution_formula) + .joins(:contribution_formula) + .where(contribution_formulas: { duration: %w[day trimester annual] }) + .order(:created_at) + + contributions.flat_map do |contribution| + contribution_issues_for(contribution) + end + end + + def contribution_issues_for(contribution) + duration = contribution.contribution_formula.duration + + case duration + when "day" + day_contribution_issues(contribution) + when "trimester", "annual" + unlimited_contribution_issues(contribution) + else + [] + end + end + + def day_contribution_issues(contribution) + issues = [] + + unless contribution.sessions_remaining.in?([ 0, 1 ]) + issues << ContributionIssue.new( + contribution: contribution, + message: "Cotisation journee avec sessions_remaining invalide (attendu: 1 puis 0)" + ) + end + + if contribution.purchased_at.present? && contribution.expires_at.present? && + contribution.expires_at.to_i != contribution.purchased_at.end_of_day.to_i + issues << ContributionIssue.new( + contribution: contribution, + message: "Cotisation journee avec expiration differente de la fin du jour d'achat" + ) + end + + issues + end + + def unlimited_contribution_issues(contribution) + return [] if contribution.sessions_remaining.nil? + + [ + ContributionIssue.new( + contribution: contribution, + message: "Cotisation #{contribution.contribution_formula.duration} avec sessions_remaining non nil" + ) + ] + end + def duplicate_keys(scope, field, normalize: true) raise ArgumentError, "unsupported field" unless DUPLICATE_FIELDS.include?(field) diff --git a/app/services/admin/users/person_route_key.rb b/app/services/admin/users/person_route_key.rb new file mode 100644 index 00000000..d8957961 --- /dev/null +++ b/app/services/admin/users/person_route_key.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Admin + module Users + class PersonRouteKey + PREFIX = "person_" + + def self.call(person_or_id) + id = person_or_id.respond_to?(:id) ? person_or_id.id : person_or_id + "#{PREFIX}#{id}" + end + + def self.person_identifier?(raw_id) + raw_id.to_s.start_with?(PREFIX) + end + + def self.extract(raw_id) + raw_id.to_s.delete_prefix(PREFIX) + end + end + end +end diff --git a/app/services/admin/users/view_user_adapter.rb b/app/services/admin/users/view_user_adapter.rb new file mode 100644 index 00000000..d919b6d6 --- /dev/null +++ b/app/services/admin/users/view_user_adapter.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Admin + module Users + class ViewUserAdapter + def self.from_person(person) + user = User.new( + id: "temp_#{person.id}", + email_address: person.email, + system_role: nil + ) + + user.association(:person).target = person + user.association(:person).loaded! + user + end + end + end +end diff --git a/app/services/base_service.rb b/app/services/base_service.rb index 6dd76c94..ea09aaa7 100644 --- a/app/services/base_service.rb +++ b/app/services/base_service.rb @@ -15,17 +15,18 @@ class BaseService def success(**data) OpenStruct.new( success?: true, - message: data.delete(:message) || "Operation completed successfully", + message: data.delete(:message) || I18n.t("services.base.success_default"), **data ) end # Méthode générique pour créer une réponse d'échec - def failure(message, errors: nil) + def failure(message = nil, errors: nil) + resolved_message = message || I18n.t("services.base.failure_default") OpenStruct.new( success?: false, - errors: errors || [ message ], - message: message + errors: errors || [ resolved_message ], + message: resolved_message ) end end diff --git a/app/services/dev_incident_logger.rb b/app/services/dev_incident_logger.rb new file mode 100644 index 00000000..d704caa0 --- /dev/null +++ b/app/services/dev_incident_logger.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "digest" + +class DevIncidentLogger + LOG_PATH = Rails.root.join("log", "dev_incidents.log") + + def self.log!(type:, details: {}) + normalized_details = details.to_h.deep_stringify_keys + + return unless Rails.env.development? + + incident = { + at: Time.current.iso8601, + type: type, + pid: Process.pid, + fingerprint: fingerprint_for(type: type, details: normalized_details), + details: normalized_details + } + + Rails.logger.warn("[dev.incident] #{incident.to_json}") + File.open(LOG_PATH, "a") { |f| f.puts(incident.to_json) } + rescue StandardError => e + Rails.logger.warn("[dev.incident] failed_to_persist type=#{type} error=#{e.class}: #{e.message}") + end + + def self.fingerprint_for(type:, details:) + keys = %w[table email_address user_exists users_count where_clause db] + compact_details = details.slice(*keys) + payload = { type: type, details: compact_details } + Digest::SHA256.hexdigest(payload.to_json).first(12) + end +end diff --git a/app/services/identity/email_policy.rb b/app/services/identity/email_policy.rb new file mode 100644 index 00000000..bbe4630f --- /dev/null +++ b/app/services/identity/email_policy.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Identity + class EmailPolicy + def self.person_email_conflicts_with_other_user?(email:, current_person_id:) + normalized_email = normalize(email) + return false if normalized_email.blank? + + User.where(email_address: normalized_email) + .where.not(person_id: current_person_id) + .exists? + end + + def self.user_email_conflicts_with_other_person?(email:, current_person_id:) + normalized_email = normalize(email) + return false if normalized_email.blank? + + Person.where(email: normalized_email) + .where.not(id: current_person_id) + .exists? + end + + def self.normalize(email) + email.to_s.strip.downcase + end + private_class_method :normalize + end +end diff --git a/app/services/member_management_service.rb b/app/services/member_management_service.rb index aee310cf..21bf3a68 100644 --- a/app/services/member_management_service.rb +++ b/app/services/member_management_service.rb @@ -6,7 +6,7 @@ def self.generate_member_number(membership_type = "U") loop do # Format: YYTNNN (ex: 25C001, 25U400) year = Date.current.year.to_s.last(2) # 2025 -> 25 - type_code = %w[CIRQUE C].include?(membership_type.upcase) ? "C" : "U" + type_code = MemberNumberManagement::Policy.type_code_for(membership_type) # Chercher le dernier numéro pour cette année et ce type # Utiliser l'historique ET les numéros actuels pour éviter les conflits @@ -72,14 +72,7 @@ def self.assign_member_number(person, membership_type = nil) person.update!(member_number: new_number) # Normaliser le type d'adhésion pour l'historique - normalized_type = case membership_type.to_s.upcase - when "CIRQUE", "C" - "Cirque" - when "BASIQUE", "U", "BASIC" - "Basique" - else - "Basique" - end + normalized_type = MemberNumberManagement::Policy.type_label_for(membership_type) # Créer l'historique person.member_number_histories.create!( @@ -202,26 +195,12 @@ def self.assign_missing_member_numbers # Analyse un numéro d'adhérent existant def self.parse_member_number(member_number) - return nil if member_number.blank? - - # Format: YYTNNN - match = member_number.match(/^(\d{2})([CU])(\d+)$/) - return nil unless match - - { - year: "20#{match[1]}", # 25 -> 2025 - type: match[2] == "C" ? "Cirque" : "Basique", - number: match[3].to_i, - full_year: match[1], - type_code: match[2] - } + MemberNumberManagement::Policy.parse(member_number) end # Valide le format d'un numéro d'adhérent def self.valid_member_number_format?(member_number) - return false if member_number.blank? - - member_number.match?(/^\d{2}[CU]\d{3,5}$/) + MemberNumberManagement::Policy.valid_format?(member_number) end # Génère des numéros d'adhérent pour les tests diff --git a/app/services/member_number_management/policy.rb b/app/services/member_number_management/policy.rb new file mode 100644 index 00000000..5a7d8973 --- /dev/null +++ b/app/services/member_number_management/policy.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module MemberNumberManagement + class Policy + TYPE_CODE_BY_INPUT = { + "CIRQUE" => "C", + "C" => "C", + "BASIQUE" => "U", + "BASIC" => "U", + "U" => "U" + }.freeze + + TYPE_LABEL_BY_CODE = { + "C" => "Cirque", + "U" => "Basique" + }.freeze + + def self.type_code_for(membership_type) + TYPE_CODE_BY_INPUT.fetch(membership_type.to_s.upcase, "U") + end + + def self.type_label_for(membership_type) + TYPE_LABEL_BY_CODE.fetch(type_code_for(membership_type), "Basique") + end + + def self.parse(member_number) + return nil if member_number.blank? + + match = member_number.match(/^(\d{2})([CU])(\d+)$/) + return nil unless match + + { + year: "20#{match[1]}", + type: type_label_for(match[2]), + number: match[3].to_i, + full_year: match[1], + type_code: match[2] + } + end + + def self.valid_format?(member_number) + return false if member_number.blank? + + member_number.match?(/^\d{2}[CU]\d{3,5}$/) + end + end +end diff --git a/app/services/partners_catalog.rb b/app/services/partners_catalog.rb index adae2fd2..fb3f0662 100644 --- a/app/services/partners_catalog.rb +++ b/app/services/partners_catalog.rb @@ -8,7 +8,7 @@ def self.public_partners Partner.new(name: "Ville de Toulouse", description: "Soutien institutionnel & logistique", link: "https://www.toulouse.fr"), Partner.new(name: "L'Usine", description: "Compagnonnage cirque contemporain", link: "https://www.usine-c.com"), Partner.new(name: "École de cirque Pop", description: "Partage de savoir-faire pédagogique", link: "https://www.ecoledecirquepop.fr"), - Partner.new(name: "Atelier Graphein", description: "Résidences graphiques & expositions", link: "https://ateliergraphein.fr") + Partner.new(name: "Atelier Graphein", description: "Temps d’accueil en création graphiques & expositions", link: "https://ateliergraphein.fr") ] end end diff --git a/app/services/people/account_merger.rb b/app/services/people/account_merger.rb index 30cbb2d1..790e0460 100644 --- a/app/services/people/account_merger.rb +++ b/app/services/people/account_merger.rb @@ -34,6 +34,8 @@ def call merge_contributions(source, target) merge_attendances(source, target) merge_newsletter(source, target) + merge_account_claims(source, target) + merge_member_number_histories(source, target) merge_attributes(source, target) if destroy_source @@ -92,6 +94,14 @@ def merge_newsletter(source, target) subscriber.update!(person: target) end + def merge_account_claims(source, target) + transfer_relation(source.account_claims, target.id) + end + + def merge_member_number_histories(source, target) + transfer_relation(source.member_number_histories, target.id) + end + def merge_attributes(source, target) attrs = {} if source.email.present? && (target.email.blank? || target_email_is_auth_placeholder?(target)) diff --git a/app/services/people/contribution_creator.rb b/app/services/people/contribution_creator.rb index b4b5101b..b27961be 100644 --- a/app/services/people/contribution_creator.rb +++ b/app/services/people/contribution_creator.rb @@ -32,30 +32,52 @@ def call contribution_formula = ContributionFormula.find(contribution_formula_id) recorded_by = resolve_recorded_by - result = target_person.create_contribution!( - contribution_formula, - payment_method: payment_method.to_sym, - recorded_by: recorded_by, - record_attendance: record_attendance, - custom_amount_cents: custom_amount_cents, - offer_reason: offer_reason, - donation_cents: donation_cents - ) + result = nil + ActiveRecord::Base.transaction do + People::OfferPolicy.validate!( + recorded_by: recorded_by, + person: target_person, + offer_type: "contribution", + offer_reason: offer_reason, + contribution_formula: contribution_formula + ) if payment_method == "offered" + + raise "Cette personne doit avoir une adhésion Cirque active pour acheter une cotisation" unless target_person.can_buy_contribution_formulas? + + contribution = target_person.contributions.create!( + People::ContributionPayloadBuilder.call(contribution_formula) + .merge(contribution_formula: contribution_formula, status: :active, purchased_at: Time.current) + ) + + amount_cents = amount_for(contribution_formula.price_cents) + description = payment_description(contribution_formula.name) + payment = record_payment!( + person: target_person, + recorded_by: recorded_by, + item_type: "Contribution", + item_id: contribution.id, + amount_cents: amount_cents, + description: description, + notes: "Paiement pour #{description}" + ) + + result = { contribution: contribution, payment: payment } + end instrument_contribution_created(target_person, contribution_formula, recorded_by, result[:contribution], result[:payment]) success( contribution: result[:contribution], payment: result[:payment], - message: "Contribution created successfully" + message: I18n.t("services.success.contribution_created") ) rescue ActiveRecord::RecordNotFound => e ActiveSupport::Notifications.instrument("contribution.failed", error: e.message, reason: "record_not_found") - failure("Record not found: #{e.message}") + failure(I18n.t("services.errors.record_not_found", message: e.message)) rescue StandardError => e Rails.logger.error("[People::ContributionCreator] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}") ActiveSupport::Notifications.instrument("contribution.failed", error: e.message, reason: "exception") - failure("Error creating contribution: #{e.message}") + failure(I18n.t("services.errors.unexpected_error", action: "contribution creation", message: e.message)) end private @@ -75,6 +97,55 @@ def resolve_recorded_by Current.user end + def amount_for(base_price_cents) + return custom_amount_cents || 0 if payment_method == "offered" + + base_price_cents + end + + def payment_description(name) + payment_method == "offered" ? "Cotisation offerte #{name}" : "Cotisation #{name}" + end + + def record_payment!(person:, recorded_by:, item_type:, item_id:, amount_cents:, description:, notes:) + lines = [ + { + item_type: item_type, + item_id: item_id, + amount_cents: amount_cents, + description: description + } + ] + + if normalized_donation_cents.present? + lines << { + item_type: "Donation", + amount_cents: normalized_donation_cents, + description: "Donation" + } + end + + payment_result = People::PaymentRecorder.new( + person: person, + recorded_by: recorded_by, + payment_method: payment_method, + status: "success", + notes: notes, + offer_reason: offer_reason, + total_cents: lines.sum { |line| line[:amount_cents].to_i }, + payment_lines: lines + ).call + + raise payment_result.message unless payment_result.success? + + payment_result.payment + end + + def normalized_donation_cents + cents = donation_cents.to_i + cents.positive? ? cents : nil + end + def instrument_contribution_created(person, contribution_formula, recorded_by, contribution, payment) ActiveSupport::Notifications.instrument( "contribution.created", diff --git a/app/services/people/contribution_payload_builder.rb b/app/services/people/contribution_payload_builder.rb new file mode 100644 index 00000000..04fcc601 --- /dev/null +++ b/app/services/people/contribution_payload_builder.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module People + class ContributionPayloadBuilder + def self.call(contribution_formula, reference_date: Date.current) + sessions_remaining = case contribution_formula.duration + when "pack10" + contribution_formula.sessions_count || 10 + when "day" + 1 + when "trimester", "annual" + nil + else + contribution_formula.sessions_count || 1 + end + + expires_at = case contribution_formula.duration + when "pack10" + nil + when "day" + reference_date.end_of_day + when "trimester" + reference_date + 90.days + when "annual" + reference_date + 1.year + else + contribution_formula.validity_days ? reference_date + contribution_formula.validity_days.days : nil + end + + { sessions_remaining: sessions_remaining, expires_at: expires_at } + end + end +end diff --git a/app/services/people/contribution_upgrader.rb b/app/services/people/contribution_upgrader.rb index 90c15d4e..dd42fcec 100644 --- a/app/services/people/contribution_upgrader.rb +++ b/app/services/people/contribution_upgrader.rb @@ -16,6 +16,7 @@ class ContributionUpgrader attribute :to_formula_id, :integer attribute :payment_method, :string, default: "cash" attribute :recorded_by_id, :integer + attribute :offer_reason, :string validates :from_contribution_id, presence: true validates :to_formula_id, presence: true @@ -29,12 +30,47 @@ def call target_person = resolve_person recorded_by = resolve_user - result = target_person.upgrade_contribution!( - from_contribution_id: from_contribution_id, - to_formula_id: to_formula_id, - payment_method: payment_method.to_sym, - recorded_by: recorded_by - ) + result = nil + ActiveRecord::Base.transaction do + raise "Adhésion Cirque active requise" unless target_person.can_buy_contribution_formulas? + + from_contribution = target_person.contributions.find(from_contribution_id) + to_formula = ContributionFormula.find(to_formula_id) + + validate_contribution_upgrade!(from_contribution, to_formula) + + People::OfferPolicy.validate!( + recorded_by: recorded_by, + person: target_person, + offer_type: "contribution_upgrade", + offer_reason: offer_reason, + contribution_formula: to_formula + ) if payment_method == "offered" + + credit_cents = calculate_contribution_credit(from_contribution) + from_contribution.suspend!(reason: "Upgrade vers #{to_formula.name}") + + amount_to_pay = [ to_formula.price_cents - credit_cents, 0 ].max + new_contribution = target_person.contributions.create!( + People::ContributionPayloadBuilder.call(to_formula) + .merge(contribution_formula: to_formula, status: :active, purchased_at: Time.current) + ) + + payment = record_payment!( + person: target_person, + recorded_by: recorded_by, + contribution: new_contribution, + amount_cents: amount_to_pay, + notes: "Upgrade cotisation: #{from_contribution.contribution_formula.name} → #{to_formula.name}. Crédit: #{credit_cents / 100.0}€" + ) + + result = { + old_contribution: from_contribution, + new_contribution: new_contribution, + payment: payment, + credit_applied: credit_cents + } + end instrument_upgrade(target_person, recorded_by, result) @@ -43,13 +79,13 @@ def call new_contribution: result[:new_contribution], payment: result[:payment], credit_applied: result[:credit_applied] || 0, - message: "Contribution upgraded successfully" + message: I18n.t("services.success.contribution_upgraded") ) rescue ActiveRecord::RecordNotFound => e - failure("Record not found: #{e.message}") + failure(I18n.t("services.errors.record_not_found", message: e.message)) rescue StandardError => e Rails.logger.error("[People::ContributionUpgrader] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}") - failure("Error upgrading contribution: #{e.message}") + failure(I18n.t("services.errors.unexpected_error", action: "contribution upgrade", message: e.message)) end private @@ -77,6 +113,61 @@ def instrument_upgrade(person, recorded_by, result) ) end + def record_payment!(person:, recorded_by:, contribution:, amount_cents:, notes:) + payment_result = People::PaymentRecorder.new( + person: person, + recorded_by: recorded_by, + payment_method: payment_method, + status: "success", + notes: notes, + offer_reason: offer_reason, + total_cents: amount_cents, + payment_lines: [ + { + item_type: "Contribution", + item_id: contribution.id, + amount_cents: amount_cents, + description: "Upgrade avec crédit prorata" + } + ] + ).call + + raise payment_result.message unless payment_result.success? + + payment_result.payment + end + + def validate_contribution_upgrade!(from_contribution, to_formula) + valid_upgrades = { + "pack10" => %w[trimester annual], + "trimester" => [ "annual" ] + } + + from_duration = from_contribution.contribution_formula.duration + to_duration = to_formula.duration + allowed = valid_upgrades[from_duration] + raise "Upgrade #{from_duration} → #{to_duration} non autorisé" unless allowed&.include?(to_duration) + end + + def calculate_contribution_credit(contribution) + formula = contribution.contribution_formula + + case formula.duration + when "pack10" + 0 + when "trimester" + total_days = 90 + days_remaining = (contribution.expires_at.to_date - Date.current).to_i + (formula.price_cents * days_remaining / total_days.to_f).round + when "annual" + total_days = 365 + days_remaining = (contribution.expires_at.to_date - Date.current).to_i + (formula.price_cents * days_remaining / total_days.to_f).round + else + 0 + end + end + def person_identifier_present errors.add(:person_id, "must be provided") if person.blank? && person_id.blank? end diff --git a/app/services/people/membership_creator.rb b/app/services/people/membership_creator.rb index d8b04f96..560e9033 100644 --- a/app/services/people/membership_creator.rb +++ b/app/services/people/membership_creator.rb @@ -29,20 +29,41 @@ def call "membership.skipped", person_id: person.id, reason: "already_active", membership_type_id: membership_type_id ) - return Result.new(success?: true, membership: person.memberships.active.current.first, payment: nil, errors: [], message: "Person already has an active membership", already_existed: true) + return Result.new(success?: true, membership: person.memberships.active.current.first, payment: nil, errors: [], message: I18n.t("services.success.membership_already_active"), already_existed: true) end membership_type = MembershipType.find(membership_type_id) recorded_by = find_recorded_by - membership_data = person.create_membership!( - membership_type, - payment_method: payment_method.to_sym, - recorded_by: recorded_by, - custom_amount_cents: custom_amount_cents, - offer_reason: offer_reason, - donation_cents: donation_cents - ) + membership_data = nil + ActiveRecord::Base.transaction do + People::OfferPolicy.validate!( + recorded_by: recorded_by, + person: person, + offer_type: "membership", + offer_reason: offer_reason + ) if payment_method == "offered" + + membership = person.memberships.create!( + membership_type: membership_type, + started_at: Date.current, + ended_at: Date.current + 1.year, + status: :active + ) + + assign_member_number_if_needed!(membership_type) + + amount_cents = amount_for(membership_type.price_cents) + payment = record_payment!( + item_type: "Membership", + item_id: membership.id, + amount_cents: amount_cents, + description: payment_description(membership_type.name), + notes: "Paiement pour #{payment_description(membership_type.name)}" + ) + + membership_data = { membership: membership, payment: payment } + end ActiveSupport::Notifications.instrument( "membership.created", @@ -55,20 +76,20 @@ def call membership: membership_data[:membership], payment: membership_data[:payment], errors: [], - message: "Membership created successfully", + message: I18n.t("services.success.membership_created"), already_existed: false ) rescue ActiveRecord::RecordNotFound => e ActiveSupport::Notifications.instrument("membership.failed", error: e.message, reason: "record_not_found") - failure("Record not found: #{e.message}") + failure(I18n.t("services.errors.record_not_found", message: e.message)) rescue ActiveRecord::RecordInvalid => e ActiveSupport::Notifications.instrument("membership.failed", error: e.message, reason: "validation") - failure("Validation error: #{e.message}") + failure(I18n.t("services.errors.validation_error", message: e.message)) rescue StandardError => e db_path = ActiveRecord::Base.connection_db_config&.database Rails.logger.error("[People::MembershipCreator] db=#{db_path} #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}") ActiveSupport::Notifications.instrument("membership.failed", error: e.message, reason: "exception") - failure("Error creating membership: #{e.message}") + failure(I18n.t("services.errors.unexpected_error", action: "membership creation", message: e.message)) end private @@ -85,6 +106,62 @@ def find_recorded_by end end + def assign_member_number_if_needed!(membership_type) + return if person.member_number.present? + + category = membership_type.circus? ? "CIRQUE" : "BASIQUE" + MemberManagementService.assign_member_number(person, category) unless Rails.env.test? + end + + def amount_for(base_price_cents) + return custom_amount_cents || 0 if payment_method == "offered" + + base_price_cents + end + + def payment_description(name) + PaymentLine.normalize_membership_name(name) + end + + def record_payment!(item_type:, item_id:, amount_cents:, description:, notes:) + lines = [ + { + item_type: item_type, + item_id: item_id, + amount_cents: amount_cents, + description: description + } + ] + + if normalized_donation_cents.present? + lines << { + item_type: "Donation", + amount_cents: normalized_donation_cents, + description: "Donation" + } + end + + result = People::PaymentRecorder.new( + person: person, + recorded_by: find_recorded_by, + payment_method: payment_method, + status: "success", + notes: notes, + offer_reason: offer_reason, + total_cents: lines.sum { |line| line[:amount_cents].to_i }, + payment_lines: lines + ).call + + raise result.message unless result.success? + + result.payment + end + + def normalized_donation_cents + cents = donation_cents.to_i + cents.positive? ? cents : nil + end + def failure(message, error_list = nil) Result.new( success?: false, diff --git a/app/services/people/membership_deactivator.rb b/app/services/people/membership_deactivator.rb index bd14d2c7..ca3cf468 100644 --- a/app/services/people/membership_deactivator.rb +++ b/app/services/people/membership_deactivator.rb @@ -24,7 +24,7 @@ def call target_membership = membership || Membership.find(membership_id) user = resolve_user - return failure("Insufficient permissions to deactivate this membership") unless can_deactivate?(user) + return failure(I18n.t("services.errors.insufficient_permissions.membership_deactivate")) unless can_deactivate?(user) ActiveRecord::Base.transaction do target_membership.update!(status: :inactive) @@ -33,16 +33,16 @@ def call success?: true, membership: target_membership, errors: [], - message: "Membership deactivated successfully" + message: I18n.t("services.success.membership_deactivated") ) end rescue ActiveRecord::RecordNotFound => e - failure("Record not found: #{e.message}") + failure(I18n.t("services.errors.record_not_found", message: e.message)) rescue ActiveRecord::RecordInvalid => e - failure("Validation error: #{e.message}") + failure(I18n.t("services.errors.validation_error", message: e.message)) rescue StandardError => e Rails.logger.error("[People::MembershipDeactivator] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}") - failure("Error deactivating membership: #{e.message}") + failure(I18n.t("services.errors.unexpected_error", action: "membership deactivation", message: e.message)) end private diff --git a/app/services/people/membership_updater.rb b/app/services/people/membership_updater.rb index 57e7cb68..a44a0929 100644 --- a/app/services/people/membership_updater.rb +++ b/app/services/people/membership_updater.rb @@ -27,7 +27,7 @@ def call membership_type = MembershipType.find(membership_type_id) user = resolve_user - return failure("Insufficient permissions to update this membership") unless can_update?(user) + return failure(I18n.t("services.errors.insufficient_permissions.membership_update")) unless can_update?(user) ActiveRecord::Base.transaction do target_membership.update!( @@ -40,16 +40,16 @@ def call success?: true, membership: target_membership, errors: [], - message: "Membership updated successfully" + message: I18n.t("services.success.membership_updated") ) end rescue ActiveRecord::RecordNotFound => e - failure("Record not found: #{e.message}") + failure(I18n.t("services.errors.record_not_found", message: e.message)) rescue ActiveRecord::RecordInvalid => e - failure("Validation error: #{e.message}") + failure(I18n.t("services.errors.validation_error", message: e.message)) rescue StandardError => e Rails.logger.error("[People::MembershipUpdater] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}") - failure("Error updating membership: #{e.message}") + failure(I18n.t("services.errors.unexpected_error", action: "membership update", message: e.message)) end private diff --git a/app/services/people/membership_upgrader.rb b/app/services/people/membership_upgrader.rb index 2037a31a..b5690e96 100644 --- a/app/services/people/membership_upgrader.rb +++ b/app/services/people/membership_upgrader.rb @@ -27,14 +27,39 @@ def call new_membership_type = MembershipType.find(new_membership_type_id) recorded_by = resolve_recorded_by - result = person.upgrade_membership!( - new_membership_type, - payment_method: payment_method.to_sym, - recorded_by: recorded_by, - custom_amount_cents: custom_amount_cents, - offer_reason: offer_reason, - donation_cents: donation_cents - ) + result = nil + ActiveRecord::Base.transaction do + current_membership = person.current_membership + raise "Aucune adhésion active à upgrader" unless current_membership + + People::OfferPolicy.validate!( + recorded_by: recorded_by, + person: person, + offer_type: "membership_upgrade", + offer_reason: offer_reason + ) if payment_method == "offered" + + old_membership_type = current_membership.membership_type + new_membership = current_membership.upgrade_to!(new_membership_type) + old_member_number = person.member_number + new_member_number = handle_member_number_change!(old_membership_type, new_membership_type, recorded_by) + amount_cents = amount_for(new_membership_type.price_cents) + + payment = record_payment!( + item_type: "Membership", + item_id: new_membership.id, + amount_cents: amount_cents, + description: upgrade_payment_description(old_membership_type, new_membership_type) + ) + + result = { + membership: new_membership, + payment: payment, + member_number_changed: old_member_number != new_member_number, + old_member_number: old_member_number, + new_member_number: new_member_number + } + end ActiveSupport::Notifications.instrument( "membership.upgraded", @@ -51,15 +76,15 @@ def call old_member_number: result[:old_member_number], new_member_number: result[:new_member_number], errors: [], - message: "Membership upgraded successfully" + message: I18n.t("services.success.membership_upgraded") ) rescue ActiveRecord::RecordNotFound => e ActiveSupport::Notifications.instrument("membership.upgrade_failed", error: e.message, reason: "record_not_found") - failure("Record not found: #{e.message}") + failure(I18n.t("services.errors.record_not_found", message: e.message)) rescue StandardError => e Rails.logger.error("[People::MembershipUpgrader] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}") ActiveSupport::Notifications.instrument("membership.upgrade_failed", error: e.message, reason: "exception") - failure("Error upgrading membership: #{e.message}") + failure(I18n.t("services.errors.unexpected_error", action: "membership upgrade", message: e.message)) end private @@ -76,6 +101,94 @@ def resolve_recorded_by end end + def amount_for(base_price_cents) + return custom_amount_cents || 0 if payment_method == "offered" + + base_price_cents + end + + def record_payment!(item_type:, item_id:, amount_cents:, description:) + lines = [ + { + item_type: item_type, + item_id: item_id, + amount_cents: amount_cents, + description: description + } + ] + + if normalized_donation_cents.present? + lines << { + item_type: "Donation", + amount_cents: normalized_donation_cents, + description: "Donation" + } + end + + payment_result = People::PaymentRecorder.new( + person: person, + recorded_by: resolve_recorded_by, + payment_method: payment_method, + status: "success", + notes: description, + offer_reason: offer_reason, + total_cents: lines.sum { |line| line[:amount_cents].to_i }, + payment_lines: lines + ).call + + raise payment_result.message unless payment_result.success? + + payment_result.payment + end + + def normalized_donation_cents + cents = donation_cents.to_i + cents.positive? ? cents : nil + end + + def upgrade_payment_description(old_membership_type, new_membership_type) + "Passage d'adhésion : #{old_membership_type.name} -> #{new_membership_type.name}" + end + + def handle_member_number_change!(old_membership_type, new_membership_type, recorded_by) + old_type_code = membership_type_code(old_membership_type) + new_type_code = membership_type_code(new_membership_type) + return person.member_number if old_type_code == new_type_code + + new_member_number = MemberManagementService.generate_member_number(new_type_code) + create_member_number_change_history!( + old_member_number: person.member_number, + new_member_number: new_member_number, + old_type: old_type_code, + new_type: new_type_code, + recorded_by: recorded_by + ) + person.update!(member_number: new_member_number) + new_member_number + end + + def membership_type_code(membership_type) + membership_type.circus? ? "CIRQUE" : "BASIQUE" + end + + def create_member_number_change_history!(old_member_number:, new_member_number:, old_type:, new_type:, recorded_by:) + if old_member_number.present? + old_history = person.member_number_histories.where(member_number: old_member_number, replaced_at: nil).first + old_history&.mark_as_replaced! + end + + old_type_name = old_type == "CIRQUE" ? "Cirque" : "Basique" + new_type_name = new_type == "CIRQUE" ? "Cirque" : "Basique" + + person.member_number_histories.create!( + member_number: new_member_number, + membership_type: new_type_name, + year: Date.current.year, + notes: "Changement automatique lors de l'upgrade d'adhésion (#{old_type_name} → #{new_type_name}) - Enregistré par #{recorded_by.email}", + assigned_at: Time.current + ) + end + def failure(message, error_list = nil) Result.new( success?: false, diff --git a/app/services/people/offer_policy.rb b/app/services/people/offer_policy.rb new file mode 100644 index 00000000..34c737af --- /dev/null +++ b/app/services/people/offer_policy.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module People + class OfferPolicy + def self.validate!(recorded_by:, person:, offer_type:, offer_reason:, contribution_formula: nil) + new( + recorded_by: recorded_by, + person: person, + offer_type: offer_type, + offer_reason: offer_reason, + contribution_formula: contribution_formula + ).validate! + end + + def initialize(recorded_by:, person:, offer_type:, offer_reason:, contribution_formula: nil) + @recorded_by = recorded_by + @person = person + @offer_type = offer_type + @offer_reason = offer_reason + @contribution_formula = contribution_formula + end + + def validate! + raise "Seuls les bénévoles, admins et super-admins peuvent offrir des #{offer_type}s" unless allowed_actor? + raise "Une raison doit être fournie pour offrir une #{offer_type}" if offer_reason.blank? + raise "Les bénévoles ne peuvent offrir que des cotisations 'journée'" if volunteer_limited_contribution? + + audit! + true + end + + private + + attr_reader :recorded_by, :person, :offer_type, :offer_reason, :contribution_formula + + def allowed_actor? + recorded_by.super_admin? || recorded_by.admin? || recorded_by.volunteer? + end + + def volunteer_limited_contribution? + recorded_by.volunteer? && offer_type == "contribution" && contribution_formula&.duration != "day" + end + + def audit! + Rails.logger.info "OFFER AUDIT: #{recorded_by.email} offered #{offer_type} to #{person.full_name} (#{person.id}) - Reason: #{offer_reason}" + end + end +end diff --git a/app/services/people/payment_creator.rb b/app/services/people/payment_creator.rb index fc01b28b..e56eaf9e 100644 --- a/app/services/people/payment_creator.rb +++ b/app/services/people/payment_creator.rb @@ -21,6 +21,7 @@ class PaymentCreator attribute :item_id, :integer attribute :description, :string attribute :notes, :string + attribute :offer_reason, :string attribute :status, :string, default: "success" validates :payment_method, presence: true, inclusion: { in: %w[cash card cheque transfer offered pending] } @@ -32,32 +33,21 @@ class PaymentCreator def call return failure("Invalid payment data: #{errors.full_messages.join(', ')}") unless valid? - target_person = resolve_person - recorded_by = resolve_recorded_by normalized_lines = normalize_payment_lines total = determine_total(normalized_lines) - payment = nil - - ActiveRecord::Base.transaction do - payment = target_person.payments.create!( - total_cents: total, - payment_method: payment_method, - status: status, - recorded_by: recorded_by, - notes: notes - ) - - created_lines = if normalized_lines.any? - create_multiple_lines(payment, normalized_lines) - else - [ create_single_line(payment) ] - end - - instrument_payment_created(payment, created_lines.length) - - return success(payment: payment, payment_lines: created_lines) - end + lines = normalized_lines.any? ? normalized_lines : [ single_line_attributes ] + People::PaymentRecorder.new( + person: person, + person_id: person_id, + recorded_by_id: recorded_by_id, + total_cents: total, + payment_method: payment_method, + status: status, + notes: notes, + offer_reason: offer_reason, + payment_lines: lines + ).call rescue ActiveRecord::RecordNotFound => e failure("Record not found: #{e.message}") rescue ActiveRecord::RecordInvalid => e @@ -69,49 +59,24 @@ def call private - def resolve_person - return person if person.present? - raise ActiveRecord::RecordNotFound, "Person not found" if person_id.blank? - - Person.find(person_id) - end - - def resolve_recorded_by - return User.find(recorded_by_id) if recorded_by_id.present? - - raise ActiveRecord::RecordNotFound, "Recorded_by user not provided" unless Current.respond_to?(:user) && Current.user.present? - - Current.user - end - - def create_single_line(payment) - line_amount = amount_cents.to_i + def single_line_attributes line_item_type = item_type.presence || "Donation" line_description = description.presence || default_description_for(line_item_type) - payment.payment_lines.create!( + { item_type: line_item_type, - item_id: donation_line?(line_item_type) ? payment.id : item_id, - amount_cents: line_amount, + item_id: item_id, + amount_cents: amount_cents.to_i, description: line_description - ) - end - - def create_multiple_lines(payment, lines) - lines.map do |line| - payment.payment_lines.create!( - item_type: line[:item_type], - item_id: line[:item_id], - amount_cents: line[:amount_cents].to_i, - description: line[:description].presence || default_description_for(line[:item_type]) - ) - end + } end def normalize_payment_lines Array(payment_lines).map do |line| line = line.to_unsafe_h if line.respond_to?(:to_unsafe_h) - line.symbolize_keys + line.symbolize_keys.tap do |attrs| + attrs[:description] = attrs[:description].presence || default_description_for(attrs[:item_type]) + end end end @@ -136,18 +101,6 @@ def donation_line?(item_type) item_type.to_s.casecmp("donation").zero? end - def instrument_payment_created(payment, lines_count) - ActiveSupport::Notifications.instrument( - "payment.created", - payment_id: payment.id, - person_id: payment.person_id, - total_cents: payment.total_cents, - payment_method: payment.payment_method, - recorded_by_id: payment.recorded_by_id, - lines_count: lines_count - ) - end - def success(payment:, payment_lines: []) Result.new(success?: true, payment: payment, payment_lines: payment_lines, errors: [], message: "Payment created successfully") end @@ -170,15 +123,19 @@ def recorded_by_present def validate_amount_requirements if Array(payment_lines).any? + return if payment_method == "offered" && total_cents.to_i >= 0 + errors.add(:total_cents, "must be greater than zero when payment lines are provided") if total_cents.blank? || total_cents.to_i <= 0 else - errors.add(:amount_cents, "must be greater than zero") if amount_cents.blank? || amount_cents.to_i <= 0 + unless payment_method == "offered" && amount_cents.to_i >= 0 + errors.add(:amount_cents, "must be greater than zero") if amount_cents.blank? || amount_cents.to_i <= 0 + end errors.add(:item_type, "cannot be blank when no payment lines provided") if item_type.blank? && description.blank? end end def validate_payment_lines_sum - lines = Array(payment_lines) + lines = normalize_payment_lines return if lines.empty? lines_sum = lines.sum { |line| line[:amount_cents].to_i } diff --git a/app/services/people/payment_recorder.rb b/app/services/people/payment_recorder.rb new file mode 100644 index 00000000..422faa26 --- /dev/null +++ b/app/services/people/payment_recorder.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +module People + class PaymentRecorder + include ActiveModel::Model + include ActiveModel::Attributes + + Result = Struct.new(:success?, :payment, :payment_lines, :errors, :message, keyword_init: true) + + attr_accessor :person, :recorded_by + + attribute :person_id, :integer + attribute :recorded_by_id, :integer + attribute :total_cents, :integer + attribute :payment_method, :string, default: "cash" + attribute :status, :string, default: "success" + attribute :notes, :string + attribute :offer_reason, :string + attribute :payment_lines, default: [] + + validates :payment_method, presence: true, inclusion: { in: %w[cash card cheque transfer offered] } + validates :status, presence: true, inclusion: { in: %w[pending success cancel] } + validate :person_present + validate :recorded_by_present + validate :payment_lines_present + validate :total_matches_lines + validate :total_allowed_for_payment_method + validate :offer_reason_required_for_offered + + def call + return failure("Invalid payment data: #{errors.full_messages.join(', ')}") unless valid? + + target_person = resolve_person + recorder = resolve_recorded_by + normalized_lines = normalize_payment_lines + + payment = nil + created_lines = [] + + ActiveRecord::Base.transaction do + payment = target_person.payments.create!( + total_cents: total_cents.to_i, + payment_method: payment_method, + status: status, + recorded_by: recorder, + notes: notes, + offer_reason: normalized_offer_reason + ) + + created_lines = normalized_lines.map do |line| + payment.payment_lines.create!( + item_type: line[:item_type], + item_id: item_id_for(line, payment), + amount_cents: line[:amount_cents].to_i, + description: line[:description] + ) + end + + instrument_payment_created(payment, created_lines.length) + end + + success(payment: payment, payment_lines: created_lines) + rescue ActiveRecord::RecordNotFound => e + failure("Record not found: #{e.message}") + rescue ActiveRecord::RecordInvalid => e + failure("Validation error: #{e.message}") + rescue StandardError => e + Rails.logger.error("[People::PaymentRecorder] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}") + failure("Error creating payment: #{e.message}") + end + + private + + def resolve_person + return person if person.present? + raise ActiveRecord::RecordNotFound, "Person not found" if person_id.blank? + + Person.find(person_id) + end + + def resolve_recorded_by + return recorded_by if recorded_by.present? + return User.find(recorded_by_id) if recorded_by_id.present? + + raise ActiveRecord::RecordNotFound, "Recorded_by user not provided" unless Current.respond_to?(:user) && Current.user.present? + + Current.user + end + + def normalize_payment_lines + Array(payment_lines).map do |line| + line = line.to_unsafe_h if line.respond_to?(:to_unsafe_h) + line.symbolize_keys + end + end + + def item_id_for(line, payment) + return payment.id if donation_line?(line[:item_type]) + + line[:item_id] + end + + def donation_line?(item_type) + item_type.to_s.casecmp("donation").zero? + end + + def normalized_offer_reason + return if payment_method != "offered" + + offer_reason.to_s.strip.presence + end + + def instrument_payment_created(payment, lines_count) + ActiveSupport::Notifications.instrument( + "payment.created", + payment_id: payment.id, + person_id: payment.person_id, + total_cents: payment.total_cents, + payment_method: payment.payment_method, + recorded_by_id: payment.recorded_by_id, + lines_count: lines_count + ) + end + + def person_present + return if person.present? || person_id.present? + + errors.add(:person, "must be provided") + end + + def recorded_by_present + return if recorded_by.present? || recorded_by_id.present? || (Current.respond_to?(:user) && Current.user.present?) + + errors.add(:recorded_by_id, "must be provided") + end + + def payment_lines_present + errors.add(:payment_lines, "must include at least one line") if normalize_payment_lines.empty? + end + + def total_matches_lines + lines = normalize_payment_lines + return if lines.empty? + + lines_sum = lines.sum { |line| line[:amount_cents].to_i } + return if total_cents.to_i == lines_sum + + errors.add(:payment_lines, "La somme des lignes (#{lines_sum} cents) ne correspond pas au total (#{total_cents.to_i} cents)") + end + + def total_allowed_for_payment_method + return if total_cents.present? && total_cents.to_i.positive? + return if payment_method == "offered" && total_cents.to_i >= 0 + + errors.add(:total_cents, "must be greater than zero") + end + + def offer_reason_required_for_offered + return unless payment_method == "offered" + return if normalized_offer_reason.present? + + errors.add(:offer_reason, "must be provided for an offered payment") + end + + def success(payment:, payment_lines:) + Result.new(success?: true, payment: payment, payment_lines: payment_lines, errors: [], message: "Payment created successfully") + end + + def failure(message) + Result.new(success?: false, payment: nil, payment_lines: [], errors: [ message ], message: message) + end + end +end diff --git a/app/services/people/payment_updater.rb b/app/services/people/payment_updater.rb index 369f9ad7..f1176e67 100644 --- a/app/services/people/payment_updater.rb +++ b/app/services/people/payment_updater.rb @@ -16,6 +16,7 @@ class PaymentUpdater attribute :payment_method, :string attribute :status, :string attribute :notes, :string + attribute :offer_reason, :string attribute :updated_by_id, :integer validates :updated_by_id, presence: true @@ -23,6 +24,7 @@ class PaymentUpdater validates :total_cents, numericality: { greater_than: 0 }, allow_nil: true validates :payment_method, inclusion: { in: %w[cash card cheque transfer offered] }, allow_nil: true validates :status, inclusion: { in: %w[pending success cancel] }, allow_nil: true + validate :offer_reason_required_for_offered_update def call return failure("Invalid payment data: #{errors.full_messages.join(', ')}") unless valid? @@ -74,6 +76,8 @@ def build_update_attributes attrs[:payment_method] = payment_method if payment_method.present? attrs[:status] = status if status.present? attrs[:notes] = notes if notes.present? + attrs[:offer_reason] = normalized_offer_reason if should_update_offer_reason? + attrs[:offer_reason] = nil if clears_offer_reason? end end @@ -81,6 +85,31 @@ def payment_identifier_present errors.add(:payment_id, "must be provided") if payment.blank? && payment_id.blank? end + def normalized_offer_reason + offer_reason.to_s.strip.presence + end + + def should_update_offer_reason? + offer_reason.present? || payment_method == "offered" + end + + def clears_offer_reason? + payment_method.present? && payment_method != "offered" + end + + def resulting_payment_method + payment_method.presence || resolve_payment.payment_method + end + + def offer_reason_required_for_offered_update + return unless resulting_payment_method == "offered" + return if normalized_offer_reason.present? || resolve_payment.offer_reason.present? + + errors.add(:offer_reason, "must be provided for an offered payment") + rescue ActiveRecord::RecordNotFound + nil + end + def success(payment:, message:) Result.new(success?: true, payment: payment, errors: [], message: message) end diff --git a/app/services/people/register.rb b/app/services/people/register.rb index b84d1295..911f2dba 100644 --- a/app/services/people/register.rb +++ b/app/services/people/register.rb @@ -35,7 +35,7 @@ def call person = person_result.person - return failure("Cette personne a déjà un compte web.") if create_user_account && person.user.present? + return failure(I18n.t("services.errors.register_existing_web_account")) if create_user_account && person.user.present? user = nil if create_user_account @@ -72,7 +72,7 @@ def call end rescue StandardError => e Rails.logger.error("[People::Register] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}") - failure("Registration failed: #{e.message}") + failure(I18n.t("services.errors.unexpected_error", action: "registration", message: e.message)) end private @@ -103,10 +103,10 @@ def failure_from(service_result) def build_success_message(person_created, user_created, membership_created) parts = [] - parts << (person_created ? "Person created" : "Person updated") - parts << "user account" if user_created - parts << "membership" if membership_created - "Successfully processed #{parts.join(' and ')}".strip + parts << I18n.t(person_created ? "services.success.register_person_created" : "services.success.register_person_updated") + parts << I18n.t("services.success.register_user_account") if user_created + parts << I18n.t("services.success.register_membership") if membership_created + I18n.t("services.success.register_processed", parts: parts.join(I18n.t("services.success.register_parts_joiner"))) end def failure(message, errors = []) diff --git a/app/services/people/user_account_creator.rb b/app/services/people/user_account_creator.rb index 78a64aef..3b6a461b 100644 --- a/app/services/people/user_account_creator.rb +++ b/app/services/people/user_account_creator.rb @@ -77,6 +77,7 @@ def create_user(target_person) user.privacy_policy = privacy_policy user.save! + user.welcome_send success( user: user, diff --git a/app/services/profile_section_dom_ids.rb b/app/services/profile_section_dom_ids.rb new file mode 100644 index 00000000..efd4cce3 --- /dev/null +++ b/app/services/profile_section_dom_ids.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Single source of truth for Turbo Stream targets on the profile/settings UI. +# Keep controllers, layouts, and partial defaults aligned with these IDs. +module ProfileSectionDomIds + CONTACT_SECTION = "profile_contact_section" + ACCOUNT_SECTION = "profile_account_section" + FLASH_FRAME = "flash" +end diff --git a/app/services/user_management/user_updater.rb b/app/services/user_management/user_updater.rb index 4dff15ff..0056a14f 100644 --- a/app/services/user_management/user_updater.rb +++ b/app/services/user_management/user_updater.rb @@ -20,7 +20,7 @@ def call updated_by = User.find(updated_by_id) # Vérifier les permissions - return failure("Insufficient permissions to update this user") unless can_update_user?(user, updated_by) + return failure(I18n.t("services.errors.insufficient_permissions.user_update")) unless can_update_user?(user, updated_by) ActiveRecord::Base.transaction do # Mettre à jour User (seulement si des attributs sont fournis) @@ -30,17 +30,17 @@ def call user_updated = user_attrs.empty? || user.update(user_attrs) - # Mettre à jour Person si présente + # Mettre à jour Person si présente (tester person_attributes en premier pour éviter un load Person inutile) person_updated = true - if user.person.present? && person_attributes.present? + if person_attributes.present? && user.person.present? user.person.skip_membership_validation = true person_updated = user.person.update(person_attributes.except(:newsletter_subscribed)) end - # Gérer newsletter si nécessaire - if person_updated && user.person.present? && !newsletter_subscribed.nil? + # Gérer newsletter si nécessaire (nil d'abord pour ne pas charger Person quand la clé était absente de la requête) + if person_updated && !newsletter_subscribed.nil? && user.person.present? newsletter_result = update_newsletter(user.person) - return failure("Error updating newsletter: #{newsletter_result.message}") unless newsletter_result.success? + return failure(I18n.t("services.errors.newsletter_update_failed", message: newsletter_result.message)) unless newsletter_result.success? end if user_updated && person_updated @@ -53,19 +53,19 @@ def call changes: user.previous_changes.merge(user.person&.previous_changes || {}) ) - success(user: user, person: user.person, message: "User updated successfully") + success(user: user, person: user.person, message: I18n.t("services.success.user_updated")) else errors_array = [] errors_array.concat(user.errors.full_messages) if user.errors.any? errors_array.concat(user.person.errors.full_messages) if user.person&.errors&.any? - failure("Validation errors: #{errors_array.join(', ')}") + failure(I18n.t("services.validation.invalid_data_with_details", details: errors_array.join(", "))) end end rescue ActiveRecord::RecordNotFound => e - failure("User not found: #{e.message}") + failure(I18n.t("services.errors.user_not_found", message: e.message)) rescue StandardError => e Rails.logger.error "[UserUpdater] Error: #{e.message}" - failure("Error updating user: #{e.message}") + failure(I18n.t("services.errors.unexpected_error", action: "user update", message: e.message)) end end @@ -80,7 +80,7 @@ def can_update_user?(user, updated_by) end def update_newsletter(person) - return success(message: "Newsletter update skipped (no email)") if person.email.blank? + return success(message: I18n.t("services.success.newsletter_update_skipped_no_email")) if person.email.blank? updater = NewsletterManagement::NewsletterUpdater.new( person_id: person.id, diff --git a/app/views/admin/attendance_lists/edit.html.erb b/app/views/admin/attendance_lists/edit.html.erb index 34e67cb0..f72743d5 100644 --- a/app/views/admin/attendance_lists/edit.html.erb +++ b/app/views/admin/attendance_lists/edit.html.erb @@ -1,7 +1,7 @@ <% content_for :title, "Modifier la liste de présence" %>
    -
    +
    <%= render 'shared/breadcrumbs' %> @@ -12,7 +12,7 @@
    -
    +

    Informations de la liste

    @@ -70,7 +70,7 @@
    -
    +

    Zone de danger

    @@ -88,4 +88,3 @@
    - diff --git a/app/views/admin/attendance_lists/index.html.erb b/app/views/admin/attendance_lists/index.html.erb index acbbac64..08f24d9c 100644 --- a/app/views/admin/attendance_lists/index.html.erb +++ b/app/views/admin/attendance_lists/index.html.erb @@ -1,24 +1,24 @@ -<% content_for :title, "Gestion des listes de présence" %> +<% content_for :title, "Registre de présence" %>
    -
    +
    <%= render 'shared/breadcrumbs' %>
    -

    Listes de présence

    +

    Registre de présence

    <%= link_to new_admin_attendance_list_path, class: "px-4 py-2 bg-[#1F5C55] hover:bg-[#194A45] text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55] flex items-center" do %> - + <% end %>
    -

    Gérez les listes de présence pour les formations, événements et réunions

    +

    Gérez les registres de présence du jour pour les formations, événements et réunions

    - diff --git a/app/views/admin/dashboard/_admin_lateral_navbar.html.erb b/app/views/admin/dashboard/_admin_lateral_navbar.html.erb index e7a1e900..9dae3a53 100644 --- a/app/views/admin/dashboard/_admin_lateral_navbar.html.erb +++ b/app/views/admin/dashboard/_admin_lateral_navbar.html.erb @@ -42,7 +42,7 @@ <% end %> <% if current_user.can_manage_attendance_lists? %>
  • <%= link_to admin_attendance_lists_path, class: "sidebar-link flex items-center" do %> - Liste de présence + Registre de présence <% end %>
  • <% end %> @@ -57,8 +57,8 @@ Types d'Adhésion <% end %> -
  • <%= link_to admin_subscription_plans_path, class: "sidebar-link flex items-center" do %> - Cotisations +
  • <%= link_to admin_contribution_formulas_path, class: "sidebar-link flex items-center" do %> + Formules de cotisation <% end %>
  • <% end %> @@ -93,4 +93,4 @@
    -
    \ No newline at end of file +
    diff --git a/app/views/admin/dashboard/index.html.erb b/app/views/admin/dashboard/index.html.erb index bed18d70..3ad54f95 100644 --- a/app/views/admin/dashboard/index.html.erb +++ b/app/views/admin/dashboard/index.html.erb @@ -1,12 +1,12 @@ <% content_for :title, "Tableau de bord administrateur" %>
    -
    +
    <%= render 'shared/breadcrumbs' %> -
    +

    Tableau de bord administrateur

    @@ -37,7 +37,7 @@
    - <%= link_to admin_users_path, class: "group bg-white rounded-lg shadow-lg p-4 border border-gray-200 hover:shadow-xl hover:border-[#1F5C55] transition-all duration-200 cursor-pointer" do %> + <%= link_to admin_users_path, class: "admin-metric-card group cursor-pointer hover:border-[#1F5C55] transition-all duration-200" do %>

    Personnes

    @@ -58,7 +58,7 @@
    <% end %> -
    +

    Utilisateurs

    @@ -73,7 +73,7 @@
    -
    +

    Adhésions Basic

    @@ -87,7 +87,7 @@
    -
    +

    Adhésions Circus

    @@ -101,7 +101,7 @@
    -
    +

    Adhésions actives

    @@ -120,7 +120,7 @@
    -
    +

    @@ -136,7 +136,7 @@

    -
    +

    @@ -153,7 +153,7 @@

    -
    +

    diff --git a/app/views/admin/donations/new.html.erb b/app/views/admin/donations/new.html.erb index 85cd7fdc..f848386e 100644 --- a/app/views/admin/donations/new.html.erb +++ b/app/views/admin/donations/new.html.erb @@ -1,10 +1,10 @@ <% content_for :title, t("admin.donations.new.title") %>
    -
    +
    <%= render "shared/breadcrumbs" %> -
    +

    <%= t("admin.donations.new.heading") %>

    @@ -32,7 +32,7 @@ <%= f.submit t("admin.donations.new.submit"), class: "inline-flex justify-center rounded-md border border-transparent bg-[#1F5C55] px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-[#1F5C55] focus:ring-offset-2" %> <%= link_to t("admin.donations.new.cancel"), - (@user.present? ? admin_user_path(@user) : admin_user_path("person_#{@person.id}")), + (@user.present? ? admin_user_path(@user) : admin_user_path(Admin::Users::PersonRouteKey.call(@person))), class: "inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50" %>
    <% end %> diff --git a/app/views/admin/events/edit.html.erb b/app/views/admin/events/edit.html.erb index 50d5331d..127681ff 100644 --- a/app/views/admin/events/edit.html.erb +++ b/app/views/admin/events/edit.html.erb @@ -1,8 +1,8 @@ -
    +
    <%= render 'shared/breadcrumbs' %> -
    +

    Modifier l'événement

    <%= form_with model: @event, url: admin_event_path(@event), method: "put" do |form| %> diff --git a/app/views/admin/events/index.html.erb b/app/views/admin/events/index.html.erb index c1227a09..ef0f19ee 100644 --- a/app/views/admin/events/index.html.erb +++ b/app/views/admin/events/index.html.erb @@ -1,12 +1,12 @@ <% content_for :title, "Gestion des événements" %>
    -
    +
    <%= render 'shared/breadcrumbs' %> -
    +

    Gestion des événements

    Créer et gérer les événements du Circographe

    diff --git a/app/views/admin/events/new.html.erb b/app/views/admin/events/new.html.erb index 1d8a9ac3..8ffad08c 100644 --- a/app/views/admin/events/new.html.erb +++ b/app/views/admin/events/new.html.erb @@ -1,10 +1,10 @@
    <% authenticated? %> -
    +
    <%= render 'shared/breadcrumbs' %> -
    +

    Créer un événement

    <%= form_with model: @event, url: admin_events_path, method: "post" do |form| %> diff --git a/app/views/admin/health_reports/index.html.erb b/app/views/admin/health_reports/index.html.erb index b666d806..15f9bb61 100644 --- a/app/views/admin/health_reports/index.html.erb +++ b/app/views/admin/health_reports/index.html.erb @@ -1,36 +1,52 @@ <% content_for :title, "Rapport d'integrite" %>
    -
    +
    <%= render 'shared/breadcrumbs' %> -
    +

    Rapport d'integrite

    Verifications rapides pour detecter les donnees orphelines ou incoherentes

    -
    -
    +
    +

    Utilisateurs sans Person

    <%= @users_without_person_count %>

    -
    +

    Person sans User

    <%= @people_without_user_count %>

    -
    +

    Doublons email

    <%= @duplicate_people_by_email_count %>

    -
    +

    Doublons telephone

    <%= @duplicate_people_by_phone_count %>

    +
    +

    Paiements sans ligne

    +

    <%= @payments_without_lines_count %>

    +
    +
    +

    Totaux incoherents

    +

    <%= @payments_with_mismatched_totals_count %>

    +
    +
    +

    Lignes don legacy

    +

    <%= @legacy_donation_lines_count %>

    +
    +
    +

    Cotisations incoherentes

    +

    <%= @contribution_invariant_issues_count %>

    +
    -
    +

    Utilisateurs sans Person

    <% if @users_without_person.any? %>
    @@ -63,7 +79,7 @@ <% end %>
    -
    +

    Person sans User

    <% if @people_without_user.any? %>
    @@ -79,7 +95,7 @@ <% @people_without_user.each do |person| %> - <%= link_to person.id, admin_user_path("person_#{person.id}"), class: "text-[#1F5C55] hover:underline" %> + <%= link_to person.id, admin_user_path(Admin::Users::PersonRouteKey.call(person)), class: "text-[#1F5C55] hover:underline" %> <%= person.full_name %> <%= person.email.presence || "-" %> <%= l(person.created_at.to_date) %> @@ -96,7 +112,7 @@ <% end %>
    -
    +

    Doublons par email

    <% if @duplicate_people_by_email_groups.any? %>
    @@ -105,7 +121,7 @@

    <%= email %>

    <% people.each do |person| %> - <%= link_to person.full_name, admin_user_path("person_#{person.id}"), class: "text-[#1F5C55] hover:underline" %> + <%= link_to person.full_name, admin_user_path(Admin::Users::PersonRouteKey.call(person)), class: "text-[#1F5C55] hover:underline" %> (##<%= person.id %>) <% end %>
    @@ -120,7 +136,7 @@ <% end %>
    -
    +

    Doublons par telephone

    <% if @duplicate_people_by_phone_groups.any? %>
    @@ -129,7 +145,7 @@

    <%= phone %>

    <% people.each do |person| %> - <%= link_to person.full_name, admin_user_path("person_#{person.id}"), class: "text-[#1F5C55] hover:underline" %> + <%= link_to person.full_name, admin_user_path(Admin::Users::PersonRouteKey.call(person)), class: "text-[#1F5C55] hover:underline" %> (##<%= person.id %>) <% end %>
    @@ -144,7 +160,7 @@ <% end %>
    -
    +

    Paiements sans Person

    <% if @payments_without_person.any? %>
    @@ -176,5 +192,172 @@

    Aucun paiement orphelin detecte.

    <% end %>
    + +
    +

    Paiements sans ligne

    + <% if @payments_without_lines.any? %> +
    + + + + + + + + + + + + <% @payments_without_lines.each do |payment| %> + + + + + + + + <% end %> + +
    IDPersonMontantStatutCree le
    <%= payment.id %> + <% if payment.person.present? %> + <%= link_to payment.person.full_name, admin_user_path(Admin::Users::PersonRouteKey.call(payment.person)), class: "text-[#1F5C55] hover:underline" %> + <% else %> + - + <% end %> + <%= payment.total_euros %>€<%= payment.status_humanized %><%= l(payment.created_at.to_date) %>
    +
    + <% if @payments_without_lines_count > @payments_without_lines.size %> +

    Liste limitee a <%= @list_limit %> elements.

    + <% end %> + <% else %> +

    Aucun paiement sans ligne detecte.

    + <% end %> +
    + +
    +

    Paiements dont le total ne correspond pas aux lignes

    + <% if @payments_with_mismatched_totals.any? %> +
    + + + + + + + + + + + + + <% @payments_with_mismatched_totals.each do |payment| %> + + + + + + + + + <% end %> + +
    IDPersonTotal paiementSomme lignesNb lignesCree le
    <%= payment.id %> + <% if payment.person.present? %> + <%= link_to payment.person.full_name, admin_user_path(Admin::Users::PersonRouteKey.call(payment.person)), class: "text-[#1F5C55] hover:underline" %> + <% else %> + - + <% end %> + <%= payment.total_euros %>€<%= payment[:lines_total_cents].to_i / 100.0 %>€<%= payment[:payment_lines_count].to_i %><%= l(payment.created_at.to_date) %>
    +
    + <% if @payments_with_mismatched_totals_count > @payments_with_mismatched_totals.size %> +

    Liste limitee a <%= @list_limit %> elements.

    + <% end %> + <% else %> +

    Aucun paiement avec total incoherent detecte.

    + <% end %> +
    + +
    +

    Lignes de don legacy

    + <% if @legacy_donation_lines.any? %> +
    + + + + + + + + + + + + + <% @legacy_donation_lines.each do |line| %> + + + + + + + + + <% end %> + +
    LignePaiementPersonDescriptionMontantCree le
    <%= line.id %><%= line.payment_id %> + <% if line.payment&.person.present? %> + <%= link_to line.payment.person.full_name, admin_user_path(Admin::Users::PersonRouteKey.call(line.payment.person)), class: "text-[#1F5C55] hover:underline" %> + <% else %> + - + <% end %> + <%= line.description.presence || "-" %><%= line.amount_euros %>€<%= l(line.created_at.to_date) %>
    +
    + <% if @legacy_donation_lines_count > @legacy_donation_lines.size %> +

    Liste limitee a <%= @list_limit %> elements.

    + <% end %> + <% else %> +

    Aucune ligne de don legacy detectee.

    + <% end %> +
    + +
    +

    Cotisations incoherentes

    + <% if @contribution_invariant_issues.any? %> +
    + + + + + + + + + + + + + <% @contribution_invariant_issues.each do |issue| %> + <% contribution = issue.contribution %> + + + + + + + + + <% end %> + +
    IDPersonFormuleSessionsExpire leAnomalie
    <%= contribution.id %> + <%= link_to contribution.person.full_name, admin_user_path(Admin::Users::PersonRouteKey.call(contribution.person)), class: "text-[#1F5C55] hover:underline" %> + <%= contribution.contribution_formula.name %><%= contribution.sessions_remaining.nil? ? "-" : contribution.sessions_remaining %><%= contribution.expires_at.present? ? l(contribution.expires_at.to_date) : "-" %><%= issue.message %>
    +
    + <% if @contribution_invariant_issues_count > @contribution_invariant_issues.size %> +

    Liste limitee a <%= @list_limit %> elements.

    + <% end %> + <% else %> +

    Aucune cotisation incoherente detectee.

    + <% end %> +
    diff --git a/app/views/admin/membership_types/edit.html.erb b/app/views/admin/membership_types/edit.html.erb index b3633838..fdd6898e 100644 --- a/app/views/admin/membership_types/edit.html.erb +++ b/app/views/admin/membership_types/edit.html.erb @@ -1,7 +1,7 @@
    -
    +
    -
    +

    Modifier le Type d'Adhésion

    <%= @membership_type.name %>

    @@ -9,7 +9,7 @@
    -
    +

    Informations du Type d'Adhésion

    @@ -56,11 +56,20 @@ { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" } %>

    Basique : Accès de base à l'association
    - Cirque : Accès aux cours de cirque (tarifs différenciés selon nom/prix)
    + Cirque : Accès aux cours de cirque
    Événement : Pour événements spéciaux

    +
    + <%= form.label :rate_kind, "Type tarifaire", class: "block text-sm font-medium text-gray-700" %> + <%= form.select :rate_kind, + options_for_select(MembershipType.rate_kind_options, @membership_type.rate_kind), + {}, + { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" } %> +

    Contrôle l'éligibilité standard/réduit indépendamment du libellé.

    +
    +
    <%= form.label :price_cents, "Prix (en centimes)", class: "block text-sm font-medium text-gray-700" %> <%= form.number_field :price_cents, diff --git a/app/views/admin/membership_types/index.html.erb b/app/views/admin/membership_types/index.html.erb index 19d24248..08afbebb 100644 --- a/app/views/admin/membership_types/index.html.erb +++ b/app/views/admin/membership_types/index.html.erb @@ -1,7 +1,7 @@
    -
    +
    -
    +

    Types d'Adhésion

    @@ -15,7 +15,7 @@
    -
    +
    @@ -35,6 +35,9 @@ end %>"> <%= membership_type.category_humanized %> + + <%= membership_type.rate_kind_humanized %> + <%= membership_type.memberships.count %> membres @@ -120,6 +123,9 @@ end %>"> <%= membership_type.category_humanized %> + + <%= membership_type.rate_kind_humanized %> + <%= membership_type.memberships.count %> membres diff --git a/app/views/admin/membership_types/new.html.erb b/app/views/admin/membership_types/new.html.erb index ba70620a..b653e6c6 100644 --- a/app/views/admin/membership_types/new.html.erb +++ b/app/views/admin/membership_types/new.html.erb @@ -1,7 +1,7 @@
    -
    +
    -
    +

    Nouveau Type d'Adhésion

    Créer un nouveau type d'adhésion

    @@ -9,7 +9,7 @@
    -
    +

    Informations du Type d'Adhésion

    @@ -57,11 +57,20 @@ { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" } %>

    Basique : Accès de base à l'association
    - Cirque : Accès aux cours de cirque (tarifs différenciés selon nom/prix)
    + Cirque : Accès aux cours de cirque
    Événement : Pour événements spéciaux

    +
    + <%= form.label :rate_kind, "Type tarifaire", class: "block text-sm font-medium text-gray-700" %> + <%= form.select :rate_kind, + options_for_select(MembershipType.rate_kind_options, @membership_type.rate_kind), + {}, + { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" } %> +

    Utilisé pour les règles métier d'éligibilité. Ne dépend pas du nom affiché.

    +
    +
    <%= form.label :price_cents, "Prix (en centimes)", class: "block text-sm font-medium text-gray-700" %> <%= form.number_field :price_cents, diff --git a/app/views/admin/memberships/_subscription_options.html.erb b/app/views/admin/memberships/_contribution_options.html.erb similarity index 93% rename from app/views/admin/memberships/_subscription_options.html.erb rename to app/views/admin/memberships/_contribution_options.html.erb index 8ef8ccf4..7621aed5 100644 --- a/app/views/admin/memberships/_subscription_options.html.erb +++ b/app/views/admin/memberships/_contribution_options.html.erb @@ -19,7 +19,7 @@
    - <%= form_with url: admin_user_path("person_#{@person.id}"), method: :post, local: true do |form| %> + <%= form_with url: admin_user_path(Admin::Users::PersonRouteKey.call(@person)), method: :post, local: true do |form| %> <%= form.hidden_field :contribution_formula_id, value: contribution_formula.id %> <%= form.hidden_field :payment_method, value: 'cash' %> <%= form.submit "Acheter", diff --git a/app/views/admin/memberships/_create_membership.html.erb b/app/views/admin/memberships/_create_membership.html.erb index 769a7fe3..f304510d 100644 --- a/app/views/admin/memberships/_create_membership.html.erb +++ b/app/views/admin/memberships/_create_membership.html.erb @@ -1,26 +1,40 @@
    <% if @is_upgrade %> -

    Upgrade vers Cirque

    -
    -

    Adhésion actuelle

    -

    - <%= @current_membership.membership_type.name %> - - <%= @current_membership.membership_type.price_euros %>€ -

    +
    +
    +
    +

    Passage en adhésion Cirque

    +

    + Adhésion actuelle : + <%= @current_membership.membership_type.name %> - <%= @current_membership.membership_type.price_euros %>€ +

    +
    +
    + Règle d'upgrade : + <%= upgrade_membership_rule_hint %> +
    +
    + + <% if @person.reduced_rate_eligible? %> +

    + Tarif réduit éligible : + <%= @person.reduced_rate_reason %> +

    + <% end %>
    <% else %>

    Créer une Adhésion

    <% end %> - <%= form_with model: [:admin, @membership || Membership.new], url: admin_memberships_path, method: :post, local: true, class: "space-y-4" do |form| %> + <%= form_with model: [:admin, @membership || Membership.new], url: admin_memberships_path, method: :post, local: true, class: "space-y-4", data: { controller: "offer-fields" } do |form| %> <%= form.hidden_field :person_id, value: @person.id %> <%= form.hidden_field :upgrade, value: @is_upgrade %>
    - <%= form.label :membership_type_id, @is_upgrade ? "Nouveau type d'adhésion" : "Type d'adhésion", class: "block text-sm font-medium text-gray-700" %> + <%= form.label :membership_type_id, @is_upgrade ? "Nouvelle adhésion Cirque" : "Type d'adhésion", class: "block text-sm font-medium text-gray-700" %> <%= form.select :membership_type_id, @is_upgrade ? - @membership_types.map { |mt| [upgrade_name_with_price(mt, current_membership: @current_membership), mt.id] } : + upgrade_membership_options_for(@membership_types) : options_from_collection_for_select(@membership_types, :id, :name_with_price), { prompt: @is_upgrade ? "Sélectionner le nouveau type" : "Sélectionner un type d'adhésion" }, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm", required: true } %> @@ -28,14 +42,8 @@

    ⚠️ Aucun type d'adhésion disponible

    <% end %> <% if @is_upgrade %> -
    -

    - 💡 Upgrade : Vous paierez le plein tarif de la nouvelle adhésion -

    -
    -

    Adhésion actuelle : <%= @current_membership.membership_type.name %> - <%= @current_membership.membership_type.price_euros %>€

    -

    Nouvelle adhésion : Sélectionnez ci-dessus pour voir le tarif

    -
    +
    + Le prix affiche est celui qui sera enregistre sur le paiement d'upgrade.
    <% end %>
    @@ -45,19 +53,21 @@ <%= form.select :payment_method, options_for_select(payment_method_options), { selected: 'cash' }, - { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm", id: "membership-payment-method" } %> + { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm", + data: { offer_fields_target: "paymentMethod", action: "change->offer-fields#refresh" } } %>
    - - - diff --git a/app/views/admin/memberships/index.html.erb b/app/views/admin/memberships/index.html.erb index 5ce305a8..f9cee4ee 100644 --- a/app/views/admin/memberships/index.html.erb +++ b/app/views/admin/memberships/index.html.erb @@ -1,50 +1,80 @@ -

    Gestion des Adhésions

    +<% content_for :title, "Gestion des adhésions" %> -
    - - - - - - - - - - - - <% @people.each do |person| %> - - - - - - +
    PersonneEmailAdhésion ActuelleStatutActions
    <%= person.full_name %><%= person.email %> - <% if person.current_membership %> - <%= person.current_membership.membership_type.name %> - (<%= person.current_membership.membership_type.price_euros %>€) - <% else %> - Aucune adhésion - <% end %> - - <% if person.current_membership %> - Active - <% else %> - Inactive - <% end %> - - <% if person.current_membership %> - <%= link_to "Voir", admin_membership_path(person), class: "btn btn-sm btn-primary" %> - <%= link_to "Modifier", edit_admin_membership_path(person), class: "btn btn-sm btn-secondary" %> - <% else %> - <%= link_to "Créer", new_admin_membership_path(person_id: person.id), class: "btn btn-sm btn-success" %> +
    +
    + <%= render "shared/breadcrumbs" %> + +
    +
    +

    Gestion des adhésions

    +

    Vue d’ensemble des adhésions actives et à créer

    +
    +
    + +
    +
    + + + + + + + + + + + + <% @people.each do |person| %> + <% membership = person.current_membership %> + + + + + + + <% end %> - - - <% end %> - -
    PersonneEmailAdhésion actuelleStatutActions
    + <%= person.full_name %> + + <%= person.email.presence || "Non renseigné" %> + + <% if membership %> + <%= membership.membership_type.name %> + (<%= membership.membership_type.price_euros %>€) + <% else %> + Aucune adhésion + <% end %> + + <% if membership %> + + Active + + <% else %> + + Inactive + + <% end %> + +
    + <% if membership %> + <%= link_to "Voir", admin_membership_path(person), + class: "inline-flex items-center rounded-md bg-[#1F5C55] px-3 py-1.5 text-xs font-medium text-white hover:bg-[#194A45]" %> + <%= link_to "Modifier", edit_admin_membership_path(person), + class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50" %> + <% else %> + <%= link_to "Créer", new_admin_membership_path(person_id: person.id), + class: "inline-flex items-center rounded-md bg-[#1F5C55] px-3 py-1.5 text-xs font-medium text-white hover:bg-[#194A45]" %> + <% end %> +
    +
    -
    +
    +
    +
    -
    - <%= link_to "Retour à l'administration", admin_root_path, class: "btn btn-secondary" %> +
    + <%= link_to "Retour à l’administration", admin_root_path, + class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-[#1F5C55] focus:ring-offset-2" %> +
    +
    diff --git a/app/views/admin/memberships/new.html.erb b/app/views/admin/memberships/new.html.erb index c443002a..383def6e 100644 --- a/app/views/admin/memberships/new.html.erb +++ b/app/views/admin/memberships/new.html.erb @@ -1,13 +1,13 @@
    -
    +
    <%= render 'shared/breadcrumbs' %> -
    +
    -

    Créer une Adhésion

    +

    <%= @is_upgrade ? "Upgrade d'adhésion" : "Créer une adhésion" %>

    - Créer une adhésion pour <%= @person.full_name %> + <%= @is_upgrade ? "Passer l'adhésion de #{@person.full_name} en Cirque" : "Créer une adhésion pour #{@person.full_name}" %>

    @@ -16,7 +16,7 @@
    - <%= link_to "← Retour à la fiche", admin_user_path("person_#{@person.id}"), + <%= link_to "← Retour à la fiche", admin_user_path(Admin::Users::PersonRouteKey.call(@person)), class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" %>
    diff --git a/app/views/admin/memberships/show.html.erb b/app/views/admin/memberships/show.html.erb index 7edd58fa..66a28740 100644 --- a/app/views/admin/memberships/show.html.erb +++ b/app/views/admin/memberships/show.html.erb @@ -1,7 +1,7 @@ <% content_for :title, "Détails de l'adhésion" %>
    -
    +
    <%= render 'shared/breadcrumbs' %> @@ -22,7 +22,7 @@ <%= turbo_frame_tag "membership_container" %> <% if @person.has_active_membership? %> - <%= render "subscription_options" %> + <%= render "contribution_options" %> <% end %>
    diff --git a/app/views/admin/notepads/edit.html.erb b/app/views/admin/notepads/edit.html.erb index 0f8c4a02..3d563cd6 100644 --- a/app/views/admin/notepads/edit.html.erb +++ b/app/views/admin/notepads/edit.html.erb @@ -1,7 +1,7 @@ <% content_for :title, "Modifier le bloc-note" %>
    -
    +
    <%= render 'shared/breadcrumbs' %> @@ -12,7 +12,7 @@
    -
    +

    Contenu du bloc-note

    @@ -38,4 +38,4 @@
    -
    \ No newline at end of file +
    diff --git a/app/views/admin/opening_hours/edit.html.erb b/app/views/admin/opening_hours/edit.html.erb index 775ecb6b..7d35364b 100644 --- a/app/views/admin/opening_hours/edit.html.erb +++ b/app/views/admin/opening_hours/edit.html.erb @@ -1,12 +1,12 @@ <% content_for :title, "Modification des horaires d'ouverture" %> -
    -
    +
    +
    <%= render 'shared/breadcrumbs' %> -
    +
    @@ -32,7 +32,7 @@
    -
    +

    Formulaire de modification

    @@ -88,24 +88,28 @@
    <%= check_box_tag "closed_#{day}", "1", hours.to_s.downcase == "fermé", class: "closed-checkbox h-4 w-4 text-red-600 border-gray-300 rounded focus:ring-red-500", - data: { day: day }, + data: { + day: day, + opening_hours_editor_target: "closedCheckbox", + action: "change->opening-hours-editor#toggleClosed" + }, checked: hours.to_s.downcase == "fermé" %> <%= label_tag "closed_#{day}", "Fermé", class: "ml-2 text-sm text-gray-700" %>
    -
    +
    - <% (0..23).each do |h| %> <% selected = hours.to_s.downcase != "fermé" && hours.split(' - ').first&.split(':')&.first&.to_i == h %> <% end %> : - <% [0, 15, 30, 45].each do |m| %> <% selected = hours.to_s.downcase != "fermé" && hours.split(' - ').first&.split(':')&.last&.to_i == m %> @@ -117,14 +121,14 @@
    - <% (0..23).each do |h| %> <% selected = hours.to_s.downcase != "fermé" && hours.split(' - ').last&.split(':')&.first&.to_i == h %> <% end %> : - <% [0, 15, 30, 45].each do |m| %> <% selected = hours.to_s.downcase != "fermé" && hours.split(' - ').last&.split(':')&.last&.to_i == m %> @@ -136,7 +140,7 @@ <%= hidden_field_tag "opening_hours[#{day}]", hours, class: "final-time-input", - data: { day: day } %> + data: { day: day, opening_hours_editor_target: "finalInput" } %>
    @@ -156,12 +160,12 @@
    -
    +

    Aperçu des horaires

    -
    +
    <%= render 'shared/opening_hours_compact', horaires: @opening_hours %>
    @@ -187,158 +191,8 @@ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%231F5C55' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); } -/* Effet hover moderne */ .hour-select:hover, .minute-select:hover { border-color: #1F5C55; box-shadow: 0 0 0 1px rgba(31, 92, 85, 0.1); } - - \ No newline at end of file diff --git a/app/views/admin/opening_hours/show.html.erb b/app/views/admin/opening_hours/show.html.erb index 56345400..38b8bb10 100644 --- a/app/views/admin/opening_hours/show.html.erb +++ b/app/views/admin/opening_hours/show.html.erb @@ -1,5 +1,5 @@
    -
    +
    <%= render 'shared/breadcrumbs' %> @@ -25,8 +25,8 @@
    -
    +
    <%= render 'shared/opening_hours_compact', horaires: @opening_hours %>
    -
    \ No newline at end of file +
    diff --git a/app/views/admin/payments/_edit_form.html.erb b/app/views/admin/payments/_edit_form.html.erb index f2893f8f..03b16f28 100644 --- a/app/views/admin/payments/_edit_form.html.erb +++ b/app/views/admin/payments/_edit_form.html.erb @@ -4,7 +4,7 @@ url: admin_payment_path(payment, **qp), method: :patch, data: { - controller: "payment-form", + controller: "payment-form offer-fields", action: "turbo:submit-end->payment-form#handleSubmit", turbo_frame: "_top" }, @@ -33,7 +33,10 @@ ['Offert', 'offered'] ], payment.payment_method), {}, - { class: "w-full px-2 py-1 text-xs border border-gray-300 rounded focus:ring-[#1F5C55] focus:border-[#1F5C55]" } %> + { + class: "w-full px-2 py-1 text-xs border border-gray-300 rounded focus:ring-[#1F5C55] focus:border-[#1F5C55]", + data: { offer_fields_target: "paymentMethod", action: "change->offer-fields#refresh" } + } %>
    @@ -56,6 +59,15 @@ value: payment.notes, class: "w-full px-2 py-1 text-xs border border-gray-300 rounded focus:ring-[#1F5C55] focus:border-[#1F5C55]" %>
    + +
    + <%= f.label :offer_reason, "Raison de l'offre", class: "block text-xs font-medium text-gray-700 mb-1" %> + <%= f.text_field :offer_reason, + value: payment.offer_reason, + class: "w-full px-2 py-1 text-xs border border-gray-300 rounded focus:ring-[#1F5C55] focus:border-[#1F5C55]", + placeholder: "Obligatoire si le paiement est offert", + data: { offer_fields_target: "offerReasonInput" } %> +
    diff --git a/app/views/admin/payments/_modal.html.erb b/app/views/admin/payments/_modal.html.erb index d4adb4b4..5f996c12 100644 --- a/app/views/admin/payments/_modal.html.erb +++ b/app/views/admin/payments/_modal.html.erb @@ -23,7 +23,7 @@ <%= form_with model: [:admin, Payment.new], url: admin_payments_path, data: { - controller: "payment-form", + controller: "payment-form offer-fields", action: "turbo:submit-end->payment-modal#handleSubmit", turbo_frame: "_top" }, @@ -68,7 +68,19 @@ ['Offert', 'offered'] ]), {}, - { class: "w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-[#1F5C55] focus:border-[#1F5C55]" } %> + { + class: "w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-[#1F5C55] focus:border-[#1F5C55]", + data: { offer_fields_target: "paymentMethod", action: "change->offer-fields#refresh" } + } %> +
    + + diff --git a/app/views/admin/payments/index.html.erb b/app/views/admin/payments/index.html.erb index fce2558c..22b9662b 100644 --- a/app/views/admin/payments/index.html.erb +++ b/app/views/admin/payments/index.html.erb @@ -1,12 +1,12 @@ <% content_for :title, "Historique des paiements" %>
    -
    +
    <%= render 'shared/breadcrumbs' %> -
    +

    Historique des paiements

    @@ -24,20 +24,11 @@ ) %> <%= link_to "Export CSV", admin_payments_path(format: :csv), - class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#5836a5] hover:bg-[#4c2d8a] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5836a5]" %> + class: "admin-btn-accent" %>

    - - - -
    +
    <%= form_with url: admin_payments_path, method: :get, local: true, class: "flex flex-col md:flex-row gap-4" do |form| %>
    @@ -98,7 +89,7 @@
    - <%= form.submit "Filtrer", class: "px-4 py-2 bg-[#1F5C55] text-white rounded-md hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-[#1F5C55]" %> + <%= form.submit "Filtrer", class: "admin-btn-primary" %>
    <% end %>
    @@ -110,7 +101,7 @@ total_donation: @total_donation %> -
    +
    @@ -233,4 +224,4 @@ - \ No newline at end of file + diff --git a/app/views/admin/subscription_plans/new.html.erb b/app/views/admin/subscription_plans/new.html.erb deleted file mode 100644 index 4138bbb8..00000000 --- a/app/views/admin/subscription_plans/new.html.erb +++ /dev/null @@ -1,125 +0,0 @@ -<% content_for :title, "Nouvelle cotisation" %> - -
    -
    - - <%= render 'shared/breadcrumbs' %> - - -
    -
    -

    Nouvelle cotisation

    -

    - <% if @person %> - Pour <%= @person.full_name %> - <% else %> - Sélectionnez un plan de cotisation - <% end %> -

    -
    -
    - - -
    -
    - <% if @person&.can_buy_contribution_formulas? %> - -
    - <% @contribution_formulas.each do |subscription_plan| %> -
    -

    <%= subscription_plan.name %>

    -

    <%= subscription_plan.description %>

    - -
    -

    <%= subscription_plan.price_euros %> €

    - <% if subscription_plan.sessions_count %> -

    <%= subscription_plan.sessions_count %> séances

    - <% end %> - <% if subscription_plan.validity_days %> -

    Valide <%= subscription_plan.validity_days %> jours

    - <% end %> -
    - -
    - <%= form_with url: admin_subscription_plans_path, method: :post, local: true, scope: :subscription_plan, class: "space-y-3" do |form| %> - <%= form.hidden_field :subscription_plan_id, value: subscription_plan.id %> - <%= form.hidden_field :person_id, value: @person.id %> - -
    - <%= form.label :payment_method, "Mode de paiement", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= form.select :payment_method, - options_for_select(payment_method_options), - { selected: 'cash' }, - { class: "w-full text-sm border-gray-300 rounded-md focus:border-[#1F5C55] focus:ring-[#1F5C55]" } %> -
    - - - - - -
    - <%= form.label :donation_amount, "Donation (€) (optionnel)", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= form.number_field :donation_amount, - step: 0.01, - min: 0, - placeholder: "0.00", - class: "w-full text-sm border-gray-300 rounded-md focus:border-[#1F5C55] focus:ring-[#1F5C55]" %> -
    - - <%= form.submit "Acheter la cotisation", - class: "w-full inline-flex justify-center items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" %> - <% end %> -
    -
    - <% end %> -
    - <% else %> -
    -

    Cette personne doit avoir une adhésion Cirque pour acheter des plans de cotisation.

    -
    - <% end %> -
    -
    -
    -
    - - diff --git a/app/views/admin/users/_attendance_subscription_options.html.erb b/app/views/admin/users/_attendance_contribution_options.html.erb similarity index 66% rename from app/views/admin/users/_attendance_subscription_options.html.erb rename to app/views/admin/users/_attendance_contribution_options.html.erb index 3ca594f6..2ccf157b 100644 --- a/app/views/admin/users/_attendance_subscription_options.html.erb +++ b/app/views/admin/users/_attendance_contribution_options.html.erb @@ -4,18 +4,20 @@ Acheter une cotisation pour enregistrer la présence + <% contribution_formulas = local_assigns[:available_formulas] || [] %>
    - <% available_plans.each do |plan| %> + <% contribution_formulas.each do |formula| %>
    -
    <%= plan.name %>
    -

    <%= plan.duration_humanized %>

    -
    <%= plan.price_euros %>€
    +
    <%= formula.name %>
    +

    <%= formula.duration_humanized %>

    +
    <%= formula.price_euros %>€
    - <%= form_with url: admin_subscription_plans_path, + <%= form_with url: admin_contribution_formulas_path, method: :post, + scope: :contribution_formula, data: { turbo_stream: true }, class: "space-y-2" do |form| %> - <%= form.hidden_field :subscription_plan_id, value: plan.id %> + <%= form.hidden_field :contribution_formula_id, value: formula.id %> <%= form.hidden_field :person_id, value: person.id %> <%= form.hidden_field :record_attendance, value: true %> <%= form.hidden_field :attendance_date, value: date %> diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb index 234c932a..e64897f7 100644 --- a/app/views/admin/users/_form.html.erb +++ b/app/views/admin/users/_form.html.erb @@ -1,39 +1,44 @@ <%= form_with(model: [:admin, user]) do |form| %> <% if user.errors.any? %> -
    -

    <%= pluralize(user.errors.count, "error") %> Interdit à cet utilisateur d'être enregistré :

    -
      +
      +

      <%= pluralize(user.errors.count, "error") %> interdit à cet utilisateur d'être enregistré :

      +
        <% user.errors.each do |error| %> -
      • <%= error.full_message %>
      • +
      • <%= error.full_message %>
      • <% end %> -
      -
      -<% end %> +
    +
    + <% end %> -
    - <%= form.label :email_address %> - <%= form.text_field :email_address %> -
    +
    +
    + <%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :email_address, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" %> +
    -
    - <%= form.label :first_name, "Prénom" %> - <%= form.text_field :first_name %> -
    +
    + <%= form.label :first_name, "Prénom", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :first_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" %> +
    -
    - <%= form.label :last_name, "Nom" %> - <%= form.text_field :last_name %> -
    +
    + <%= form.label :last_name, "Nom", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :last_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" %> +
    - <% if @user.persisted? %> -
    -

    Changer le rôle

    - <%= form.select :system_role, options_for_select(@array_right, @default_role) %> -
    - <% end %> + <% if @user.persisted? %> +
    +

    Changer le rôle

    + <%= form.select :system_role, + options_for_select(@array_right, @default_role), + {}, + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" %> +
    + <% end %> -
    - <%= form.submit "Créer", class: "btn btn-primary" %> +
    + <%= form.submit "Créer", class: "btn-primary" %> +
    -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/admin/users/_payments_tab.html.erb b/app/views/admin/users/_payments_tab.html.erb index 12e216c5..001d1376 100644 --- a/app/views/admin/users/_payments_tab.html.erb +++ b/app/views/admin/users/_payments_tab.html.erb @@ -7,7 +7,7 @@
    -
    +
    <% if payment.is_offered? %> Offert @@ -15,15 +15,23 @@ <%= number_to_currency(payment.total_cents / 100.0, unit: "€", format: "%n %u") %> <% end %>
    - <% if payment.payment_lines.any? %> + <% payment.payment_lines.each do |line| %> - <%= payment.payment_lines.first.item_description %> + <%= line.history_description %> + <% if line.item_type == "Donation" %> + (<%= number_to_currency(line.amount_cents / 100.0, unit: "€", format: "%n %u") %>) + <% end %> <% end %>
    <%= payment.created_at.strftime("%d/%m/%Y") %>
    + <% if payment.offer_reason.present? %> +
    + Raison : <%= payment.offer_reason %> +
    + <% end %>
    <% status_class = case payment.status @@ -37,18 +45,13 @@
    -
    - <%= link_to admin_payment_path(payment), class: "text-[#1F5C55] hover:text-[#194A45] text-xs" do %> - Voir détails → - <% end %> -
    <% end %>
    <% if person.present? %>
    - <%= link_to "Voir tout l'historique", admin_payments_path(person_id: person.id), + <%= link_to "Voir les paiements", admin_payments_path(person_id: person.id), class: "text-[#1F5C55] hover:underline text-sm font-medium" %>
    <% end %> diff --git a/app/views/admin/users/_user.html.erb b/app/views/admin/users/_user.html.erb index 17f339ed..8f2c2b8e 100644 --- a/app/views/admin/users/_user.html.erb +++ b/app/views/admin/users/_user.html.erb @@ -1,4 +1,4 @@ -
    +
    @@ -80,4 +80,4 @@ <%= link_to "Voir", admin_user_path(user), class: "px-4 py-2 bg-[#1F5C55] text-white rounded-md hover:bg-[#194A45] transition-colors" %>
    -
    \ No newline at end of file +
    diff --git a/app/views/admin/users/check_attendance_eligibility.turbo_stream.erb b/app/views/admin/users/check_attendance_eligibility.turbo_stream.erb index ccb9376b..ff48b8cd 100644 --- a/app/views/admin/users/check_attendance_eligibility.turbo_stream.erb +++ b/app/views/admin/users/check_attendance_eligibility.turbo_stream.erb @@ -12,10 +12,10 @@ membership_type: @result.membership_type, date: @date %> - <% elsif @result.needs_subscription? %> - <%= render "admin/users/attendance_subscription_options", - person: @person, - available_plans: @result.available_plans, + <% elsif @result.needs_contribution_purchase? %> + <%= render "admin/users/attendance_contribution_options", + person: @person, + available_formulas: @result.available_formulas, date: @date %> <% elsif @result.already_present? %> diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb index 00cd2d3c..62921f3c 100644 --- a/app/views/admin/users/edit.html.erb +++ b/app/views/admin/users/edit.html.erb @@ -1,8 +1,8 @@ -
    +
    <%= render 'shared/breadcrumbs' %> -
    +
    @@ -66,20 +66,32 @@
    <% end %> - -
    - -
    + + +
    - -
    + +
    @@ -123,7 +135,7 @@
    -
    @@ -210,38 +223,3 @@
    - - \ No newline at end of file diff --git a/app/views/admin/users/edit_person.html.erb b/app/views/admin/users/edit_person.html.erb index 7f637fcf..1658e900 100644 --- a/app/views/admin/users/edit_person.html.erb +++ b/app/views/admin/users/edit_person.html.erb @@ -1,7 +1,7 @@
    -
    +
    -
    +

    Modifier les informations

    <%= @person.full_name %>

    @@ -9,13 +9,13 @@
    -
    +

    Informations personnelles

    - <%= form_with model: @person, url: admin_user_path("person_#{@person.id}"), method: :patch, local: true, class: "space-y-6" do |form| %> + <%= form_with model: @person, url: admin_user_path(Admin::Users::PersonRouteKey.call(@person)), method: :patch, local: true, class: "space-y-6" do |form| %> <% if @person.errors.any? %>
    @@ -121,7 +121,7 @@

    Préférences

    - <%= form.check_box :newsletter_subscribed, class: "h-4 w-4 text-[#1F5C55] focus:ring-[#1F5C55] border-gray-300 rounded" %> + <%= form.check_box :newsletter_subscribed, { class: "h-4 w-4 text-[#1F5C55] focus:ring-[#1F5C55] border-gray-300 rounded", checked: @person.newsletter_subscribed? }, "1", "0" %> <%= form.label :newsletter_subscribed, "S'abonner à la newsletter", class: "ml-2 block text-sm text-gray-700" %>
    @@ -153,12 +153,20 @@ <%= form.select :reduced_rate_reason, options_for_select(['RSA', 'Mineur', 'Situation Handicap', 'Étudiant', 'Autre']), { include_blank: "Sélectionner..." }, - { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" } %> + { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm", + data: { reduced_rate_target: "reason", action: "change->reduced-rate#reasonChanged" } } %> +
    + +
    + <%= form.label :reduced_rate_proof, "Précision", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :reduced_rate_proof, + placeholder: "Préciser le motif ou le justificatif", + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" %>
    - <%= link_to "Annuler", admin_user_path("person_#{@person.id}"), + <%= link_to "Annuler", admin_user_path(Admin::Users::PersonRouteKey.call(@person)), class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" %> <%= form.submit "Mettre à jour", class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" %> diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index 0b09c43a..60d6db54 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -1,12 +1,12 @@ <% content_for :title, t("views.admin.users.index.page_title") %> -
    -
    +
    +
    <%= render 'shared/breadcrumbs' %> -
    +

    <%= t("views.admin.users.index.page_title") %>

    @@ -78,10 +78,10 @@

    <%= link_to t("views.admin.users.index.create_member"), new_admin_user_path, - class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" %> + class: "admin-btn-primary" %> <%= link_to "Export CSV", all_users_admin_exports_path, - class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#5836a5] hover:bg-[#4c2d8a] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5836a5]", + class: "admin-btn-accent", data: { action: "click->admin-users#exportData", format: "csv" @@ -90,7 +90,7 @@
    -
    +
    <%= form_with url: admin_users_path, method: :get, local: true, class: "flex flex-col md:flex-row gap-4" do |form| %>
    @@ -101,14 +101,27 @@
    - <%= form.text_field :search, + <%= hidden_field_tag :search, + params[:search], + id: nil, + data: { admin_users_index_target: "hiddenSearch" } %> + <%= text_field_tag :directory_query, + params[:search], id: "search", - value: params[:search], placeholder: "Nom, email, téléphone... (Ctrl+K pour focus)", + autocomplete: "new-password", + spellcheck: "false", + data_lpignore: "true", + data_1p_ignore: "true", + data_form_type: "other", + data: { + admin_users_index_target: "searchInput", + action: "input->admin-users-index#debouncedSearch" + }, class: "search-input pl-10 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" %> <% if params[:search].present? %>
    -
    +
    @@ -256,7 +269,9 @@
    -
    - - - - - - - - - - - <% @recent_payments.each do |payment| %> - - - - - - - - <% end %> - -
    DateMontantStatutTypeActions
    - <%= payment.created_at.strftime("%d/%m/%Y") %> - - <%= number_to_currency(payment.total_cents / 100.0, unit: "€", format: "%n %u") %> - - <% status_class = case payment.status - when "success" then "bg-green-100 text-green-800" - when "pending" then "bg-yellow-100 text-yellow-800" - when "cancel" then "bg-red-100 text-red-800" - else "bg-gray-100 text-gray-800" - end %> - - <%= payment.status.humanize %> - - - <%= payment.payment_method_humanized %> - - <%= link_to admin_payment_path(payment), class: "text-[#1F5C55] hover:text-[#194A45]" do %> - - - - - <% end %> -
    -
    - -
    - <% if @person.present? %> - <%= link_to "Voir tout l'historique", admin_payments_path(person_id: @person.id), class: "text-[#1F5C55] hover:underline text-sm font-medium" %> - <% end %> -
    - <% else %> -
    -

    Aucun paiement enregistré pour cet utilisateur.

    -
    - <% end %> -
    -
    - - -
    - <%= render Admin::Users::UserActionsComponent.new( - user: @user, - person: @person, - is_person_without_user: @is_person_without_user, - is_deleted: @is_deleted, - current_user: current_user - ) %> -
    -
    -
    -
    - - diff --git a/app/views/events/_interest_button.html.erb b/app/views/events/_interest_button.html.erb index ba728e7f..3eba3179 100644 --- a/app/views/events/_interest_button.html.erb +++ b/app/views/events/_interest_button.html.erb @@ -3,7 +3,7 @@ <%= link_to event_interest_path(@event), method: :delete, - class: "w-full px-4 py-2 tracking-wide text-[#1F5C55] font-bold bg-white border-2 border-[#1F5C55] rounded-lg shadow-lg hover:bg-[#1F5C55] hover:text-white hover:border-[#1F5C55] hover:shadow-lg transition-colors durée-300 cursor-pointer block text-center", + class: "public-cta block w-full cursor-pointer text-center !bg-white !text-[#1F5C55] hover:!bg-[#1F5C55] hover:!text-white", data: { turbo_method: :delete } do %> @@ -14,7 +14,7 @@ <%= link_to event_interests_path(event_id: @event.id), method: :post, - class: "w-full px-4 py-2 tracking-wide text-white font-bold bg-[#1F5C55] border-2 border-[#1F5C55] rounded-lg shadow-lg hover:bg-gray-100 hover:text-[#1F5C55] hover:border-[#1F5C55] hover:shadow-lg transition-colors durée-300 cursor-pointer block text-center", + class: "public-cta block w-full cursor-pointer text-center", data: { turbo_method: :post } do %> diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index 7696a0b7..bce954e1 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -1,9 +1,9 @@ <% content_for :title, "Le Circographe - Détail Événement" %> -
    +

    <%= @event.title %>

    -
    +
    <%= image_tag( @@ -46,9 +46,9 @@ <%= render "events/interest_button", event: @event, current_user: current_user %> <% else %> <%= link_to "Je suis intéressé", new_session_path, - class: "block w-full px-4 py-2 tracking-wide text-white font-bold bg-[#1F5C55] border-2 border-[#1F5C55] rounded-lg shadow-lg hover:bg-gray-100 hover:text-[#1F5C55] hover:border-[#1F5C55] hover:shadow-lg transition-colors duration-300 cursor-pointer text-center" %> + class: "public-cta block w-full text-center" %> <% end %> - <%= button_to "Payer 10€", checkout_create_path(event_id: @event.id), method: :post, data: { turbo: "false" }, class: "w-full px-4 py-2 tracking-wide text-white font-bold bg-[#1F5C55] border-2 border-[#1F5C55] rounded-lg shadow-lg hover:bg-gray-100 hover:text-[#1F5C55] hover:border-[#1F5C55] hover:shadow-lg transition-colors duration-300 cursor-pointer" %> + <%= button_to "Payer 10€", checkout_create_path(event_id: @event.id), method: :post, data: { turbo: "false" }, class: "public-cta w-full cursor-pointer" %>
    diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index 62f90a71..44cc2132 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -2,17 +2,17 @@ <% content_for :background_image, "background.webp" %>
    -
    -
    -

    Le Circographe

    +
    +
    +

    Le Circographe

    <%= link_to "Découvrir l'association", page_path('association'), - class: "inline-flex items-center justify-center px-5 py-3 text-base font-semibold text-gray-800 bg-white/95 border border-white/80 rounded-full hover:bg-white shadow-md transition sm:px-8 sm:py-4 sm:text-lg md:text-xl" %> + class: "inline-flex items-center justify-center px-5 py-3 text-base font-semibold rounded-full border-2 border-white/85 bg-white/95 text-gray-900 shadow-md transition-colors duration-300 hover:bg-[#1F5C55] hover:text-white hover:border-[#1F5C55] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1F5C55] sm:px-8 sm:py-4 sm:text-lg md:text-xl" %>
    - <%= render "shared/scroll_hint_arrow", section_id: "main-content", position_class: "absolute bottom-10 sm:bottom-12 left-1/2 -translate-x-1/2" %> + <%= render "shared/scroll_hint_arrow", section_id: "main-content", position_class: "absolute bottom-24 sm:bottom-28 md:bottom-32 left-1/2 -translate-x-1/2" %>
    @@ -21,17 +21,17 @@

    Informations clés

    -
    -
    +
    +

    Horaires

    -

    Quand passer nous voir ?

    -

    - Retrouve les créneaux d'accueil de la semaine et prépare ta prochaine visite sous le chapiteau du Circographe. +

    Quand passer nous voir ?

    +

    + Retrouve les créneaux d'accueil de la semaine et prépare ta prochaine visite au Circographe.

    -
    - <%= render "shared/opening_hours", horaires: @opening_hours, bare: true %> +
    + <%= render "shared/opening_hours", horaires: @opening_hours, bare: true, compact: true %>
    @@ -39,28 +39,22 @@ <%= render "home/upcoming_events", events: @upcoming_events %> -

    - Découvre plus d'activités dans l'onglet « Nos activités » si tu veux planifier ta venue à l'avance. -

    <%= link_to page_path("association", anchor: "fonctionnement"), - class: "inline-flex items-center justify-center px-6 py-3 text-sm font-semibold text-white bg-[#1F5C55] border border-[#1F5C55] rounded-full shadow-md hover:bg-white hover:text-[#1F5C55] transition-colors duration-300" do %> + class: "public-cta-pill" do %> Découvrir l'autogestion & le bénévolat <% end %>
    -
    -
    -

    Nous trouver

    -

    Le Circographe sur la carte

    -

    Chapiteau, ateliers graphiques et convivialité : pousse la porte au 27 bis Allée Maurice Sarraut, Toulouse.

    -
    -
    - -
    +
    +

    Nous trouver

    +

    97 bis Boulevard de Suisse, 31200 Toulouse

    +

    Entraînements libres, murs illustrés par les artistes du lieu et une ambiance chaleureuse : passe quand tu veux, on t’accueille avec plaisir.

    + <%= link_to "Voir la carte et l'accès", page_path("contact_us", anchor: "map"), + class: "public-cta-pill" %>
    @@ -75,4 +69,3 @@ - diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index f10221e6..c108b154 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -15,6 +15,11 @@ + <%# Leaflet : hors bundle Tailwind (@import après @layer = CSS invalide / carte miniature). %> + <%= tag.link rel: "stylesheet", + href: "https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css", + crossorigin: "anonymous", + "data-turbo-track": "reload" %> <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "flowbite", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", media: "all", "data-turbo-track": "reload" %> @@ -35,7 +40,7 @@
    <%= render 'admin/dashboard/admin_lateral_navbar' %>
    - <%= turbo_frame_tag "flash" do %> + <%= turbo_frame_tag ProfileSectionDomIds::FLASH_FRAME do %> <%= render 'shared/flash' %> <% end %>
    @@ -46,14 +51,16 @@ <% elsif controller_name == 'sessions' && action_name == 'new' || controller_name == 'registrations' && action_name == 'new' || controller_name == 'pages' && action_name == 'terms' || controller_name == 'pages' && action_name == 'privacy_policy' %> -
    +
    <%= render 'shared/navbar' %>
    - <%= turbo_frame_tag "flash" do %> + <%= turbo_frame_tag ProfileSectionDomIds::FLASH_FRAME do %> <%= render 'shared/flash' %> <% end %> - <%= yield %> +
    + <%= yield %> +
    <% else %> @@ -62,11 +69,11 @@ <%= render 'shared/navbar' %>
    - <%= turbo_frame_tag "flash" do %> + <%= turbo_frame_tag ProfileSectionDomIds::FLASH_FRAME do %> <%= render 'shared/flash' %> <% end %> -
    -
    +
    +
    <%= yield %>
    @@ -77,4 +84,4 @@ <%= render 'shared/confirm_modal' %> - \ No newline at end of file + diff --git a/app/views/pages/about.html.erb b/app/views/pages/about.html.erb index 95e664c9..8cd7cb03 100644 --- a/app/views/pages/about.html.erb +++ b/app/views/pages/about.html.erb @@ -1,6 +1,6 @@ <% content_for :title, t("views.pages.about.page_title") %> -
    +
    <%= image_tag hero_image(:about_hero), class: "absolute inset-0 w-full h-full object-cover hero-img-filter", alt: "" %>
    @@ -14,7 +14,7 @@
    -
    +

    <%= t("views.pages.about.our_mission") %>

    @@ -34,7 +34,7 @@
    -
    +

    Le Conseil d'administration

    Celles et ceux qui font tourner le Circographe

    @@ -57,11 +57,15 @@
    -

    Notre histoire

    @@ -98,14 +102,14 @@
    <% [ - { year: "2022", title: "Ouverture du hangar à la Cartoucherie", body: "Lancement des créneaux d'entraînement et installation des ateliers graphiques." }, + { year: "2022", title: "Ouverture du hangar", body: "Lancement des créneaux d'entraînement et installation des ateliers graphiques." }, { year: "2023", title: "Premiers événements publics", body: "Plateaux partagés, expositions et ateliers ouverts au quartier." }, { year: "2024", title: "Autogestion consolidée", body: "Conseil d'administration élargi et équipes bénévoles structurées." }, { year: "2025", title: "Nouveaux partenariats & hors les murs", body: "Actions extérieures, collaborations et projets itinérants." } ].each do |entry| %>
    <%= entry[:year] %>
    -
    +

    <%= entry[:title] %>

    <%= entry[:body] %>

    @@ -114,36 +118,59 @@
    -
    -
    -

    Partenaires & soutiens

    -

    Ils rendent l'aventure possible

    -

    Collectivités, structures culturelles, écoles de cirque, ateliers d'artistes et sponsors solidaires : merci à celles et ceux qui nous accompagnent.

    +
    +
    +

    Partenaires & soutiens

    +

    Ils rendent l'aventure possible

    +

    Quelques partenaires mis en avant — la liste complète évolue avec les projets du lieu.

    -
    - <% @partners.each do |partner| %> -
    - <% if partner[:logo].present? && asset_available?(partner[:logo]) %> -
    - <%= image_tag partner[:logo], alt: partner[:name], class: "max-h-full max-w-full object-contain" %> -
    - <% else %> -
    - <%= partner[:name].to_s.first(2).upcase %> -
    - <% end %> -
    -

    <%= partner[:category] %>

    -

    <%= partner[:name] %>

    - <% if partner[:url].present? %> - <%= link_to "Visiter", partner[:url], class: "text-sm text-brand-primary hover:text-brand-accent", target: "_blank", rel: "noopener" %> - <% end %> + + <% partner_link_icon = tag.svg(xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "1.75", class: "w-5 h-5") do + safe_join([ + tag.path(stroke_linecap: "round", stroke_linejoin: "round", d: "M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5M7.5 16.5L21 3M15 3h6v6") + ]) + end %> + +
    - <% end %> + <% end %> +
    +
    + + +
    <%= link_to t("views.pages.about.join"), page_path('become_member'), class: "btn-primary px-8 py-4 tracking-wide font-bold shadow-lg" %> -
    \ No newline at end of file + diff --git a/app/views/pages/association.html.erb b/app/views/pages/association.html.erb index 6ada0d4c..e7528d3d 100644 --- a/app/views/pages/association.html.erb +++ b/app/views/pages/association.html.erb @@ -1,37 +1,36 @@ <% content_for :title, "Le Circographe - L'association" %> -
    +
    <%= image_tag hero_image(:association_hero), class: "absolute inset-0 w-full h-full object-cover hero-img-filter", alt: "" %>
    -
    +

    Bienvenue au Circographe

    -

    Un lieu pour créer, pratiquer et partager

    -

    Le Circographe est une association qui accueille des projets et activités artistiques, lié autour des arts du cirque et de l'art graphique. Un modèle d'auto-gestion assuré par des bénévoles.
    Venez !

    +

    Créer, pratiquer, partager — en autogestion

    +

    Association loi 1901 : cirque, arts graphiques et projets artistiques, portés par les bénévoles et les adhérent·es.

    <%= link_to "Découvrir le lieu", page_path("association", anchor: "le-cirque"), class: "inline-block px-6 py-3 tracking-wide text-white font-semibold bg-[#1F5C55] border border-white/40 rounded-lg shadow-lg hover:bg-white hover:text-[#1F5C55] transition-all duration-300 transform hover:-translate-y-1" %> - <%= link_to "Comment adhérer", page_path("become_member"), class: "inline-block px-6 py-3 tracking-wide text-white font-semibold bg-[#5836A5] border border-white/40 rounded-lg shadow-lg hover:bg-white hover:text-[#5836A5] hover:border-[#5836A5] transition-all duration-300 transform hover:-translate-y-1" %>
    -
    -
    +
    +

    Deux pôles, un même lieu

    -

    Les Arts du Cirque & Graphiques se répondent

    -

    Pensé comme une véritable boîte à outils culturelle, le Circographe favorise la rencontre entre les disciplines et les personnes. On y partage l'espace, on échange des savoirs et on accompagne les créations.

    +

    Cirque & arts graphiques

    +

    Un même hangar pour entraînements, ateliers et créations — espace partagé, savoirs qui circulent.

    -
    +
    <%= image_tag hero_image(:association_circus_card), class: "w-full h-full object-cover", alt: "Entraînement de cirque au Circographe" %>
    Les Arts du Cirque
    -
    +

    Entraînements libres & accueils de compagnies

    -

    Un espace multidisciplinaire où amateur·es, passionné·es et professionnel·les se retrouvent pour s’entraîner, échanger et partager leur passion commune : le cirque.

    +

    Tous niveaux : entraînements libres, échanges ; accueils ponctuels pour les compagnies.

    <%= link_to page_path("news", anchor: "pratiques-regulieres"), class: "inline-flex items-center gap-2 text-sm font-semibold text-[#1F5C55] hover:text-[#15403c] transition-colors" do %> Explorer l’univers cirque @@ -41,16 +40,16 @@
    -
    +
    <%= image_tag hero_image(:association_graphics_card), class: "w-full h-full object-cover", alt: "Atelier graphique au Circographe" %>
    Les Arts Graphiques
    -
    -

    De l'art, illustré en images

    -

    Une ambiance street art qui invite à découvrir des œuvres originales, créées par une communauté d’artistes passionné·es. Ici, les murs s’animent, les couleurs dialoguent, et l’art se partage au rythme de la création collective.

    +
    +

    Arts graphiques & ateliers

    +

    Street art, œuvres collectives et ateliers : la communauté anime les murs et les projets visuels.

    <%= link_to page_path("news", anchor: "pratiques-visuelles"), class: "inline-flex items-center gap-2 text-sm font-semibold text-slate-700 hover:text-slate-900 transition-colors" do %> Plonger dans l’atelier graphique @@ -61,42 +60,39 @@
    -
    -

    - Une journée type ? Un café en équilibre et une musique entraînante. - Une discussion tête en l’air avec des bénévoles trop bavards. - Et toi qui passes dire bonjour. -

    -

    Allez viens, on va tartiner de la culture ensemble !

    +
    + <%= link_to "Nos activités", page_path("news"), class: "font-semibold text-[#1F5C55] hover:text-[#15403c] underline-offset-2 hover:underline" %> + · + <%= link_to "Adhérer (sur place)", page_path("become_member"), class: "font-semibold text-[#1F5C55] hover:text-[#15403c] underline-offset-2 hover:underline" %>
    -
    -
    +
    +

    Autogestion & bénévolat

    -

    Un lieu qui tourne parce qu’on s’implique

    -

    Chaque adhérent·e participe à sa manière : tenir un créneau, bricoler un agrès, invitée ou chapeauter une expo. La salle reste vivante parce que tout le monde y met du sien.

    +

    Le lieu tient par l’engagement

    +

    Créneaux, technique, expo, accueil : chaque adhérent·e peut contribuer à sa manière.

    Décider ensemble

    -

    Une assemblée générale par an, et au fil de l’année des réunions ouvertes : on discute des usages de la salle, des temps forts à programmer. L’essentiel se joue dans l’échange humain pour faire vivre le lieu.

    +

    AG annuelle et réunions : usages de la salle, programmation, décisions collectives.

    Bénévolat en action

    -

    Ouvertures, clé de bras sur la maintenance, coups de main pour les ateliers, communication… nous faisons au mieux pour inclure toutes les pratiques.

    +

    Accueil, maintenance, ateliers, communication… des missions variées selon les envies et les compétences.

    Adhérer sur place

    -

    Pour assurer la sécurité de toutes et tous, l’adhésion se fait en direct lors de ta venue. On en profite pour faire la visite, répondre aux questions et te brancher sur le lieu et ses usagers.

    +

    Adhésion lors d’une venue : visite du lieu, sécurité, questions — mise en lien avec la communauté.

    - Envie de proposer une mise à disposition de la salle ou de rejoindre l’équipe bénévole ? Passe nous voir lors d’un créneau d’ouverture et on construit ça ensemble. + Projet dans la salle ou bénévolat : <%= link_to "passe en créneau d’ouverture", page_path("contact_us", anchor: "horaires"), class: "font-semibold text-[#1F5C55] hover:text-[#15403c] underline-offset-2 hover:underline" %> — on construit ça avec toi.
    -
    -

    Prêt·e à mêler ton énergie à la nôtre ? Allez viens, on va tartiner de la culture !

    - <%= link_to "Adhérer au Circographe", page_path('become_member'), class: "inline-block px-8 py-4 tracking-wide text-white font-bold bg-[#1F5C55] border-2 border-[#1F5C55] rounded-lg shadow-lg hover:bg-gray-100 hover:text-[#1F5C55] hover:border-[#1F5C55] hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 text-lg" %> -
    \ No newline at end of file +
    +

    Envie de rejoindre le lieu ? L’adhésion se fait sur place avec les bénévoles.

    + <%= link_to "Adhérer au Circographe", page_path('become_member'), class: "public-cta" %> +
    diff --git a/app/views/pages/become_member.html.erb b/app/views/pages/become_member.html.erb index 98813cb9..838d3499 100644 --- a/app/views/pages/become_member.html.erb +++ b/app/views/pages/become_member.html.erb @@ -1,133 +1,107 @@ <% content_for :title, "Le Circographe - Adhérer" %> - - -
    +
    <%= image_tag hero_image(:become_member_hero), class: "absolute inset-0 w-full h-full object-cover hero-img-filter", alt: "" %>
    -
    +

    Rejoindre la communauté

    Adhérer au Circographe

    -

    Tout commence par une rencontre sur place. Rejoins la communauté du Circographe, découvre le lieu et participe à la vie associative.

    +

    Une rencontre sur place pour rejoindre la communauté, découvrir le lieu et la vie associative.

    -
    -
    -

    Pourquoi adhérer ?

    -

    Un lieu pour pratiquer, créer et s'entraider

    -

    L'adhésion te donne accès aux entraînements libres de cirque, aux temps de création graphique, aux résidences d'artistes et à une programmation collective tout au long de l'année.

    -

    Sur place, on t'accueille pour t'expliquer le fonctionnement autogéré du lieu, te présenter l'équipe bénévole et t'aider à trouver les bons créneaux selon ta pratique.

    -
    - - -
    +<%# Une carte : pourquoi + résumé + adhésion + cotisations (séparateurs internes) %> +
    +
    +
    +
    +

    Pourquoi adhérer ?

    +

    Pratiquer, créer, s'entraider

    +

    Accès aux entraînements libres cirque, création graphique, temps d’accueil en création et programmation collective. Les bénévoles t’expliquent l’autogestion et t’aident à choisir tes créneaux.

    +
    + +
    +
    +
    +

    Adhésion annuelle

    +

    Types & tarifs

    +
      +
    • Adhésion visiteur : prix libre à partir de 1 € / an – pour soutenir l'association et profiter des temps ouverts en simple découverte (sans pratique).
    • +
    • + Adhésion cirque : + Plein tarif : 10 € / an. + Tarif réduit : 7 € / an (sur justificatif). + Chaque adhésion est valable de date à date. +
    • +
    +

    Paiement sur place (CB ou espèces). Reçu fiscal sur demande.

    +
    +
    +

    Cotisations

    +

    Autogestion du lieu

    +

    Choisis la formule qui colle à ta pratique.

    +
      +
    • Séance libre : 4 € la journée pour profiter d’un créneau d’entraînement.
    • +
    • Carnet 10 séances : 30 € pour venir quand tu veux et partager l’espace en toute autonomie.
    • +
    • Trimestre (3 mois) : 60 € — accès cirque sur la période.
    • +
    • Cotisation annuelle : 120 € — accès cirque sur un an.
    • +
    +
    -
    -
    -

    Tarifs & types d'adhésion

    -

    Adhésion annuelle

    -
      -
    • Adhésion visiteur : prix libre à partir de 1 € / an – pour soutenir l'association et profiter des temps ouverts en simple découverte (sans pratique).
    • -
    • - Adhésion cirque : - Plein tarif : 10 € / an. - Tarif réduit : 7 € / an (sur justificatif). - Chaque adhésion est valable de date à date. -
    • -
    • Adhésion soutien : 20 € / an – pour contribuer davantage à l'autogestion et accompagner nos projets.
    • -
    -

    Le paiement se fait sur place (CB ou espèces). Un reçu fiscal peut être remis sur demande.

    -
    +

    + À retenir : l’adhésion (cotisation associative annuelle) et la cotisation cirque sont deux paiements distincts. Les formules ci-contre supposent une adhésion cirque active. +

    +
    -
    -

    Cotisations

    -

    Participer à l'autogestion

    -

    La cotisation finance l’entretien du lieu et le renouvellement du matériel. Merci de choisir la formule qui correspond à ta pratique, tout en participant au fonctionnement collectif.

    -
      -
    • Séance libre : 4 € la journée pour profiter d’un créneau d’entraînement.
    • -
    • Carnet 10 séances : 30 € pour venir quand tu veux et partager l’espace en toute autonomie.
    • -
    • Trimestre (3 mois) : 65 € pour t’installer durablement dans la dynamique de la salle.
    • -
    • Cotisation annuelle « super soutiens » : 120 € pour accompagner l’autogestion sur la durée.
    • -
    -

    Tu peux t’impliquer selon tes disponibilités : accueil, maintenance, communication… l’équipe bénévole t’aide à trouver ta place et à rester branché·e sur la communauté.

    +

    + Bénévolat & fonctionnement : + <%= link_to "Association — autogestion", page_path("association", anchor: "fonctionnement"), class: "font-semibold text-[#1F5C55] hover:text-[#15403c] underline-offset-2 hover:underline" %>. +

    -
    -
    -

    S'impliquer au quotidien

    -

    Bénévolat et vie associative

    -

    Au Circographe, chaque adhésion se prolonge par une participation active : l’autogestion repose sur les bénévoles qui accueillent, maintiennent et imaginent les projets du lieu.

    -
    -
    -
    -

    Accueil & convivialité

    -

    Tenir un créneau, présenter les règles du lieu et s’assurer que tout le monde se sente bienvenu·e.

    -
    -
    -

    Technique & maintenance

    -

    Installer ou régler un agrès, bricoler un équipement, maintenir l’espace propre et sécurisé.

    -
    -
    -

    Communication & projets

    -

    Partager les actualités, documenter la vie du lieu, impulser des événements qui relient cirque et arts graphiques.

    -
    -
    -

    Pour te proposer, passe sur un créneau d’ouverture ou contacte-nous via le <%= link_to "formulaire de contact", page_path('contact_us'), class: "text-[#1F5C55] underline" %>.

    -
    -
    - <%= render "shared/faq_section", id: "adhesion-faq", label: "Questions fréquentes", title: "Avant de te déplacer", subtitle: "Quelques repères supplémentaires pour préparer ton adhésion.", faqs: @adhesion_faqs, footer: "Besoin d'un coup de main ? Contacte-nous en amont ou passe pendant un créneau d'ouverture." %> +
    + <%= render "shared/faq_section", + id: "adhesion-faq", + label: "Questions fréquentes", + title: "Avant de te déplacer", + subtitle: "Quelques repères pour préparer ton adhésion.", + faqs: @adhesion_faqs, + footer: "Besoin d'un coup de main ? Écris-nous ou passe pendant un créneau d'ouverture." %>
    -
    -
    -

    Horaires d'accueil

    -

    Passe nous voir sur un créneau d'ouverture

    - <%= render "shared/opening_hours", horaires: @opening_hours %> -
    +<%# Horaires centrés (lisibilité), bande accès en dessous %> +
    +
    +
    +

    Horaires

    +

    Créneaux d’accueil

    +
    +
    + <%= render "shared/opening_hours", horaires: @opening_hours, bare: true, compact: true %> +
    -
    -

    Nous trouver

    -

    Le Circographe sur la carte

    -
    - +
    +
    +

    Accès

    +

    97 bis bd de Suisse, 31200 Toulouse

    +

    Bus 15 (Tisséo), le long du boulevard de Suisse.

    +
    +
    + <%= link_to "Carte & contact", page_path("contact_us", anchor: "map"), + class: "inline-flex items-center justify-center px-5 py-2.5 text-sm font-semibold text-white bg-[#1F5C55] border border-[#1F5C55] rounded-full hover:bg-white hover:text-[#1F5C55] transition-colors" %> + <%= link_to "Écrire à l’équipe", page_path("contact_us", anchor: "contact-form"), + class: "inline-flex items-center justify-center px-5 py-2.5 text-sm font-semibold text-[#1F5C55] border border-[#1F5C55]/40 rounded-full hover:bg-[#1F5C55]/10 transition-colors" %> +
    -

    27 bis allée Maurice Sarraut – 31300 Toulouse • Tram T1/T2 – Arrêt Cartoucherie.

    - -
    - <%= link_to "Passer nous voir", page_path('contact_us'), class: "inline-flex items-center gap-2 px-8 py-4 tracking-wide text-white font-bold bg-[#1F5C55] border-2 border-[#1F5C55] rounded-full shadow-lg hover:bg-gray-100 hover:text-[#1F5C55] hover:border-[#1F5C55] transition-all duration-300" %> -
    \ No newline at end of file diff --git a/app/views/pages/blog_newsletter.html.erb b/app/views/pages/blog_newsletter.html.erb index 63cad458..c32daf52 100644 --- a/app/views/pages/blog_newsletter.html.erb +++ b/app/views/pages/blog_newsletter.html.erb @@ -1,11 +1,9 @@ <% content_for :title, "Le Circographe - Blog & Newsletter" %> -
    - -
    -

    Blog & Newsletter

    -
    +
    +
    +

    Blog & Newsletter

    +
    - - <%= render 'shared/blog_card' %> -
    \ No newline at end of file + <%= render 'shared/blog_card' %> +
    diff --git a/app/views/pages/circus_details.html.erb b/app/views/pages/circus_details.html.erb deleted file mode 100644 index 8086fcc9..00000000 --- a/app/views/pages/circus_details.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<% content_for :title, "Le Cirque - Le Circographe" %> - -<%= render "pages/le_lieu/circus_section", standalone: true %> diff --git a/app/views/pages/contact/_faq.html.erb b/app/views/pages/contact/_faq.html.erb deleted file mode 100644 index 52d45a23..00000000 --- a/app/views/pages/contact/_faq.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -<%= render "shared/faq_section", - id: "contact-faq", - label: "FAQ rapide", - title: "Les questions rapides avant d'écrire", - subtitle: "Prépare ton message, vérifie les créneaux et découvre comment proposer un projet.", - faqs: faqs, - footer: "Tu ne trouves pas ta question ? Envoie-nous un message depuis le formulaire, on te répond rapidement." %> diff --git a/app/views/pages/contact/_form.html.erb b/app/views/pages/contact/_form.html.erb index 13b40711..5c2429fc 100644 --- a/app/views/pages/contact/_form.html.erb +++ b/app/views/pages/contact/_form.html.erb @@ -1,6 +1,7 @@ -
    -

    Écris-nous

    -

    Une question, une envie de résidence ou un partenariat ? On te répond sous quelques jours ouvrés.

    +<% embedded = local_assigns.fetch(:embedded, false) %> +
    +

    Écris-nous

    +

    Une question, un projet de temps d’accueil en création ou un partenariat ? On te répond sous quelques jours ouvrés.

    <%= turbo_frame_tag "contact_form" do %> <%= render "pages/contact/form_inner", contact: contact, status: local_assigns[:status] %> diff --git a/app/views/pages/contact/_form_inner.html.erb b/app/views/pages/contact/_form_inner.html.erb index f5f412de..388e587c 100644 --- a/app/views/pages/contact/_form_inner.html.erb +++ b/app/views/pages/contact/_form_inner.html.erb @@ -5,38 +5,38 @@

    <%= t("contacts.create.sent_notice") %>

    <% else %> - <%= form_with url: submit_contact_path, method: :post, data: { turbo_stream: true }, class: "space-y-4 mt-6" do |form| %> + <%= form_with url: submit_contact_path, method: :post, data: { turbo_stream: true }, class: "space-y-5 mt-5 sm:mt-6" do |form| %>
    - <%= form.label :name, "Nom complet", class: "block mb-2 text-sm text-gray-600" %> - <%= form.text_field :name, value: contact[:name], placeholder: "Nom Prénom", class: "block w-full px-5 py-3 text-gray-700 bg-white border border-gray-200 rounded-md focus:border-[#5836A5] focus:ring-[#5836A5] focus:ring-1 focus:outline-none" %> + <%= form.label :name, "Nom complet", class: "block mb-1.5 text-sm font-medium text-gray-700" %> + <%= form.text_field :name, value: contact[:name], placeholder: "Nom Prénom", class: "block w-full min-h-[2.75rem] px-4 py-3 text-base text-gray-800 bg-white border border-gray-200 rounded-xl focus:border-[#5836A5] focus:ring-2 focus:ring-[#5836A5]/25 focus:outline-none touch-manipulation" %>
    - <%= form.label :email, "Adresse email", class: "block mb-2 text-sm text-gray-600" %> - <%= form.email_field :email, value: contact[:email], placeholder: "adresse@example.com", class: "block w-full px-5 py-3 text-gray-700 bg-white border border-gray-200 rounded-md focus:border-[#5836A5] focus:ring-[#5836A5] focus:ring-1 focus:outline-none" %> + <%= form.label :email, "Adresse email", class: "block mb-1.5 text-sm font-medium text-gray-700" %> + <%= form.email_field :email, value: contact[:email], placeholder: "adresse@example.com", class: "block w-full min-h-[2.75rem] px-4 py-3 text-base text-gray-800 bg-white border border-gray-200 rounded-xl focus:border-[#5836A5] focus:ring-2 focus:ring-[#5836A5]/25 focus:outline-none touch-manipulation" %>
    - <%= form.label :category, "Catégorie", class: "block mb-2 text-sm text-gray-600" %> + <%= form.label :category, "Catégorie", class: "block mb-1.5 text-sm font-medium text-gray-700" %> <%= form.select :category, options_for_select([ ["Question générale", "general"], ["Technique / accès", "technical"], - ["Résidence", "residence"], + ["Temps d’accueil en création", "creative_hosting"], ["Partenariat", "partnership"] ], contact[:category]), { include_blank: "Sélectionne une catégorie" }, - class: "block w-full px-5 py-3 text-gray-700 bg-white border border-gray-200 rounded-md focus:border-[#5836A5] focus:ring-[#5836A5] focus:ring-1 focus:outline-none" %> + class: "block w-full min-h-[2.75rem] px-4 py-3 text-base text-gray-800 bg-white border border-gray-200 rounded-xl focus:border-[#5836A5] focus:ring-2 focus:ring-[#5836A5]/25 focus:outline-none touch-manipulation" %>
    - <%= form.label :message, "Message", class: "block mb-2 text-sm text-gray-600" %> - <%= form.text_area :message, value: contact[:message], rows: 6, placeholder: "Parle-nous de ton projet, de ta question ou de ton besoin…", class: "block w-full px-5 py-3 text-gray-700 bg-white border border-gray-200 rounded-md focus:border-[#5836A5] focus:ring-[#5836A5] focus:ring-1 focus:outline-none" %> + <%= form.label :message, "Message", class: "block mb-1.5 text-sm font-medium text-gray-700" %> + <%= form.text_area :message, value: contact[:message], rows: 5, placeholder: "Parle-nous de ton projet, de ta question ou de ton besoin…", class: "block w-full px-4 py-3 text-base text-gray-800 bg-white border border-gray-200 rounded-xl focus:border-[#5836A5] focus:ring-2 focus:ring-[#5836A5]/25 focus:outline-none touch-manipulation resize-y min-h-[8rem]" %>
    -
    - <%= form.submit "Envoyer le message", class: "inline-flex items-center justify-center px-6 py-3 tracking-wide text-white font-bold bg-[#1F5C55] border-2 border-[#1F5C55] rounded-full shadow-lg hover:bg-gray-100 hover:text-[#1F5C55] hover:border-[#1F5C55] transition-colors duration-300 cursor-pointer" %> - +
    + <%= form.submit "Envoyer le message", class: "inline-flex items-center justify-center w-full sm:w-auto min-h-[3rem] px-6 py-3 text-base tracking-wide text-white font-bold bg-[#1F5C55] border-2 border-[#1F5C55] rounded-full shadow-md hover:bg-gray-100 hover:text-[#1F5C55] hover:border-[#1F5C55] transition-colors duration-300 cursor-pointer touch-manipulation" %> +
    <% end %> <% end %> diff --git a/app/views/pages/contact_us.html.erb b/app/views/pages/contact_us.html.erb index 7f4c7a62..a70a1a99 100644 --- a/app/views/pages/contact_us.html.erb +++ b/app/views/pages/contact_us.html.erb @@ -1,43 +1,35 @@ <% content_for :title, "Le Circographe - Contact" %> -
    +
    <%= image_tag hero_image(:contact_hero), class: "absolute inset-0 w-full h-full object-cover hero-img-filter", alt: "" %>

    Écris-nous

    Contact

    -

    Besoin d’un renseignement ? Une question sur l’adhésion ou la mise à disposition de la salle ? On te répond avec plaisir.

    +

    Adhésion, cotisation (accès cirque), temps d’accueil en création ou locaux : passe aux créneaux ou écris via le formulaire ci-dessous.

    -
    -
    -
    +
    + <%# Ne pas mettre .fade-in sur un ancêtre de Leaflet : translateY + will-change cassent la carte et peuvent provoquer des boucles ResizeObserver. %> +
    +
    +

    Infos pratiques

    -

    Passer nous voir ou écrire à l'équipe

    -

    Rencontre les bénévoles pendant les créneaux ouverts, ou écris-nous : on te répond rapidement.

    +

    Horaires, accès et formulaire

    +

    Réponse sous quelques jours ouvrés par message ; conseils sur place aux créneaux d’ouverture.

    -
    -
    +
    +
    -
    -

    Adresse

    -

    97 bis Boulevard de Suisse
    31200 Toulouse

    -
    -
    -

    Nous écrire

    -

    - <%= mail_to "contact@lecircographe.fr", "contact@lecircographe.fr", class: "underline text-[#5836A5] hover:text-[#412886]" %> -

    -
    -
    +

    Accès

    -

    Tram T1/T2 – Arrêt Cartoucherie
    Bus L2/L9 – Arrêt Cartoucherie

    +

    Bus 15 (Tisséo) — dessert le boulevard de Suisse ; descends à l’arrêt le plus proche de ton numéro.

    -
    +

    Suivre l'actu

    <%= link_to "Facebook", "https://www.facebook.com/lecircographe/?locale=fr_FR", target: "_blank", rel: "noopener", class: "inline-flex items-center gap-2 px-3 py-2 rounded-full border border-[#1F5C55]/40 hover:bg-[#1F5C55] hover:text-white transition" %> @@ -47,39 +39,40 @@
    -
    +

    Horaires d'accueil

    Ouverture du mardi au dimanche selon disponibilité bénévoles — Fermé le lundi.

    <%= render "shared/opening_hours", horaires: @opening_hours, bare: true %>
    - -

    Besoin d’écrire tout de suite ? <%= link_to "Écrire à l’équipe", "#contact-form", class: "underline text-[#5836A5] hover:text-[#412886]" %>

    -
    -
    +
    +

    Un doute rapide ?

    -

    Passe pendant un créneau d’ouverture : on t’oriente vers la bonne personne (adhésion, résidence, technique…).

    +

    Aux créneaux : orientation rapide (adhésion, création, technique).

    -
    - <%= render "pages/contact/form", contact: @contact || {}, status: nil %> +
    + <%= render "pages/contact/form", contact: @contact || {}, status: nil, embedded: true %>
    +
    -
    - <%= render "pages/contact/faq", faqs: @faqs %> +
    +

    + <%= link_to "Questions fréquentes", page_path("faq"), class: "font-semibold text-[#5836A5] hover:text-[#412886] underline-offset-2 hover:underline" %> + · + <%= link_to "contact & projets", page_path("faq", anchor: "faq-contact"), class: "text-[#5836A5] hover:text-[#412886] underline-offset-2 hover:underline" %> +

    <%= turbo_frame_tag "map" do %> -
    +

    Nous trouver

    Le Circographe sur la carte

    -
    - -
    -

    Tram T1/T2 – Arrêt Cartoucherie • Bus L2/L9 – Arrêt Cartoucherie

    + <%# Carte responsive (hauteur clamp) — passer height: N uniquement si besoin d’un px fixe %> + <%= render "shared/map_embed" %>
    <% end %>
    diff --git a/app/views/pages/faq.html.erb b/app/views/pages/faq.html.erb index 0a3387bd..d5956797 100644 --- a/app/views/pages/faq.html.erb +++ b/app/views/pages/faq.html.erb @@ -1,147 +1,67 @@ <% content_for :title, "Le Circographe - FAQ" %> -
    +
    <%= image_tag hero_image(:faq_hero), class: "absolute inset-0 w-full h-full object-cover hero-img-filter", alt: "" %>
    -
    -

    Besoin d'un guide ?

    +

    Questions fréquentes

    -

    Avant de passer nous voir, d'adhérer ou de proposer un projet, voici tout ce que l'on nous demande le plus souvent. Cette FAQ t'accompagne pas à pas : comprendre notre fonctionnement, rejoindre la communauté, et savoir comment nous écrire.

    -
    - <%= link_to "Je découvre l'esprit du lieu", page_path("association"), class: "btn-primary shadow-lg" %> - <%= link_to "Je prépare mon adhésion", page_path("become_member"), class: "btn-secondary shadow-lg" %> +

    Peu de blabla — les infos utiles, avec le ton du lieu.

    +
    + <%= link_to "L'association", page_path("association"), class: "btn-primary shadow-lg" %> + <%= link_to "Adhérer", page_path("become_member"), class: "btn-secondary shadow-lg" %>
    -
    -
    - - -
    -

    Le Circographe vit grâce à ses membres : une structure autogérée, des bénévoles qui se relaient, des artistes en résidence. Cette FAQ regroupe l’essentiel pour faciliter la transmission d’information.

    -

    Lis-la comme une petite histoire : tu viens nous rencontrer, tu adhères, tu participes. Chaque section renvoie vers une page si tu veux creuser les détails (horaires, tarifs, formulaires…)

    -
    -

    Astuce : cette page sert aussi de support aux bénévoles. Envoie-la à une personne intéressée, elle y retrouvera l’ensemble des repères clés.

    -
    -
    -
    - -
    -
    -

    Étape 1 — Rejoindre la communauté

    -

    Adhérer, comprendre, participer

    -

    On adhère sur place, parce que la rencontre humaine est au cœur du projet. Voici les réponses aux questions les plus courantes avant de franchir le pas.

    -
    +
    + +
    <%= render "shared/faq_section", id: "faq-adhesion-list", label: nil, - title: "Adhésion en pratique", + title: "Adhérer & pratiquer", subtitle: nil, faqs: @adhesion_faqs, - footer: "Tu viens d'adhérer ? Retrouve toutes les infos sur la page Adhérer : calendrier, checklist documents et infos bénévolat." %> + footer: "Tarifs et créneaux : page Adhérer." %>
    - <%= link_to "Préparer ma visite", page_path("become_member"), class: "btn-primary shadow-lg" %> - <%= link_to "Découvrir les créneaux", page_path("association", anchor: "fonctionnement"), class: "btn-tertiary shadow" %> + <%= link_to "Page Adhérer", page_path("become_member"), class: "btn-primary shadow-lg" %> + <%= link_to "Créneaux & fonctionnement", page_path("association", anchor: "fonctionnement"), class: "btn-tertiary shadow" %>
    -
    -
    -

    Étape 2 — Entrer en contact

    -

    Nous écrire, proposer un projet, demander une résidence

    -

    Le Circographe reçoit des demandes variées : résidences, ateliers, partenariats culturels, questions logistiques. Ce bloc te permet de préparer ton message et de gagner du temps.

    -
    - +
    <%= render "shared/faq_section", id: "faq-contact-list", label: nil, - title: "Avant d'envoyer ton mail", - subtitle: "Les infos clés à partager pour que l'équipe bénévole puisse te répondre rapidement.", + title: "Écrire & proposer", + subtitle: nil, faqs: @contact_faqs, - footer: "Quand tu es prêt·e, direction la page Contact pour nous écrire via le formulaire Turbo." %> + footer: "Formulaire sur la page Contact (bas de page)." %>
    - <%= link_to "Écrire à l'équipe", page_path("contact_us"), class: "btn-primary shadow-lg" %> - <%= link_to "Consulter les horaires", page_path("contact_us", anchor: "horaires"), class: "btn-tertiary shadow" %> + <%= link_to "Page Contact", page_path("contact_us"), class: "btn-primary shadow-lg" %> + <%= link_to "Horaires", page_path("contact_us", anchor: "horaires"), class: "btn-tertiary shadow" %>
    -
    -
    -

    Étape 3 — Vivre le lieu

    -

    Repères quotidiens & soutien au projet

    -

    Tu veux revenir régulièrement, inviter des proches ou soutenir financièrement ? Ces repères t’aideront à orienter ton entourage et à faire rayonner le lieu.

    -
    - +
    <%= render "shared/faq_section", id: "faq-general-list", label: nil, - title: "Vie du Circographe", + title: "Lieu & horaires", subtitle: nil, faqs: @general_faqs, - footer: "Pour suivre l'actualité, pense à la page Nos Activités et à la newsletter." %> + footer: "Programmation : Nos activités · newsletter en bas de site." %>
    - <%= link_to "Découvrir la programmation", page_path("news"), class: "btn-primary shadow-lg" %> - <%= link_to "Soutenir l'association", page_path("become_member", anchor: "tarifs"), class: "btn-tertiary shadow" %> + <%= link_to "Nos activités", page_path("news"), class: "btn-primary shadow-lg" %> + <%= link_to "Tarifs / adhérer", page_path("become_member", anchor: "tarifs"), class: "btn-tertiary shadow" %>
    - -
    -
    -

    Une histoire collective

    -

    Pourquoi on prend le temps de répondre

    -
    -
    -
    -

    Autogestion au cœur

    -

    Chaque adhérent·e peut rejoindre l'équipe bénévole et transmettre l'information. Cette FAQ est un socle commun pour accueillir et guider les nouveaux membres.

    -
    -
    -

    Transmission vivante

    -

    Les questions évoluent avec les saisons, les projets en résidence, les sorties publiques. On actualise ce guide régulièrement pour qu'il reste fidèle à la réalité du terrain.

    -
    -
    -

    Invitation à la rencontre

    -

    Après la lecture, viens ressentir l'énergie sur place : un entraînement libre, un vernissage, une soirée portes ouvertes… Le Circographe se vit autant qu'il se lit.

    -
    -
    -
    -
    \ No newline at end of file +
    diff --git a/app/views/pages/gallery.html.erb b/app/views/pages/gallery.html.erb index 397badfc..dd72a11a 100644 --- a/app/views/pages/gallery.html.erb +++ b/app/views/pages/gallery.html.erb @@ -1,8 +1,8 @@ -
    +

    Galerie Photos

    -
    +
    -
    +
    <% base_images = %w[ lelieu1.webp lelieu2.webp @@ -31,9 +31,13 @@ <% dynamic_hero_images = hero_pool.sample([needed, hero_pool.size].min) %> <% image_files = (base_images + dynamic_hero_images).uniq.first(12) %> - <% image_files.each do |image| %> -
    - <%= image_tag asset_path(image), alt: "Image du Circographe", class: "w-full h-64 object-cover rounded-lg shadow-md hover:shadow-lg transition-shadow duration-300" %> -
    - <% end %> -
    \ No newline at end of file + <% image_files.each_with_index do |image, index| %> +
    + <%= image_tag asset_path(image), + alt: "Image du Circographe", + class: "w-full h-64 object-cover rounded-lg shadow-md hover:shadow-lg transition-shadow duration-300", + loading: (index.zero? ? "eager" : "lazy"), + decoding: "async" %> +
    + <% end %> +
    diff --git a/app/views/pages/graphic_arts_details.html.erb b/app/views/pages/graphic_arts_details.html.erb deleted file mode 100644 index 2fc09260..00000000 --- a/app/views/pages/graphic_arts_details.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<% content_for :title, "Le Circographe - Les Arts Graphiques" %> - -<%= render "pages/le_lieu/graphic_arts_section", standalone: true %> \ No newline at end of file diff --git a/app/views/pages/le_lieu/_circus_section.html.erb b/app/views/pages/le_lieu/_circus_section.html.erb deleted file mode 100644 index 91dd1c9c..00000000 --- a/app/views/pages/le_lieu/_circus_section.html.erb +++ /dev/null @@ -1,118 +0,0 @@ -<% standalone = local_assigns.fetch(:standalone, false) %> - -
    -
    -

    Le Cirque

    -

    Nos entraînements libres sont ouverts aux amateur·es confirmé·es et aux professionnel·les du cirque.

    -
    - -
    - - <%= render "shared/carousel_image", - images: [ - "circus-img2.webp", - hero_image(:circus_carousel_two), - hero_image(:circus_carousel_three), - hero_image(:circus_carousel_four) - ] %> -
    - -
    -
    -
    -
    - - - -
    -
    -

    Entraînements libres

    -

    - Voltige, jonglage, aérien, équilibre : chacun explore sa pratique en autonomie, dans un cadre collectif et bienveillant. -

    -

    - Tu veux rejoindre l'aventure ? Passe nous voir, on te fait visiter, on t'explique la vie du lieu et tu adhères pour évoluer en toute sécurité avec les autres pratiquant·es. -

    -
    -
    -
    - -
    -
    -
    - - - -
    -
    -

    Débutant·e ?

    -

    - On t'invite plutôt à te rapprocher d'une école de cirque pour commencer dans les meilleures conditions. -

    -
    -
    -
    -
    - -
    -
    -
    -
    - - - -
    -
    -

    Bénévoles bienvenu·es

    -

    - L'ouverture des créneaux d'entraînement repose sur une équipe de bénévoles engagé·es. - Si tu veux rejoindre cette aventure collective, viens nous filer un coup de main : chaque présence compte pour que le lieu vive et s'organise. -

    -
    -
    -
    - -
    -
    -
    - - - -
    -
    -

    Un couteau suisse du cirque

    -

    - Le Circographe, c'est plus qu'un hangar : c'est un outil partagé, un lieu autogéré par ses adhérent·es, où se croisent disciplines, passions, idées et projets. - On assure l'accueil de compagnies, des stages menés par des artistes pros et une foule d'initiatives hybrides. -

    -

    - Allez viens, on va tartiner de la culture ! -

    -
    -
    -
    -
    - -
    -
    -
    - - - -
    -
    -

    Adhésion obligatoire

    -

    - Le Circographe est un lieu privé. L'adhésion annuelle est obligatoire, que tu sois en simple visite lors d'un événement ou en pleine répétition aérienne. - Elle nous permet de couvrir les assurances, de faire tourner le lieu, et de continuer à tracer cette belle route ensemble. -

    -
    -
    -
    - -
    -

    Adhérer au Circographe

    - <%= link_to "Adhérer au Circographe", page_path('become_member'), class: "inline-block px-8 py-4 tracking-wide text-white font-bold bg-[#1F5C55] border-2 border-[#1F5C55] rounded-lg shadow-lg hover:bg-gray-100 hover:text-[#1F5C55] hover:border-[#1F5C55] hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 text-lg" %> -
    -
    - diff --git a/app/views/pages/le_lieu/_graphic_arts_section.html.erb b/app/views/pages/le_lieu/_graphic_arts_section.html.erb deleted file mode 100644 index 21b4ea4d..00000000 --- a/app/views/pages/le_lieu/_graphic_arts_section.html.erb +++ /dev/null @@ -1,36 +0,0 @@ -<% standalone = local_assigns.fetch(:standalone, false) %> - -
    -
    -

    Les Arts Graphiques

    -
    - -
    -
    -

    - L’espace graphique du Circographe accueille illustrateur·ices, peintres, dessinateur·ices ou collectifs en création. On y trouve un atelier vivant, partagé et propice aux croisements artistiques. -

    -

    - L’association propose des temps de pratique libre, d’exposition et la mise à disposition de la salle pour soutenir vos projets visuels. Ici, chaque adhérent·e contribue à faire vibrer les murs du lieu. -

    -

    - Allez viens, on va tartiner de la culture ! -

    -
    -
    - -
    - <%= link_to "Adhérer au Circographe", page_path('become_member'), class: "px-4 py-2 tracking-wide text-white font-bold bg-[#1F5C55] border-2 border-[#1F5C55] rounded-lg shadow-lg hover:bg-gray-100 hover:text-[#1F5C55] hover:border-[#1F5C55] hover:shadow-lg transition-colors duration-300" %> -
    - -
    - <%= render "shared/carousel_image", - images: [ - "graff-img1.webp", - "flowersZoomed.webp", - hero_image(:graphics_carousel_three), - hero_image(:graphics_carousel_four) - ] %> -
    -
    - diff --git a/app/views/pages/news.html.erb b/app/views/pages/news.html.erb index de7be153..647ceb18 100644 --- a/app/views/pages/news.html.erb +++ b/app/views/pages/news.html.erb @@ -1,17 +1,17 @@ <% content_for :title, "Le Circographe - Nos activités" %> -
    +
    <%= image_tag hero_image(:news_hero), class: "absolute inset-0 w-full h-full object-cover hero-img-filter", alt: "" %>

    Programmation

    Nos activités

    -

    Découvrez tout ce qui fait vibrer Le Circographe — un lieu vivant dédié aux pratiques artistiques, à la création partagée et à la rencontre entre disciplines.

    +

    Cours, accueils de compagnies, expos, événements : la programmation du lieu.

    -
    -
    +
    +

    Pratiques régulières

    Le Cirque

    Les entraînements libres sont ouverts sur adhésion et accompagnent les pratiquant·es confirmé·es dans le développement de leur discipline : aérien, jonglage, équilibre, acrobatie, roue cyr, contorsion et bien plus encore.

    @@ -29,7 +29,7 @@
    -
    +
    <%= image_tag hero_image(:association_graphics_card), class: "w-full h-full object-cover", alt: "Pratique graphique au Circographe" %> @@ -44,17 +44,13 @@
    -
    +

    Moments partagés

    Événements et actualités

    Tout au long de l’année, le Circographe propose des expositions, des plateaux partagés, des soirées d’ouverture de piste et des rendez-vous associatifs ouverts au quartier. Filtre les actualités pour préparer ta prochaine venue.

    -
    - -
    -
    Actualités -
    @@ -106,19 +97,12 @@ <%= render "pages/news/blog_grid", blogs: @latest_posts %>
    - -
    -
    +

    Autres projets

    Ateliers, médiation et projets hybrides

    En plus des créneaux réguliers, on imagine des formats qui croisent disciplines et publics : ateliers de transmission, médiations scolaires, projections, scènes ouvertes graphiques et rencontres avec les artistes de passage.

    @@ -140,58 +124,44 @@
    -
    -
    - - - -
    -

    Lettre d'information

    -

    Abonne-toi à la newsletter

    -

    Stages, accueils de compagnies, événements : reçois les actualités chaudes du Circographe directement dans ta boîte mail.

    -
    -
    - <%= form_with scope: :user, url: newsletter_signup_path, method: :post, data: { controller: "form-feedback" }, class: "items-center justify-center sm:flex gap-4" do |form| %> - <%= form.email_field :email_address, placeholder: "Entrez votre email", required: true, class: "text-gray-500 w-full p-3 rounded-full border outline-none focus:border-[#5836A5] focus:ring-[#5836A5] focus:ring-1" %> - <%= form.hidden_field :website, value: "" %> - <%= form.submit "S'abonner", class: "w-full sm:w-auto mt-3 sm:mt-0 btn-primary cursor-pointer" %> - - <% end %> -

    - Pas de spam, juste les infos importantes. Consulte notre <%= link_to "Politique de confidentialité", page_path('privacy_policy'), class: "text-brand-primary underline" %>. -

    -
    -
    -
    - -
    +
    -
    -
    +

    Envie de tester un créneau ou de venir en spectateur·rice ?

    - <%= link_to "Consulter les prochains créneaux", page_path("news", anchor: "evenements"), class: "inline-block px-8 py-4 tracking-wide text-white font-bold bg-[#1F5C55] border-2 border-[#1F5C55] rounded-lg shadow-lg hover:bg-gray-100 hover:text-[#1F5C55] hover:border-[#1F5C55] hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 text-lg" %> + <%= link_to "Consulter les prochains créneaux", page_path("news", anchor: "evenements"), class: "public-cta" %>
    -
    \ No newline at end of file +
    diff --git a/app/views/pages/news/_blog_grid.html.erb b/app/views/pages/news/_blog_grid.html.erb index c45dd635..53213b76 100644 --- a/app/views/pages/news/_blog_grid.html.erb +++ b/app/views/pages/news/_blog_grid.html.erb @@ -5,7 +5,11 @@
    <% if blog.cover_image_url.present? %>
    - <%= image_tag blog.cover_image_url, alt: blog.title, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" %> + <%= image_tag blog.cover_image_url, + alt: blog.title, + class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-500", + loading: "lazy", + decoding: "async" %>
    <% end %>
    diff --git a/app/views/pages/privacy_policy.html.erb b/app/views/pages/privacy_policy.html.erb index ed9b557b..e7c48847 100644 --- a/app/views/pages/privacy_policy.html.erb +++ b/app/views/pages/privacy_policy.html.erb @@ -1,10 +1,8 @@ <% content_for :title, "Le Circographe - Politique de Confidentialité" %>
    - -
    - -
    +
    +

    Politique de Confidentialité @@ -26,7 +24,7 @@
    Adresse physique : 97 bis Bd de Suisse, 31200 Toulouse
    - Contact e-mail : contact@circographe.fr + Contact e-mail : contact@lecircographe.fr

    Pour toute question concernant vos données personnelles ou pour exercer vos droits, vous pouvez nous contacter à l'adresse e-mail ci-dessus.

    @@ -44,7 +42,7 @@
    • Adresse e-mail : Pour les communications et la gestion de votre adhésion.
    - • Informations relatives à l'adhésion : Type d'abonnement, dates d'inscription. + • Informations relatives à l'adhésion : Type d'adhésion et statut, dates liées à l'adhésion.

    2.2. Données collectées automatiquement
    @@ -64,7 +62,7 @@

    3. Finalités de la Collecte des Données

    - • Gestion des adhésions : Inscription et suivi des abonnements, organisation des activités et événements. + • Gestion des adhésions : Inscription et suivi des adhésions et cotisations, organisation des activités et événements.
    Communication : Envoi d'informations relatives à l'association et à ses activités.
    @@ -132,7 +130,7 @@
    Droit d'opposition : Vous pouvez vous opposer au traitement de vos données dans certains cas.

    - Pour exercer vos droits, contactez-nous à contact@circographe.fr. Une preuve d'identité pourra être demandée. + Pour exercer vos droits, contactez-nous à contact@lecircographe.fr. Une preuve d'identité pourra être demandée.

    @@ -164,7 +162,7 @@

    10. Litiges et Contact

    - En cas de litige lié à vos données personnelles, nous vous encourageons à nous contacter à contact@circographe.fr. Nous ferons notre possible pour résoudre vos préoccupations. Si vous estimez que vos droits n'ont pas été respectés, vous pouvez également déposer une plainte auprès de la CNIL. + En cas de litige lié à vos données personnelles, nous vous encourageons à nous contacter à contact@lecircographe.fr. Nous ferons notre possible pour résoudre vos préoccupations. Si vous estimez que vos droits n'ont pas été respectés, vous pouvez également déposer une plainte auprès de la CNIL.

    @@ -189,4 +187,4 @@

    -
    \ No newline at end of file +
    diff --git a/app/views/pages/terms.html.erb b/app/views/pages/terms.html.erb index 13adb18f..45e7c89d 100644 --- a/app/views/pages/terms.html.erb +++ b/app/views/pages/terms.html.erb @@ -1,10 +1,8 @@ <% content_for :title, "Le Circographe - CGU" %>
    - -
    - -
    +
    +

    Conditions Générales d'Utilisation (CGU) @@ -60,7 +58,7 @@
    • Accès à l'espace adhérent pour suivre les activités réservées.
    - • Consultation des abonnements annuels ou à la carte. + • Consultation de l'adhésion et des cotisations (formules selon le catalogue en ligne).

    3.3. Inscription et gestion des comptes
    @@ -117,4 +115,4 @@

    -
    \ No newline at end of file +
    diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb index 648d5f5e..89e1efb1 100644 --- a/app/views/passwords/edit.html.erb +++ b/app/views/passwords/edit.html.erb @@ -1,6 +1,6 @@
    -
    -
    +
    +

    Réinitialiser votre mot de passe

    @@ -32,11 +32,11 @@
    <%= form.label :password, "Nouveau mot de passe", class: "block text-sm font-medium text-gray-700" %> - <%= form.password_field :password, required: true, autofocus: true, autocomplete: "new-password", placeholder: "Entrez votre nouveau mot de passe", class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" %> + <%= form.password_field :password, required: true, autofocus: true, autocomplete: "new-password", placeholder: "Entrez votre nouveau mot de passe", class: "auth-field mt-1" %>
    <%= form.label :password_confirmation, "Confirmer le mot de passe", class: "block text-sm font-medium text-gray-700" %> - <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirmez votre nouveau mot de passe", class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" %> + <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirmez votre nouveau mot de passe", class: "auth-field mt-1" %>
    @@ -47,4 +47,4 @@
    -
    \ No newline at end of file +
    diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb index 2dad2e7c..a28fbaca 100644 --- a/app/views/passwords/new.html.erb +++ b/app/views/passwords/new.html.erb @@ -5,7 +5,7 @@ <%= form_with url: passwords_path, method: :post, local: true do |form| %>
    <%= form.label :email_address, "Adresse email", class: "font-medium" %> - <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Entrez votre adresse email", value: params[:email_address], class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %> + <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Entrez votre adresse email", value: params[:email_address], class: "auth-field mt-2" %>
    @@ -13,4 +13,4 @@
    <% end %>
    -
    \ No newline at end of file +
    diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index bfed0803..9749883d 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -1,4 +1,4 @@ -
    +
    <%= image_tag("about.jpg", alt: "", class: "w-full h-full object-cover") %> @@ -39,13 +39,13 @@
    - <%= form.email_field :email_address, required: true, placeholder: "example@example.com", maxlength: 72, id: "signup_email_address", class: "block w-full px-4 py-2 text-black placeholder-gray-400 bg-white border border-gray-200 rounded-lg focus:border-[#5836A5] focus:ring-[#5836A5] focus:ring-1 focus:outline-none", style: "background-color: white;" %> + <%= form.email_field :email_address, required: true, placeholder: "example@example.com", maxlength: 72, id: "signup_email_address", class: "auth-field" %>
    - <%= form.password_field :password, required: true, placeholder: "Votre mot de passe", maxlength: 72, id: "password", class: "block w-full px-4 py-2 text-black placeholder-gray-400 bg-white border border-gray-200 rounded-lg focus:border-[#5836A5] focus:ring-[#5836A5] focus:ring-1 focus:outline-none password-field", style: "background-color: white;" %> + <%= form.password_field :password, required: true, placeholder: "Votre mot de passe", maxlength: 72, id: "password", class: "auth-field password-field" %> @@ -54,7 +54,7 @@
    - <%= form.password_field :password_confirmation, required: true, placeholder: "Confirmer votre mot de passe", maxlength: 72, id: "password_confirmation", class: "block w-full px-4 py-2 text-black placeholder-gray-400 bg-white border border-gray-200 rounded-lg focus:border-[#5836A5] focus:ring-[#5836A5] focus:ring-1 focus:outline-none password-field", style: "background-color: white;" %> + <%= form.password_field :password_confirmation, required: true, placeholder: "Confirmer votre mot de passe", maxlength: 72, id: "password_confirmation", class: "auth-field password-field" %> @@ -105,6 +105,3 @@
    - - - diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 9304c329..c6136039 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,4 +1,4 @@ -
    +
    <%= image_tag("about.jpg", alt: "", class: "w-full h-full object-cover") %> @@ -18,11 +18,11 @@

    Se connecter

    - <%= form_with url: session_url, class: "signup-form space-y-4" do |form| %> + <%= form_with url: session_url, class: "signup-form space-y-4", data: { turbo: false } do |form| %>
    - <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "jean@example.com", value: params[:email_address], id: "email_address", class: "block w-full px-4 py-2 text-black placeholder-gray-400 bg-white border border-gray-200 rounded-lg focus:border-[#5836A5] focus:ring-[#5836A5] focus:ring-1 focus:outline-none", style: "background-color: white;" %> + <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "jean@example.com", value: params[:email_address], id: "email_address", class: "auth-field" %>
    @@ -31,7 +31,7 @@ <%= link_to "Mot de passe oublié ?", new_password_path, class: "text-sm text-gray-400 hover:text-brand-accent hover:underline focus:outline-brand-accent outline-offset-1" %>
    - <%= form.password_field :password, required: true, placeholder: "Mot de passe", maxlength: 72, id: "password", class: "block w-full px-4 py-2 text-black placeholder-gray-400 bg-white border border-gray-200 rounded-lg focus:border-[#5836A5] focus:ring-[#5836A5] focus:ring-1 focus:outline-none password-field", style: "background-color: white;" %> + <%= form.password_field :password, required: true, placeholder: "Mot de passe", maxlength: 72, id: "password", class: "auth-field password-field" %> @@ -65,4 +65,3 @@
    - diff --git a/app/views/settings/_account_panel.html.erb b/app/views/settings/_account_panel.html.erb new file mode 100644 index 00000000..c0a962d3 --- /dev/null +++ b/app/views/settings/_account_panel.html.erb @@ -0,0 +1,205 @@ +<% user = local_assigns.fetch(:user) %> +<% embedded = local_assigns.fetch(:embedded, false) %> +<% compact = local_assigns.fetch(:compact, false) %> +<% frame_id = local_assigns.fetch(:frame_id, ProfileSectionDomIds::ACCOUNT_SECTION) %> +<% pref_error_fields = %i[image_rights newsletter_subscribed get_involved dyslexic_font] %> +<% account_errors = user.errors.attribute_names.any? { |name| pref_error_fields.include?(name) } %> + +
    " + data-section-edit-modify-label-value="<%= t("users.space.edit") %>" + data-section-edit-accept-label-value="<%= t("users.space.accept") %>" + data-section-edit-preview-title-value="<%= t("users.space.preview_submit_title") %>" + data-section-edit-preview-intro-value="<%= t("users.space.preview_account_intro") %>" + data-section-edit-preview-confirm-label-value="<%= t("users.space.preview_confirm") %>" + data-section-edit-preview-back-label-value="<%= t("users.space.preview_back") %>" + data-section-edit-yes-label-value="<%= t("users.space.yes") %>" + data-section-edit-no-label-value="<%= t("users.space.no") %>" + data-email-change-modal-current-email-value="<%= user.email_address %>" + data-email-change-modal-blank-message-value="<%= t("settings.show.email_change_error_blank") %>" + data-email-change-modal-mismatch-message-value="<%= t("settings.show.email_change_error_mismatch") %>" + data-email-change-modal-unchanged-message-value="<%= t("settings.show.email_change_error_unchanged") %>" + data-email-change-modal-preview-title-value="<%= t("settings.show.email_change_preview_title") %>" + data-email-change-modal-preview-intro-value="<%= t("settings.show.email_change_preview_intro") %>" + data-email-change-modal-preview-confirm-label-value="<%= t("users.space.preview_confirm") %>" + data-email-change-modal-preview-back-label-value="<%= t("users.space.preview_back") %>" + data-email-change-modal-invalid-code-message-value="<%= t("settings.show.email_change_error_invalid_code_format") %>"> +
    +
    +
    +

    + <%= embedded ? t("users.space.account_heading") : t("settings.show.page_title") %> +

    + <% unless embedded || compact %> + + <% end %> +
    +
    +
    + +
    + + +
    +
    + <% unless embedded %> +

    <%= t("settings.show.lead") %>

    + <% end %> +
    + +
    + <% if user.errors.any? %> + + <% end %> + +
    +
    +

    + <%= t("settings.show.section_account") %> +

    + + <%= t("settings.show.email_shared_badge") %> + +
    +

    <%= t("settings.show.field_email") %>

    +

    <%= user.email_address %>

    +

    <%= t("settings.show.email_hint") %>

    + +
    + +
    +

    + <%= t("settings.show.section_preferences") %> +

    +
    +
    + <%= t("settings.show.field_image_rights") %> + "> + <%= user.image_rights ? t("users.space.yes") : t("users.space.no") %> + +
    +
    + <%= t("settings.show.field_newsletter") %> + "> + <%= user.newsletter_subscribed ? t("users.space.yes") : t("users.space.no") %> + +
    +
    + <%= t("settings.show.field_get_involved") %> + "> + <%= user.get_involved ? t("users.space.yes") : t("users.space.no") %> + +
    +
    + <%= t("settings.show.field_dyslexic_font") %> + "> + <%= user.dyslexic_font ? t("users.space.yes") : t("users.space.no") %> + +
    +
    +
    + + <%= form_with(model: user, url: settings_path, method: :patch, class: compact ? "hidden space-y-5" : "hidden space-y-6", data: { section_edit_target: "edit form", action: "submit->section-edit#handleFormSubmit input->section-edit#handleInput change->section-edit#handleInput" }) do |f| %> + <%= hidden_field_tag :ui_context, embedded ? "profile" : "settings" %> + +
    +

    + <%= t("settings.show.section_preferences") %> +

    +

    + <%= t("settings.show.section_preferences_edit_hint") %> +

    +
    + + + + +
    +
    + + + <% end %> + +
    +

    + <%= t("settings.show.section_security") %> +

    + <%= button_to t("settings.show.password_reset_cta"), request_reset_password_path, + method: :get, + form: { data: { turbo_confirm: t("settings.show.password_reset_confirm", email: user.email_address), section_edit_target: "protectedAction" } }, + class: "w-full flex justify-center py-2.5 px-4 border border-gray-300 rounded-lg shadow-sm text-sm font-medium text-gray-800 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55] transition-colors" %> +
    + +
    +

    + <%= t("settings.show.section_danger") %> +

    + <%= button_to t("settings.show.destroy_account_cta"), user, + method: :delete, + form: { data: { turbo_confirm: t("settings.show.destroy_confirm"), section_edit_target: "protectedAction" } }, + class: "w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors" %> +
    +
    + + <%= render "settings/email_change_modal", embedded: embedded, frame_id: frame_id %> +
    diff --git a/app/views/settings/_account_section.html.erb b/app/views/settings/_account_section.html.erb new file mode 100644 index 00000000..550f31ee --- /dev/null +++ b/app/views/settings/_account_section.html.erb @@ -0,0 +1,14 @@ +<% user = local_assigns.fetch(:user, @user) %> +<% frame_id = local_assigns.fetch(:frame_id, ProfileSectionDomIds::ACCOUNT_SECTION) %> +<% embedded = local_assigns.fetch(:embedded, true) %> +<% compact = local_assigns.fetch(:compact, true) %> + +<%= turbo_frame_tag frame_id do %> +
    + <%= render "settings/account_panel", + user: user, + embedded: embedded, + compact: compact, + frame_id: frame_id %> +
    +<% end %> diff --git a/app/views/settings/_email_change_modal.html.erb b/app/views/settings/_email_change_modal.html.erb new file mode 100644 index 00000000..bbb2c465 --- /dev/null +++ b/app/views/settings/_email_change_modal.html.erb @@ -0,0 +1,106 @@ +<%# locals: embedded:, frame_id: %> + diff --git a/app/views/settings/show.html.erb b/app/views/settings/show.html.erb index 6636302e..acb78dad 100644 --- a/app/views/settings/show.html.erb +++ b/app/views/settings/show.html.erb @@ -1,82 +1,9 @@ -
    -
    -
    -
    -

    Paramètres du compte

    - - <%= form_with(model: @user, url: settings_path, method: :patch, class: "space-y-6") do |f| %> - <% if @user.errors.any? %> -
    -
    -
    - - - -
    -
    -

    - <%= pluralize(@user.errors.count, "erreur") %> à corriger : -

    -
    -
      - <% @user.errors.full_messages.each do |message| %> -
    • <%= message %>
    • - <% end %> -
    -
    -
    -
    -
    - <% end %> - -
    -
    - <%= f.label :email_address, "Adresse email", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= f.email_field :email_address, class: "block w-full rounded-lg border-gray-300 shadow-sm focus:border-[#6D28D9] focus:ring-[#6D28D9] sm:text-sm transition-colors duration-200" %> -
    - -
    - <%= f.check_box :image_rights, class: "h-4 w-4 rounded border-gray-300 text-[#1F5C55] focus:ring-[#1F5C55] cursor-pointer" %> - <%= f.label :image_rights, "Droit à l'image", class: "text-sm font-medium text-gray-700 cursor-pointer" %> -
    - -
    - <%= f.check_box :newsletter_subscribed, class: "h-4 w-4 rounded border-gray-300 text-[#1F5C55] focus:ring-[#1F5C55] cursor-pointer" %> - <%= f.label :newsletter_subscribed, "S'inscrire à la newsletter", class: "text-sm font-medium text-gray-700 cursor-pointer" %> -
    - -
    - <%= f.check_box :dyslexic_font, class: "h-4 w-4 rounded border-gray-300 text-[#1F5C55] focus:ring-[#1F5C55] cursor-pointer" %> - <%= f.label :dyslexic_font, "Utiliser la police pour dyslexiques", class: "text-sm font-medium text-gray-700 cursor-pointer" %> -
    - -
    - <%= f.check_box :get_involved, class: "h-4 w-4 rounded border-gray-300 text-[#1F5C55] focus:ring-[#1F5C55] cursor-pointer" %> - <%= f.label :get_involved, "S'investir dans le projet", class: "text-sm font-medium text-gray-700 cursor-pointer" %> -
    -
    - -
    - <%= f.submit "Enregistrer les modifications", class: "mx-auto max-w-xs w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#1F5C55]/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55] transition-all duration-200 hover:transform hover:scale-[1.02]" %> -
    - <% end %> - -
    -

    Sécurité

    - <%= button_to "Modifier le mot de passe", request_reset_password_path, - method: :get, - form: { data: { turbo_confirm: "Un email de réinitialisation sera envoyé à #{@user.email_address}. Continuer ?" } }, - class: "mx-auto max-w-xs w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#1F5C55]/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55] transition-all duration-200 hover:transform hover:scale-[1.02]" %> -
    - -
    -

    Zone de danger

    - <%= button_to "Supprimer mon compte", @user, - method: :delete, - form: { data: { turbo_confirm: "Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible." } }, - class: "mx-auto max-w-xs w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all duration-200 hover:transform hover:scale-[1.02]" %> -
    -
    -
    -
    -
    \ No newline at end of file +<% content_for :title, t("settings.show.page_title") %> + +
    + <%= render "settings/account_section", + user: @user, + frame_id: ProfileSectionDomIds::ACCOUNT_SECTION, + embedded: false, + compact: true %> +
    diff --git a/app/views/shared/_card.html.erb b/app/views/shared/_card.html.erb index c6a51997..894d9ce3 100644 --- a/app/views/shared/_card.html.erb +++ b/app/views/shared/_card.html.erb @@ -31,7 +31,11 @@
    - <%= image_tag image, class: "w-full h-full object-cover", alt: "Artiste réalisant une performance acrobatique lors des entraînements libres" %> + <%= image_tag image, + class: "w-full h-full object-cover", + alt: "Artiste réalisant une performance acrobatique lors des entraînements libres", + loading: "lazy", + decoding: "async" %>
    diff --git a/app/views/shared/_card_reverse.html.erb b/app/views/shared/_card_reverse.html.erb index 9b4e545f..635a8561 100644 --- a/app/views/shared/_card_reverse.html.erb +++ b/app/views/shared/_card_reverse.html.erb @@ -2,7 +2,11 @@
    - <%= image_tag image, class: "w-full h-full object-cover", alt: "Artiste réalisant une performance acrobatique lors des entraînements libres" %> + <%= image_tag image, + class: "w-full h-full object-cover", + alt: "Artiste réalisant une performance acrobatique lors des entraînements libres", + loading: "lazy", + decoding: "async" %>
    diff --git a/app/views/shared/_carousel_image.html.erb b/app/views/shared/_carousel_image.html.erb index 00c95075..2cf8d818 100644 --- a/app/views/shared/_carousel_image.html.erb +++ b/app/views/shared/_carousel_image.html.erb @@ -14,10 +14,14 @@ data-controller="slider" data-slider-options-value="<%= slider_options.to_json %>">
    - <% images.each do |image| %> + <% images.each_with_index do |image, index| %>
    - <%= image_tag image, alt: "Carousel Image", class: "object-cover w-full h-full" %> + <%= image_tag image, + alt: "Carousel Image", + class: "object-cover w-full h-full", + loading: (index.zero? ? "eager" : "lazy"), + decoding: "async" %>
    <% end %> diff --git a/app/views/shared/_confirm_modal.html.erb b/app/views/shared/_confirm_modal.html.erb index ac97bc03..7b78500e 100644 --- a/app/views/shared/_confirm_modal.html.erb +++ b/app/views/shared/_confirm_modal.html.erb @@ -14,9 +14,11 @@

    Confirmation

    -

    - Êtes-vous sûr de vouloir poursuivre cette action ? -

    +
    +

    + Êtes-vous sûr de vouloir poursuivre cette action ? +

    +
    diff --git a/app/views/shared/_event_card.html.erb b/app/views/shared/_event_card.html.erb index 12b4a1c7..2505650a 100644 --- a/app/views/shared/_event_card.html.erb +++ b/app/views/shared/_event_card.html.erb @@ -6,7 +6,9 @@ <%= image_tag( event.picture_url.presence || hero_image("event_card_#{event.id || event.object_id}".to_sym), alt: event.title, - class: "w-full h-[250px] object-cover rounded-xl" + class: "w-full h-[250px] object-cover rounded-xl", + loading: "lazy", + decoding: "async" ) %>
    diff --git a/app/views/shared/_faq_section.html.erb b/app/views/shared/_faq_section.html.erb index 15061004..7885563c 100644 --- a/app/views/shared/_faq_section.html.erb +++ b/app/views/shared/_faq_section.html.erb @@ -32,7 +32,11 @@

    <% end %> diff --git a/app/views/shared/_footer.html.erb b/app/views/shared/_footer.html.erb index 9baaa115..477ebb73 100644 --- a/app/views/shared/_footer.html.erb +++ b/app/views/shared/_footer.html.erb @@ -1,14 +1,14 @@
    \ No newline at end of file diff --git a/app/views/shared/_footer_social_icons.html.erb b/app/views/shared/_footer_social_icons.html.erb new file mode 100644 index 00000000..fc48dcaf --- /dev/null +++ b/app/views/shared/_footer_social_icons.html.erb @@ -0,0 +1,23 @@ +<% wrapper_class = local_assigns.fetch(:wrapper_class, "flex flex-wrap gap-[50px] justify-center") %> +<% icon_class = local_assigns.fetch(:icon_class, "w-6 h-6") %> +<% link_class = local_assigns.fetch(:link_class, "text-gray-500 hover:text-gray-900") %> +
    + <%= link_to "https://www.facebook.com/lecircographe/?locale=fr_FR", target: "_blank", rel: "noopener noreferrer", class: link_class do %> + + Facebook page + <% end %> + <%= link_to "https://www.instagram.com/lecircographe/", target: "_blank", rel: "noopener noreferrer", class: link_class do %> + + Instagram page + <% end %> + <%= link_to "https://wa.me/33612345678", target: "_blank", rel: "noopener noreferrer", class: link_class do %> + + WhatsApp page + <% end %> +
    diff --git a/app/views/shared/_map_embed.html.erb b/app/views/shared/_map_embed.html.erb new file mode 100644 index 00000000..f6e9d7d1 --- /dev/null +++ b/app/views/shared/_map_embed.html.erb @@ -0,0 +1,30 @@ +<% + caption = local_assigns.fetch(:caption, "Bus 15 (Tiss\u00e9o) \u2014 dessert le boulevard de Suisse sur toute sa longueur (arr\u00eats selon ton sens de marche). Infos : tisseo.fr") + # Default lat/lng: OSM POI "Circographe" (node 11726901184) — matches the green label on the map. + lat = local_assigns.fetch(:lat, 43.6195896) + lng = local_assigns.fetch(:lng, 1.4223463) + zoom = local_assigns.fetch(:zoom, 17) + fixed_height_px = local_assigns[:height] + shell_classes = %w[ + leaflet-map-shell rounded-2xl overflow-hidden border border-gray-200 shadow-inner w-full bg-gray-100 + ] + shell_style = nil + if fixed_height_px.present? + shell_style = "height: #{fixed_height_px.to_i}px;" + else + shell_classes += %w[min-h-[280px] h-[clamp(280px,48vw,520px)]] + end +%> +
    "<%= " style=\"#{shell_style}\"".html_safe if shell_style %>> + +
    +<% if caption.present? %> +

    <%= caption %>

    +<% end %> diff --git a/app/views/shared/_membership_card_active_contributions.html.erb b/app/views/shared/_membership_card_active_contributions.html.erb new file mode 100644 index 00000000..6ef8390a --- /dev/null +++ b/app/views/shared/_membership_card_active_contributions.html.erb @@ -0,0 +1,36 @@ +<%# locals: person (required) %> +<% active_contributions = person.contributions.where(status: :active) %> +
    +
    + Cotisations actives : + <% if active_contributions.exists? %> +
    + <% active_contributions.each do |contribution| %> +
    + <% if contribution.is_pack10? %> + <% sessions_remaining = contribution.sessions_remaining.to_i %> + + <%= contribution.contribution_formula.name %>: <%= sessions_remaining %> séances + + <% if sessions_remaining <= 3 && sessions_remaining > 0 %> + Bientôt épuisé + <% end %> + <% elsif contribution.expires_at.present? %> + + <%= contribution.contribution_formula.name %> + — valable jusqu'au <%= contribution.expires_at.strftime("%d/%m/%Y") %> + + <% else %> + + <%= contribution.contribution_formula.name %> + + <% end %> +
    + <% end %> +
    + <% else %> + Aucune cotisation active + <% end %> +
    +
    diff --git a/app/views/shared/_membership_card_cotisation_panel.html.erb b/app/views/shared/_membership_card_cotisation_panel.html.erb new file mode 100644 index 00000000..6a1fe973 --- /dev/null +++ b/app/views/shared/_membership_card_cotisation_panel.html.erb @@ -0,0 +1,17 @@ +<%# locals: person (required), current_membership, show_contributions_block %> +
    +

    + <%= t("users.space.membership_card.section_contribution") %> +

    + <% if show_contributions_block %> + <%= render "shared/membership_card_active_contributions", person: person %> + <% elsif current_membership.present? %> +

    + <%= t("users.space.membership_card.cotisation_wrong_category") %> +

    + <% else %> +

    + <%= t("users.space.membership_card.cotisation_no_membership") %> +

    + <% end %> +
    diff --git a/app/views/shared/_membership_card_membership_panel.html.erb b/app/views/shared/_membership_card_membership_panel.html.erb new file mode 100644 index 00000000..5de4312d --- /dev/null +++ b/app/views/shared/_membership_card_membership_panel.html.erb @@ -0,0 +1,42 @@ +<%# locals: person (required), current_membership, recent_membership, membership_lapsed %> +
    +

    + <%= t("users.space.membership_card.section_membership") %> +

    + <% if current_membership.present? %> +
    + <%= t("users.space.membership_card.valid_until_label") %> + + <%= current_membership.ended_at.strftime("%d/%m/%Y") %> + +
    + +
    + <%= t("users.space.membership_card.membership_type_label") %> + + <%= current_membership.membership_type.name %> + +
    + <% elsif membership_lapsed %> +
    + <%= t("users.space.membership_card.last_membership_ended_label") %> + + <%= recent_membership.ended_at.strftime("%d/%m/%Y") %> + +
    +
    + <%= t("users.space.membership_card.membership_type_label") %> + + <%= recent_membership.membership_type.name %> + +
    +

    + <%= t("users.space.membership_card.renew_prompt") %> +

    + <% else %> +
    + <%= t("users.space.membership_card.status_label") %> + <%= t("users.space.membership_card.not_yet_member") %> +
    + <% end %> +
    diff --git a/app/views/shared/_membershipcard.html.erb b/app/views/shared/_membershipcard.html.erb index 8dc0a953..d5d1790d 100644 --- a/app/views/shared/_membershipcard.html.erb +++ b/app/views/shared/_membershipcard.html.erb @@ -1,177 +1,99 @@ -
    - -
    -
    - -
    - <%= @user.system_role.humanize %> -
    -
    +<% show_account_settings_link = local_assigns.fetch(:show_account_settings_link, true) %> +<% wrapper_classes = local_assigns.fetch(:wrapper_classes, "max-w-2xl mx-auto mb-12") %> +<% person = @user.person %> +<% current_membership = person&.current_membership %> +<% recent_membership = person&.most_recent_membership %> +<% membership_lapsed = person.present? && current_membership.blank? && recent_membership.present? %> +<% show_contributions_block = current_membership.present? && (current_membership.basic? || current_membership.circus?) %> +<% avatar_src, avatar_alt = membership_card_avatar_source_and_alt(@user) %> - -
    -
    - <% case @user.system_role %> - <% when 'super_admin' %> - <%= image_tag "super_admin.webp", alt: "Avatar Super Admin", class: "h-full w-full object-cover" %> - <% when 'admin' %> - <%= image_tag "admin.webp", alt: "Avatar Admin", class: "h-full w-full object-cover" %> - <% when 'volunteer' %> - <%= image_tag "volunteer.webp", alt: "Avatar Bénévole", class: "h-full w-full object-cover" %> - <% when 'web_visitor' %> - <%= image_tag "users.png", alt: "Avatar Utilisateur", class: "h-full w-full object-cover" %> - <% else %> - <%= image_tag "users.png", alt: "Avatar", class: "h-full w-full object-cover" %> - <% end %> -
    -
    +
    + +
    +
    +
    + <%= t("users.space.membership_card.member_number_label") %> + <%= person&.formatted_member_number %> +
    + +
    + <%= @user.role_humanized %> +
    - -
    - -
    -

    - <% if @user.full_name.present? %> - <%= @user.full_name %> - <% else %> - <%= "Membre #{@user.id}" %> - <% end %> -

    -

    <%= @user.email_address %>

    +
    +
    + <%= image_tag avatar_src, alt: avatar_alt, class: "h-full w-full object-cover" %> +
    +
    +
    - - <% begin %> - <% if @user.person&.has_active_membership? %> - <% current_membership = @user.person.current_membership %> - - <%= current_membership.status == 'active' ? "Adhésion Active" : "Adhésion Inactive" %> - - <% else %> - - Non adhérent - - <% end %> - <% rescue => e %> - - Non adhérent - - <% end %> -
    + +
    +
    +

    + <%= membership_card_display_name(@user) %> +

    +

    <%= @user.email_address %>

    - -
    - <% if @user.person&.has_active_membership? %> - <% current_membership = @user.person.current_membership %> -
    - Adhésion valable jusqu'au : - - <%= current_membership.ended_at.strftime("%d/%m/%Y") %> - -
    + <% if current_membership.present? %> + + <%= current_membership.status == 'active' ? "Adhésion active" : "Adhésion inactive" %> + + <% elsif membership_lapsed %> + + Adhésion terminée + + <% else %> + + Pas d'adhésion en cours + + <% end %> +
    -
    - Type d'adhésion : - - <%= current_membership.membership_type.name %> - -
    + +
    +
    + <%= render "shared/membership_card_membership_panel", + person: person, + current_membership: current_membership, + recent_membership: recent_membership, + membership_lapsed: membership_lapsed %> +
    +
    + <%= render "shared/membership_card_cotisation_panel", + person: person, + current_membership: current_membership, + show_contributions_block: show_contributions_block %> +
    +
    - <% if current_membership.membership_type.basic? %> -
    -
    - Carnets disponibles : - <% active_booklets = @user.person.contributions.where(status: :active) %> - <% if active_booklets.exists? %> -
    - <% active_booklets.each do |booklet| %> -
    - <% if booklet.contribution_formula.duration == "pack10" %> - <% sessions_remaining = booklet.sessions_remaining.to_i %> - - <%= booklet.contribution_formula.name %>: <%= sessions_remaining %> séances - - <% if sessions_remaining <= 3 && sessions_remaining > 0 %> - Bientôt épuisé - <% end %> - <% else %> - - <%= booklet.contribution_formula.name %> : Accès illimité - - <% end %> -
    - <% end %> -
    - <% else %> - - Aucun carnet actif - - <% end %> -
    -
    - <% elsif current_membership.membership_type.circus? %> -
    -
    - Carnets disponibles : - <% active_booklets = @user.person.contributions.where(status: :active) %> - <% if active_booklets.exists? %> -
    - <% active_booklets.each do |booklet| %> -
    - <% if booklet.contribution_formula.duration == "pack10" %> - <% sessions_remaining = booklet.sessions_remaining.to_i %> - - <%= booklet.contribution_formula.name %>: <%= sessions_remaining %> séances - - <% if sessions_remaining <= 3 && sessions_remaining > 0 %> - Bientôt épuisé - <% end %> - <% else %> - - <%= booklet.contribution_formula.name %> : Accès illimité - - <% end %> -
    - <% end %> -
    - <% else %> - - Aucun carnet actif - - <% end %> -
    -
    - <% end %> - <% else %> -
    - Statut : - Pas encore membre -
    - <% end %> - -
    - Newsletter : - - <%= @user.newsletter_subscribed ? "Inscrit" : "Non inscrit" %> - -
    -
    +
    + <%= t("users.space.newsletter_row_label") %> + + <%= @user.newsletter_subscribed ? t("users.space.newsletter_subscribed_yes") : t("users.space.newsletter_subscribed_no") %> + +
    - -
    - <% if defined?(form) && form.present? %> - <%= form.submit "Enregistrer les modifications", class: "w-full flex justify-center py-2 sm:py-2.5 px-3 sm:px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500" %> - <% else %> - <%= link_to "Paramètres du compte et notifications", settings_path, class: "block w-full text-center py-2 sm:py-2.5 px-3 sm:px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500" %> - <% end %> -
    + +
    + <% if defined?(form) && form.present? %> + <%= form.submit "Enregistrer les modifications", class: "w-full flex justify-center py-2 sm:py-2.5 px-3 sm:px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500" %> + <% else %> + <% unless current_membership.present? %> + <%= link_to(membership_lapsed ? "Renouveler mon adhésion" : "Adhérer", + page_path("become_member"), + class: "block w-full text-center py-2 sm:py-2.5 px-3 sm:px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#1F5C55]/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]") %> + <% end %> + <% if @user.has_privileges? %> + <%= link_to "Espace administration", admin_root_path, + class: "block w-full text-center py-2 sm:py-2.5 px-3 sm:px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-800 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" %> + <% end %> + <% if show_account_settings_link %> + <%= link_to t("users.space.membership_card_settings_link"), settings_path, + class: "block w-full text-center py-2 sm:py-2.5 px-3 sm:px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" %> + <% end %> + <% end %>
    +
    diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 4224d324..ee9e798e 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -1,23 +1,23 @@ <%= turbo_frame_tag "navigation", target: "_top" do %> -