Created
July 22, 2026 23:00
-
-
Save matedemorphy/9ce6c011507828bd37a55ce1bd202d47 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <%# app/views/onboarding/steps/role.html.erb %> | |
| <%= render Onboarding::LayoutComponent.new( | |
| current_step_key: :role, | |
| role: @role, | |
| show_previous: false, | |
| continue_enabled: true | |
| ) do |layout| %> | |
| <%= form_with url: onboarding_step_path(step: :role), method: :patch, | |
| html: { novalidate: true } do |f| %> | |
| <%= render Onboarding::RoleSelectionStepComponent.new( | |
| form: f, | |
| creator_eligible: @creator_eligible, | |
| geocoder_error: @geocoder_error, | |
| selected_type: f.object.respond_to?(:account_type) ? f.object.account_type : nil | |
| ) %> | |
| <% if flash[:alert].present? %> | |
| <p class="mt-3 text-sm text-red-600 font-medium"><%= flash[:alert] %></p> | |
| <% end %> | |
| <div class="mt-6 flex justify-end"> | |
| <%= f.submit "Continue →", | |
| name: "nav", value: "next", | |
| class: "px-6 py-3 rounded-xl bg-indigo-600 text-white text-sm font-semibold | |
| hover:bg-indigo-700 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500" %> | |
| </div> | |
| <% end %> | |
| <% end %> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // role_selection_controller.js | |
| // | |
| // Stimulus controller for the account type selection step. | |
| // Enables the Continue button only when a radio is selected. | |
| // Server is always authoritative; this is progressive enhancement only. | |
| // | |
| // Constitution: Stimulus for progressive enhancement only (Principle XVI). | |
| import { Controller } from "@hotwired/stimulus" | |
| export default class extends Controller { | |
| static targets = ["radio"] | |
| connect() { | |
| this.updateSubmitState() | |
| } | |
| updateSubmitState() { | |
| const anyChecked = this.radioTargets.some(r => r.checked && !r.disabled) | |
| const submit = this.element.closest("form")?.querySelector("[type=submit]") | |
| if (submit) { | |
| submit.disabled = !anyChecked | |
| if (!anyChecked) { | |
| submit.classList.add("opacity-50", "cursor-not-allowed") | |
| } else { | |
| submit.classList.remove("opacity-50", "cursor-not-allowed") | |
| } | |
| } | |
| } | |
| // Called by data-action="change->role-selection#select" on each radio | |
| select() { | |
| this.updateSubmitState() | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <%# Role selection step — PrebuiltUI-adapted card radios %> | |
| <div data-controller="role-selection"> | |
| <h2 class="text-2xl font-bold text-gray-900 mb-2">How do you want to use WishLink?</h2> | |
| <p class="text-gray-500 mb-6">Choose your account type. You can always explore as a fan later.</p> | |
| <div class="space-y-4"> | |
| <%# Creator card %> | |
| <label class="relative block cursor-pointer<%= ' opacity-50 cursor-not-allowed' unless creator_eligible %>" | |
| for="account_type_creator"> | |
| <input type="radio" | |
| id="account_type_creator" | |
| name="account_type" | |
| value="creator" | |
| <%= "checked" if selected_type == "creator" %> | |
| <%= "disabled" unless creator_eligible %> | |
| data-role-selection-target="radio" | |
| data-action="change->role-selection#select" | |
| class="sr-only peer" /> | |
| <div class="rounded-2xl border-2 border-gray-200 p-5 transition-all | |
| peer-checked:border-indigo-600 peer-checked:bg-indigo-50 | |
| hover:border-indigo-300 <%= creator_eligible ? '' : 'pointer-events-none' %>"> | |
| <div class="flex items-start gap-4"> | |
| <span class="flex-shrink-0 text-3xl">🎁</span> | |
| <div> | |
| <p class="font-semibold text-gray-900">Creator</p> | |
| <p class="text-sm text-gray-500 mt-1"> | |
| Build your wishlist, share it with fans, and receive gifts. | |
| </p> | |
| <% unless creator_eligible %> | |
| <p class="mt-2 text-xs font-medium text-amber-600"> | |
| ⚠ <%= creator_unavailable_message %> | |
| </p> | |
| <% end %> | |
| </div> | |
| </div> | |
| </div> | |
| </label> | |
| <%# Fan card %> | |
| <label class="relative block cursor-pointer" for="account_type_fan"> | |
| <input type="radio" | |
| id="account_type_fan" | |
| name="account_type" | |
| value="fan" | |
| <%= "checked" if selected_type == "fan" %> | |
| data-role-selection-target="radio" | |
| data-action="change->role-selection#select" | |
| class="sr-only peer" /> | |
| <div class="rounded-2xl border-2 border-gray-200 p-5 transition-all | |
| peer-checked:border-indigo-600 peer-checked:bg-indigo-50 | |
| hover:border-indigo-300"> | |
| <div class="flex items-start gap-4"> | |
| <span class="flex-shrink-0 text-3xl">✨</span> | |
| <div> | |
| <p class="font-semibold text-gray-900">Fan</p> | |
| <p class="text-sm text-gray-500 mt-1"> | |
| Discover creators, explore wishlists, and send gifts. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </label> | |
| </div> | |
| <%# Render Simple Form errors if any %> | |
| <% if form.object.respond_to?(:errors) && form.object.errors[:account_type].any? %> | |
| <p class="mt-3 text-sm text-red-600 font-medium"> | |
| <%= form.object.errors[:account_type].first %> | |
| </p> | |
| <% end %> | |
| </div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Onboarding::SelectAccountType | |
| # | |
| # Persists the user's role selection (creator or fan) and creates the | |
| # appropriate profile draft record. Called when the user submits the role | |
| # step with nav=next. | |
| # | |
| # Context inputs: | |
| # user [User] – current authenticated user | |
| # account_type [String] – "creator" or "fan" | |
| # session [ActionDispatch::Session] – to access geo-eligibility cache | |
| # request [ActionDispatch::Request] – passed to VerifyCreatorEligibility | |
| # | |
| # Context outputs: | |
| # next_step [Symbol] – step key to redirect to after success | |
| # | |
| # Failures: | |
| # - account_type is neither "creator" nor "fan" | |
| # - Creator selected but IP is not from Colombia | |
| module Onboarding | |
| class SelectAccountType | |
| include Interactor | |
| VALID_TYPES = %w[creator fan].freeze | |
| def call | |
| validate_account_type! | |
| handle_role_switch! | |
| case context.account_type | |
| when "creator" then select_creator | |
| when "fan" then select_fan | |
| end | |
| end | |
| private | |
| def validate_account_type! | |
| return if VALID_TYPES.include?(context.account_type) | |
| context.fail!(errors: { account_type: ["is not valid; choose Creator or Fan"] }) | |
| end | |
| # When switching between roles, clean up the previous path's data. | |
| def handle_role_switch! | |
| user = context.user | |
| if context.account_type == "fan" && user.creator_profile.present? | |
| Onboarding::ResetOnboardingPath.call(user: user) | |
| elsif context.account_type == "creator" && user.fan_profile.present? | |
| user.fan_profile.destroy! | |
| user.remove_role(:fan) | |
| end | |
| end | |
| def select_creator | |
| result = VerifyCreatorEligibility.call( | |
| request: context.request, | |
| session: context.session | |
| ) | |
| unless result.creator_eligible | |
| message = if result.geocoder_error | |
| "Creator accounts are temporarily unavailable. Please try again or select Fan." | |
| else | |
| "Creator accounts are only available in Colombia for now." | |
| end | |
| context.fail!(errors: { account_type: [message] }) | |
| return | |
| end | |
| user = context.user | |
| user.creator_profile || user.build_creator_profile | |
| user.update!(onboarding_step: "photo") | |
| context.next_step = :photo | |
| end | |
| def select_fan | |
| user = context.user | |
| user.fan_profile || user.build_fan_profile.save! | |
| user.update!(onboarding_step: "complete") | |
| context.next_step = :complete | |
| end | |
| end | |
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Onboarding::StepsController | |
| # | |
| # Handles rendering and processing every wizard step. | |
| # | |
| # GET /onboarding/steps/:step → show – render step UI | |
| # PATCH /onboarding/steps/:step → update – validate + advance or go back | |
| # | |
| # Constitution: thin controller — business logic lives in Interactors; | |
| # controller only authorizes, dispatches, and decides the HTTP response. | |
| module Onboarding | |
| class StepsController < ApplicationController | |
| before_action :authenticate_user! | |
| before_action :validate_step_param | |
| before_action :guard_future_step | |
| # GET /onboarding/steps/:step | |
| def show | |
| authorize :onboarding, :show? | |
| load_onboarding_context | |
| render_step | |
| end | |
| # PATCH /onboarding/steps/:step | |
| def update | |
| authorize :onboarding, :update? | |
| load_onboarding_context | |
| if params[:nav] == "back" | |
| handle_back_navigation | |
| else | |
| handle_forward_navigation | |
| end | |
| end | |
| private | |
| # ------------------------------------------------------------------------- | |
| # Filters | |
| # ------------------------------------------------------------------------- | |
| def validate_step_param | |
| unless Onboarding::StepCatalog.valid_step?(params[:step]) | |
| render plain: "Not Found", status: :not_found | |
| end | |
| end | |
| # Prevent users from jumping ahead to steps they haven't reached yet. | |
| # Redirects to their canonical current step if they try to deep-link | |
| # to a future step. | |
| def guard_future_step | |
| return unless user_signed_in? | |
| return if current_user.profile_completed? | |
| canonical = Onboarding::StepCatalog.earliest_incomplete_step(onboarding_user) | |
| requested = params[:step].to_sym | |
| # Allow accessing any completed step (back navigation) and the next step | |
| role = onboarding_role | |
| list = Onboarding::StepCatalog.steps_for(role) | |
| canon_idx = list.index(canonical) || 0 | |
| request_idx = list.index(requested) || 0 | |
| if request_idx > canon_idx | |
| redirect_to onboarding_step_path(step: canonical) | |
| end | |
| end | |
| # ------------------------------------------------------------------------- | |
| # Navigation handlers | |
| # ------------------------------------------------------------------------- | |
| def handle_back_navigation | |
| prev_step = Onboarding::StepCatalog.prev_step(params[:step], onboarding_role) | |
| if prev_step | |
| onboarding_user.update(onboarding_step: prev_step.to_s) | |
| redirect_to onboarding_step_path(step: prev_step) | |
| else | |
| redirect_to onboarding_step_path(step: params[:step]) | |
| end | |
| end | |
| def handle_forward_navigation | |
| interactor = Onboarding::StepCatalog.interactor_for(params[:step]) | |
| unless interactor | |
| # No interactor registered — just advance step | |
| next_step = Onboarding::StepCatalog.next_step(params[:step], onboarding_role) | |
| redirect_to onboarding_step_path(step: next_step || params[:step]) | |
| return | |
| end | |
| result = interactor.call(interactor_context) | |
| if result.success? | |
| handle_success(result) | |
| else | |
| handle_failure(result) | |
| end | |
| end | |
| def handle_success(result) | |
| if result.redirect_path.present? | |
| # Completion interactors set an explicit redirect target | |
| redirect_to result.redirect_path, | |
| notice: "Welcome to WishLink! Your account is ready." | |
| elsif result.next_step.present? | |
| redirect_to onboarding_step_path(step: result.next_step) | |
| else | |
| redirect_to onboarding_flow_path | |
| end | |
| end | |
| def handle_failure(result) | |
| @interactor_errors = result.errors || {} | |
| flash.now[:alert] = @interactor_errors.values.flatten.first if @interactor_errors.any? | |
| render_step(status: :unprocessable_entity) | |
| end | |
| # ------------------------------------------------------------------------- | |
| # Context helpers | |
| # ------------------------------------------------------------------------- | |
| # Load associations once per request to avoid N+1 | |
| def onboarding_user | |
| @onboarding_user ||= current_user.tap do |u| | |
| u.association(:creator_profile).load_target | |
| u.association(:fan_profile).load_target | |
| end | |
| end | |
| def onboarding_role | |
| @onboarding_role ||= begin | |
| return :creator if onboarding_user.has_role?(:creator) | |
| return :fan if onboarding_user.has_role?(:fan) | |
| # Infer from persisted draft | |
| cp = onboarding_user.creator_profile | |
| cp&.name.present? || cp&.handle.present? ? :creator : nil | |
| end | |
| end | |
| def load_onboarding_context | |
| @step = params[:step].to_sym | |
| @user = onboarding_user | |
| @role = onboarding_role | |
| @profile = @user.creator_profile | |
| @fan_profile = @user.fan_profile | |
| @errors = @interactor_errors || {} | |
| # Geocoder eligibility for the role step | |
| if @step == :role | |
| eligibility = Onboarding::VerifyCreatorEligibility.call( | |
| request: request, | |
| session: session | |
| ) | |
| @creator_eligible = eligibility.creator_eligible | |
| @geocoder_error = eligibility.geocoder_error | |
| end | |
| # Step progress info for the layout component | |
| @step_list = Onboarding::StepCatalog.steps_for(@role) | |
| @step_index = (@step_list.index(@step) || 0) + 1 | |
| @total_steps = @step_list.length | |
| @step_labels = @step_list.map { |s| Onboarding::StepCatalog::STEP_LABELS[s] } | |
| @show_previous = @step != :role | |
| end | |
| # Build the interactor context hash from request params | |
| def interactor_context | |
| base = { user: onboarding_user, session: session, request: request } | |
| case params[:step].to_sym | |
| when :role | |
| base.merge(account_type: params[:account_type]) | |
| when :photo | |
| base.merge(photo: params.dig(:creator_profile, :photo)) | |
| when :name | |
| base.merge(name: params.dig(:creator_profile, :name)) | |
| when :handle | |
| base.merge(handle: params.dig(:creator_profile, :handle)) | |
| when :bio | |
| base.merge(bio: params.dig(:creator_profile, :bio)) | |
| when :country | |
| base.merge( | |
| country: params.dig(:creator_profile, :country), | |
| age_affirmation: params[:age_affirmation] | |
| ) | |
| when :complete | |
| base | |
| else | |
| base | |
| end | |
| end | |
| # ------------------------------------------------------------------------- | |
| # Rendering | |
| # ------------------------------------------------------------------------- | |
| def render_step(status: :ok) | |
| render template: "onboarding/steps/#{@step}", status: status | |
| rescue ActionView::MissingTemplate | |
| # Fallback: render a generic step wrapper if no specific template exists | |
| render template: "onboarding/steps/show", status: status | |
| end | |
| end | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment