mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-11-03 03:34:26 -05:00
Implement garden stall feature with opening/closing functionality and related UI components
This commit is contained in:
1 parent
08fa57ca28
commit
ae8fddbc7b
21 files changed
+710
-2
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
@import "photos";
|
||||
@import "posts";
|
||||
@import "seeds";
|
||||
@import "stalls";
|
||||
|
||||
@import "predictions";
|
||||
@import "homepage";
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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?
|
||||
|
||||
@@ -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'
|
||||
@@ -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')
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}'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
|
||||
Reference in new issue
Block a user