diff --git a/Gemfile b/Gemfile index cff1eb00..d4ca8eee 100644 --- a/Gemfile +++ b/Gemfile @@ -80,6 +80,11 @@ gem "flamegraph" gem "skylight" +gem "airrecord" + +# For activity logging +gem "public_activity" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" diff --git a/Gemfile.lock b/Gemfile.lock index 9ed3b1fa..ab69d6ec 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -79,6 +79,10 @@ GEM uri (>= 0.13.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + airrecord (1.0.12) + faraday (>= 1.0, < 3.0) + faraday-net_http_persistent + net-http-persistent ast (2.4.2) avo (3.19.3) actionview (>= 6.1) @@ -108,7 +112,7 @@ GEM brakeman (7.0.2) racc builder (3.3.0) - bullet (8.0.3) + bullet (8.0.4) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) capybara (3.40.0) @@ -123,7 +127,7 @@ GEM childprocess (5.1.0) logger (~> 1.5) concurrent-ruby (1.3.5) - connection_pool (2.5.0) + connection_pool (2.5.1) crass (1.0.6) date (3.4.1) debug (1.10.0) @@ -151,6 +155,9 @@ GEM multipart-post (~> 2.0) faraday-net_http (3.4.0) net-http (>= 0.5.0) + faraday-net_http_persistent (2.3.0) + faraday (~> 2.5) + net-http-persistent (>= 4.0.4, < 5) ffi (1.17.1-aarch64-linux-gnu) ffi (1.17.1-aarch64-linux-musl) ffi (1.17.1-arm-linux-gnu) @@ -252,6 +259,8 @@ GEM multipart-post (2.4.1) net-http (0.6.0) uri + net-http-persistent (4.0.5) + connection_pool (~> 2.2) net-imap (0.5.6) date net-protocol @@ -304,6 +313,11 @@ GEM psych (5.2.3) date stringio + public_activity (3.0.1) + actionpack (>= 6.1.0) + activerecord (>= 6.1) + i18n (>= 0.5.0) + railties (>= 6.1.0) public_suffix (6.0.1) puma (6.6.0) nio4r (~> 2.0) @@ -491,6 +505,7 @@ PLATFORMS DEPENDENCIES activerecord-import + airrecord avo (>= 3.2.1) avo-record_link_field (~> 0.0.2) bootsnap @@ -511,6 +526,7 @@ DEPENDENCIES paper_trail pg propshaft + public_activity puma (>= 5.0) query_count rack-cors diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 71069f13..78661e6e 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -11,6 +11,7 @@ @import "https://uchu.style/color.css"; @import "https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"; +@import "membership.css"; /* colors */ @@ -18,8 +19,13 @@ --muted-color: var(--uchu-gray); --black: rgb(42, 42, 42); --smoke: rgb(242, 242, 242); - /* color: var(--black); */ - /* background-color: var(--smoke); */ + + /* Status colors */ + --basic-color: #1fa0eb; + --bronze-color: #CD7F32; + --silver-color: #E8E8E8; + --gold-color: #FFD700; + --diamond-color: #B9F2FF; } :root.development { @@ -295,19 +301,40 @@ .avatar-container { position: relative; - cursor: pointer; - transition: transform 0.2s ease; - z-index: 1; - margin-left: -15px; + display: inline-block; } -.avatar-container:first-child { - margin-left: 0; +.avatar-container .status-icon { + position: absolute; + bottom: -3px; + right: -3px; + width: 16px; + height: 16px; + margin: 0; +} + +.avatar-container .status-icon.basic { + fill: var(--basic-color); } -.avatar-container:hover { - transform: translateY(-3px); - z-index: 5; +.avatar-container .status-icon.bronze { + fill: var(--bronze-color); +} + +.avatar-container .status-icon.silver { + fill: var(--silver-color); +} + +.avatar-container .status-icon.gold { + fill: var(--gold-color); +} + +.avatar-container .status-icon.diamond { + fill: var(--diamond-color); +} + +.avatar-container:first-child { + margin-left: 0; } .setup-avatar { @@ -477,3 +504,33 @@ code { word-break: break-word; } + +/* Status icons in membership index */ +.status-card .status-icon { + width: 24px; + height: 24px; + border-radius: 50%; + margin-bottom: 1rem; + border: 2px solid white; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.status-card .status-icon.basic { + background: var(--basic-color); +} + +.status-card .status-icon.bronze { + background: var(--bronze-color); +} + +.status-card .status-icon.silver { + background: var(--silver-color); +} + +.status-card .status-icon.gold { + background: var(--gold-color); +} + +.status-card .status-icon.diamond { + background: var(--diamond-color); +} \ No newline at end of file diff --git a/app/assets/stylesheets/flash.css b/app/assets/stylesheets/flash.css index 8cf8afca..b20aeadf 100644 --- a/app/assets/stylesheets/flash.css +++ b/app/assets/stylesheets/flash.css @@ -52,8 +52,7 @@ footer a:hover { } .impersonate-link { - color: #cc5555 !important; - font-weight: bold; + color: var(--primary-color) !important; } @media (prefers-color-scheme: dark) { @@ -64,8 +63,4 @@ footer a:hover { footer:hover { color: rgba(220, 220, 220, 0.9); } - - .impersonate-link { - color: #ff9999 !important; - } } \ No newline at end of file diff --git a/app/assets/stylesheets/membership.css b/app/assets/stylesheets/membership.css new file mode 100644 index 00000000..a9cc819c --- /dev/null +++ b/app/assets/stylesheets/membership.css @@ -0,0 +1,552 @@ +/* Status icons - global styles */ +.status-icon { + width: 40px; + height: 40px; + margin-bottom: 1.5rem; + transition: transform 0.4s cubic-bezier(0.2, 1, 0.3, 1); +} + +*:hover > .status-icon { + transform: scale(1.05); +} + +.status-icon.basic { + filter: drop-shadow(0 4px 12px rgba(117, 154, 112, 0.2)); + color: #1fa0eb; +} + +.status-icon.bronze { + filter: drop-shadow(0 4px 12px rgba(205, 127, 50, 0.2)); + color: #CD7F32; +} + +.status-icon.silver { + filter: drop-shadow(0 4px 12px rgba(192, 192, 192, 0.2)); + color: #E8E8E8; +} + +.status-icon.gold { + filter: drop-shadow(0 4px 12px rgba(255, 215, 0, 0.2)); + color: #FFD700; +} + +.status-icon.diamond { + filter: drop-shadow(0 4px 12px rgba(185, 242, 255, 0.3)); + color: #B9F2FF; +} + +/* Membership page specific styles */ +.membership-page { + /* Main container styling */ + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + color: var(--text-color); +} + +/* Banner styling */ +.membership-page .membership-banner { + margin: -2rem -2rem 3rem; + position: relative; + overflow: hidden; +} + +.membership-page .membership-banner img { + width: 100%; + height: auto; + display: block; +} + +/* Main heading styling */ +.membership-page h1 { + font-size: 2.5rem; + font-weight: 300; + letter-spacing: 1px; + margin-bottom: 1.5rem; + color: var(--text-color); +} + +/* Description text styling */ +.membership-page .membership-description { + font-size: 1.1rem; + line-height: 1.6; + color: var(--text-color); + max-width: 800px; + margin-bottom: 2.5rem; + letter-spacing: 0.3px; + font-weight: 300; +} + +/* Status check button styling */ +.membership-page .status-check { + margin-bottom: 4rem; + position: relative; +} + +.membership-page .button { + display: inline-block; + padding: 1rem 2.5rem; + background-color: var(--button-bg); + color: var(--button-text); + text-decoration: none; + border-radius: 2px; + font-size: 0.95rem; + letter-spacing: 1.5px; + transition: all 0.5s cubic-bezier(0.2, 1, 0.3, 1); + border: none; + font-weight: 400; + text-transform: uppercase; + position: relative; + overflow: hidden; + z-index: 1; +} + +.membership-page .button:hover { + background-color: var(--button-hover-bg); + transform: translateY(-2px); + box-shadow: + 0 6px 20px rgba(0, 0, 0, 0.15), + 0 2px 8px rgba(0, 0, 0, 0.1); + letter-spacing: 2px; +} + +/* Status section styling */ +.membership-page .preferred-status-section { + margin: 4rem 0; +} + +.membership-page .preferred-status-section h2 { + font-size: 2.5rem; + margin-bottom: 1.5rem; + color: var(--text-color); + font-weight: 300; +} + +.membership-page .status-explanation { + font-size: 1.1rem; + line-height: 1.6; + margin-bottom: 2rem; + max-width: 900px; + color: var(--text-color); +} + +.membership-page .status-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1.5rem; + margin-top: 1.5rem; +} + +.membership-page .status-card { + background: var(--card-bg); + border: none; + border-radius: 2px; + padding: 2rem; + position: relative; + overflow: hidden; + transition: all 0.4s cubic-bezier(0.2, 1, 0.3, 1); + box-shadow: + 0 1px 3px rgba(0,0,0,0.02), + 0 1px 2px rgba(0,0,0,0.04); +} + +.membership-page .status-card:hover { + transform: translateY(-4px); + box-shadow: + 0 20px 40px rgba(0,0,0,0.06), + 0 8px 16px rgba(0,0,0,0.02); +} + +.membership-page .status-card h3 { + font-size: 1.25rem; + margin-bottom: 1rem; + color: var(--text-color); + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.5rem; + font-weight: 300; +} + +/* Benefits section styling */ +.membership-page .preferred-benefits-section { + margin: 6rem 0; +} + +.membership-page .preferred-benefits-section h2 { + font-size: 3rem; + margin-bottom: 1.5rem; + color: var(--text-color); + font-weight: 300; + letter-spacing: -0.5px; +} + +.membership-page .benefits-intro { + font-size: 1.25rem; + line-height: 1.6; + margin-bottom: 3rem; + max-width: 900px; + color: var(--text-color); +} + +.membership-page .benefits-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 2.5rem; + margin-top: 2rem; +} + +.membership-page .benefit-card { + background: var(--card-bg); + padding: 2rem; + border: 1px solid var(--border-color); + border-radius: 12px; +} + +.membership-page .benefit-card h3 { + font-size: 1.75rem; + margin-bottom: 2rem; + color: var(--text-color); + font-weight: 300; + letter-spacing: -0.5px; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.membership-page .benefits-list { + list-style: none; + padding: 0; + margin: 0; +} + +.membership-page .benefits-list li { + display: flex; + align-items: flex-start; + margin-bottom: 1.25rem; + font-size: 1.1rem; + line-height: 1.5; + color: var(--text-color); +} + +.membership-page .benefit-icon { + margin-right: 1rem; + font-size: 1.25rem; + flex-shrink: 0; + position: relative; + top: 0.1rem; +} + +/* Responsive adjustments */ +@media (max-width: 1200px) { + .membership-page .benefits-grid { + gap: 2rem; + } + + .membership-page .benefit-card { + padding: 1.5rem; + } + + .membership-page .benefit-card h3 { + font-size: 1.5rem; + } +} + +@media (max-width: 1024px) { + .membership-page .benefits-grid { + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; + } +} + +@media (max-width: 640px) { + .membership-page .benefits-grid { + grid-template-columns: 1fr; + } + + .membership-page .benefit-card h3 { + font-size: 1.25rem; + } + + .membership-page .benefits-list li { + font-size: 1rem; + } + + .membership-page { + padding: 1rem; + } + + .membership-page .membership-banner { + margin: -1rem -1rem 2rem; + } +} + +/* Premium Card Showcase */ +.premium-card-showcase { + width: 100%; + padding: 2rem 0; + display: flex; + justify-content: center; +} + +.cards-row { + width: 600px; +} + +.card-container { + width: 600px; + height: 340px; + perspective: 1500px; + position: relative; + cursor: pointer; +} + +.premium-card { + position: relative; + width: 100%; + height: 100%; + transform-style: preserve-3d; + transition: all 0.8s cubic-bezier(0.34, 1.56, 0.64, 1); + border-radius: 20px; + box-shadow: + 0 15px 35px rgba(0, 0, 0, 0.1), + 0 3px 10px rgba(0, 0, 0, 0.07); + overflow: hidden; +} + +/* Status-based card backgrounds */ +.basic .premium-card { + background: linear-gradient(135deg, #93e9e9 0%, #1fa0eb 100%); +} + +.bronze .premium-card { + background: linear-gradient(135deg, #CD7F32 0%, #8B4513 100%); +} + +.silver .premium-card { + background: linear-gradient(135deg, #E8E8E8 0%, #A8A8A8 100%); +} + +.gold .premium-card { + background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%); +} + +.diamond .premium-card { + background: linear-gradient(135deg, #E8F5F7 0%, #B9F2FF 100%); +} + +.card-container:hover .premium-card { + transform: rotateY(5deg) rotateX(-2deg) translateZ(10px); + box-shadow: + 20px 20px 60px rgba(0, 0, 0, 0.1), + 1px 1px 0px 1px rgba(255, 255, 255, 0.5); +} + +/* Holographic effect */ +.premium-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 125deg, + rgba(255, 255, 255, 0.3) 0%, + rgba(255, 255, 255, 0.2) 10%, + rgba(255, 255, 255, 0) 100% + ); + opacity: 0; + transition: opacity 0.5s ease; + z-index: 2; +} + +.card-container:hover .premium-card::before { + opacity: 1; +} + +.card-content { + position: relative; + width: 100%; + height: 100%; + padding: 30px; + display: flex; + flex-direction: column; + justify-content: space-between; + z-index: 3; + color: #1a1a1a; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.hack-club-logo { + font-size: 24px; + font-weight: 800; + letter-spacing: 1px; + color: #1a1a1a; +} + +.chip { + width: 50px; + height: 40px; + background: #FFD700; + border-radius: 6px; + position: relative; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.card-body { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + position: relative; +} + +.hack-club-emblem { + width: 80px; + height: 80px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.9); + display: flex; + justify-content: center; + align-items: center; + padding: 15px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + transition: transform 0.3s ease; +} + +.card-container:hover .hack-club-emblem { + transform: scale(1.05); +} + +.flag-icon { + width: 100%; + height: 100%; + object-fit: contain; +} + +.card-footer { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-top: auto; +} + +.card-number { + font-size: 28px; + letter-spacing: 2px; + font-family: monospace; + font-weight: 500; +} + +.member-info { + text-align: center; + font-size: 10px; + line-height: 1.4; + letter-spacing: 1px; + opacity: 0.8; + text-transform: uppercase; +} + +.member-info strong { + display: block; + font-size: 14px; + margin-top: 2px; +} + +.cardholder-info { + text-align: right; +} + +.cardholder-name { + font-size: 24px; + letter-spacing: 1px; + font-weight: 600; + margin-bottom: 5px; +} + +.card-label { + font-size: 12px; + letter-spacing: 2px; + opacity: 0.7; + text-transform: uppercase; +} + +/* Membership details section */ +.membership-details { + margin: 2rem 0; + font-size: 1.2rem; + line-height: 1.6; +} + +.membership-details p { + margin: 0.5rem 0; + color: var(--text-color); +} + +.membership-details .super { + font-style: italic; + color: var(--text-muted, #666); + font-size: 0.9em; +} + +/* Project list styling */ +.membership-details + ul { + list-style: none; + padding: 0; + margin: 2rem 0; +} + +.membership-details + ul li { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; + margin-bottom: 1rem; + background: var(--card-bg); +} + +.project-name { + flex: 1; + font-weight: 500; +} + +.super { + color: var(--text-muted, #666); + font-size: 0.9em; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .cards-row, .card-container { + width: 100%; + height: 280px; + } + + .card-content { + padding: 20px; + } + + .hack-club-logo { + font-size: 20px; + } + + .hack-club-emblem { + width: 60px; + height: 60px; + padding: 10px; + } + + .card-number { + font-size: 24px; + } + + .cardholder-name { + font-size: 20px; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/membership_requirements.css b/app/assets/stylesheets/membership_requirements.css new file mode 100644 index 00000000..5bad9609 --- /dev/null +++ b/app/assets/stylesheets/membership_requirements.css @@ -0,0 +1,75 @@ +.status-requirements { + margin: 2rem 0; + padding: 1.5rem; + background: var(--bg-secondary); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.status-requirements h3 { + margin: 0 0 1.5rem 0; + color: var(--text-primary); + font-size: 1.25rem; + font-weight: 600; +} + +.requirement-item { + display: flex; + align-items: flex-start; + padding: 1rem; + margin-bottom: 1rem; + background: var(--bg-primary); + border-radius: 6px; + transition: all 0.2s ease; +} + +.requirement-item:last-child { + margin-bottom: 0; +} + +.requirement-item.met { + border-left: 4px solid var(--success); +} + +.requirement-item.not-met { + border-left: 4px solid var(--warning); +} + +.requirement-icon { + margin-right: 1rem; + font-size: 1.25rem; + line-height: 1.5; +} + +.requirement-details { + flex: 1; +} + +.requirement-name { + margin: 0 0 0.5rem 0; + font-weight: 500; + color: var(--text-primary); +} + +.requirement-status { + margin: 0; + color: var(--success); + font-size: 0.875rem; +} + +.requirement-progress { + margin: 0; + color: var(--text-secondary); + font-size: 0.875rem; +} + +/* Dark mode adjustments */ +@media (prefers-color-scheme: dark) { + .status-requirements { + background: var(--bg-secondary-dark); + } + + .requirement-item { + background: var(--bg-primary-dark); + } +} \ No newline at end of file diff --git a/app/avo/resources/membership_upgrade_request.rb b/app/avo/resources/membership_upgrade_request.rb new file mode 100644 index 00000000..7ffa3d7e --- /dev/null +++ b/app/avo/resources/membership_upgrade_request.rb @@ -0,0 +1,16 @@ +class Avo::Resources::MembershipUpgradeRequest < Avo::BaseResource + # self.includes = [] + # self.attachments = [] + # self.search = { + # query: -> { query.ransack(id_eq: params[:q], m: "or").result(distinct: false) } + # } + + def fields + field :id, as: :id + field :user, as: :belongs_to + field :from_status, as: :number + field :to_status, as: :number + field :payment_method, as: :number + field :status, as: :number + end +end diff --git a/app/controllers/avo/membership_upgrade_requests_controller.rb b/app/controllers/avo/membership_upgrade_requests_controller.rb new file mode 100644 index 00000000..cc9e5a0a --- /dev/null +++ b/app/controllers/avo/membership_upgrade_requests_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/3.0/controllers.html +class Avo::MembershipUpgradeRequestsController < Avo::ResourcesController +end diff --git a/app/controllers/membership_upgrade_requests_controller.rb b/app/controllers/membership_upgrade_requests_controller.rb new file mode 100644 index 00000000..72d5065e --- /dev/null +++ b/app/controllers/membership_upgrade_requests_controller.rb @@ -0,0 +1,32 @@ +class MembershipUpgradeRequestsController < ApplicationController + def new + unless current_user.eligible_for_next_status? + redirect_to my_membership_path, alert: "You are not eligible for an upgrade." + end + + @membership_upgrade_request = MembershipUpgradeRequest.new + end + + def create + @membership_upgrade_request = MembershipUpgradeRequest.new(membership_upgrade_request_params) + @membership_upgrade_request.user = current_user + @membership_upgrade_request.status = "pending" + + if @membership_upgrade_request.save + if @membership_upgrade_request.payment_method == "direct_payment" + # TODO: Create and enqueue a job to send the invoice + # SendInvoiceJob.perform_later(@membership_upgrade_request) + end + + redirect_to my_membership_path, notice: "Your upgrade request has been submitted!" + else + render :new, status: :unprocessable_entity + end + end + + private + + def membership_upgrade_request_params + params.require(:membership_upgrade_request).permit(:payment_method, :from_status, :to_status) + end +end diff --git a/app/controllers/memberships_controller.rb b/app/controllers/memberships_controller.rb new file mode 100644 index 00000000..d1d8757a --- /dev/null +++ b/app/controllers/memberships_controller.rb @@ -0,0 +1,29 @@ +class MembershipsController < ApplicationController + before_action :set_membership, only: [ :show ] + + def index + end + + def show + @eligible_for_upgrade = @user.eligible_for_next_status? + end + + private + + def set_membership + set_user + @membership = Membership.new(@user) + end + + def set_user + @user = begin + if params[:slack_uid] == "my" + current_user + else + User.find_by!(slack_uid: params[:slack_uid]) + end + rescue + nil + end + end +end diff --git a/app/controllers/scrapyard_leaderboards_controller.rb b/app/controllers/scrapyard_leaderboards_controller.rb index 4044bc18..a31c95d6 100644 --- a/app/controllers/scrapyard_leaderboards_controller.rb +++ b/app/controllers/scrapyard_leaderboards_controller.rb @@ -17,12 +17,12 @@ def index # Cache the expensive computations for 10 seconds @event_stats = Rails.cache.fetch("scrapyard_leaderboard_stats", expires_in: 10.seconds) do # Get all attendees and their emails in one query - event_attendees = Warehouse::ScrapyardLocalAttendee + event_attendees = Warehouse::Scrapyard::LocalAttendee .where.not(email: nil) .group_by(&:event_id) # Only get events that have attendees - events = Warehouse::ScrapyardEvent + events = Warehouse::Scrapyard::LocalEvent .where(id: event_attendees.keys) .order(created_at: :desc) @@ -82,10 +82,10 @@ def pin end def show - @event = Warehouse::ScrapyardEvent.find(params[:id]) + @event = Warehouse::Scrapyard::LocalEvent.find(params[:id]) @attendee_stats = Rails.cache.fetch("scrapyard_leaderboard_event_#{@event.id}", expires_in: 10.seconds) do - attendees = Warehouse::ScrapyardLocalAttendee + attendees = Warehouse::Scrapyard::LocalAttendee .where.not(email: nil) .for_event(@event) .uniq { |attendee| attendee.email } diff --git a/app/helpers/membership_upgrade_requests_helper.rb b/app/helpers/membership_upgrade_requests_helper.rb new file mode 100644 index 00000000..da475d23 --- /dev/null +++ b/app/helpers/membership_upgrade_requests_helper.rb @@ -0,0 +1,2 @@ +module MembershipUpgradeRequestsHelper +end diff --git a/app/javascript/premium_card.js b/app/javascript/premium_card.js new file mode 100644 index 00000000..46b1b74d --- /dev/null +++ b/app/javascript/premium_card.js @@ -0,0 +1,70 @@ +document.addEventListener('DOMContentLoaded', () => { + const cardContainer = document.querySelector('.card-container'); + if (!cardContainer) return; + + const card = cardContainer.querySelector('.premium-card'); + const shine = cardContainer.querySelector('.card-shine'); + + // Track whether the card is being touched (for mobile) + let isTouch = false; + + // Handle mouse movement + cardContainer.addEventListener('mousemove', (e) => { + if (isTouch) return; + + const rect = cardContainer.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Calculate rotation based on mouse position + const centerX = rect.width / 2; + const centerY = rect.height / 2; + const rotateX = (y - centerY) / 15; + const rotateY = -(x - centerX) / 15; + + // Apply smooth rotation + card.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; + + // Update shine effect + const shineMoveX = (x / rect.width) * 100; + const shineMoveY = (y / rect.height) * 100; + shine.style.background = `radial-gradient(circle at ${shineMoveX}% ${shineMoveY}%, rgba(255,255,255,0.25) 0%, rgba(255,255,255,0) 60%)`; + }); + + // Handle mouse enter + cardContainer.addEventListener('mouseenter', () => { + if (isTouch) return; + card.style.transition = 'none'; + }); + + // Handle mouse leave + cardContainer.addEventListener('mouseleave', () => { + if (isTouch) return; + card.style.transition = 'transform 0.8s cubic-bezier(0.71, 0, 0.33, 1.56)'; + card.style.transform = 'rotateX(0) rotateY(0)'; + shine.style.opacity = '0'; + }); + + // Handle touch events for mobile + cardContainer.addEventListener('touchstart', () => { + isTouch = true; + card.style.transform = 'rotateY(-15deg) rotateX(5deg)'; + }); + + cardContainer.addEventListener('touchend', () => { + card.style.transform = 'rotateX(0) rotateY(0)'; + }); + + // Add subtle floating animation + const floatingAnimation = () => { + if (!isTouch && !cardContainer.matches(':hover')) { + const time = Date.now() / 2000; + const floatY = Math.sin(time) * 3; + const floatX = Math.cos(time) * 2; + card.style.transform = `rotateX(${floatY}deg) rotateY(${floatX}deg)`; + } + requestAnimationFrame(floatingAnimation); + }; + + floatingAnimation(); +}); \ No newline at end of file diff --git a/app/jobs/leaderboard_update_job.rb b/app/jobs/leaderboard_update_job.rb index 52606ce0..45d8db4d 100644 --- a/app/jobs/leaderboard_update_job.rb +++ b/app/jobs/leaderboard_update_job.rb @@ -39,16 +39,17 @@ def perform(period_type = :daily, date = Date.current) .group(:user_id) .duration_seconds - entries_data = entries_data.filter { |_, total_seconds| total_seconds > 60 } + entries_data = entries_data.filter { |_, total_seconds| total_seconds > 60 }.sort_by { |_, total_seconds| -total_seconds } streaks = Heartbeat.daily_streaks_for_users(entries_data.map { |user_id, _| user_id }) - entries_data = entries_data.map do |user_id, total_seconds| + entries_data = entries_data.map.with_index(1) do |(user_id, total_seconds), index| { leaderboard_id: leaderboard.id, user_id: user_id, total_seconds: total_seconds, - streak_count: streaks[user_id] || 0 + streak_count: streaks[user_id] || 0, + rank: index } end diff --git a/app/jobs/membership_eligibility_notification_job.rb b/app/jobs/membership_eligibility_notification_job.rb new file mode 100644 index 00000000..03ebccd4 --- /dev/null +++ b/app/jobs/membership_eligibility_notification_job.rb @@ -0,0 +1,36 @@ +class MembershipEligibilityNotificationJob < ApplicationJob + def perform + users_with_new_ysws_records.each do |user| + if user.eligible_for_next_status? + puts "User: #{user.email_addresses.first.email}" + puts "Current status: #{user.current_status}" + puts "Eligible for next status: #{user.eligible_for_next_status?}" + next if user.next_status.nil? + + specific_project = user.ysws_projects.where(approved_at: 4.days.ago..).last + specific_project_link = specific_project.try(:code_url) + + MembershipMailer.notify_eligible_for_status(user, + user.next_status, + specific_project, + specific_project_link).deliver_now + + user.update!(membership_eligibility_sent_for_status: user.next_status) + end + end + end + + private + + def users_with_new_ysws_records + @users_with_new_ysws_records ||= begin + ea = ::Warehouse::UnifiedYsws::ApprovedProject.where(approved_at: 4.days.ago..) + .distinct.pluck(:email) + + user_ids = EmailAddress.where(email: ea).pluck(:user_id) + User.where(id: user_ids) + .where.not(membership_eligibility_sent_for_status: User.membership_types.last) + .where.not(membership_upgrade_requests: { status: :pending }) + end + end +end diff --git a/app/jobs/send_invoice_job.rb b/app/jobs/send_invoice_job.rb new file mode 100644 index 00000000..4e2204ae --- /dev/null +++ b/app/jobs/send_invoice_job.rb @@ -0,0 +1,20 @@ +class SendInvoiceJob < ApplicationJob + def perform(membership_upgrade_request) + # "https://hcb.hackclub.com/api/v4/invoices" + HTTP.auth("Bearer #{ENV['HCB_API_KEY']}") + .post("https://hcb.hackclub.com/api/v4/invoices", + json: { + due_date: 1.month.from_now.to_date, + item_description: "Membership Upgrade", + item_amount: 10 * 100, + sponsor_name: membership_upgrade_request.user.name, + sponsor_email: membership_upgrade_request.user.email_addresses.first.email, + sponsor_address_line1:, + sponsor_address_line2:, + sponsor_address_city:, + sponsor_address_state:, + sponsor_address_postal_code:, + sponsor_address_country: + }) + end +end diff --git a/app/mailers/membership_mailer.rb b/app/mailers/membership_mailer.rb new file mode 100644 index 00000000..bc962de3 --- /dev/null +++ b/app/mailers/membership_mailer.rb @@ -0,0 +1,13 @@ +class MembershipMailer < ApplicationMailer + def notify_eligible_for_status(user, status, specific_project, specific_project_link) + @user = user + @status = status + @specific_project = specific_project + @specific_project_link = specific_project_link + + mail( + to: user.email_addresses.first.email, + subject: "Welcome to Hack Club's #{status} membership!" + ) + end +end diff --git a/app/models/concerns/membership_requirements.rb b/app/models/concerns/membership_requirements.rb new file mode 100644 index 00000000..746a8e9a --- /dev/null +++ b/app/models/concerns/membership_requirements.rb @@ -0,0 +1,113 @@ +module MembershipRequirements + extend ActiveSupport::Concern + + class << self + def requirements_for_status(status) + send("#{status}_requirements") + end + + def eligible_for_status?(user, status) + requirements = requirements_for_status(status) + requirements.all? { |requirement| requirement.met?(user) } + end + + def current_status(user) + user.membership_type + end + + def next_status(current_status) + statuses = User.membership_types.keys + current_index = statuses.index(current_status.to_s) + return nil if current_index.nil? || current_index == statuses.length - 1 + statuses[current_index + 1].to_sym + end + + def humanized_status(status) + { + basic: "Basic", + bronze: "Preferred Bronze", + silver: "Preferred Silver", + gold: "Preferred Gold", + platinum: "Preferred Platinum" + }[status] + end + + def requirements_for(status) + requirements_for_status(status) + end + + private + + def total_ysws_hours(user) + user.ysws_projects.sum { |p| p["hours_spent"].to_f } + end + + def basic_requirements + [ + Requirement.new( + name: :has_account, + description: "Has created an account", + met_predicate: ->(_) { true } + ) + ] + end + + def bronze_requirements + basic_requirements + [ + Requirement.new( + name: :hackatime_hours, + description: "Has logged at least 10 hours with Hackatime", + met_predicate: ->(user) do + ::Heartbeat.where(user: user) + .with_valid_timestamps + .duration_seconds >= 10.hours + end + ) + ] + end + + def silver_requirements + bronze_requirements + [ + Requirement.new( + name: :ysws_hours, + description: "Has at least 20 hours of approved YSWS projects", + met_predicate: ->(user) { total_ysws_hours(user) >= 20 } + ) + ] + end + + def gold_requirements + silver_requirements + [ + Requirement.new( + name: :ysws_hours, + description: "Has at least 50 hours of approved YSWS projects", + met_predicate: ->(user) { total_ysws_hours(user) >= 50 } + ) + ] + end + + def platinum_requirements + gold_requirements + [ + Requirement.new( + name: :ysws_hours, + description: "Has at least 100 hours of approved YSWS projects", + met_predicate: ->(user) { total_ysws_hours(user) >= 100 } + ) + ] + end + end + + class Requirement + attr_reader :name, :description, :met_predicate + + def initialize(name:, description:, met_predicate:) + @name = name + @description = description + @met_predicate = met_predicate + end + + def met?(user) + met_predicate.call(user) + end + end +end diff --git a/app/models/membership.rb b/app/models/membership.rb new file mode 100644 index 00000000..701fada6 --- /dev/null +++ b/app/models/membership.rb @@ -0,0 +1,60 @@ +class Membership + attr_reader :user + + # This is a virtual model that is used to store the membership information for + # a userโ€“ it's not a real model in the database + + def initialize(user) + @user = user + end + + def total_hours + @total_hours ||= ysws_projects.sum do |project| + # handle if the Hours Spent is an array + if project["hours_spent"].is_a?(Array) + project["hours_spent"].first + else + project["hours_spent"] || 0.0 + end + end.round(1) + end + + def ysws_projects + @ysws_projects ||= begin + user_emails = @user.email_addresses.pluck(:email) + ::Warehouse::UnifiedYsws::ApprovedProject.where(email: user_emails) + end + end + + def member_since + @member_since ||= begin + user_emails = @user.email_addresses.pluck(:email) + ::Warehouse::ProgramEngagement::HackClubber.where(email: user_emails).minimum(:first_engagement_at) + end + end + + def current_status + case total_hours + when 0...10 + :basic + when 10...80 + :bronze + when 80...200 + :silver + when 200...500 + :gold + else + :diamond + end + end + + def humanized_status + { + basic: "Basic", + bronze: "Preferred Bronze", + silver: "Preferred Silver", + gold: "Preferred Gold", + diamond: "Preferred Diamond" + }[current_status] + end +end diff --git a/app/models/membership_upgrade_request.rb b/app/models/membership_upgrade_request.rb new file mode 100644 index 00000000..a644d2c5 --- /dev/null +++ b/app/models/membership_upgrade_request.rb @@ -0,0 +1,16 @@ +class MembershipUpgradeRequest < ApplicationRecord + belongs_to :user + + enum :from_status, User.membership_types, prefix: true + enum :to_status, User.membership_types, prefix: true + + enum :payment_method, { + project: 0, + cash: 1 + } + enum :status, { + pending: 0, + approved: 1, + rejected: 2 + } +end diff --git a/app/models/user.rb b/app/models/user.rb index bb30e1a2..dafd743f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,4 +1,5 @@ class User < ApplicationRecord + include MembershipRequirements has_paper_trail encrypts :slack_access_token, :github_access_token @@ -30,6 +31,15 @@ class User < ApplicationRecord delegate :streak_days, :streak_days_formatted, to: :heartbeats + enum :membership_type, { + basic: 0, + bronze: 1, + silver: 2, + gold: 3, + platinum: 4 + }, prefix: "membership" + enum :membership_eligibility_sent_for_status, User.membership_types, prefix: "sent_notice_of_membership" + enum :hackatime_extension_text_type, { simple_text: 0, clock_emoji: 1, @@ -46,11 +56,41 @@ def data_migration_jobs ).order(created_at: :desc).limit(10).all end - def format_extension_text(duration) - case hackatime_extension_text_type + def leaderboard_rank_text + @leaderboard_rank_text ||= begin + entry = LeaderboardEntry.joins(:leaderboard) + .where(user: self) + .where(leaderboards: { period_type: [ :daily, :weekly, :last_7_days ] }) + .where(rank: 1..3) + .order("leaderboards.period_type ASC") + .first + + return nil unless entry + + rank_emoji = { + 1 => "๐Ÿฅ‡", + 2 => "๐Ÿฅˆ", + 3 => "๐Ÿฅ‰" + }[entry.rank] + + leaderboard_type = case entry.leaderboard.period_type + when "daily" then "daily" + when "weekly" then "weekly" + when "last_7_days" then "7-day" + end + + "#{rank_emoji} on #{leaderboard_type} ldbrd" + end + end + + def format_extension_text(duration, text_type = hackatime_extension_text_type) + case text_type when "simple_text" return "Start coding to track your time" if duration.zero? - ::ApplicationController.helpers.short_time_simple(duration) + + msg = ::ApplicationController.helpers.short_time_simple(duration) + msg += " (#{leaderboard_rank_text})" if leaderboard_rank_text.present? + msg when "clock_emoji" ::ApplicationController.helpers.time_in_emoji(duration) when "compliment_text" @@ -329,6 +369,39 @@ def find_valid_token(token) sign_in_tokens.valid.find_by(token: token) end + def membership + @membership ||= Membership.new(self) + end + + def total_hours + membership.total_hours + end + + def ysws_projects + membership.ysws_projects + end + + def member_since + membership.member_since + end + + def current_status + MembershipRequirements.current_status(self) + end + + def next_status + MembershipRequirements.next_status(current_status) + end + + def eligible_for_status?(status) + MembershipRequirements.eligible_for_status?(self, status) + end + + def eligible_for_next_status? + next_status_type = next_status + next_status_type && eligible_for_status?(next_status_type) + end + private def invalidate_activity_graph_cache diff --git a/app/models/warehouse/program_engagement/hack_clubber.rb b/app/models/warehouse/program_engagement/hack_clubber.rb new file mode 100644 index 00000000..b444ad22 --- /dev/null +++ b/app/models/warehouse/program_engagement/hack_clubber.rb @@ -0,0 +1,7 @@ +class Warehouse::ProgramEngagement::HackClubber < WarehouseRecord + self.table_name = "airtable_analytics___program_engagements_appcgb6lccmzwkjzg.hack_clubbers" + + def member_since + self.first_engagement_at + end +end diff --git a/app/models/warehouse/program_engagement/program.rb b/app/models/warehouse/program_engagement/program.rb new file mode 100644 index 00000000..1aa45beb --- /dev/null +++ b/app/models/warehouse/program_engagement/program.rb @@ -0,0 +1,5 @@ +class Warehouse::ProgramEngagement::Program < WarehouseRecord + self.table_name = "airtable_analytics___program_engagements_appcgb6lccmzwkjzg.programs" + + has_many :hack_clubbers, class_name: "Warehouse::ProgramEngagement::HackClubber", foreign_key: "program_id" +end diff --git a/app/models/warehouse/scrapyard_local_attendee.rb b/app/models/warehouse/scrapyard/local_attendee.rb similarity index 83% rename from app/models/warehouse/scrapyard_local_attendee.rb rename to app/models/warehouse/scrapyard/local_attendee.rb index eed600f9..6ca542a6 100644 --- a/app/models/warehouse/scrapyard_local_attendee.rb +++ b/app/models/warehouse/scrapyard/local_attendee.rb @@ -1,4 +1,4 @@ -class Warehouse::ScrapyardLocalAttendee < WarehouseRecord +class Warehouse::Scrapyard::LocalAttendee < WarehouseRecord self.table_name = "airtable_hack_club_scrapyard_appigkif7gbvisalg.local_attendees" # The event field in Airtable is a JSONB array with a single event ID @@ -9,7 +9,7 @@ def event_id # Override the event association to handle the array field def event return nil if event_id.nil? - Warehouse::ScrapyardEvent.find_by(id: event_id) + Warehouse::Scrapyard::LocalEvent.find_by(id: event_id) end # Find the associated user through their email diff --git a/app/models/warehouse/scrapyard_event.rb b/app/models/warehouse/scrapyard/local_event.rb similarity index 58% rename from app/models/warehouse/scrapyard_event.rb rename to app/models/warehouse/scrapyard/local_event.rb index 27b8e430..a1d98dbd 100644 --- a/app/models/warehouse/scrapyard_event.rb +++ b/app/models/warehouse/scrapyard/local_event.rb @@ -1,9 +1,9 @@ -class Warehouse::ScrapyardEvent < WarehouseRecord +class Warehouse::Scrapyard::LocalEvent < WarehouseRecord self.table_name = "airtable_hack_club_scrapyard_appigkif7gbvisalg.events" # Prevent these columns from messing with acriverecord self.ignored_columns += [ "errors" ] # local attendees is a list of airtable ids - has_many :local_attendees, class_name: "Warehouse::ScrapyardLocalAttendee", foreign_key: "event_id" + has_many :local_attendees, class_name: "Warehouse::Scrapyard::LocalAttendee", foreign_key: "event_id" end diff --git a/app/models/warehouse/unified_ysws/approved_project.rb b/app/models/warehouse/unified_ysws/approved_project.rb new file mode 100644 index 00000000..dcbbb2e8 --- /dev/null +++ b/app/models/warehouse/unified_ysws/approved_project.rb @@ -0,0 +1,12 @@ +class Warehouse::UnifiedYsws::ApprovedProject < WarehouseRecord + self.table_name = "airtable_unified_ysws_projects_db_app3a5kjwyqxmlogh.approved_projects" + + def find_by_user(user) + emails = EmailAddress.where(user: user).pluck(:email) + where(email: emails) + end + + def humanized_ysws_name + ysws_name.first + end +end diff --git a/app/views/membership_mailer/notify_eligible_for_status.html.erb b/app/views/membership_mailer/notify_eligible_for_status.html.erb new file mode 100644 index 00000000..32ed6f50 --- /dev/null +++ b/app/views/membership_mailer/notify_eligible_for_status.html.erb @@ -0,0 +1,36 @@ + + + + + + +

+ Welcome to Hack Club Preferred
+ <%= @status.to_s.humanize %> Status +

+

+ <% if specific_project %> + Thanks to your work on + <% if specific_project_link %> + <%= link_to specific_project.humanized_ysws_name, specific_project_link %> + <% else %> + <%= specific_project %> + <% end %> + , you have a new approved YSWS project. + <% end %> + We're pleased to welcome you to a new tier of Hack Club membership! + With your new status comes a host of benefits, including: +

+

+

+ You can upgrade your membership immediately at the link below. +

+

+ <%= link_to 'Activate your membership', my_membership_url %> +

+ + \ No newline at end of file diff --git a/app/views/membership_upgrade_requests/create.html.erb b/app/views/membership_upgrade_requests/create.html.erb new file mode 100644 index 00000000..8ce3d602 --- /dev/null +++ b/app/views/membership_upgrade_requests/create.html.erb @@ -0,0 +1,2 @@ +

MembershipUpgradeRequests#create

+

Find me in app/views/membership_upgrade_requests/create.html.erb

diff --git a/app/views/membership_upgrade_requests/new.html.erb b/app/views/membership_upgrade_requests/new.html.erb new file mode 100644 index 00000000..755d51cb --- /dev/null +++ b/app/views/membership_upgrade_requests/new.html.erb @@ -0,0 +1,121 @@ +
+

Request Membership Upgrade

+ +

+ Congratulations on your new status! + In order for us to ship your membership card and other rewards, we'll need to pay for the items and shipping. + You can pay the $15 yourself, or by submitting a new YSWS project or with cash. +

+ +
+
+
+

Pay with Project

+
+
+

Submit a project to cover your upgrade costs.

+ <%= link_to "Submit Project", "https://forms.hackclub.com/t/4rZYZejBSDus", + class: "button", + target: "_blank" %> +
+
+ +
+
+

Pay with USD

+
+
+

We'll send you an invoice for the upgrade costs.

+ <%= form_with(model: @membership_upgrade_request, local: true) do |f| %> + <%= f.hidden_field :payment_method, value: 'direct_payment' %> + <%= f.hidden_field :from_status, value: current_user.membership_type %> + <%= f.hidden_field :to_status, value: current_user.next_status %> + + <%= f.submit "Request Invoice", class: "button" %> + <% end %> +
+
+
+
+ + diff --git a/app/views/memberships/index.html.erb b/app/views/memberships/index.html.erb new file mode 100644 index 00000000..5b900a89 --- /dev/null +++ b/app/views/memberships/index.html.erb @@ -0,0 +1,168 @@ +<%# app/views/memberships/index.html.erb %> +
+
+ <%= image_tag "/membership-banner.jpg", alt: "Hack Club Membership Banner", class: "img-fluid" %> + +
+ +

Hack Club Membership

+ +

+ Hack Club Preferred<%= FlavorText.legal_thingies.sample %> status gives you access to an elevated experience in Hack Club, and incentivizes you to ship more projects through the year! +

+ +
+ <%= link_to my_membership_path, class: "button" do %> + Check my status +
+ <% end %> +
+ +
+

Preferred Status Levels

+ +

+ Preferred status is earned by shipping projects and tracking hours through Hackatime! To qualify for the lowest tier (bronze), you must code for at least 10 hours towards any project(s) of your choice (even if they're a part of another YSWS!). After you reach the bronze tier, you become eligible to order your physical Hack Club membership card! +

+ +
+
+
+

Preferred
Bronze

+
+

10 approved YSWS hours

+

or

+

20 hours tracked on Hackatime

+
+
+ +
+
+

Preferred
Silver

+
+

80 approved YSWS hours

+
+
+ +
+
+

Preferred
Gold

+
+

200 approved YSWS hours

+
+
+ +
+
+

Preferred
Diamond

+
+

500 approved YSWS hours

+
+
+
+
+ +
+

Preferred Status Benefits

+ +

+ Here are some of the top benefits you'll get with Preferred status. Each tier unlocks additional perks and multipliers. +

+ +
+
+

Preferred
Bronze

+
    +
  • + ๐Ÿ’ณ + Eligible for a physical bronze membership card. +
  • +
  • + ๐Ÿท๏ธ + Exclusive Bronze Member Laptop Sticker. +
  • +
  • + ๐Ÿ… + Exclusive membership badge on Hackatime and Slack. +
  • +
+
+ +
+

Preferred
Silver

+
    +
  • + ๐Ÿ’ณ + Eligible for a physical silver membership card. +
  • +
  • + ๐Ÿท๏ธ + Exclusive Silver Member Metal Pin. +
  • +
  • + ๐Ÿ… + Exclusive membership badge on Hackatime and Slack. +
  • +
+
+ +
+

Preferred
Gold

+
    +
  • + โญ๏ธ + Priority access to check-in and food lines at all in-person Hack Club events. +
  • +
  • + ๐Ÿ’ณ + Eligible for a physical gold membership card. +
  • +
  • + ๐Ÿท๏ธ + Exclusive Gold Member Baggage Tags. +
  • +
  • + ๐Ÿ… + Exclusive membership badge on Hackatime and Slack. +
  • +
+
+ +
+

Preferred
Diamond

+
    +
  • + โœจ + In-person internship at Hack Club HQ in Vermont + i
    Limited spots available! The first 5 diamond members will get preferred spots in the internship program
    +
  • +
  • + โญ๏ธ + Priority access to check-in and food lines at all in-person Hack Club events. +
  • +
  • + ๐Ÿ’ณ + Eligible for a physical diamond membership card. +
  • +
  • + ๐Ÿท๏ธ + Exclusive Diamond Member Backpack. +
  • +
  • + ๐Ÿ… + Exclusive membership badge on Hackatime and Slack. +
  • +
+
+
+
+ +
+

Ready to Join?

+ <% if current_user %> +

You're already a member! Check out your <%= link_to "membership status", my_membership_path %>.

+ <% else %> +

<%= link_to "Sign in with Slack", slack_auth_path, class: "button" %> to become a member!

+ <% end %> +
+
\ No newline at end of file diff --git a/app/views/memberships/show.html.erb b/app/views/memberships/show.html.erb new file mode 100644 index 00000000..046584bc --- /dev/null +++ b/app/views/memberships/show.html.erb @@ -0,0 +1,112 @@ +
+

Membership for <%= render "shared/user_mention", user: @user %>

+ +
+
+
+
+
+
+ +
+
+
+
+ <%= image_tag "/flag-standalone-bw.png", class: "flag-icon", alt: "Hack Club Flag" %> +
+
+ +
+
+
+
+
+ +
+ <% if @membership.member_since %> +

"> + Member Since <%= @membership.member_since.strftime("%b '%y") %> + (<%= time_ago_in_words(@membership.member_since) %> ago) +

+ <% end %> + <% if @user.streak_days_formatted %> +

Current streak: <%= @user.streak_days_formatted %> days ๐Ÿ”ฅ

+ <% end %> + + <% if @eligible_for_upgrade %> +
+

๐ŸŽ‰ You're eligible to upgrade to <%= @user.next_status.to_s.titleize %> status!

+

Upgrading will send you a physical card in the mail. You can either pay for it directly, or submit a YSWS project to cover your costs.

+ + <%= link_to "Request Upgrade", new_membership_upgrade_request_path, class: "button button-primary" %> + + <%# pay with $$$ %> + + <%# pay with ysws project %> +
+ <% else %> +
+

Requirements for <%= @user.next_status.to_s.titleize %> Status

+ <% requirements = MembershipRequirements.requirements_for(@user.next_status) %> + <% requirements.each do |requirement| %> +
+
+ <%= requirement.met?(@user) ? 'โœ…' : 'โณ' %> +
+
+

<%= requirement.description %>

+ <% if requirement.met?(@user) %> +

Completed!

+ <% else %> +

+ <% case requirement.name %> + <% when :hackatime_hours %> + <%= number_with_precision(::Heartbeat.where(user: @user).with_valid_timestamps.duration_seconds / 1.hour, precision: 1) %> / 10 hours + <% when :ysws_hours %> + <%= number_with_precision(@membership.total_ysws_hours, precision: 1) %> hours + <% else %> + In progress + <% end %> +

+ <% end %> +
+
+ <% end %> +
+ <% end %> + + <% dev_tool do %> +

Current Status: <%= @user.membership_type %>

+

Next Status: <%= @user.next_status %>

+

Eligible for Upgrade: <%= @eligible_for_upgrade %>

+

Total YSWS Hours: <%= @membership.total_hours %>

+

Debug: Controller Action: <%= controller.action_name %>

+

Debug: Request Path: <%= request.path %>

+

Debug: Object ID: <%= @membership.object_id %>

+

Debug: Template ID: membership-show-<%= Time.current.to_i %>

+ <% end %> +
+ + +
\ No newline at end of file diff --git a/app/views/shared/_nav.html.erb b/app/views/shared/_nav.html.erb index 21f131f3..30f2bdd1 100644 --- a/app/views/shared/_nav.html.erb +++ b/app/views/shared/_nav.html.erb @@ -33,6 +33,16 @@ Leaderboards <% end %> + <% dev_tool(nil, "li") do %> + <%= link_to membership_path, class: "nav-item #{current_page?(membership_path) ? 'active' : ''}" do %> + Membership + <% end %> + <% end %> + <% admin_tool(nil, "li") do %> + <%= link_to my_membership_path, class: "nav-item #{current_page?(my_membership_path) ? 'active' : ''}" do %> + My Membership + <% end %> + <% end %> <% if current_user %>
  • <%= link_to my_settings_path, class: "nav-item #{current_page?(my_settings_path) ? 'active' : ''}" do %> diff --git a/app/views/shared/_user_mention.html.erb b/app/views/shared/_user_mention.html.erb index 7315fa7c..bdf4dd97 100644 --- a/app/views/shared/_user_mention.html.erb +++ b/app/views/shared/_user_mention.html.erb @@ -1,8 +1,17 @@
    "> - <%= image_tag user.avatar_url, - size: "32x32", - class: "avatar", - alt: "#{user.username}'s avatar" if user.avatar_url %> +
    + <%= image_tag user.avatar_url, + size: "32x32", + class: "avatar", + alt: "#{user.username}'s avatar" if user.avatar_url %> + <% unless user.membership_basic? %> + <%= link_to user_membership_path(user.slack_uid), class: "status-icon-link" do %> + + + + <% end %> + <% end %> +
    <% if local_assigns.fetch(:show, []).include?(:slack) && user.slack_uid.present? %> <%= link_to "@#{user.display_name}", "https://slack.com/app_redirect?channel=#{user.slack_uid}", target: "_blank" %> <% else %> diff --git a/config/database.yml b/config/database.yml index 74a5ae45..a7d4dd6e 100644 --- a/config/database.yml +++ b/config/database.yml @@ -13,7 +13,7 @@ development: <<: *default adapter: postgresql encoding: unicode - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 2 } %> url: <%= ENV['DATABASE_URL'] %> wakatime: adapter: postgresql diff --git a/config/routes.rb b/config/routes.rb index 7a0d690b..b2abfda6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,11 +8,18 @@ def self.matches?(request) end Rails.application.routes.draw do + get "membership_upgrade_requests/new" + get "membership_upgrade_requests/create" constraints AdminConstraint do mount Avo::Engine, at: Avo.configuration.root_path mount GoodJob::Engine => "good_job" get "/impersonate/:id", to: "sessions#impersonate", as: :impersonate_user + + # Membership routes + get "membership", to: "memberships#index", as: :membership + get "my/membership", to: "memberships#show", as: :my_membership, slack_uid: "my" + get ":slack_uid/membership", to: "memberships#show", as: :user_membership end get "/stop_impersonating", to: "sessions#stop_impersonating", as: :stop_impersonating @@ -92,4 +99,6 @@ def self.matches?(request) end resources :scrapyard_leaderboards, only: [ :index, :show ] + + resources :membership_upgrade_requests, only: [ :new, :create ] end diff --git a/db/migrate/20250421013843_add_membership_type_to_users.rb b/db/migrate/20250421013843_add_membership_type_to_users.rb new file mode 100644 index 00000000..bde11c19 --- /dev/null +++ b/db/migrate/20250421013843_add_membership_type_to_users.rb @@ -0,0 +1,5 @@ +class AddMembershipTypeToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :membership_type, :integer, default: 0 + end +end diff --git a/db/migrate/20250421031610_add_membership_eligibility_sent_for_status_to_users.rb b/db/migrate/20250421031610_add_membership_eligibility_sent_for_status_to_users.rb new file mode 100644 index 00000000..00b9dc56 --- /dev/null +++ b/db/migrate/20250421031610_add_membership_eligibility_sent_for_status_to_users.rb @@ -0,0 +1,5 @@ +class AddMembershipEligibilitySentForStatusToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :membership_eligibility_sent_for_status, :integer, default: 0 + end +end diff --git a/db/migrate/20250421150633_create_membership_upgrade_requests.rb b/db/migrate/20250421150633_create_membership_upgrade_requests.rb new file mode 100644 index 00000000..ecd817ed --- /dev/null +++ b/db/migrate/20250421150633_create_membership_upgrade_requests.rb @@ -0,0 +1,13 @@ +class CreateMembershipUpgradeRequests < ActiveRecord::Migration[8.0] + def change + create_table :membership_upgrade_requests do |t| + t.references :user, null: false, foreign_key: true + t.integer :from_status + t.integer :to_status + t.integer :payment_method + t.integer :status + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index fe652ab3..53a3d367 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_03_24_203539) do +ActiveRecord::Schema[8.0].define(version: 2025_04_21_150633) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -176,6 +176,17 @@ t.integer "period_type", default: 0, null: false end + create_table "membership_upgrade_requests", force: :cascade do |t| + t.bigint "user_id", null: false + t.integer "from_status" + t.integer "to_status" + t.integer "payment_method" + t.integer "status" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_membership_upgrade_requests_on_user_id" + end + create_table "project_repo_mappings", force: :cascade do |t| t.bigint "user_id", null: false t.string "project_name", null: false @@ -250,6 +261,8 @@ t.text "github_access_token" t.string "github_username" t.string "slack_username" + t.integer "membership_type", default: 0 + t.integer "membership_eligibility_sent_for_status", default: 0 t.index ["slack_uid"], name: "index_users_on_slack_uid", unique: true t.index ["timezone"], name: "index_users_on_timezone" end @@ -270,6 +283,7 @@ add_foreign_key "heartbeats", "users" add_foreign_key "leaderboard_entries", "leaderboards" add_foreign_key "leaderboard_entries", "users" + add_foreign_key "membership_upgrade_requests", "users" add_foreign_key "project_repo_mappings", "users" add_foreign_key "sign_in_tokens", "users" end diff --git a/lib/flavor_text.rb b/lib/flavor_text.rb index 42ec6f35..85a64b83 100644 --- a/lib/flavor_text.rb +++ b/lib/flavor_text.rb @@ -11,6 +11,14 @@ def self.same_user ] end + def self.legal_thingies + [ + "ยฉ", + "โ„ข", + "ยฎ" + ] + end + def self.slack_loading_messages [ ".split() === :large_blue_circle::large_green_circle::large_yellow_circle::large_orange_circle::red_circle::large_purple_circle:", diff --git a/public/flag-standalone-bw.png b/public/flag-standalone-bw.png new file mode 100644 index 00000000..caf8e675 Binary files /dev/null and b/public/flag-standalone-bw.png differ diff --git a/public/membership-banner.jpg b/public/membership-banner.jpg new file mode 100644 index 00000000..bf444c87 Binary files /dev/null and b/public/membership-banner.jpg differ diff --git a/test/controllers/membership_upgrade_requests_controller_test.rb b/test/controllers/membership_upgrade_requests_controller_test.rb new file mode 100644 index 00000000..ffb9de4a --- /dev/null +++ b/test/controllers/membership_upgrade_requests_controller_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +class MembershipUpgradeRequestsControllerTest < ActionDispatch::IntegrationTest + test "should get new" do + get membership_upgrade_requests_new_url + assert_response :success + end + + test "should get create" do + get membership_upgrade_requests_create_url + assert_response :success + end +end diff --git a/test/fixtures/membership_upgrade_requests.yml b/test/fixtures/membership_upgrade_requests.yml new file mode 100644 index 00000000..0c4d5414 --- /dev/null +++ b/test/fixtures/membership_upgrade_requests.yml @@ -0,0 +1,15 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: one + from_status: 1 + to_status: 1 + payment_method: 1 + status: 1 + +two: + user: two + from_status: 1 + to_status: 1 + payment_method: 1 + status: 1 diff --git a/test/models/membership_upgrade_request_test.rb b/test/models/membership_upgrade_request_test.rb new file mode 100644 index 00000000..ac4c4452 --- /dev/null +++ b/test/models/membership_upgrade_request_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class MembershipUpgradeRequestTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end