Implement garden stall feature with opening/closing functionality and related UI components

This commit is contained in:
Brenda Wallace committed 2026-11-02 18:06:42 +13:00
1 parent 08fa57ca28
commit ae8fddbc7b
21 files changed
+710 -2

No files matched your search

+269
View File
@@ -0,0 +1,269 @@
// A member's garden stall: awning, hanging sign and a table, boarded up behind
// a shutter while it is closed. When it has just been opened the pieces animate
// in one after another (.stall-opening); without motion they are simply there.
$stall-wood: #6d4c41;
$stall-outline: #3e2a1c;
$stall-label: #fff8e1;
.stall-page {
max-width: 760px;
margin: 0 auto;
}
.stall {
position: relative;
overflow: clip;
border-radius: $card-radius;
margin-bottom: 1.75rem;
}
.stall-awning {
display: flex;
position: relative;
z-index: 2;
padding: 0 3px;
transform-origin: top center;
span {
flex: 1 1 0;
min-width: 0;
height: 34px;
margin-left: -3px;
border: 3px solid $stall-outline;
border-top-width: 0;
border-bottom-left-radius: 50% 34px;
border-bottom-right-radius: 50% 34px;
}
.stall-stripe-green {
background: $green;
}
.stall-stripe-white {
background: $white;
}
}
.stall-body {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 1;
}
.stall-sign {
box-sizing: border-box;
width: min(100%, 560px);
margin-top: 22px;
padding: 14px 20px;
border: 3px solid $stall-outline;
border-radius: $card-radius;
background: $stall-wood;
color: $white;
text-align: center;
transform-origin: top center;
p {
margin: 0;
}
}
.stall-sign-name {
font-size: clamp(1.25rem, 4.2vw, 1.9rem);
font-weight: 600;
letter-spacing: 0.06em;
line-height: 1.15;
text-transform: uppercase;
}
.stall-sign-text {
margin-top: 4px;
font-size: 1rem;
}
.stall-table {
width: 100%;
margin-top: 22px;
}
.stall-spots {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
padding: 0 12px 16px;
}
.stall-spot {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
border: 3px dashed $stall-wood;
border-radius: $card-radius;
color: $brown;
font-size: 1.4rem;
font-weight: 600;
}
.stall-tabletop {
height: 26px;
border-top: 3px solid $stall-outline;
background: $stall-wood;
box-shadow: inset 0 6px 0 rgba(255, 255, 255, 0.12);
}
// Rolled up and out of sight whenever the stall is open.
.stall-shutter {
display: none;
position: absolute;
inset: 0;
z-index: 5;
align-items: center;
justify-content: center;
border: 3px solid $stall-outline;
border-radius: $card-radius;
background: repeating-linear-gradient(180deg, $stall-wood 0 26px, #5a3d33 26px 30px);
}
.stall-closed .stall-shutter,
.stall-opening .stall-shutter {
display: flex;
}
.stall-shutter-sign {
padding: 6px 26px;
border: 3px solid $stall-outline;
border-radius: 4px 16px 16px 4px;
background: $stall-label;
font-size: 2rem;
font-weight: 600;
transform: rotate(-3deg);
}
.stall-closed {
.stall-awning {
transform: scaleY(0);
}
.stall-sign,
.stall-table {
opacity: 0;
}
// Keeps the shutter from collapsing to nothing with the contents hidden.
.stall-body {
min-height: 300px;
}
}
.stall-panel {
box-sizing: border-box;
padding: 1.6rem 1.75rem;
border-radius: $card-radius;
background: $white;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.14), 0 3px 4px rgba(0, 0, 0, 0.12), 0 1px 5px rgba(0, 0, 0, 0.2);
h1 {
font-size: 150%;
font-weight: 500;
}
}
.stall-choices {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.25rem;
}
.stall-choice {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.6rem;
padding: 1.4rem;
border: 2px dashed rgba($brown, 0.35);
border-radius: $card-radius;
background: $white;
color: inherit;
text-align: left;
strong {
font-size: 150%;
}
}
.stall-choice-soon {
color: $ink-muted;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.stall-card {
padding: 1rem 1.25rem;
border: 2px solid $stall-outline;
border-radius: 4px 16px 16px 4px;
background: $stall-label;
}
@media (prefers-reduced-motion: no-preference) {
.stall-opening {
.stall-shutter {
animation: stall-shutter-up 800ms cubic-bezier(0.65, 0, 0.35, 1) forwards;
}
.stall-awning {
animation: stall-awning-drop 700ms cubic-bezier(0.3, 1.5, 0.5, 1) 450ms backwards;
}
.stall-sign {
animation: stall-sign-swing 800ms cubic-bezier(0.3, 1.6, 0.5, 1) 1000ms backwards;
}
.stall-table {
animation: stall-table-in 600ms cubic-bezier(0.3, 1.3, 0.5, 1) 1400ms backwards;
}
.stall-spot {
animation: stall-spot-in 500ms cubic-bezier(0.3, 1.6, 0.5, 1) 1800ms backwards;
}
}
.stall-reveal-delayed {
animation: stall-spot-in 500ms cubic-bezier(0.3, 1.4, 0.5, 1) 2100ms backwards;
}
}
@keyframes stall-shutter-up {
to {
transform: translateY(-108%);
}
}
@keyframes stall-awning-drop {
from {
transform: scaleY(0);
}
}
@keyframes stall-sign-swing {
from {
opacity: 0;
transform: translateY(-60px) rotate(-10deg);
}
}
@keyframes stall-table-in {
from {
opacity: 0;
transform: scale(0.6);
}
}
@keyframes stall-spot-in {
from {
opacity: 0;
transform: translateY(24px) scale(0.7);
}
}
+1
View File
@@ -53,6 +53,7 @@
@import "photos";
@import "posts";
@import "seeds";
@import "stalls";
@import "predictions";
@import "homepage";
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
# Controllers for something still being rolled out: not found until an admin
# switches the feature on.
module FeatureGated
extend ActiveSupport::Concern
class_methods do
def require_feature(name)
before_action { raise ActiveRecord::RecordNotFound unless Feature.enabled?(name) }
end
end
end
@@ -0,0 +1,22 @@
# frozen_string_literal: true
# A member opening their garden stall, or closing it again.
module Stalls
class OpeningsController < ApplicationController
include FeatureGated
require_feature :garden_stall
before_action :authenticate_member!
def create
current_member.stall!.open!
flash[:stall_opened] = true
redirect_to member_stall_path(current_member)
end
def destroy
current_member.stall!.close!
redirect_to member_stall_path(current_member), notice: t('.closed')
end
end
end
+19
View File
@@ -0,0 +1,19 @@
# frozen_string_literal: true
# A member's garden stall, shared by link. Only the owner sees it while it is
# closed.
class StallsController < ApplicationController
include FeatureGated
require_feature :garden_stall
def show
@member = Member.confirmed.find_by!(slug: params[:member_slug])
@owner = @member == current_member
@stall = @member.stall || @member.build_stall
raise ActiveRecord::RecordNotFound unless @stall.open? || @owner
# The shutter rolls up the first time the owner sees it open.
@opening = flash[:stall_opened].present?
end
end
+1 -1
View File
@@ -3,7 +3,7 @@
# The features admins can switch on and off, kept in Flipper. Each is off until
# an admin switches it on.
class Feature
NAMES = %i(job_ideas enable_planting_reminders enable_harvest_reminders planting_weather).freeze
NAMES = %i(job_ideas enable_planting_reminders enable_harvest_reminders planting_weather garden_stall).freeze
attr_reader :name
+6
View File
@@ -17,6 +17,7 @@ class Member < ApplicationRecord
has_many :comments, foreign_key: 'author_id', dependent: :destroy, inverse_of: :author
has_many :forums, foreign_key: 'owner_id', dependent: :nullify, inverse_of: :owner
has_many :gardens, foreign_key: 'owner_id', dependent: :destroy, inverse_of: :owner
has_one :stall, dependent: :destroy
# Where they raise seedlings before planting them out.
has_one :nursery, -> { nurseries }, class_name: 'Garden', foreign_key: 'owner_id', dependent: nil, inverse_of: :owner
has_many :plantings, foreign_key: 'owner_id', dependent: :destroy, inverse_of: :owner
@@ -188,6 +189,11 @@ class Member < ApplicationRecord
# which can happen sometimes especially with FactoryBot associations
after_create { |member| Garden.create(name: "Garden", owner_id: member.id) }
# Their stall, made the first time they open it.
def stall!
stall || create_stall!
end
# Their nursery, made the first time it's wanted, as most members never need
# one. The member may already have a garden called "Nursery", and garden names
# are unique per member, so ours is numbered until it's free.
+22
View File
@@ -0,0 +1,22 @@
# frozen_string_literal: true
# A member's garden stall: one page where they offer spare seedlings and seeds
# to friends. A member has at most one. It is open when it has an opened_at, and
# closing it keeps the row so opening it again is just a timestamp.
class Stall < ApplicationRecord
belongs_to :member
validates :member_id, uniqueness: true
def open?
opened_at.present?
end
def open!
update!(opened_at: Time.current)
end
def close!
update!(opened_at: nil)
end
end
+1
View File
@@ -23,6 +23,7 @@
.mb-3
= render 'jobs/ideas_banner', href: suggestions_path, open: false,
text: t('.suggested_plantings', count: @suggestion_count), go: t('.suggested_plantings_go')
= render 'stalls/card', garden: @garden
= render 'gardens/facts', garden: @garden, growing_now: @current_plantings.size
= render 'gardens/description', garden: @garden
= render 'gardens/join_prompt', garden: @garden unless member_signed_in?
+11
View File
@@ -0,0 +1,11 @@
-# On the nursery garden: where the owner opens their garden stall, or goes to it.
- if garden.nursery? && can?(:edit, garden) && Feature.enabled?(:garden_stall)
- stall = garden.owner.stall
.stall-card.mb-3
%h2.h5= t('.heading')
- if stall&.open?
%p= t('.open_text')
= link_to t('.go'), member_stall_path(garden.owner), class: 'btn btn-outline-success'
- else
%p= t('.text')
= link_to t('.open'), member_stall_path(garden.owner), class: 'btn btn-success'
+18
View File
@@ -0,0 +1,18 @@
-# The stall itself: awning, sign and table. Closed, it is boarded up behind a
-# shutter. Just opened, the pieces animate in; see _stalls.scss.
- name = t('.sign', name: member)
%section.stall{ 'aria-label': name, class: [('stall-closed' unless open), ('stall-opening' if opening)] }
.stall-awning{ 'aria-hidden': 'true' }
- 12.times do |i|
%span{ class: (i.even? ? 'stall-stripe-green' : 'stall-stripe-white') }
.stall-body
.stall-sign
%p.stall-sign-name= name
%p.stall-sign-text= t('.tagline')
.stall-table
.stall-spots{ 'aria-hidden': 'true' }
- %i(seedlings seeds more).each do |spot|
.stall-spot= t(".spots.#{spot}")
.stall-tabletop
.stall-shutter{ 'aria-hidden': 'true' }
%span.stall-shutter-sign= t('.closed')
+41
View File
@@ -0,0 +1,41 @@
- content_for :title, t('.title', name: @member)
- content_for :breadcrumbs do
%li.breadcrumb-item= link_to t('layouts.menu.gardens'), gardens_path
%li.breadcrumb-item.active= t('.breadcrumb')
.stall-page
.record-identity.mb-4
= render 'shared/record_owner', owner: @member
.record-identity-text
%p.garden-grower
= t('.garden_of')
%strong.record-grower-name= link_to @member, @member
= render 'stalls/stall', member: @member, open: @stall.open?, opening: @opening
- if @owner
- if @stall.open?
%section.stall-panel{ 'aria-labelledby': 'stall-first-h', class: ('stall-reveal-delayed' if @opening) }
%h1#stall-first-h= t('.first_heading')
%p.text-muted= t('.first_intro')
.stall-choices
%button.stall-choice{ type: 'button', disabled: true }
%strong= t('.seedlings')
%span= t('.seedlings_text')
%span.stall-choice-soon= t('.coming_soon')
%button.stall-choice{ type: 'button', disabled: true }
%strong= t('.seeds')
%span= t('.seeds_text')
%span.stall-choice-soon= t('.coming_soon')
.d-flex.flex-wrap.gap-3.align-items-center.mt-3
= button_to t('.close'), stall_opening_path, method: :delete, class: 'btn btn-secondary'
%span.text-muted= t('.link_is', url: member_stall_url(@member))
- else
%section.stall-panel{ 'aria-labelledby': 'stall-open-h' }
%h1#stall-open-h= t('.open_heading')
%p= t('.open_intro')
%ul
- %i(choose_what choose_who choose_where expiry privacy).each do |point|
%li= t(".points.#{point}")
= button_to t('.open'), stall_opening_path, class: 'btn btn-primary btn-lg'
+42
View File
@@ -97,6 +97,7 @@ en:
descriptions:
enable_harvest_reminders: Sends the weekly harvest reminder emails to members who want them.
enable_planting_reminders: Sends the weekly planting reminder emails to members who want them.
garden_stall: A page where members offer spare seedlings and seeds to friends. Switch on once all the parts are deployed.
job_ideas: Suggested jobs for members, from their past jobs and plantings, crop templates and what gardeners nearby are doing.
planting_weather: Temperature and rainfall charts on each planting, from daily weather for the garden's area.
index:
@@ -111,6 +112,7 @@ en:
names:
enable_harvest_reminders: Harvest reminders
enable_planting_reminders: Planting reminders
garden_stall: Garden stall
job_ideas: Job ideas
planting_weather: Planting weather
update:
@@ -1192,6 +1194,46 @@ en:
heading: "%{crop} seeds"
string: "%{crop} seeds belonging to %{owner}"
updated: Seed was successfully updated.
stalls:
openings:
destroy:
closed: Your garden stall is closed.
card:
go: Go to your stall
heading: Garden stall
open: Open a garden stall
open_text: Your garden stall is open for friends.
text: Share spare seedlings and seeds with friends. You choose what and who, offers close after 4 weeks, and no addresses are kept.
show:
breadcrumb: Garden stall
close: Close the stall
coming_soon: Coming soon
first_heading: Your stall is open. What goes on it first?
first_intro: You can add more any time. Nothing shows to friends until something is on the table.
garden_of: Garden of
link_is: Your link is %{url}
open: Open my stall
open_heading: Open a garden stall
open_intro: Got spare seedlings or seeds? Put them on a stall and share the link with your friends. Give them away or swap.
points:
choose_what: You choose what goes on the table.
choose_where: 'You choose where friends can be: your region, your country or anywhere.'
choose_who: 'You choose who can bagsy: anyone signed in, or only people you follow.'
expiry: Offers close after 4 weeks, so the stall never goes stale.
privacy: Growstuff keeps no addresses. Friends message you to arrange pick-up.
seedlings: Seedlings
seedlings_text: Plants you've raised that need a home.
seeds: Seeds
seeds_text: Packets you've saved and will trade.
title: "%{name}'s garden stall"
stall:
closed: Closed for now
sign: "%{name}'s garden stall"
spots:
more: anything else
seedlings: seedlings here
seeds: seeds here
tagline: Seedlings and seeds for friends. Free, or swap.
suggestions:
index:
lede: What gardeners at %{band} are planting this month, most planted first.
+6
View File
@@ -51,6 +51,11 @@ Rails.application.routes.draw do
end
# Before the gardens resource, so "nursery" isn't taken for a garden's slug.
# Opening and closing your own garden stall.
resource :stall, only: [] do
resource :opening, only: %i(create destroy), module: :stalls
end
resource :nursery, only: %i(show create) do
get :panel
resource :decline, only: :create, module: :nurseries
@@ -152,6 +157,7 @@ Rails.application.routes.draw do
get '/timeline', to: redirect('/my/neighbours')
resources :members, param: :slug do
resource :stall, only: :show
# Before the gardens resource, so "inactive" isn't taken for a garden's slug.
get 'gardens/inactive' => 'gardens#inactive', as: :inactive_gardens
# param: :slug to match the top-level gardens resource, so the controller's
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class CreateStalls < ActiveRecord::Migration[8.1]
def change
create_table :stalls do |t|
t.references :member, null: false, foreign_key: true, index: { unique: true }
t.datetime :opened_at
t.timestamps
end
end
end
+10 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_11_01_000000) do
ActiveRecord::Schema[8.1].define(version: 2026_11_01_010000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "pg_trgm"
@@ -1117,6 +1117,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_11_01_000000) do
t.index ["source"], name: "index_seeds_on_source"
end
create_table "stalls", force: :cascade do |t|
t.datetime "created_at", null: false
t.bigint "member_id", null: false
t.datetime "opened_at"
t.datetime "updated_at", null: false
t.index ["member_id"], name: "index_stalls_on_member_id", unique: true
end
create_table "versions", force: :cascade do |t|
t.datetime "created_at"
t.string "event", null: false
@@ -1188,4 +1196,5 @@ ActiveRecord::Schema[8.1].define(version: 2026_11_01_000000) do
add_foreign_key "posts", "photos", column: "card_photo_id", on_delete: :nullify
add_foreign_key "reminder_events", "members", on_delete: :cascade
add_foreign_key "seeds", "plantings", column: "parent_planting_id", name: "parent_planting", on_delete: :nullify
add_foreign_key "stalls", "members"
end
+12
View File
@@ -0,0 +1,12 @@
# frozen_string_literal: true
FactoryBot.define do
factory :stall do
member
opened_at { nil }
trait :open do
opened_at { Time.zone.local(2026, 11, 1, 12) }
end
end
end
+29
View File
@@ -0,0 +1,29 @@
# frozen_string_literal: true
require 'rails_helper'
describe Stall do
subject(:stall) { create(:stall) }
it { is_expected.not_to be_open }
describe '#open!' do
before { stall.open! }
it { is_expected.to be_open }
end
describe '#close!' do
subject(:stall) { create(:stall, :open) }
before { stall.close! }
it { is_expected.not_to be_open }
end
describe 'with a second stall for the same member' do
subject(:second) { build(:stall, member: stall.member) }
it { is_expected.not_to be_valid }
end
end
+27
View File
@@ -0,0 +1,27 @@
# frozen_string_literal: true
require 'rails_helper'
describe GardensController, 'garden stall card', flipper: :garden_stall do
let(:owner) { create(:member) }
let(:nursery) { create(:garden, owner:, nursery: true) }
context 'when the owner looks at their nursery' do
before do
sign_in owner
get garden_path(nursery)
end
it 'offers to open a stall' do
expect(Capybara.string(response.body)).to have_link('Open a garden stall', href: member_stall_path(owner))
end
end
context 'when a visitor looks at the nursery' do
before { get garden_path(nursery) }
it 'has no stall card' do
expect(Capybara.string(response.body)).to have_no_css('.stall-card')
end
end
end
@@ -0,0 +1,70 @@
# frozen_string_literal: true
require 'rails_helper'
describe Stalls::OpeningsController, flipper: :garden_stall do
let(:owner) { create(:member) }
let(:visitor) { create(:member) }
describe 'POST /stall/opening' do
context 'when signed in as the owner' do
before do
sign_in owner
post stall_opening_path
end
it 'opens the stall' do
expect(owner.reload.stall).to be_open
end
it 'goes to the stall' do
expect(response).to redirect_to(member_stall_path(owner))
end
end
context 'when signed in as someone else' do
before do
sign_in visitor
post stall_opening_path
end
it 'leaves the stall alone' do
expect(owner.reload.stall).to be_nil
end
end
context 'when signed out' do
before { post stall_opening_path }
it 'asks them to sign in' do
expect(response).to redirect_to(new_member_session_path)
end
end
end
describe 'DELETE /stall/opening' do
let!(:stall) { create(:stall, :open, member: owner) }
context 'when signed in as the owner' do
before do
sign_in owner
delete stall_opening_path
end
it 'closes the stall' do
expect(stall.reload).not_to be_open
end
end
context 'when signed in as someone else' do
before do
sign_in visitor
delete stall_opening_path
end
it 'leaves the stall open' do
expect(stall.reload).to be_open
end
end
end
end
+79
View File
@@ -0,0 +1,79 @@
# frozen_string_literal: true
require 'rails_helper'
describe StallsController, flipper: :garden_stall do
let(:owner) { create(:member) }
let(:visitor) { create(:member) }
describe 'GET /members/:slug/stall' do
context 'when signed in as the owner without a stall' do
before do
sign_in owner
get member_stall_path(owner)
end
it 'invites them to open one' do
expect(Capybara.string(response.body)).to have_button('Open my stall')
end
it 'shows the shutter' do
expect(response.body).to include('stall-shutter')
end
end
context 'when signed in as the owner with an open stall' do
before do
create(:stall, :open, member: owner)
sign_in owner
get member_stall_path(owner)
end
it 'asks what goes on first' do
expect(Capybara.string(response.body)).to have_text('What goes on it first?')
end
it 'offers to close it' do
expect(Capybara.string(response.body)).to have_button('Close the stall')
end
end
context 'when a visitor looks at an open stall' do
before do
create(:stall, :open, member: owner)
get member_stall_path(owner)
end
it 'shows the sign' do
expect(response.body).to include("#{owner}&#39;s garden stall")
end
it 'has no button to close it' do
expect(Capybara.string(response.body)).to have_no_button('Close the stall')
end
end
context 'when a visitor looks at a closed stall' do
before do
create(:stall, member: owner)
get member_stall_path(owner)
end
it 'is not found' do
expect(response).to have_http_status(:not_found)
end
end
context 'when the feature is off', flipper: [] do
before do
Flipper.disable(:garden_stall)
create(:stall, :open, member: owner)
get member_stall_path(owner)
end
it 'is not found' do
expect(response).to have_http_status(:not_found)
end
end
end
end