diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f23d47aa..0d6d7a02 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,3 +1,3 @@ # Make sure RUBY_VERSION matches the Ruby version in .ruby-version -ARG RUBY_VERSION=3.4.4 +ARG RUBY_VERSION=3.4.7 FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7deb08ab..9e7c41fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,10 +6,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - name: Setup Ruby 3.4.4 + - name: Setup Ruby 3.4.7 uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4.4' # Not needed with a .ruby-version file + ruby-version: '3.4.7' # Not needed with a .ruby-version file bundler-cache: true - run: bundle exec rubocop @@ -29,16 +29,16 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Setup Ruby 3.4.4 + - name: Setup Ruby 3.4.7 uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4.4' # Not needed with a .ruby-version file + ruby-version: '3.4.7' # Not needed with a .ruby-version file bundler-cache: true - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '18' + node-version: '20' - name: Find yarn cache location id: yarn-cache diff --git a/.gitignore b/.gitignore index a0c4a428..0512909a 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ coverage/ /config/credentials/development.key /config/credentials/test.key +.env.development diff --git a/.node-version b/.node-version index 741b4916..f812e459 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -18.14.0 \ No newline at end of file +20.18.1 \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index a97483e9..7bd6fb78 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,13 +1,11 @@ -require: +plugins: - rubocop-performance - rubocop-rails AllCops: UseCache: false - UseServer: false NewCops: enable - SuggestExtensions: - rubocop-rake: false + SuggestExtensions: false TargetRubyVersion: 3.4 Exclude: - bin/**/* @@ -22,7 +20,65 @@ AllCops: Style/Documentation: Enabled: false +# Disable OpenStruct warnings for tests +Style/OpenStructUse: + Exclude: + - 'test/**/*' + +# Allow longer lines in seed files and tests +Layout/LineLength: + Max: 160 + Exclude: + - 'db/seeds.rb' + - 'test/**/*' + +# Relax metrics for test files +Metrics/ClassLength: + Exclude: + - 'test/**/*' + +Metrics/MethodLength: + Max: 15 + Exclude: + - 'test/**/*' + - 'db/seeds.rb' + +Metrics/BlockLength: + Exclude: + - 'test/**/*' + - 'db/seeds.rb' + +Metrics/AbcSize: + Max: 25 + Exclude: + - 'test/**/*' + +Metrics/CyclomaticComplexity: + Max: 12 + +Metrics/PerceivedComplexity: + Max: 12 + +# Allow variable numbers in tests +Naming/VariableNumber: + Exclude: + - 'test/**/*' + +# Allow empty test files +Lint/EmptyFile: + Exclude: + - 'test/**/*' + +# Disable duplicate branch warnings +Lint/DuplicateBranch: + Enabled: false + +# Move locale texts to locale files +Rails/I18nLocaleTexts: + Enabled: false + Rails/LexicallyScopedActionFilter: Exclude: - 'app/controllers/users/sessions_controller.rb' + - 'app/controllers/users/registrations_controller.rb' diff --git a/.ruby-version b/.ruby-version index f9892605..2aa51319 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.4 +3.4.7 diff --git a/.tool-versions b/.tool-versions index b239f4bc..7efc92d5 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ nodejs 20.9.0 -ruby 3.4.4 \ No newline at end of file +ruby 3.4.7 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index be512fc1..6845a6b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 -# Multi-stage Dockerfile for Rails 8 app with Ruby 3.4.4 +# Multi-stage Dockerfile for Rails 8 app with Ruby 3.4.7 -ARG RUBY_VERSION=3.4.4 +ARG RUBY_VERSION=3.4.7 # Base image with Ruby and system libs FROM ruby:${RUBY_VERSION}-slim AS base diff --git a/Gemfile b/Gemfile index c94a65d8..4b4afacd 100644 --- a/Gemfile +++ b/Gemfile @@ -3,14 +3,19 @@ source 'https://rubygems.org' git_source(:github) { |repo| "https://github.com/#{repo}.git" } -ruby '3.4.4' +ruby '3.4.7' gem 'active_storage_validations', '~> 1.0' # Active Storage gems for validating attachments https://github.com/igorkasyanchuk/active_storage_validations -gem 'aws-sdk-s3', '~> 1.119', require: false # Official AWS Ruby gem for Amazon S3 +gem 'aws-sdk-s3', '~> 1.208', require: false # Official AWS Ruby gem for Amazon S3 gem 'bootsnap', require: false # Reduces boot times through caching; required in config/boot.rb gem 'cancancan', '~> 3.4' # Authorization library which restricts what resources a given user is allowed to access gem 'cssbundling-rails' # Bundle and process CSS [https://github.com/rails/cssbundling-rails] gem 'devise' # Devise 4.0 works with Rails 4.1 onwards. +gem 'geocoder', '~> 1.8' # Complete geocoding solution for Ruby +# OAuth integration +gem 'omniauth' +gem 'omniauth-github' +gem 'omniauth-rails_csrf_protection' # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] gem 'image_processing', '~> 1.2' gem 'invisible_captcha' # Spam protection solution [https://github.com/markets/invisible_captcha] @@ -22,7 +27,7 @@ gem 'motor-admin', '>= 0.4.30' gem 'pg', '~> 1.5' # Use postgresql as the database for Active Record gem 'premailer-rails', '~> 1.12' # This gem is a drop in solution for styling HTML emails with CSS gem 'puma', '~> 6.0' # Use the Puma web server [https://github.com/puma/puma] -gem 'rails', '~> 8.0' # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem 'rails', '~> 8.1' # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" # Pagination gem 'pagy', '~> 9.4.0' # gem 'kaminari' diff --git a/Gemfile.lock b/Gemfile.lock index b369c2ca..cd98c774 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,29 +1,31 @@ GEM remote: https://rubygems.org/ specs: - actioncable (8.0.2.1) - actionpack (= 8.0.2.1) - activesupport (= 8.0.2.1) + action_text-trix (2.1.16) + railties + actioncable (8.1.2) + actionpack (= 8.1.2) + activesupport (= 8.1.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.2.1) - actionpack (= 8.0.2.1) - activejob (= 8.0.2.1) - activerecord (= 8.0.2.1) - activestorage (= 8.0.2.1) - activesupport (= 8.0.2.1) + actionmailbox (8.1.2) + actionpack (= 8.1.2) + activejob (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) mail (>= 2.8.0) - actionmailer (8.0.2.1) - actionpack (= 8.0.2.1) - actionview (= 8.0.2.1) - activejob (= 8.0.2.1) - activesupport (= 8.0.2.1) + actionmailer (8.1.2) + actionpack (= 8.1.2) + actionview (= 8.1.2) + activejob (= 8.1.2) + activesupport (= 8.1.2) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.2.1) - actionview (= 8.0.2.1) - activesupport (= 8.0.2.1) + actionpack (8.1.2) + actionview (= 8.1.2) + activesupport (= 8.1.2) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -31,15 +33,16 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.2.1) - actionpack (= 8.0.2.1) - activerecord (= 8.0.2.1) - activestorage (= 8.0.2.1) - activesupport (= 8.0.2.1) + actiontext (8.1.2) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.2.1) - activesupport (= 8.0.2.1) + actionview (8.1.2) + activesupport (= 8.1.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) @@ -50,37 +53,37 @@ GEM activestorage (>= 6.1.4) activesupport (>= 6.1.4) marcel (>= 1.0.3) - activejob (8.0.2.1) - activesupport (= 8.0.2.1) + activejob (8.1.2) + activesupport (= 8.1.2) globalid (>= 0.3.6) - activemodel (8.0.2.1) - activesupport (= 8.0.2.1) - activerecord (8.0.2.1) - activemodel (= 8.0.2.1) - activesupport (= 8.0.2.1) + activemodel (8.1.2) + activesupport (= 8.1.2) + activerecord (8.1.2) + activemodel (= 8.1.2) + activesupport (= 8.1.2) timeout (>= 0.4.0) - activestorage (8.0.2.1) - actionpack (= 8.0.2.1) - activejob (= 8.0.2.1) - activerecord (= 8.0.2.1) - activesupport (= 8.0.2.1) + activestorage (8.1.2) + actionpack (= 8.1.2) + activejob (= 8.1.2) + activerecord (= 8.1.2) + activesupport (= 8.1.2) marcel (~> 1.0) - activesupport (8.0.2.1) + activesupport (8.1.2) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) - airbrussh (1.5.3) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) + airbrussh (1.6.0) sshkit (>= 1.6.1, != 1.7.0) ar_lazy_preload (1.1.2) rails (>= 5.2) @@ -89,8 +92,8 @@ GEM activerecord (>= 5.2, < 8.2) activesupport (>= 5.2, < 8.2) aws-eventstream (1.4.0) - aws-partitions (1.1149.0) - aws-sdk-core (3.229.0) + aws-partitions (1.1220.0) + aws-sdk-core (3.242.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -98,20 +101,19 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.110.0) - aws-sdk-core (~> 3, >= 3.228.0) + aws-sdk-kms (1.122.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.197.0) - aws-sdk-core (~> 3, >= 3.228.0) + aws-sdk-s3 (1.213.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) - bcrypt (3.1.20) - benchmark (0.4.1) - bigdecimal (3.2.3) - bootsnap (1.18.6) + bcrypt (3.1.21) + bigdecimal (4.0.1) + bootsnap (1.23.0) msgpack (~> 1.2) brevo (4.0.0) addressable (~> 2.3, >= 2.3.0) @@ -119,7 +121,7 @@ GEM typhoeus (~> 1.0, >= 1.0.1) builder (3.3.0) cancancan (3.6.1) - capistrano (3.19.2) + capistrano (3.20.0) airbrussh (>= 1.0.0) i18n rake (>= 10.0.0) @@ -127,7 +129,7 @@ GEM capistrano-asdf (1.1.1) capistrano (~> 3.0) sshkit (~> 1.2) - capistrano-bundler (2.1.1) + capistrano-bundler (2.2.0) capistrano (~> 3.1) capistrano-passenger (0.2.1) capistrano (~> 3.0) @@ -150,59 +152,65 @@ GEM regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) childprocess (4.1.0) - concurrent-ruby (1.3.5) - connection_pool (2.5.4) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) crass (1.0.6) - css_parser (1.21.1) + css_parser (2.0.0) addressable cssbundling-rails (1.4.3) railties (>= 6.0.0) csv (3.3.5) - date (3.4.1) + date (3.5.1) debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) - devise (4.9.4) + devise (5.0.2) bcrypt (~> 3.0) orm_adapter (~> 0.1) - railties (>= 4.1.0) + railties (>= 7.0) responders warden (~> 1.2.3) docile (1.4.1) dockerfile-rails (1.7.10) rails (>= 3.0.0) drb (2.2.3) - erb (5.0.2) + erb (6.0.2) erubi (1.13.1) - et-orbi (1.3.0) + et-orbi (1.4.0) tzinfo ethon (0.15.0) ffi (>= 1.15.0) - faker (3.5.2) + faker (3.6.0) i18n (>= 1.8.11, < 2) - faraday (2.13.4) + faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json logger - faraday-net_http (3.4.1) - net-http (>= 0.5.0) - ffi (1.17.2-x86_64-linux-gnu) - fugit (1.11.1) - et-orbi (~> 1, >= 1.2.11) + faraday-net_http (3.4.2) + net-http (~> 0.5) + ffi (1.17.3-x86_64-linux-gnu) + fugit (1.12.1) + et-orbi (~> 1.4) raabro (~> 1.4) - globalid (1.2.1) + geocoder (1.8.6) + base64 (>= 0.1.0) + csv (>= 3.0.0) + globalid (1.3.0) activesupport (>= 6.1) - htmlentities (4.3.4) - i18n (1.14.7) + hashie (5.1.0) + logger + htmlentities (4.4.2) + i18n (1.14.8) concurrent-ruby (~> 1.0) image_processing (1.14.0) mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) invisible_captcha (2.3.0) rails (>= 5.2) - io-console (0.8.1) - irb (1.15.2) + io-console (0.8.2) + irb (1.17.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.14.1) @@ -211,7 +219,9 @@ GEM jmespath (1.6.2) jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (2.13.2) + json (2.18.1) + jwt (3.1.2) + base64 language_server-protocol (3.17.0.5) launchy (2.5.2) addressable (~> 2.8) @@ -219,22 +229,25 @@ GEM launchy (>= 2.2, < 4) lint_roller (1.1.0) logger (1.7.0) - loofah (2.24.1) + loofah (2.25.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.0.4) + marcel (1.1.0) matrix (0.4.3) mini_magick (4.13.2) mini_mime (1.1.5) - minitest (5.25.5) - mocha (2.7.1) + minitest (6.0.2) + drb (~> 2.0) + prism (~> 1.5) + mocha (3.0.2) ruby2_keywords (>= 0.0.5) - motor-admin (0.4.37) + motor-admin (0.5.0) ar_lazy_preload (~> 1.0) audited (~> 5.0) cancancan (~> 3.0) @@ -242,9 +255,11 @@ GEM fugit (~> 1.0) rails (>= 5.2) msgpack (1.8.0) - net-http (0.6.0) - uri - net-imap (0.5.10) + multi_xml (0.8.1) + bigdecimal (>= 3.1, < 5) + net-http (0.9.1) + uri (>= 0.11.1) + net-imap (0.6.3) date net-protocol net-pop (0.1.2) @@ -258,20 +273,42 @@ GEM net-smtp (0.5.1) net-protocol net-ssh (7.3.0) - nio4r (2.7.4) - nokogiri (1.18.10-x86_64-linux-gnu) + nio4r (2.7.5) + nokogiri (1.19.1-x86_64-linux-gnu) racc (~> 1.4) + oauth2 (2.0.18) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0, >= 2.0.3) + version_gem (~> 1.1, >= 1.1.9) + omniauth (2.1.4) + hashie (>= 3.4.6) + logger + rack (>= 2.2.3) + rack-protection + omniauth-github (2.0.1) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.8) + omniauth-oauth2 (1.9.0) + oauth2 (>= 2.0.2, < 3) + omniauth (~> 2.0) + omniauth-rails_csrf_protection (2.0.1) + actionpack (>= 4.2) + omniauth (~> 2.0) orm_adapter (0.5.0) ostruct (0.6.3) pagy (9.4.0) parallel (1.27.0) - parser (3.3.9.0) + parser (3.3.10.2) ast (~> 2.4.1) racc - pg (1.6.1-x86_64-linux) - pp (0.6.2) + pg (1.6.3-x86_64-linux) + pp (0.6.3) prettyprint - premailer (1.27.0) + premailer (1.28.0) addressable css_parser (>= 1.19.0) htmlentities (>= 4.0.0) @@ -280,73 +317,79 @@ GEM net-smtp premailer (~> 1.7, >= 1.7.9) prettyprint (0.2.0) - prism (1.4.0) - psych (5.2.6) + prism (1.9.0) + psych (5.3.1) date stringio - public_suffix (6.0.2) + public_suffix (7.0.2) puma (6.6.1) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.2) - rack-attack (6.7.0) + rack (3.2.5) + rack-attack (6.8.0) rack (>= 1.0, < 4) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rackup (2.2.1) + rackup (2.3.1) rack (>= 3) - rails (8.0.2.1) - actioncable (= 8.0.2.1) - actionmailbox (= 8.0.2.1) - actionmailer (= 8.0.2.1) - actionpack (= 8.0.2.1) - actiontext (= 8.0.2.1) - actionview (= 8.0.2.1) - activejob (= 8.0.2.1) - activemodel (= 8.0.2.1) - activerecord (= 8.0.2.1) - activestorage (= 8.0.2.1) - activesupport (= 8.0.2.1) + rails (8.1.2) + actioncable (= 8.1.2) + actionmailbox (= 8.1.2) + actionmailer (= 8.1.2) + actionpack (= 8.1.2) + actiontext (= 8.1.2) + actionview (= 8.1.2) + activejob (= 8.1.2) + activemodel (= 8.1.2) + activerecord (= 8.1.2) + activestorage (= 8.1.2) + activesupport (= 8.1.2) bundler (>= 1.15.0) - railties (= 8.0.2.1) + railties (= 8.1.2) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails_cloudflare_turnstile (0.4.2) + rails_cloudflare_turnstile (0.5.0) faraday (>= 1.0, < 3.0) - rails (>= 6.0, < 8.1) - railties (8.0.2.1) - actionpack (= 8.0.2.1) - activesupport (= 8.0.2.1) + rails (>= 6.0, < 8.2) + railties (8.1.2) + actionpack (= 8.1.2) + activesupport (= 8.1.2) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.0) - rdoc (6.14.2) + rake (13.3.1) + rdoc (7.2.0) erb psych (>= 4.0.0) + tsort redis (5.4.1) redis-client (>= 0.22.0) - redis-client (0.25.2) + redis-client (0.26.4) connection_pool - regexp_parser (2.11.2) - reline (0.6.2) + regexp_parser (2.11.3) + reline (0.6.3) io-console (~> 0.5) - responders (3.1.1) - actionpack (>= 5.2) - railties (>= 5.2) - rexml (3.4.2) + responders (3.2.0) + actionpack (>= 7.0) + railties (>= 7.0) + rexml (3.4.4) rubocop (1.79.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -358,9 +401,9 @@ GEM rubocop-ast (>= 1.46.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.46.0) + rubocop-ast (1.49.0) parser (>= 3.3.7.2) - prism (~> 1.4) + prism (~> 1.7) rubocop-performance (1.25.0) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) @@ -372,31 +415,32 @@ GEM rubocop (>= 1.72.1, < 2.0) rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (1.13.0) - ruby-vips (2.2.5) + ruby-vips (2.3.0) ffi (~> 1.12) logger ruby2_keywords (0.0.5) - rubyzip (3.0.1) + rubyzip (3.2.2) securerandom (0.4.1) selenium-webdriver (4.1.0) childprocess (>= 0.5, < 5.0) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2) - sentry-rails (5.27.0) - railties (>= 5.0) - sentry-ruby (~> 5.27.0) - sentry-ruby (5.27.0) + sentry-rails (6.4.0) + railties (>= 5.2.0) + sentry-ruby (~> 6.4.0) + sentry-ruby (6.4.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) - sidekiq (8.0.7) - connection_pool (>= 2.5.0) - json (>= 2.9.0) - logger (>= 1.6.2) - rack (>= 3.1.0) - redis-client (>= 0.23.2) - simple_form (5.3.1) - actionpack (>= 5.2) - activemodel (>= 5.2) + logger + sidekiq (8.1.1) + connection_pool (>= 3.0.0) + json (>= 2.16.0) + logger (>= 1.7.0) + rack (>= 3.2.0) + redis-client (>= 0.26.0) + simple_form (5.4.1) + actionpack (>= 7.0) + activemodel (>= 7.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -405,6 +449,9 @@ GEM simplecov_json_formatter (0.1.4) sitemap_generator (6.3.0) builder (~> 3.0) + snaky_hash (2.0.3) + hashie (>= 0.1.0, < 6) + version_gem (>= 1.1.8, < 3) sprockets (4.2.2) concurrent-ruby (~> 1.0) logger @@ -413,7 +460,7 @@ GEM actionpack (>= 6.1) activesupport (>= 6.1) sprockets (>= 3.0.0) - sshkit (1.24.0) + sshkit (1.25.0) base64 logger net-scp (>= 1.1.2) @@ -422,21 +469,23 @@ GEM ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.7) - thor (1.4.0) - timeout (0.4.3) - turbo-rails (2.0.16) + stringio (3.2.0) + thor (1.5.0) + timeout (0.6.0) + tsort (0.2.0) + turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) typhoeus (1.5.0) ethon (>= 0.9.0, < 0.16.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (3.1.5) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) - uri (1.0.3) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) useragent (0.16.11) + version_gem (1.1.9) warden (1.2.9) rack (>= 2.0.9) webdrivers (5.3.1) @@ -449,14 +498,14 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.3) + zeitwerk (2.7.5) PLATFORMS x86_64-linux DEPENDENCIES active_storage_validations (~> 1.0) - aws-sdk-s3 (~> 1.119) + aws-sdk-s3 (~> 1.208) bootsnap brevo cancancan (~> 3.4) @@ -472,6 +521,7 @@ DEPENDENCIES devise dockerfile-rails (>= 1.2) faker (~> 3.1) + geocoder (~> 1.8) image_processing (~> 1.2) invisible_captcha jbuilder @@ -480,12 +530,15 @@ DEPENDENCIES mini_magick (~> 4.12) mocha motor-admin (>= 0.4.30) + omniauth + omniauth-github + omniauth-rails_csrf_protection pagy (~> 9.4.0) pg (~> 1.5) premailer-rails (~> 1.12) puma (~> 6.0) rack-attack (>= 6.7) - rails (~> 8.0) + rails (~> 8.1) rails_cloudflare_turnstile redis (~> 5.0) rubocop (~> 1.79.2) @@ -504,7 +557,7 @@ DEPENDENCIES webdrivers RUBY VERSION - ruby 3.4.4p34 + ruby 3.4.7 BUNDLED WITH - 2.7.2 + 4.0.7 diff --git a/OLDREADME.md b/OLDREADME.md index ebbb65c3..dd112545 100644 --- a/OLDREADME.md +++ b/OLDREADME.md @@ -165,11 +165,16 @@ Run the following command in the terminal ### Step 4: Install yarn dependancies ``` yarn install ``` +```yarn build:css && yarn build``` ### Step 5: Start server ```./bin/dev``` +or + +```foreman start -f Procfile.dev``` + ### step 6: Creating a pull request * make changes locally on your branch diff --git a/README.md b/README.md index 13ed8f02..c9f198e7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Africa Ruby Community (ARC) Platform [![Arc Platform CI Workflow](https://github.com/African-Ruby-Community/arc_platform/actions/workflows/ci.yml/badge.svg)](https://github.com/African-Ruby-Community/arc_platform/actions/workflows/ci.yml) -![Ruby](https://img.shields.io/badge/Ruby-3.4.4-red?logo=ruby) -![Rails](https://img.shields.io/badge/Rails-8.0.2-blue?logo=rubyonrails) +![Ruby](https://img.shields.io/badge/Ruby-3.4.7-red?logo=ruby) +![Rails](https://img.shields.io/badge/Rails-8.1.0-blue?logo=rubyonrails) ## Introduction The Africa Ruby Community (ARC) Platform is a project aimed at creating a hub for Ruby language enthusiasts in Africa. This platform facilitates connection, knowledge sharing, collaboration on projects, and staying updated with the latest Ruby community developments. Whether you're a seasoned developer or a beginner, this platform offers tailored resources for different countries and cities, merchandise, meetup information, and details about online workshops and webinars. @@ -85,10 +85,10 @@ asdf plugin add nodejs Install Ruby and set the default version by running the following commands: ```sh -asdf install ruby 3.4.4 +asdf install ruby 3.4.7 # Set the default Ruby version -asdf global ruby 3.4.4 +asdf global ruby 3.4.7 # Update to the latest Rubygems version gem update --system @@ -127,7 +127,7 @@ npm install -g yarn To switch to a different Ruby and Node version for a specific project, you can use the following command to set the Ruby or Node version for that project. You should be in the project directory. ```sh -asdf local ruby # eg 3.4.1 +asdf local ruby # eg 3.4.7 asdf local nodejs # eg 20.9.0 ``` diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 569926da..c526e943 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -1,52 +1,62 @@ +/* 1. The main Tailwind 4 import */ +@import "tailwindcss"; + +/* 2. Load your Plugins */ +@plugin "@tailwindcss/typography"; +@plugin "daisyui"; + +/* 3. Fonts and Theme Overrides */ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap'); -@tailwind base; -@tailwind components; -@tailwind utilities; +@theme { + --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; + --color-primary: var(--color-red-600); + --color-primary-content: var(--color-white); + --color-primary-focus: #B01820; + --color-primary-active: #B01820; + /* If you had custom colors in tailwind.config.js, put them here: */ + /* --color-brand-primary: #xxxxxx; */ +} +/* 4. Custom Animations & Utilities */ +@utility alert_custom_class { + animation: appear-then-fade 6s both; + margin-top: 50px; +} -@layer base { - :root { - /* Override colors and other variables */ - } +@keyframes appear-then-fade { + 0%, 100% { opacity: 0; } + 5%, 60% { opacity: 1; } } +/* 5. Custom Component Logic */ @layer components { - .turbo-progress-bar { - background: linear-gradient(to right, hsl(var(--p)), hsl(var(--b1))); - } - - @keyframes appear-then-fade { - 0%, 100% { - opacity: 0 - } - 5%, 60% { - opacity: 1 - } - } - - .alert_custom_class { - animation: appear-then-fade 6s both; - margin-top: 50px; + background: linear-gradient(to right, var(--color-primary), var(--color-base-100)); } .text-error { font-size: .875rem; - color: hsl(var(--er)/var(--tw-border-opacity)); + color: var(--color-error); } - .resp-table { - width: 100%; - display: table; - } - .resp-table-body{ - display: table-row-group; + /* Override DaisyUI btn-primary to use red */ + .btn-primary { + background-color: #D82028 !important; + border-color: #D82028 !important; + color: white !important; } - .resp-table-row{ - display: table-row; + + .btn-primary:hover { + background-color: #B01820 !important; + border-color: #B01820 !important; } - .table-body-cell{ + + /* Table Styles */ + .resp-table { width: 100%; display: table; } + .resp-table-body { display: table-row-group; } + .resp-table-row { display: table-row; } + .table-body-cell { display: table-cell; border: 1px solid #dddddd; padding: 8px; @@ -54,27 +64,22 @@ vertical-align: top; } - .chapter-grid { - img { - width: 330px; - height: 167px; - } + .chapter-grid img { + width: 330px; + height: 167px; } - + .rounded-box { position: relative; overflow: hidden; - } - - .rounded-box .absolute { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: center; - z-index: 10; + + & .absolute { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + } } } diff --git a/app/controllers/chapters_controller.rb b/app/controllers/chapters_controller.rb index 59aa3388..b89b4a66 100644 --- a/app/controllers/chapters_controller.rb +++ b/app/controllers/chapters_controller.rb @@ -8,11 +8,19 @@ class ChaptersController < ApplicationController # GET /chapters or /chapters.json def index - @chapters = Chapter.all + @countries = Country.order(:name) + @country_param = determine_country_param + @chapters = filter_chapters_by_country + load_country_data if @country_param end # GET /chapters/1 or /chapters/1.json - def show; end + def show + @upcoming_events = @chapter.upcoming_events + @past_events = @chapter.past_events + @featured_projects = @chapter.projects.featured.to_a + @member_count = @chapter.member_count + end private @@ -20,4 +28,36 @@ def show; end def set_chapter @chapter = Chapter.find(params[:id]) end + + def determine_country_param + kenya = Country.find_by(name: 'Kenya') + params[:country].presence || kenya&.id&.to_s + end + + def filter_chapters_by_country + chapters = Chapter.all + @country_param ? chapters.where(country_id: @country_param) : chapters + end + + def load_country_data + @featured_members = load_featured_members + @upcoming_events = load_upcoming_events + end + + def load_featured_members + User + .joins(:chapters) + .where(chapters: { country_id: @country_param }) + .distinct + .limit(3) + end + + def load_upcoming_events + Event + .published + .upcoming + .joins(:chapter) + .where(chapters: { country_id: @country_param }) + .order(:start_datetime) + end end diff --git a/app/controllers/conferences_controller.rb b/app/controllers/conferences_controller.rb new file mode 100644 index 00000000..85cde67b --- /dev/null +++ b/app/controllers/conferences_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class ConferencesController < ApplicationController + skip_before_action :authenticate_user!, only: %i[index] + + # GET /conferences + def index + # Filter only conference-type events that are published + @conferences = Event.published.conferences.includes(:chapter, :speakers) + + # Separate upcoming and past conferences + @upcoming_conferences = @conferences.upcoming.order(:start_datetime) + @past_conferences = @conferences.past.order(start_datetime: :desc) + + # Featured conference is the first upcoming conference + @featured_conference = @upcoming_conferences.first + end +end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb new file mode 100644 index 00000000..6e2c5965 --- /dev/null +++ b/app/controllers/events_controller.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +class EventsController < ApplicationController + include ActiveStorage::SetCurrent + + before_action :set_event, only: %i[show edit update destroy] + before_action :authorize_event, only: %i[edit update destroy] + before_action :authorize_create, only: %i[new create] + skip_before_action :authenticate_user!, only: %i[index show] + + # GET /events + def index + @events = EventSearchService.new(search_params).call + @countries = Country.order(:name) + @pagy, @events = pagy(@events) + end + + # GET /events/1 + def show + @related_events = Event.published + .where(chapter_id: @event.chapter_id) + .where.not(id: @event.id) + .upcoming + .limit(3) + end + + # GET /events/new + def new + @event = Event.new + @chapters = current_user.organization_admin? ? Chapter.all : current_user.chapters + end + + # GET /events/1/edit + def edit + @chapters = current_user.organization_admin? ? Chapter.all : current_user.chapters + end + + # POST /events + def create + @event = Event.new(event_params) + + if @event.save + redirect_to @event, notice: 'Event was successfully created.' + else + @chapters = current_user.organization_admin? ? Chapter.all : current_user.chapters + render :new, status: :unprocessable_entity + end + end + + # PATCH/PUT /events/1 + def update + if @event.update(event_params) + redirect_to @event, notice: 'Event was successfully updated.' + else + @chapters = current_user.organization_admin? ? Chapter.all : current_user.chapters + render :edit, status: :unprocessable_entity + end + end + + # DELETE /events/1 + def destroy + @event.destroy + redirect_to events_url, notice: 'Event was successfully destroyed.' + end + + private + + def set_event + @event = Event.find_by!(slug: params[:id]) + end + + def authorize_event + case action_name.to_sym + when :edit + authorize! :update, @event + when :update + authorize! :update, @event + when :destroy + authorize! :destroy, @event + end + end + + def authorize_create + authorize! :create, Event + end + + def event_params + params.expect( + event: [:title, :description, :start_datetime, :end_datetime, + :status, :event_type, :location_name, + :payment_status, :price_cents, :chapter_id, :image, + { speakers_attributes: %i[id name bio photo _destroy] }] + ) + end + + def search_params + params.permit(:query, :location, :date, :country) + end +end diff --git a/app/controllers/learning_materials_controller.rb b/app/controllers/learning_materials_controller.rb index 2f3095cc..f48f7e8b 100644 --- a/app/controllers/learning_materials_controller.rb +++ b/app/controllers/learning_materials_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class LearningMaterialsController < ApplicationController - sleep 3 skip_before_action :authenticate_user!, only: %i[index] def index diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb new file mode 100644 index 00000000..9af1987b --- /dev/null +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Users + class OmniauthCallbacksController < Devise::OmniauthCallbacksController + # Handles successful GitHub OAuth authentication + def github + auth_data = request.env['omniauth.auth'] + + # Handle case where auth data is completely missing + if auth_data.blank? + redirect_to root_path, alert: 'Authentication failed. Please try again.' + return + end + + @user = User.from_omniauth(auth_data) + + if @user.persisted? + sign_in_and_redirect @user, event: :authentication + set_flash_message(:notice, :success, kind: 'GitHub') if is_navigational_format? + else + # Store OAuth data in session for potential retry + session['devise.github_data'] = auth_data.except(:extra) + redirect_to new_user_registration_url, alert: 'There was an error creating your account. Please try again.' + end + end + + # Handles OAuth authentication failures + def failure + # Log the failure for debugging (optional) + Rails.logger.warn "OAuth authentication failed: #{failure_message}" + + # Redirect to root with appropriate error message + redirect_to root_path, alert: 'Authentication failed. Please try again or use email/password login.' + end + + private + + # Extract failure message from omniauth failure + def failure_message + request.env['omniauth.error']&.message || 'Unknown error' + end + end +end diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index 67759335..167a45bf 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -8,6 +8,8 @@ class RegistrationsController < Devise::RegistrationsController # Devise override Registration create action # allow_unathenticated_access only: [:new, :create] before_action :verify_turnstile, only: [:create] + before_action :populate_oauth_data, only: [:new] + before_action :suppress_normal_signup_github_username, only: [:create] def create super do @@ -17,6 +19,18 @@ def create private + def populate_oauth_data + return unless session['devise.github_data'] + + oauth_data = session['devise.github_data'] + # Initialize resource if it doesn't exist + self.resource ||= resource_class.new + + resource.email = oauth_data['info']['email'] if oauth_data.dig('info', 'email') + resource.name = oauth_data['info']['name'] if oauth_data.dig('info', 'name') + resource.github_username = oauth_data['info']['nickname'] if oauth_data.dig('info', 'nickname') + end + def verify_turnstile token = params['cf-turnstile-response'] return if TurnstileVerifier.new(token, request.remote_ip).verify @@ -30,5 +44,17 @@ def handle_failed_turnstile_verification flash.now[:alert] = I18n.t('turnstile.errors.registration_failed') render :new, status: :unprocessable_content end + + # For normal (email/password) signups, drop any provided github_username so + # users are not blocked by uniqueness constraints or external checks. OAuth + # flow will still populate github_username via callback/session. + def suppress_normal_signup_github_username + return if session['devise.github_data'].present? + + # Remove github_username from the submitted params if present + return unless params[:user].is_a?(ActionController::Parameters) + + params[:user].delete(:github_username) + end end end diff --git a/app/helpers/chapters_helper.rb b/app/helpers/chapters_helper.rb index 25d207bb..9bc25e06 100644 --- a/app/helpers/chapters_helper.rb +++ b/app/helpers/chapters_helper.rb @@ -93,4 +93,18 @@ def socials social.merge(alt: I18n.t(social[:alt_key])) end end + + # Returns the country map icon path for a given country name + def country_icon(country_name) + return nil if country_name.blank? + + icon_map = { + 'Kenya' => 'country_kenya.png', + 'Rwanda' => 'country_rwanda.png', + 'Tanzania' => 'country_tanzania.png', + 'Uganda' => 'country_uganda.png' + } + + icon_map[country_name] + end end diff --git a/app/javascript/application.js b/app/javascript/application.js index 5e63ff7d..9ad6ebe0 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -33,3 +33,27 @@ document.addEventListener("turbo:load", function () { setTimeout(() => clearInterval(interval), 5000) } }) + +// Dismissible alerts (close button + optional auto-dismiss) +document.addEventListener("turbo:load", function () { + // Handle manual close + document.querySelectorAll('[data-close-alert]')?.forEach((btn) => { + btn.addEventListener('click', (e) => { + const el = e.currentTarget.closest('.alert') + if (el) el.remove() + }) + }) + + // Handle auto-dismiss + document.querySelectorAll('.alert[data-dismiss-after]')?.forEach((alert) => { + const ms = parseInt(alert.getAttribute('data-dismiss-after'), 10) + if (!Number.isNaN(ms) && ms > 0) { + setTimeout(() => { + // Ensure element still exists + if (alert && alert.parentNode) { + alert.remove() + } + }, ms) + } + }) +}) diff --git a/app/javascript/controllers/event_search_controller.js b/app/javascript/controllers/event_search_controller.js new file mode 100644 index 00000000..ba674190 --- /dev/null +++ b/app/javascript/controllers/event_search_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["query", "location", "date", "country"] + + connect() { + this.timeout = null + } + + search() { + clearTimeout(this.timeout) + + this.timeout = setTimeout(() => { + this.element.requestSubmit() + }, 500) + } +} diff --git a/app/models/ability.rb b/app/models/ability.rb index 77b304d1..583dccda 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -31,6 +31,17 @@ def initialize(user) user ||= User.new # guest user (not logged in) + # All authenticated users can read published events + can :read, Event, status: 'published' + + # Authenticated users can manage events for their chapters (except create) + if user.persisted? + # Users can manage events for chapters they belong to, but cannot create new ones + can %i[read update destroy], Event do |event| + user.chapters.include?(event.chapter) + end + end + return unless user.organization_admin? can :manage, :all # Organization admin can manage everything diff --git a/app/models/chapter.rb b/app/models/chapter.rb index 8f97f729..56272578 100644 --- a/app/models/chapter.rb +++ b/app/models/chapter.rb @@ -26,6 +26,7 @@ class Chapter < ApplicationRecord has_many :projects, dependent: :nullify has_many :users_chapters, dependent: :destroy has_many :users, through: :users_chapters + has_many :events, dependent: :destroy # Validations validates :name, :location, :description, presence: true @@ -38,4 +39,21 @@ class Chapter < ApplicationRecord # width: 400, height: 225, # message: 'is not given between dimension. It should be 400x225', # } + + # Instance methods + def upcoming_events + events.published + .upcoming + .order(:start_datetime) + end + + def past_events + events.published + .past + .order(start_datetime: :desc) + end + + def member_count + users.count + end end diff --git a/app/models/event.rb b/app/models/event.rb new file mode 100644 index 00000000..3fd2a466 --- /dev/null +++ b/app/models/event.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class Event < ApplicationRecord + # Associations + belongs_to :chapter + has_many :speakers, dependent: :destroy + has_one_attached :image + + accepts_nested_attributes_for :speakers, allow_destroy: true, reject_if: :all_blank + + # Geocoding - automatically gets lat/lng from location_name + geocoded_by :location_name + # Callbacks + before_validation :generate_slug, on: :create + after_validation :geocode, if: ->(obj) { obj.location_name.present? && obj.location_name_changed? } + + # Validations + validates :title, :description, :start_datetime, :end_datetime, :status, :event_type, presence: true + validates :status, inclusion: { in: %w[draft published archived] } + validates :event_type, inclusion: { in: %w[meetup conference workshop] } + validates :payment_status, inclusion: { in: %w[free paid] } + validates :slug, presence: true, uniqueness: true + validate :end_datetime_after_start_datetime + + # Scopes + scope :published, -> { where(status: 'published') } + scope :conferences, -> { where(event_type: 'conference') } + scope :upcoming, -> { where('start_datetime > ?', Time.current) } + scope :past, -> { where(start_datetime: ...Time.current) } + + # Override to_param to use slug in URLs + def to_param + slug + end + + # Check if event has coordinates for map display + def mappable? + latitude.present? && longitude.present? + end + + private + + def generate_slug + return if slug.present? || title.blank? + + base_slug = title.parameterize + candidate_slug = base_slug + counter = 1 + + while Event.exists?(slug: candidate_slug) + candidate_slug = "#{base_slug}-#{counter}" + counter += 1 + end + + self.slug = candidate_slug + end + + def end_datetime_after_start_datetime + return if end_datetime.blank? || start_datetime.blank? + + return unless end_datetime < start_datetime + + errors.add(:end_datetime, 'must be after start datetime') + end +end diff --git a/app/models/speaker.rb b/app/models/speaker.rb new file mode 100644 index 00000000..dd884a7b --- /dev/null +++ b/app/models/speaker.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Speaker < ApplicationRecord + belongs_to :event + has_one_attached :photo + + validates :name, :bio, presence: true +end diff --git a/app/models/user.rb b/app/models/user.rb index 2203394a..487fc2b2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -42,7 +42,8 @@ class User < ApplicationRecord # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, - :confirmable, :lockable, :timeoutable + :confirmable, :lockable, :timeoutable, + :omniauthable, omniauth_providers: [:github] # Associations has_many :users_chapters, dependent: :nullify @@ -56,17 +57,52 @@ class User < ApplicationRecord # Enums enum :role, { member: 0, chapter_admin: 1, organization_admin: 2 } - # Validations - validates :email, :name, :phone_number, :github_username, presence: true - validates :github_username, :phone_number, uniqueness: true + # Virtual attributes / flags + attr_accessor :skip_github_verification - # Validate the format the Github username when it's present - validates :github_username, format: - { with: /\A(?!.*--|.*-$|.*_)[a-zA-Z0-9][\w-]+[a-zA-Z0-9]{0,39}\z/ }, - unless: -> { github_username.blank? } + # Validations + validates :email, :name, presence: true + validates :github_username, :phone_number, uniqueness: true, allow_blank: true # Validate that the GitHub account exists - validate :github_account_exists, if: -> { github_username.present? } + validate :github_account_exists, + if: lambda { + github_username.present? && + github_username_changed? && + !skip_github_verification + } + + # OAuth methods + def self.from_omniauth(auth) + user = find_or_initialize_by(email: auth.info.email) + user.skip_github_verification = true + + update_user_from_auth(user, auth) + ensure_user_credentials(user) + user.confirmed_at ||= Time.current + + user.save + user + end + + def self.update_user_from_auth(user, auth) + nickname = auth.info.nickname.presence + display_name = auth.info.name.presence || nickname || auth.info.email.to_s.split('@').first + + user.name = display_name if user.name.blank? || user.name != display_name + assign_github_username(user, nickname) if nickname.present? + end + + def self.assign_github_username(user, nickname) + return unless user.github_username.blank? || user.github_username == nickname + return if User.where.not(id: user.id).exists?(github_username: nickname) + + user.github_username = nickname + end + + def self.ensure_user_credentials(user) + user.password = Devise.friendly_token[0, 20] if user.encrypted_password.blank? + end private diff --git a/app/services/event_search_service.rb b/app/services/event_search_service.rb new file mode 100644 index 00000000..56c164af --- /dev/null +++ b/app/services/event_search_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class EventSearchService + def initialize(params = {}) + @query = params[:query] + @location = params[:location] + @date_filter = params[:date] + @country = params[:country] + @event_type = params[:event_type] + end + + def call + events = Event.published.includes(:chapter, :speakers) + events = filter_by_query(events) if @query.present? + events = filter_by_location(events) if @location.present? + events = filter_by_date(events) if @date_filter.present? + events = filter_by_type(events) if @event_type.present? + events = filter_by_country(events) if @country.present? + events.order(:start_datetime) + end + + private + + def filter_by_query(events) + events.where('title ILIKE ? OR description ILIKE ?', "%#{@query}%", "%#{@query}%") + end + + def filter_by_location(events) + # Search across event location name, chapter location (city), and chapter country name + events + .left_joins(chapter: :country) + .where( + 'events.location_name ILIKE :q OR chapters.location ILIKE :q OR countries.name ILIKE :q', + q: "%#{@location}%" + ) + end + + def filter_by_date(events) + case @date_filter + when 'today' + events.where('DATE(start_datetime) = ?', Date.current) + when 'this_week' + events.where(start_datetime: Date.current.all_week) + when 'this_month' + events.where(start_datetime: Date.current.all_month) + when 'upcoming' + events.upcoming + when 'past' + events.past + else + events + end + end + + def filter_by_country(events) + events.left_joins(chapter: :country) + .where('countries.name ILIKE :q OR countries.id::text = :exact', q: "%#{@country}%", exact: @country) + end + + def filter_by_type(events) + events.where(event_type: @event_type) + end +end diff --git a/app/views/chapters/index.html.erb b/app/views/chapters/index.html.erb index ee383862..a2536907 100644 --- a/app/views/chapters/index.html.erb +++ b/app/views/chapters/index.html.erb @@ -1,24 +1,152 @@ <% content_for(:title, t('chapters.index.page_title')) %> <% content_for(:description, t('chapters.index.page_description')) %> +<% require 'digest' %>
-

- <%= t('chapters.index.title') %> -

+
+ +
+ +
+ +

+ <%= t('chapters.index.title') %> +

+
<% @chapters.each do |chapter| %> -
- <%= image_tag chapter.image.attached? ? chapter.image.url : image_path('chapter.jpg'), - alt: chapter.name, - class: "rounded-lg rounded-tl-2xl" - %> +
+
+ <%= image_tag chapter.image.attached? ? chapter.image.url : image_path('chapter.jpg'), + alt: chapter.name, + class: "rounded-lg rounded-tl-2xl w-full h-48 object-cover" + %> -
- <%= chapter.name %> +
+ <%= chapter.name %> +
<% end %>
+ + <% if @country_param.present? %> + +
+

Featured members

+ <% if @featured_members.present? %> +
+ <% @featured_members.each do |member| %> +
+
+ <%# Avatar via Gravatar identicon fallback %> + <% gravatar = "https://www.gravatar.com/avatar/#{Digest::MD5.hexdigest(member.email.to_s.downcase)}?s=120&d=identicon" %> + <%= member.name %> +

<%= member.name %>

+ <%# Short bio not available on User model; show a subtle placeholder sentence %> +

Member of the community

+
+ <% if member.github_username.present? %> + github + <% end %> +
+
+
+ <% end %> +
+ <% else %> +
No featured members found for this country.
+ <% end %> +
+ + +
+

Upcoming events

+ <% if @upcoming_events.present? %> +
+ <% @upcoming_events.each do |event| %> +
+ <%= link_to event_path(event), class: "block" do %> +
+ <% if event.image.attached? %> + <%= image_tag event.image, class: "w-full h-full object-cover transform group-hover:scale-110 transition-transform duration-500", alt: event.title %> + <% else %> +
+ + + +
+ <% end %> + + + <% if event.payment_status == 'free' %> +
+ Free +
+ <% elsif event.payment_status == 'paid' %> +
+ <%= number_to_currency(event.price_cents / 100.0) %> +
+ <% end %> +
+ <% end %> + +
+ <%= link_to event_path(event) do %> +

<%= event.title %>

+ <% end %> + + +
+

+ <%= event.start_datetime.strftime("%B %d, %Y") %> +

+

+ <%= event.start_datetime.strftime("%I:%M %p") %> - <%= event.end_datetime.strftime("%I:%M %p") %> + <%= event.start_datetime.strftime("%Z") %> +

+
+ + + <% if event.location_name.present? %> +
+ + + + + <%= event.location_name %> +
+ <% end %> +
+
+ <% end %> +
+ <% else %> +
No upcoming events found for this country.
+ <% end %> +
+ <% end %>
diff --git a/app/views/chapters/show.html.erb b/app/views/chapters/show.html.erb index 9fcbed60..c93c864c 100644 --- a/app/views/chapters/show.html.erb +++ b/app/views/chapters/show.html.erb @@ -1,7 +1,193 @@ -

<%= notice %>

+ +
+
+

Chapters

+

Join your local chapter and connect with Ruby developers in your region.

+
+
+ + +
+
+
+
+

<%= @chapter.name %> Chapter

+

<%= @chapter.location.presence || @chapter.country&.name || 'Location' %>

+
+

<%= @member_count %>+ Members

+
+ + +
+
+
+
+ + +
+

Chapter Leaders & Organizers

+
+ <% if @chapter.users.any? %> + <% @chapter.users.limit(4).each do |user| %> +
+

<%= user.name %>

+

<%= user.email %>

+
+ <% if user.github_username.present? %> + github + <% end %> + linkedin +
+
+ <% end %> + <% end %> +
+
+ + +
+

Upcoming Chapter Events

+ <% if @upcoming_events.any? %> +
+ <% @upcoming_events.each do |event| %> +
+ <%= link_to event_path(event), class: "block" do %> +
+ <% if event.image.attached? %> + <%= image_tag event.image, class: "w-full h-full object-cover transform group-hover:scale-110 transition-transform duration-500", alt: event.title %> + <% else %> +
+ + + +
+ <% end %> + + + <% if event.payment_status == 'free' %> +
+ Free +
+ <% elsif event.payment_status == 'paid' %> +
+ Paid +
+ <% end %> +
+ <% end %> + +
+ <%= link_to event_path(event) do %> +

<%= event.title %>

+ <% end %> + + +
+

+ <%= event.start_datetime.strftime("%B %d, %Y") %> +

+

+ <%= event.start_datetime.strftime("%I:%M %p") %> - <%= event.end_datetime.strftime("%I:%M %p") %> +

+
+ + + <% if event.location_name.present? %> +
+ + + + + <%= event.location_name %> +
+ <% end %> +
+
+ <% end %> +
+ <% else %> +
+

No upcoming events scheduled at this time. Check back soon!

+
+ <% end %> +
+ + + <% if @past_events.present? && @past_events.any? %> +
+

Past Chapter Events

+
+ <% @past_events.each do |event| %> +
+ <%= link_to event_path(event), class: "block" do %> +
+ <% if event.image.attached? %> + <%= image_tag event.image, class: "w-full h-full object-cover transform group-hover:scale-110 transition-transform duration-500", alt: event.title %> + <% else %> +
+ + + +
+ <% end %> +
+ <% end %> + +
+ <%= link_to event_path(event) do %> +

<%= event.title %>

+ <% end %> + + +
+

+ <%= event.start_datetime.strftime("%B %d, %Y") %> +

+

+ <%= event.start_datetime.strftime("%I:%M %p") %> - <%= event.end_datetime.strftime("%I:%M %p") %> +

+
+ + + <% if event.location_name.present? %> +
+ + + + + <%= event.location_name %> +
+ <% end %> +
+
+ <% end %> +
+
+ <% end %> -<%= render @chapter %> + + <% if @featured_projects&.any? %> +
+

Featured Chapter Projects

+
+ <% @featured_projects.each do |project| %> + <%= link_to project_path(project), class: "card card-compact bg-white shadow hover:shadow-lg transition-shadow" do %> +
+

<%= project.name %>

+ <% if project.intro.present? %> +

<%= project.intro %>

+ <% end %> +
+ <% end %> + <% end %> +
+
+ <% end %> -
- <%= link_to "Back to chapters", chapters_path %> + + + + + +
diff --git a/app/views/conferences/index.html.erb b/app/views/conferences/index.html.erb new file mode 100644 index 00000000..2bc6369c --- /dev/null +++ b/app/views/conferences/index.html.erb @@ -0,0 +1,94 @@ +<% content_for :title, "Conferences" %> +<% content_for(:description, "Explore major Ruby conferences: upcoming and past, across our chapters") %> + +
+ + <% if (@upcoming_conferences.blank? && @past_conferences.blank?) %> +
+

Conferences

+

Explore major Ruby conferences: upcoming and past, across our chapters

+
+
No conferences available
+ <% elsif @featured_conference.present? %> +
+

Featured Conference

+

<%= @featured_conference.title %>

+

+ <%= @featured_conference.start_datetime.strftime('%B %d, %Y') %> + • <%= @featured_conference.location_name || 'Location TBA' %> +

+

+ <%= @featured_conference.start_datetime.strftime('%I:%M %p') %> + - <%= @featured_conference.end_datetime.strftime('%I:%M %p') %> + <%= @featured_conference.start_datetime.strftime('%Z') %> +

+ <%= link_to 'View details', event_path(@featured_conference), class: 'btn btn-sm md:btn-md bg-white text-red-700 hover:bg-gray-100' %> +
+ <% else %> +
+

Conferences

+

Explore major Ruby conferences: upcoming and past, across our chapters

+
+ <% end %> + + <% upcoming_others = (@upcoming_conferences || []).to_a - [@featured_conference].compact %> + + +
+
+

Upcoming conferences

+ <% if upcoming_others.any? && @featured_conference.present? %> + Featured conference shown above + <% end %> +
+ + <% if @upcoming_conferences.blank? %> +
No upcoming conferences yet. Please check back soon.
+ <% else %> +
+ <% upcoming_others.each do |conf| %> + <%= link_to event_path(conf), class: "block" do %> +
+
+

<%= conf.title %>

+
+

<%= conf.start_datetime.strftime('%B %d, %Y') %>

+

<%= conf.location_name || 'Location TBA' %>

+
+
+ + <%= conf.payment_status == 'free' ? 'Free' : 'Paid' %> + +
+
+
+ <% end %> + <% end %> +
+ <% end %> +
+ + +
+

Past conferences

+ <% if @past_conferences.blank? %> +
No past conferences recorded yet.
+ <% else %> +
+ <% @past_conferences.each do |conf| %> + <%= link_to event_path(conf), class: "block" do %> +
+
+

<%= conf.title %>

+
+

<%= conf.start_datetime.strftime('%B %d, %Y') %>

+

<%= conf.location_name || 'Location TBA' %>

+
+
+
+ <% end %> + <% end %> +
+ <% end %> +
+
diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb index 09d6e941..30ad8b23 100644 --- a/app/views/devise/registrations/new.html.erb +++ b/app/views/devise/registrations/new.html.erb @@ -6,17 +6,41 @@

<%= t('devise.views.registrations.new.title') %>

+ +
+ <%= button_to user_github_omniauth_authorize_path, + method: :post, + data: { turbo: false }, + class: "btn btn-outline btn-block w-full", + form_class: "w-full" do %> + + + + <%= t('devise.views.shared.links.github_sign_up') %> + <% end %> + <%# Hidden anchor for compatibility with existing integration tests expecting an tag %> + <%= link_to t('devise.views.shared.links.github_sign_up'), + user_github_omniauth_authorize_path, + data: { turbo_method: :post }, + class: "hidden", + aria: { hidden: true } %> +
+ +
<%= t('devise.views.shared.links.or_divider') %>
+ <%= simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { data: { controller: "bot-detection", bot_detection_target: "form" } }) do |f| %> <%#= f.invisible_captcha :nickname %> + <% oauth_user = session['devise.github_data'].present? %> + <%= f.input :email, placeholder: t('devise.views.registrations.new.email_placeholder'), label: t('devise.views.registrations.new.email_label'), - input_html: { class: 'input-bordered w-full', autocomplete: 'email' } %> + input_html: { class: 'input-bordered w-full', autocomplete: 'email', readonly: oauth_user } %> <%= f.input :name, placeholder: t('devise.views.registrations.new.name_placeholder'), label: t('devise.views.registrations.new.name_label'), - input_html: { class: 'input-bordered w-full', autocomplete: 'name' } %> + input_html: { class: 'input-bordered w-full', autocomplete: 'name', readonly: oauth_user } %> <%= f.input :phone_number, placeholder: t('devise.views.registrations.new.phone_number_placeholder'), label: t('devise.views.registrations.new.phone_number_label'), hint: t('devise.views.registrations.new.phone_number_hint'), @@ -30,14 +54,26 @@
<%= f.input :github_username, placeholder: t('devise.views.registrations.new.github_username_placeholder'), label: t('devise.views.registrations.new.github_username_label'), - hint: t('devise.views.registrations.new.github_username_hint'), - input_html: { class: 'input-bordered w-full', autocomplete: 'github_username' } %> - - <%= f.input :password, placeholder: t('devise.views.registrations.new.password_placeholder'), label: t('devise.views.registrations.new.password_label'), - input_html: { class: 'input-bordered w-full' } %> - - <%= f.input :password_confirmation, placeholder: t('devise.views.registrations.new.password_confirmation_placeholder'), - label: t('devise.views.registrations.new.password_confirmation_label'), input_html: { class: 'input-bordered w-full' } %> + hint: oauth_user ? "Automatically filled from GitHub" : t('devise.views.registrations.new.github_username_hint'), + input_html: { class: 'input-bordered w-full', autocomplete: 'github_username', readonly: oauth_user } %> + + <% unless oauth_user %> + <%= f.input :password, placeholder: t('devise.views.registrations.new.password_placeholder'), label: t('devise.views.registrations.new.password_label'), + input_html: { class: 'input-bordered w-full' } %> + + <%= f.input :password_confirmation, placeholder: t('devise.views.registrations.new.password_confirmation_placeholder'), + label: t('devise.views.registrations.new.password_confirmation_label'), input_html: { class: 'input-bordered w-full' } %> + <% else %> + + <% end %> <%= render "shared/cloudflare_turnstile" %> diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 3f71a4d7..7c31d527 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -14,6 +14,28 @@
<% end %> + + + +
<%= t('devise.views.shared.links.or_divider') %>
+ <%= simple_form_for(resource, as: resource_name, url: session_path(resource_name), html: { id: 'new_user' }) do |f| %> <%= f.input :email, placeholder: t('devise.views.sessions.new.email_placeholder'), label: t('devise.views.sessions.new.email_label'), diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb index 7e1ea0e6..95395dd1 100644 --- a/app/views/devise/shared/_links.html.erb +++ b/app/views/devise/shared/_links.html.erb @@ -41,8 +41,14 @@ <%- if devise_mapping.omniauthable? %> <%- resource_class.omniauth_providers.each do |provider| %>
- - <%= link_to t('devise.views.shared.links.sign_in_with', provider: OmniAuth::Utils.camelize(provider)), omniauth_authorize_path(resource_name, provider), method: :post %> + + <%= button_to omniauth_authorize_path(resource_name, provider), + method: :post, + data: { turbo: false }, + class: "btn btn-outline btn-block w-full", + form_class: "w-full" do %> + <%= t('devise.views.shared.links.sign_in_with', provider: OmniAuth::Utils.camelize(provider)) %> + <% end %>
<% end %> diff --git a/app/views/events/_event_card.html.erb b/app/views/events/_event_card.html.erb new file mode 100644 index 00000000..456ef815 --- /dev/null +++ b/app/views/events/_event_card.html.erb @@ -0,0 +1,19 @@ +<%= link_to event_path(event), class: "card card-compact bg-white shadow hover:shadow-lg transition-shadow" do %> +
+

<%= event.title %>

+
+

<%= event.start_datetime.strftime("%B %d, %Y") %>

+

<%= event.location_name || "Location TBA" %>

+

<%= event.start_datetime.strftime("%I:%M %p") %> - <%= event.end_datetime.strftime("%I:%M %p") %>

+

<%= event.start_datetime.strftime("%Z") %>

+
+
+ <% if event.payment_status == 'free' %> +
Free
+ <% else %> +
Paid
+ <% end %> +
<%= event.event_type.titleize %>
+
+
+<% end %> diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb new file mode 100644 index 00000000..d33068db --- /dev/null +++ b/app/views/events/_form.html.erb @@ -0,0 +1,133 @@ +<%= form_with(model: event, local: true, class: "space-y-4") do |form| %> + <% if event.errors.any? %> +
+

<%= pluralize(event.errors.count, "error") %> prohibited this event from being saved:

+
    + <% event.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :title, class: "label" %> + <%= form.text_field :title, class: "input input-bordered w-full" %> +
+ +
+ <%= form.label :description, class: "label" %> + <%= form.text_area :description, rows: 5, class: "textarea textarea-bordered w-full" %> +
+ +
+
+ <%= form.label :start_datetime, "Start Date & Time", class: "label" %> + <%= form.datetime_local_field :start_datetime, class: "input input-bordered w-full" %> +
+ +
+ <%= form.label :end_datetime, "End Date & Time", class: "label" %> + <%= form.datetime_local_field :end_datetime, class: "input input-bordered w-full" %> +
+
+ +
+ <%= form.label :chapter_id, "Chapter", class: "label" %> + <%= form.collection_select :chapter_id, @chapters, :id, :name, {}, class: "select select-bordered w-full" %> +
+ +
+
+ <%= form.label :event_type, class: "label" %> + <%= form.select :event_type, options_for_select([['Meetup', 'meetup'], ['Conference', 'conference'], ['Workshop', 'workshop']], event.event_type), {}, class: "select select-bordered w-full" %> +
+ +
+ <%= form.label :status, class: "label" %> + <%= form.select :status, options_for_select([['Draft', 'draft'], ['Published', 'published'], ['Archived', 'archived']], event.status), {}, class: "select select-bordered w-full" %> +
+
+ +
+ <%= form.label :location_name, "Location", class: "label" %> + <%= form.text_field :location_name, class: "input input-bordered w-full", placeholder: "e.g., iHub Nairobi, Kenya" %> + +
+ +
+
+ <%= form.label :payment_status, class: "label" %> + <%= form.select :payment_status, options_for_select([['Free', 'free'], ['Paid', 'paid']], event.payment_status), {}, class: "select select-bordered w-full" %> +
+ +
+ <%= form.label :price_cents, "Price (in cents)", class: "label" %> + <%= form.number_field :price_cents, class: "input input-bordered w-full" %> +
+
+ +
+ <%= form.label :image, class: "label" %> + <%= form.file_field :image, class: "file-input file-input-bordered w-full" %> +
+ + +
+

Speakers

+
+ <% if event.speakers.any? %> + <% event.speakers.each do |speaker| %> + <%= render 'speakers/form', form: form, speaker: speaker %> + <% end %> + <% else %> + <%= render 'speakers/form', form: form, speaker: event.speakers.build %> + <% end %> +
+ +
+ +
+ <%= form.submit class: "btn btn-red" %> +
+<% end %> + + diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb new file mode 100644 index 00000000..dd550597 --- /dev/null +++ b/app/views/events/edit.html.erb @@ -0,0 +1,11 @@ +
+
+

Edit Event

+ + <%= render 'form', event: @event %> + +
+ <%= link_to "Back to Event", event_path(@event), class: "btn btn-ghost" %> +
+
+
diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb new file mode 100644 index 00000000..898648da --- /dev/null +++ b/app/views/events/index.html.erb @@ -0,0 +1,108 @@ +<% content_for :title, "Events" %> +<% content_for(:description, "Discover Ruby community events, meetups, conferences, and workshops") %> + +
+
+

Events

+ + <% if can? :create, Event %> + <%= link_to new_event_path, class: "btn btn-primary" do %> + + + + Create Event + <% end %> + <% end %> +
+ + +
+ <%= form_with url: events_path, method: :get, data: { controller: "event-search", turbo_frame: "events_list", action: "input->event-search#search change->event-search#search" }, class: 'grid grid-cols-1 md:grid-cols-6 gap-4' do %> +
+ <%= text_field_tag :query, params[:query], placeholder: "Search by title or description...", class: 'input input-bordered w-full', data: { event_search_target: "query" } %> +
+
+ <%= select_tag :date, options_for_select([['Any date', ''], ['Today', 'today'], ['This week', 'this_week'], ['This month', 'this_month'], ['Upcoming', 'upcoming'], ['Past', 'past']], params[:date]), class: 'select select-bordered w-full', data: { event_search_target: "date" } %> +
+
+ <%= select_tag :country, options_for_select([["All countries", '']] + @countries.map { |c| [c.name, c.id] }, params[:country]), class: 'select select-bordered w-full', data: { event_search_target: "country" } %> +
+
+ <%= submit_tag "Search", class: 'btn btn-primary w-full' %> +
+ <% end %> +
+ +
+ + + <%= turbo_frame_tag "events_list" do %> +
+ <% if @events.any? %> +
+ <% @events.each do |event| %> +
+ <%= link_to event_path(event), data: { turbo_frame: "_top" }, class: "block" do %> +
+ <% if event.image.attached? %> + <%= image_tag event.image, class: "w-full h-full object-cover transform group-hover:scale-110 transition-transform duration-500", alt: event.title %> + <% else %> +
+ + + +
+ <% end %> + + + <% if event.payment_status == 'free' %> +
+ Free +
+ <% elsif event.payment_status == 'paid' %> +
+ <%= number_to_currency(event.price_cents / 100.0) %> +
+ <% end %> +
+ <% end %> + +
+ <%= link_to event_path(event), data: { turbo_frame: "_top" } do %> +

<%= event.title %>

+ <% end %> + + +
+

+ <%= event.start_datetime.strftime("%B %d, %Y") %> +

+

+ <%= event.start_datetime.strftime("%I:%M %p") %> - <%= event.end_datetime.strftime("%I:%M %p") %> + <%= event.start_datetime.strftime("%Z") %> +

+
+ + + <% country_name = event.chapter&.country&.name %> + <% if country_name %> +
+ <% if country_icon(country_name) %> + <%= image_tag country_icon(country_name), alt: country_name, class: "w-4 h-4 object-contain" %> + <% end %> + <%= country_name %> +
+ <% end %> +
+
+ <% end %> +
+ <% else %> +
+

No events found matching your search criteria.

+
+ <% end %> +
+ <% end %> + +
diff --git a/app/views/events/new.html.erb b/app/views/events/new.html.erb new file mode 100644 index 00000000..c5c546ce --- /dev/null +++ b/app/views/events/new.html.erb @@ -0,0 +1,11 @@ +
+
+

Create New Event

+ + <%= render 'form', event: @event %> + +
+ <%= link_to "Back to Events", events_path, class: "btn btn-ghost" %> +
+
+
diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb new file mode 100644 index 00000000..ff624a27 --- /dev/null +++ b/app/views/events/show.html.erb @@ -0,0 +1,180 @@ + +
+ + +
+ + +
+ <% if @event.image.attached? %> + <%= image_tag @event.image, class: "w-full h-80 object-cover rounded-xl" %> + <% else %> +
+ <% end %> +
+ + +
+
+
+

Event Details

+ +
+

Date

+

<%= @event.start_datetime.strftime("%B %d, %Y") %>

+
+ +
+

Time

+

+ <%= @event.start_datetime.strftime("%I:%M %p") %> - <%= @event.end_datetime.strftime("%I:%M %p") %> +

+

<%= @event.start_datetime.strftime("%Z") %>

+
+ +
+

Event Type

+

<%= @event.event_type.to_s.titleize %>

+
+ + <% if @event.payment_status == 'paid' %> +
+

Price

+

<%= number_to_currency(@event.price_cents / 100.0) %>

+
+ <% else %> +
+

Free Event

+
+ <% end %> + +
+ +
+ <%= link_to "Add to calendar", "#", class: "btn btn-outline btn-block" %> + <%= link_to "Attend event", "#", class: "btn btn-primary btn-block" %> +
+
+
+
+
+ + +

<%= @event.title %>

+ + +
+ + +
+

About

+
+ <%= simple_format(@event.description) %> +
+
+ + +
+ + + <% if @event.speakers.any? %> +
+

Speakers

+
+ <% @event.speakers.each do |speaker| %> +
+ <% if speaker.photo.attached? %> + <%= image_tag speaker.photo, class: "w-24 h-24 rounded-full mx-auto mb-3 object-cover" %> + <% else %> +
+ <% end %> +

<%= speaker.name %>

+

<%= truncate(speaker.bio, length: 100) %>

+
+ <% end %> +
+
+ <% end %> + + + + + + <% if @related_events.present? && @related_events.any? %> +
+

Other Events

+
+ <% @related_events.each do |event| %> + <%= link_to event_path(event), class: "block p-4 border border-gray-200 rounded-lg hover:border-red-500 hover:bg-red-50 transition" do %> +

<%= event.title %>

+

<%= event.start_datetime.strftime("%B %d, %Y") %>

+

<%= event.location_name || "Location TBA" %>

+ <% end %> + <% end %> +
+
+ <% end %> + +
+
+ +<% if @event.mappable? %> + +<% end %> diff --git a/app/views/landing/index.html.erb b/app/views/landing/index.html.erb index 6ec37ea9..221554d7 100644 --- a/app/views/landing/index.html.erb +++ b/app/views/landing/index.html.erb @@ -1,8 +1,8 @@ <%= render 'landing/home/intro' %> -<% if FeatureFlag.find_by(name: 'events').try(:enabled) %> - <%= render 'landing/home/coming_up_events' %> -<% end %> +<%# if FeatureFlag.find_by(name: 'events').try(:enabled) %> + <%#= render 'landing/home/coming_up_events' %> +<%# end %> <%= render 'landing/home/who_we_are' %> diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb index 1aaa92ff..fa0def54 100644 --- a/app/views/layouts/_footer.html.erb +++ b/app/views/layouts/_footer.html.erb @@ -11,8 +11,14 @@ <% end %>
  • <%= link_to t('footer.chapters'), chapters_path %>
  • -
  • <%= link_to t('footer.projects'), projects_path %>
  • -
  • <%= link_to t('footer.learning_materials'), learning_materials_path %>
  • + + <% if FeatureFlag.find_by(name: 'projects')&.enabled %> +
  • <%= link_to t('footer.projects'), projects_path %>
  • + <% end %> + + <% if FeatureFlag.find_by(name: 'learning_materials')&.enabled %> +
  • <%= link_to t('footer.learning_materials'), learning_materials_path %>
  • + <% end %>
      diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 6b07b6d0..488bd430 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -14,12 +14,20 @@
    • <%= link_to t('navigation.about_arc'), landing_about_path %>
    • <% if FeatureFlag.find_by(name: 'events').try(:enabled) %> -
    • <%= t('navigation.events') %>
    • +
    • <%= link_to t('navigation.events'), events_path %>
    • <% end %> + <%# Conferences removed from project %> +
    • <%= link_to t('navigation.chapters'), chapters_path %>
    • -
    • <%= link_to t('navigation.projects'), projects_path %>
    • -
    • <%= link_to t('navigation.learning_materials'), learning_materials_path %>
    • + + <% if FeatureFlag.find_by(name: 'projects')&.enabled %> +
    • <%= link_to t('navigation.projects'), projects_path %>
    • + <% end %> + + <% if FeatureFlag.find_by(name: 'learning_materials')&.enabled %> +
    • <%= link_to t('navigation.learning_materials'), learning_materials_path %>
    • + <% end %> <% if user_signed_in? %>
    • <%= button_to t('navigation.sign_out'), destroy_user_session_path, method: :delete %>
    • @@ -37,28 +45,28 @@
    <% if user_signed_in? %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 4d800d59..dee18a8d 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,16 +1,18 @@ - + ArcPlatform <%= " - " + yield(:title) if content_for?(:title) %> - > + "> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= favicon_link_tag 'favicon.ico' %> + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_include_tag "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit", "data-turbo-track": "reload", defer: true %> + <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> <%= Sentry.get_trace_propagation_meta.html_safe %> diff --git a/app/views/learning_materials/index.html.erb b/app/views/learning_materials/index.html.erb index e58af7b8..9df88f40 100644 --- a/app/views/learning_materials/index.html.erb +++ b/app/views/learning_materials/index.html.erb @@ -14,7 +14,7 @@ <%= select_tag :level, options_for_select([[t('learning_materials.index.all_levels'), ''], [t('learning_materials.index.beginner'), 'beginner'], [t('learning_materials.index.intermediate'), 'intermediate'], [t('learning_materials.index.expert'), 'expert']], @level), class: 'select select-bordered w-full', id: 'level' %>
    - <%= submit_tag t('learning_materials.index.search_button'), class: 'btn btn-primary w-full' %> + <%= submit_tag t('learning_materials.index.search_button'), class: 'btn bg-red-600 hover:bg-red-700 text-white border-0 w-full' %>
    <% end %> @@ -22,21 +22,51 @@
    <% if @featured_materials.any? %> -
    +

    <%= t('learning_materials.index.featured') %>

    -
    +
    <% @featured_materials.each do |m| %> -
    - <% if m.thumbnail.present? %> -
    - <%= m.title %> thumbnail -
    - <% end %> -
    - <%= m.level %> -

    <%= m.title %>

    -
    - <%= link_to t('learning_materials.index.open_resource'), m.link, class: 'link link-primary text-sm transition-colors duration-200 hover:underline', target: '_blank', rel: 'noopener' %> +
    + +
    + <% if m.thumbnail.present? %> + <%= m.title %> thumbnail + <% else %> +
    + + + +
    + <% end %> + + +
    + <%= t('learning_materials.index.featured') %> +
    +
    +
    + +
    + +

    <%= m.title %>

    +
    + + +
    + + + + <%= m.level %> +
    + + +
    + <%= link_to m.link, target: "_blank", rel: "noopener", class: "inline-flex items-center justify-center gap-2 px-3 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors w-full" do %> + + + + <%= t('learning_materials.index.open_resource') %> + <% end %>
    @@ -46,27 +76,52 @@ <% end %>
    -
    - <% @materials.each_with_index do |m, i| %> -
    -
    - <% if m.thumbnail.present? %> -
    - <%= m.title %> thumbnail -
    - <% end %> -
    - <%= m.level %> -

    <%= m.title %>

    -
    - <%= link_to t('learning_materials.index.open_resource'), m.link, class: 'link link-primary text-sm transition-colors duration-200 hover:underline', target: '_blank', rel: 'noopener' %> -
    + <% if @featured_materials.any? %> +

    All Learning Resources

    + <% end %> +
    + <% @materials.each do |m| %> +
    + +
    + <% if m.thumbnail.present? %> + <%= m.title %> thumbnail + <% else %> +
    + + + +
    + <% end %> +
    +
    + +
    + +

    <%= m.title %>

    +
    + + +
    + + + + <%= m.level %> +
    + + +
    + <%= link_to m.link, target: "_blank", rel: "noopener", class: "inline-flex items-center justify-center gap-2 px-3 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors w-full" do %> + + + + <%= t('learning_materials.index.open_resource') %> + <% end %>
    <% end %>
    -
    \ No newline at end of file diff --git a/app/views/projects/index.html.erb b/app/views/projects/index.html.erb index 44b1b0e6..13ee7d68 100644 --- a/app/views/projects/index.html.erb +++ b/app/views/projects/index.html.erb @@ -1,16 +1,16 @@ -
    +
    -
    +
    -

    <%= t('projects.index.title') %>

    -

    <%= t('projects.index.description') %>

    +

    <%= t('projects.index.title') %>

    +

    <%= t('projects.index.description') %>

    <%= form_with url: projects_path, method: :get, class: "max-w-2xl mx-auto" do |f| %>
    - <%= f.text_field :query, value: params[:query], placeholder: t('projects.index.search_placeholder'), class: "w-full px-6 py-4 pr-12 rounded-full text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-4 focus:ring-red-300 shadow-lg text-lg" %> + <%= f.text_field :query, value: params[:query], placeholder: t('projects.index.search_placeholder'), class: "w-full px-6 py-4 pr-12 rounded-full border-2 border-gray-300 text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-4 focus:ring-red-300 focus:border-red-500 shadow-sm text-lg bg-white" %>