Skip to content

Instantly share code, notes, and snippets.

@matedemorphy
Created July 22, 2026 23:00
Show Gist options
  • Select an option

  • Save matedemorphy/9ce6c011507828bd37a55ce1bd202d47 to your computer and use it in GitHub Desktop.

Select an option

Save matedemorphy/9ce6c011507828bd37a55ce1bd202d47 to your computer and use it in GitHub Desktop.
<%# 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 %>
// 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()
}
}
<%# 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>
# 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
# 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