From c814c24ac210bc39a01318295bd2fb2c7269f0d6 Mon Sep 17 00:00:00 2001 From: Raygen Rupe Date: Sun, 28 Jun 2026 19:10:21 -0600 Subject: [PATCH 1/5] fix(hardware): allow funding reqs of 0 --- app/models/certification/funding_request.rb | 3 ++- .../certification/funding_request_test.rb | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/models/certification/funding_request.rb b/app/models/certification/funding_request.rb index c8bb34580..12cac0028 100644 --- a/app/models/certification/funding_request.rb +++ b/app/models/certification/funding_request.rb @@ -92,7 +92,7 @@ def self.tier_discount_summary end validates :complexity_tier, inclusion: { in: TIER_MAX_CENTS.keys } - validates :requested_amount_cents, numericality: { only_integer: true, greater_than: 0 } + validates :requested_amount_cents, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :approved_amount_cents, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true validates :feedback, length: { maximum: 10_000 }, allow_blank: true @@ -296,6 +296,7 @@ def accrue_discount_for_owner! def issue_hcb_grant! return if hcb_grant_hashid.present? + return if final_amount_cents.to_i == 0 owner = project.memberships.owner.first&.user || user grant = HCBService.create_card_grant( diff --git a/test/models/certification/funding_request_test.rb b/test/models/certification/funding_request_test.rb index 37bc38437..b2a125c3a 100644 --- a/test/models/certification/funding_request_test.rb +++ b/test/models/certification/funding_request_test.rb @@ -68,6 +68,25 @@ def setup assert fr.errors[:requested_amount_cents].any? end + test "allows a $0 funding request for the no-grant (kit provided) path" do + fr = @project.certification_funding_requests.new(user: @owner, complexity_tier: 1, requested_amount_cents: 0) + assert fr.valid?, fr.errors.full_messages.to_sentence + end + + test "approving a $0 request advances to build and accrues the discount without an HCB grant" do + fr = @project.certification_funding_requests.create!( + user: @owner, complexity_tier: 3, requested_amount_cents: 0, status: :pending + ) + # No HCBService stub: a $0 request must NOT attempt to issue a card grant + # (issuing would hit the real service and raise). + fr.update!(reviewer: @reviewer, status: :approved) + + assert_equal "build", @project.reload.hardware_stage + assert_nil fr.reload.hcb_grant_hashid + # tier 3 (S) flat discount still accrues toward the Outpost Ticket. + assert_equal 300, @owner.reload.outpost_discount_stardust + end + test "approval switches the project to build and accrues the owner discount" do fr = @project.certification_funding_requests.create!( user: @owner, complexity_tier: 3, requested_amount_cents: 6_000, status: :pending From 025108660b9287f18cadd914fe59fa7bf58b82b2 Mon Sep 17 00:00:00 2001 From: Raygen Rupe Date: Sun, 28 Jun 2026 19:11:34 -0600 Subject: [PATCH 2/5] fix(hardware): improve hardware project UX --- .../stylesheets/pages/_hardware_funding.scss | 105 +++++++++- .../stylesheets/pages/projects/_new.scss | 6 + .../stylesheets/pages/projects/_show.scss | 12 ++ .../controllers/project_builder_controller.js | 36 +++- app/views/projects/new.html.erb | 44 +++- app/views/projects/show.html.erb | 192 ++++++++++++++---- 6 files changed, 336 insertions(+), 59 deletions(-) diff --git a/app/assets/stylesheets/pages/_hardware_funding.scss b/app/assets/stylesheets/pages/_hardware_funding.scss index 2bce72668..f92f0b7ca 100644 --- a/app/assets/stylesheets/pages/_hardware_funding.scss +++ b/app/assets/stylesheets/pages/_hardware_funding.scss @@ -24,10 +24,16 @@ $outpost-teal: #34e0ba; color: #fff; font-weight: 800; - &:hover, - &:focus-visible { + &:hover:not(:disabled), + &:focus-visible:not(:disabled) { filter: brightness(1.05); } + + &:disabled { + background: rgba($outpost-ink, 0.15); + color: rgba($outpost-ink, 0.35); + cursor: not-allowed; + } } .ship-modal__btn--secondary { @@ -37,6 +43,67 @@ $outpost-teal: #34e0ba; } } +// "Do you need funding?" question step +.funding-modal__question-heading { + margin: 0 0 1rem; + font-size: 1.1rem; + font-weight: 800; + text-align: center; + color: $outpost-ink; +} + +.funding-modal__question-btns { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin-bottom: 1rem; +} + +.funding-modal__question-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1rem; + border-radius: 12px; + font: inherit; + font-weight: 700; + font-size: 1rem; + cursor: pointer; + transition: filter 0.12s ease; + + &:hover { + filter: brightness(1.06); + } + + &--yes { + background: linear-gradient(180deg, $outpost-gold, $outpost-orange); + border: none; + color: #fff; + } + + &--no { + background: rgba($outpost-ink, 0.08); + border: 2px solid rgba($outpost-ink, 0.2); + color: $outpost-ink; + } +} + +.funding-modal__question-cancel { + display: flex; + justify-content: center; +} + +.funding-modal__no-funding-note { + margin: 0 0 1rem; + padding: 0.65rem 0.85rem; + border-radius: 10px; + background: color-mix(in srgb, $outpost-teal 22%, #fff); + border: 1px solid color-mix(in srgb, $outpost-teal 55%, #fff); + font-size: 0.95rem; + color: #0c6b58; + line-height: 1.45; +} + .funding-modal__header { display: flex; flex-direction: column; @@ -196,16 +263,39 @@ $outpost-teal: #34e0ba; .funding-modal__bom-check { display: flex; - align-items: center; - gap: 0.5rem; + align-items: flex-start; + gap: 0.6rem; margin-bottom: 1rem; - font-weight: 600; + padding: 0.65rem 0.75rem; + border: 2px solid rgba($outpost-ink, 0.2); + border-radius: 10px; + background: rgba($outpost-ink, 0.04); font-size: 0.9rem; color: $outpost-ink; cursor: pointer; + transition: border-color 0.12s ease, background 0.12s ease; + + &:has(input:checked) { + border-color: $outpost-orange; + background: color-mix(in srgb, $outpost-orange 8%, #fff); + } + + &--required { + border-color: rgba($outpost-orange, 0.6); + background: color-mix(in srgb, $outpost-orange 5%, #fff); + } input[type="checkbox"] { + flex: 0 0 auto; + margin-top: 2px; + width: 1.1rem; + height: 1.1rem; accent-color: $outpost-orange; + cursor: pointer; + } + + strong { + color: $outpost-orange; } code { @@ -216,6 +306,11 @@ $outpost-teal: #34e0ba; } } +// Hide the "up to $X" funding max when user selected the no-funding path +.funding-modal__tiers--hide-max .funding-tier__max { + display: none; +} + // Inline tier-max validation error (shown on cream — red reads fine). .funding-request__error { margin: 0 0 1rem; diff --git a/app/assets/stylesheets/pages/projects/_new.scss b/app/assets/stylesheets/pages/projects/_new.scss index 7c1f49bb9..c761a40bd 100644 --- a/app/assets/stylesheets/pages/projects/_new.scss +++ b/app/assets/stylesheets/pages/projects/_new.scss @@ -467,6 +467,12 @@ color: var(--color-space-text-muted); } +.project-creation__stage-chooser--sub { + margin-top: var(--space-s); + padding-top: var(--space-s); + border-top: 1px solid rgba(255, 255, 255, 0.12); +} + // ── Mobile ─────────────────────────────────────────────────────────────────── @media (max-width: 640px) { diff --git a/app/assets/stylesheets/pages/projects/_show.scss b/app/assets/stylesheets/pages/projects/_show.scss index 483159d8e..e1b996d91 100644 --- a/app/assets/stylesheets/pages/projects/_show.scss +++ b/app/assets/stylesheets/pages/projects/_show.scss @@ -206,6 +206,18 @@ turbo-frame#project_hero { background: var(--color-brand-orange-soft); color: var(--color-brand-orange); } + + &--design { + background: rgba(#EBB7FF, 0.15); + color: #EBB7FF; + border: 1px solid rgba(#EBB7FF, 0.35); + } + + &--build { + background: rgba(#81FFFF, 0.12); + color: #81FFFF; + border: 1px solid rgba(#81FFFF, 0.3); + } } .project-show__title-input { diff --git a/app/javascript/controllers/project_builder_controller.js b/app/javascript/controllers/project_builder_controller.js index 68d55c40f..ba724ab6b 100644 --- a/app/javascript/controllers/project_builder_controller.js +++ b/app/javascript/controllers/project_builder_controller.js @@ -1,12 +1,10 @@ import { Controller } from "@hotwired/stimulus"; -// Reveals the Design/Build stage chooser on /projects/new when the user clicks -// "Blank new hardware project +". Software projects submit immediately; hardware -// projects pick a stage first. The collapsed chooser is marked inert so its -// controls drop out of the tab order and the a11y tree; a CSS grid-rows -// transition animates the visual reveal. +// Reveals the hardware stage chooser on /projects/new. +// Level 1: "I need Funding" (submits directly) | "I don't need Funding" (reveals level 2) +// Level 2: "I'm still designing" | "I'm ready to build" export default class extends Controller { - static targets = ["chooser", "hardwareBtn"]; + static targets = ["chooser", "hardwareBtn", "subChooser", "noFundingBtn"]; toggle() { const open = !this.chooserTarget.classList.contains("is-open"); @@ -16,5 +14,31 @@ export default class extends Controller { if (this.hasHardwareBtnTarget) { this.hardwareBtnTarget.setAttribute("aria-expanded", String(open)); } + + // Collapse sub-chooser when the top-level chooser is closed + if (!open && this.hasSubChooserTarget) { + this._closeSubChooser(); + } + } + + showNoFundingOptions(event) { + event.preventDefault(); + if (!this.hasSubChooserTarget) return; + + const open = !this.subChooserTarget.classList.contains("is-open"); + this.subChooserTarget.classList.toggle("is-open", open); + this.subChooserTarget.inert = !open; + + if (this.hasNoFundingBtnTarget) { + this.noFundingBtnTarget.setAttribute("aria-expanded", String(open)); + } + } + + _closeSubChooser() { + this.subChooserTarget.classList.remove("is-open"); + this.subChooserTarget.inert = true; + if (this.hasNoFundingBtnTarget) { + this.noFundingBtnTarget.setAttribute("aria-expanded", "false"); + } } } diff --git a/app/views/projects/new.html.erb b/app/views/projects/new.html.erb index 1b1b6e573..431c12a5f 100644 --- a/app/views/projects/new.html.erb +++ b/app/views/projects/new.html.erb @@ -115,15 +115,45 @@ <% end %> - <%= form_with url: projects_path, method: :post, data: { turbo_frame: "_top" }, class: "project-creation__stage-form" do %> - <%= hidden_field_tag "project[title]", "Untitled" %> - <%= hidden_field_tag "project[hardware_stage]", "build" %> - - <% end %> + + + +
+
+ <%= form_with url: projects_path, method: :post, data: { turbo_frame: "_top" }, class: "project-creation__stage-form" do %> + <%= hidden_field_tag "project[title]", "Untitled" %> + <%= hidden_field_tag "project[hardware_stage]", "design" %> + + <% end %> + + <%= form_with url: projects_path, method: :post, data: { turbo_frame: "_top" }, class: "project-creation__stage-form" do %> + <%= hidden_field_tag "project[title]", "Untitled" %> + <%= hidden_field_tag "project[hardware_stage]", "build" %> + + <% end %> +
diff --git a/app/views/projects/show.html.erb b/app/views/projects/show.html.erb index bd0ca1af1..60f374bbe 100644 --- a/app/views/projects/show.html.erb +++ b/app/views/projects/show.html.erb @@ -169,7 +169,7 @@
> - Project stage <%= render "projects/help_tooltip", text: "\"I need Funding\" means you're still designing and need a grant to buy parts. Submit your design for funding to move to building. \"I don't need Funding\" means you already have your parts and are building." %> + Project stage <%= render "projects/help_tooltip", text: "\"Design Stage\" means you're working on your design. Submit your design to move to building. \"Build Stage\" means you have your parts and are actively building." %> <% if @project.has_any_funding_request? %> <% fr = @project.latest_funding_request %>
@@ -192,11 +192,11 @@
<% end %> @@ -386,6 +386,13 @@

<%= @project.title %>

<% if @project.hardware? %> Hardware + <% if Flipper.enabled?(:hardware_flow, current_user) %> + <% if @project.design_stage? %> + Design Stage + <% elsif @project.build_stage? %> + Build Stage + <% end %> + <% end %> <% end %>