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 @@ + + +
+ + + ++ <% 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 @@ +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 @@ ++ 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. +
+ + ++ 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! +
+ ++ 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! +
+ +10 approved YSWS hours
+or
+20 hours tracked on Hackatime
+80 approved YSWS hours
+200 approved YSWS hours
+500 approved YSWS hours
++ Here are some of the top benefits you'll get with Preferred status. Each tier unlocks additional perks and multipliers. +
+ +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 %> +"> + 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 %> +<%= 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 %> +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 %> +