diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index f92cdf31dc..ef354de6fb 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -319,6 +319,7 @@ summary { @import 'components/banners'; @import 'components/buttons'; @import 'components/cards'; +@import 'components/card_grants'; @import 'components/checks'; @import 'components/comments'; @import 'components/conversation'; diff --git a/app/assets/stylesheets/components/_card_grants.scss b/app/assets/stylesheets/components/_card_grants.scss new file mode 100644 index 0000000000..d1f38ec2ca --- /dev/null +++ b/app/assets/stylesheets/components/_card_grants.scss @@ -0,0 +1,982 @@ +// Card grant page. Built on `.card`, the HCB palette and `card--background-image` +// so it sits inside HCB, but with more air and bigger numbers than the dense +// `.details` lists — this page is read by grantees, not accountants. + +$grant-hairline: rgba(200, 200, 200, 0.35); + +// Bordered tile shared by the help articles and the controls. An inset shadow +// rather than a border, so the outline doesn't add to the tile's size. +@mixin grant-tile { + position: relative; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + padding: 0.875rem; + box-shadow: inset 0 0 0 1px rgba(140, 150, 165, 0.3); + + html[data-dark='true'] & { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); + } +} + +@mixin grant-hairline($side) { + border-#{$side}: 1px solid $grant-hairline; + + html[data-dark='true'] & { + border-#{$side}-color: rgba(255, 255, 255, 0.125); + } +} + +.grant-hero { + padding: 1.75rem; + + // The shared wash only fades to near-white at the very bottom, but the + // grant's numbers sit higher up, so wash the whole hero evenly instead. + &.card.card--background-image { + background-image: + linear-gradient(rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.96)), + var(--bg-image) !important; + + html[data-dark='true'] & { + background-image: + linear-gradient(rgba(37, 36, 41, 0.85), rgba(37, 36, 41, 0.94)), + var(--bg-image) !important; + } + } + + @media (max-width: 40em) { + padding: 1.25rem; + } +} + +// Status on the left, the organizer's actions on the right. +.grant-hero__top { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem 1rem; + margin-bottom: 1.25rem; +} + +// Secondary text over the background image: `.muted` is too faint to read there. +.grant-hero__soft { + color: mix($slate, $muted, 60%); + + html[data-dark='true'] & { + color: $smoke-dark; + } +} + +.grant-hero__grid { + display: grid; + gap: 1.5rem; + grid-template-columns: minmax(0, 1fr); + + @media (min-width: 56em) { + grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); + gap: 2.5rem; + } +} + +.grant-hero__aside { + @include grant-hairline(top); + padding-top: 1.25rem; + + @media (min-width: 56em) { + border-top: 0; + padding-top: 0; + padding-left: 2.5rem; + @include grant-hairline(left); + } +} + +.grant-hero__amount { + display: block; + margin: 0; + font-size: 3.25rem; + font-weight: 700; + line-height: 1; + letter-spacing: -0.035em; + font-variant-numeric: tabular-nums; + + @media (max-width: 40em) { + font-size: 2.5rem; + } +} + +.grant-hero__date { + margin: 0; + font-size: 1.625rem; + font-weight: 700; + line-height: 1.1; + letter-spacing: -0.02em; +} + +// How much of the grant is still yours to spend. +.grant-meter { + height: 8px; + border-radius: 999px; + background-color: rgba(map-get($palette, muted), 0.25); + overflow: hidden; + + html[data-dark='true'] & { + background-color: rgba(255, 255, 255, 0.2); + } +} + +.grant-meter__fill { + display: block; + height: 100%; + border-radius: inherit; + background-color: map-get($palette, success); + transition: width 0.25s ease-in-out; + + &.grant-meter__fill--muted { + background-color: map-get($palette, muted); + } +} + +// Compact label / value pairs stacked in the hero aside. Rows grow when a long +// value wraps rather than colliding with the row beneath them. +.grant-meta { + display: grid; + gap: 0.375rem; +} + +.grant-meta > div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + min-height: 1.625rem; +} + +.grant-meta__label { + flex: 0 0 auto; + white-space: nowrap; +} + +.grant-meta__value { + min-width: 0; + text-align: right; + overflow-wrap: anywhere; + + // `.mention` carries a trailing margin meant for inline prose; drop it so + // avatars line up flush with the values above and below. + &.mention, + .mention { + margin-right: 0; + } +} + +// State notices (canceled, frozen, instructions, fraud check…) stack under the +// hero with one gap between them, whatever margins the callout partial brings. +.grant-notices { + display: grid; + gap: 0.75rem; + margin-top: 1rem; + + > .card { + margin: 0; + } +} + +.grant-section { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.625rem; + margin: 2.5rem 0 0.875rem; + font-size: 1.375rem; +} + +.grant-section__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 2rem; + height: 2rem; + font-size: 17px; + font-weight: 900; + border-radius: var(--radius-xl); + background: rgba(map-get($palette, muted), 0.15); + color: map-get($palette, slate); + + html[data-dark='true'] & { + color: map-get($palette, smoke); + } + + svg { + width: 20px; + height: 20px; + } +} + +// Card artwork beside its numbers, then the controls. Laid out against the +// section's own width (not the viewport) since the sidebar and admin tools +// change how much room it gets at any given screen size. +.grant-pay { + container: grant-pay / inline-size; + display: grid; + gap: 0.75rem; +} + +.grant-panel { + display: grid; + grid-template-columns: minmax(0, 1fr); + margin: 0; + padding: 0; + + // Any narrower than 21rem and the card artwork wraps its own number. + @container grant-pay (min-width: 46rem) { + grid-template-columns: 21rem minmax(0, 1fr); + } +} + +.grant-panel__art { + display: flex; + flex-direction: column; + justify-content: center; + padding: 1.5rem; + + .btn { + max-width: 20rem; + margin-inline: auto; + } +} + +.grant-panel__fields { + display: grid; + align-content: center; + gap: 1.5rem; + padding: 1.25rem 1.5rem; + @include grant-hairline(top); + + @container grant-pay (min-width: 46rem) { + border-top: 0; + @include grant-hairline(left); + } + + // Wide enough for card and billing details side by side. + @container grant-pay (min-width: 72rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 2rem; + } +} + +// Heading inside a card, one step below the page's `.grant-section`. +.grant-subheading { + margin: 0 0 0.5rem; + font-size: 1.125rem; +} + +.grant-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.125rem 1.5rem; + min-height: 2.5rem; + padding: 0.375rem 0; + + + .grant-row { + @include grant-hairline(top); + } +} + +.grant-row__label { + display: inline-flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; + color: map-get($palette, muted); +} + +// On a narrow screen a long value drops below its label but stays right-aligned. +.grant-row__value { + margin-left: auto; + min-width: 0; + font-weight: 600; + text-align: right; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.grant-controls { + margin: 0; + padding: 1.25rem 1.5rem 1.5rem; +} + +// Tiles like the help articles below. Stacked full-width rows on phones, with +// the icon beside the copy; once there's room, one row of icon-on-top tiles. +.grant-controls__grid { + display: grid; + gap: 0.625rem; + margin-top: 0.875rem; + + .grant-action { + @include grant-tile; + flex-direction: row; + align-items: center; + height: 100%; + } + + @container grant-pay (min-width: 40rem) { + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + + .grant-action { + flex-direction: column; + align-items: flex-start; + } + + .grant-action__arrow { + position: absolute; + top: 0.5rem; + right: 0.5rem; + transform: translate(-3px, 3px); + } + } +} + +// Help card: the help center articles as tiles, then the org's own support +// as a slim tinted strip along the bottom. +.grant-help { + display: grid; + margin: 0; + padding: 0; + overflow: hidden; + + > .grant-action { + padding: 0.875rem 1.5rem; + border-radius: 0; + } + + // Under the tiles it's a footer: tinted, and "Still stuck?" runs into its + // description on one line instead of stacking. + > .grant-answers + .grant-action { + background: rgba(140, 150, 165, 0.08); + @include grant-hairline(top); + + &:hover:not(:disabled), + &:focus-visible { + background: rgba(140, 150, 165, 0.14); + } + + .grant-action__text { + flex-direction: row; + flex-wrap: wrap; + align-items: baseline; + column-gap: 0.375rem; + } + } +} + +.grant-answers { + padding: 1.25rem 1.5rem 1.5rem; +} + +// As many columns as fit (all five on a full-width page), wrapping on phones. +.grant-answers__row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr)); + gap: 0.625rem; + margin-top: 0.875rem; +} + +// A help tile's or control's colour shows in its icon, and washes over the +// whole thing on hover. +.grant-answer, +.grant-action { + --tone: #{map-get($palette, muted)}; + --tone-wash: #{rgba(map-get($palette, muted), 0.08)}; + --tone-soft: #{rgba(map-get($palette, muted), 0.14)}; + + @each $tone in (primary, warning, success, info, purple, accent) { + &.grant-tone--#{'' + $tone} { + --tone: #{map-get($palette, $tone)}; + --tone-wash: #{rgba(map-get($palette, $tone), 0.08)}; + --tone-soft: #{rgba(map-get($palette, $tone), 0.14)}; + } + } +} + +.grant-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 2.25rem; + height: 2.25rem; + border-radius: 0.625rem; + color: var(--tone); + background: var(--tone-soft); +} + +// An arrow slides in on hover to say the tile opens the help center. +.grant-answer { + @include grant-tile; + display: flex; + border-radius: var(--radius-lg); + background: transparent; + color: inherit; + text-decoration: none; + transition: background-color 0.15s ease; + + &:hover, + &:focus { + color: inherit; + } + + &:hover, + &:focus-visible { + background: var(--tone-wash); + + html[data-dark='true'] & { + background: var(--tone-wash); + } + + .grant-answer__arrow { + opacity: 1; + transform: none; + } + } + + &:focus-visible { + outline: 2px solid var(--tone); + outline-offset: 2px; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} + +.grant-answer__label { + font-size: 0.875rem; + font-weight: 600; + line-height: 1.25; +} + +// The arrow glyph only fills the middle third of its icon, so the icon is +// drawn large and tucked into the corner. +.grant-answer__arrow { + position: absolute; + top: 0.25rem; + right: 0.25rem; + display: inline-flex; + color: var(--tone); + opacity: 0; + transform: translate(-3px, 3px); + transition: + opacity 0.15s ease, + transform 0.15s ease; +} + +.grant-section-row { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem 1rem; + margin: 2.5rem 0 0.875rem; + + > .grant-section { + margin: 0; + } +} + +// Horizontal strip of facts, hairline-separated. +.grant-strip { + display: flex; + flex-wrap: wrap; + + > div { + flex: 1 1 9rem; + padding: 0.25rem 1.25rem; + @include grant-hairline(left); + + &:first-child { + padding-left: 0; + border-left: 0; + } + + @media (max-width: 40em) { + flex-basis: 45%; + padding: 0.5rem 0.75rem; + + &:nth-child(odd) { + padding-left: 0; + border-left: 0; + } + } + } +} + +.grant-strip__label { + display: block; + margin: 0 0 0.125rem; + font-size: 0.875rem; + color: map-get($palette, muted); +} + +.grant-strip__value { + margin: 0; + font-weight: 600; + line-height: 1.35; + overflow-wrap: anywhere; +} + +// A long merchant or category list would otherwise stretch its strip cell into +// a tall single column, so past a couple of entries the cell shows a count and +// the entries move to a full-width drawer under the strip. CSS-only: each +// drawer has a hidden checkbox ahead of the strip, and the count label toggles +// it — so it works without JS and survives Turbo caching. +.grant-drawer__toggle { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.grant-disclosure { + display: inline-flex; + align-items: center; + gap: 0.125rem; + cursor: pointer; + user-select: none; + color: map-get($palette, info); + + html[data-dark='true'] & { + color: lighten($blue, 12%); + } + + &:hover { + text-decoration: underline; + } +} + +.grant-disclosure__caret { + transition: transform 0.15s ease; +} + +.grant-drawer { + display: none; + margin-top: 1rem; + padding-top: 1rem; + @include grant-hairline(top); +} + +.grant-drawer__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.5rem; +} + +.grant-drawer__close { + display: inline-flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + font-size: 0.8rem; + font-weight: 600; + color: map-get($palette, muted); + + &:hover { + color: map-get($palette, slate); + } +} + +.grant-chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin: 0; + padding: 0; + list-style: none; + // Dozens of merchants stay inside one predictable block instead of pushing + // the rest of the page down. + max-height: 13rem; + overflow-y: auto; +} + +.grant-chip { + padding: 0.25rem 0.625rem; + border-radius: 999px; + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.35; + max-width: 100%; + overflow-wrap: anywhere; + background: rgba(140, 150, 165, 0.12); + + html[data-dark='true'] & { + background: rgba(255, 255, 255, 0.08); + } +} + +@each $key in ('merchants', 'categories') { + ##{'grant-' + $key}:checked { + ~ ##{'grant-' + $key}-drawer { + display: block; + } + + ~ .grant-strip + .grant-disclosure[for='#{'grant-' + $key}'] + .grant-disclosure__caret { + transform: rotate(180deg); + } + } +} + +.grant-limits__note { + margin: 1rem -1rem -1rem; + padding: 0.5rem 1rem; + border-radius: 0 0 0.5rem 0.5rem; + background: rgba(140, 150, 165, 0.1); + @include grant-hairline(top); +} + +// Pre-authorization: the cart-screenshot dropzone and its uploads share one +// panel, so an empty dropzone reads as part of the step rather than a stray box. +$grant-upload-height: 11.5rem; + +.grant-upload { + padding: 0; +} + +.grant-upload__grid { + display: flex; + flex-direction: column; + + @media (min-width: 56em) { + flex-direction: row; + align-items: stretch; + } +} + +.grant-upload__drop { + flex: 0 0 auto; + padding: 1.25rem; + + @media (min-width: 56em) { + flex-basis: 19rem; + } +} + +.grant-upload__dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.625rem; + height: 100%; + min-height: $grant-upload-height; + padding: 1.25rem; + text-align: center; + border: 2px dashed $grant-hairline; + border-radius: var(--radius-lg); + background-color: rgba(map-get($palette, smoke), 0.35); + transition: 0.125s ease-in-out; + transition-property: border-color, background-color; + + html[data-dark='true'] & { + border-color: rgba(255, 255, 255, 0.15); + background-color: rgba(255, 255, 255, 0.03); + } + + &:hover { + border-color: rgba(map-get($palette, info), 0.5); + } +} + +.grant-upload__dropzone-icon { + color: map-get($palette, muted); +} + +.grant-upload__list { + display: flex; + flex: 1 1 auto; + align-items: center; + gap: 0.75rem; + min-width: 0; + min-height: $grant-upload-height; + padding: 1.25rem; + overflow-x: auto; +} + +// Only hairline the two apart when the dropzone is actually there — once the +// pre-authorization is submitted the list stands alone. +.grant-upload__drop + .grant-upload__list { + @include grant-hairline(top); + + @media (min-width: 56em) { + border-top: 0; + @include grant-hairline(left); + } +} + +// Nothing uploaded yet: let the dropzone have the whole panel rather than +// pairing it with an empty gallery. Browsers without `:has()` keep the split. +.grant-upload__grid:has(.grant-upload__empty) { + .grant-upload__drop { + flex: 1 1 auto; + } + + .grant-upload__drop + .grant-upload__list { + display: none; + } +} + +.grant-upload__thumb { + display: block; + flex: 0 0 auto; + height: $grant-upload-height; + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-border-sm); + transition: transform 0.125s ease-in-out; + + &:hover { + transform: translateY(-2px); + } + + img { + display: block; + height: 100%; + width: auto; + max-width: none; + } +} + +.grant-upload__empty { + flex: 1 1 auto; + align-self: center; + text-align: center; +} + +.grant-upload__footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem 1rem; + padding: 0.75rem 1.25rem; + font-size: 0.875rem; + @include grant-hairline(top); + + a { + display: inline-flex; + align-items: center; + gap: 0.25rem; + } +} + +// Step 3: the explainer and its call to action sit on one line where there's room. +.grant-submit { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + + p { + flex: 1 1 22rem; + } + + .btn { + flex: 0 0 auto; + } +} + +// One control. The whole row is the button (or link): an icon in the +// control's colour, its copy, and an arrow that slides in on hover. +.grant-action { + display: flex; + align-items: center; + gap: 0.875rem; + width: 100%; + margin: 0; + padding: 0.75rem; + border: 0; + border-radius: var(--radius-lg); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + text-decoration: none; + cursor: pointer; + transition: background-color 0.125s ease-in-out; + + // Some rows are links: beat the global `a:hover, a:focus` slate, which is + // near-invisible in dark mode and sticks after a modal trigger is clicked. + &:hover, + &:focus { + color: inherit; + } + + &:hover:not(:disabled), + &:focus-visible { + background-color: var(--tone-wash); + + .grant-action__arrow { + opacity: 1; + transform: none; + } + } + + &:focus-visible { + outline: 2px solid var(--tone); + outline-offset: -2px; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.grant-action__text { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.grant-action__title { + font-weight: 600; + line-height: 1.3; +} + +.grant-action__desc { + font-size: 0.875rem; + line-height: 1.35; + color: map-get($palette, muted); +} + +.grant-action__arrow { + flex: 0 0 auto; + color: var(--tone); + opacity: 0; + transform: translateX(-3px); + transition: + opacity 0.15s ease, + transform 0.15s ease; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} + +.grant-comments__summary { + cursor: pointer; + list-style: none; + + &::-webkit-details-marker { + display: none; + } + + &::before { + content: ''; + display: inline-block; + flex: 0 0 auto; + width: 0.45rem; + height: 0.45rem; + margin-right: 0.625rem; + border-right: 2px solid map-get($palette, muted); + border-bottom: 2px solid map-get($palette, muted); + transform: rotate(-45deg); + transition: transform 0.15s ease-in-out; + } + + .grant-section { + display: inline-flex; + vertical-align: middle; + } +} + +.grant-comments[open] > .grant-comments__summary::before { + transform: rotate(45deg); +} + +.grant-comments__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + padding: 0 0.4rem; + font-size: 0.875rem; + font-weight: 700; + line-height: 1.5rem; + border-radius: var(--radius-xl); + background: rgba(map-get($palette, muted), 0.15); + color: map-get($palette, slate); + + html[data-dark='true'] & { + color: map-get($palette, smoke); + } +} + +.grant-comments__body { + margin-top: 0.875rem; +} + +// Activation pledges. Each statement is a full-width clickable row rather than +// a bare checkbox, and lights up once it's been agreed to. +.grant-pledge { + padding: 0; + overflow: hidden; +} + +.grant-pledge__row { + display: flex; + align-items: flex-start; + gap: 0.875rem; + margin: 0; + padding: 1rem 1.25rem; + font-weight: 400; + line-height: 1.45; + cursor: pointer; + transition: background-color 0.125s ease-in-out; + + + .grant-pledge__row { + @include grant-hairline(top); + } + + &:hover { + background-color: rgba(140, 150, 165, 0.06); + } + + &:has(input:checked) { + background-color: rgba(map-get($palette, success), 0.06); + } + + input[type='checkbox'] { + flex: 0 0 auto; + width: 1.125rem; + height: 1.125rem; + margin: 0.125rem 0 0; + accent-color: map-get($palette, success); + cursor: pointer; + } +} + +.grant-pledge__footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem 1rem; + padding: 1rem 1.25rem; + font-size: 0.875rem; + background: rgba(140, 150, 165, 0.06); + @include grant-hairline(top); + + .btn { + flex: 0 0 auto; + } +} diff --git a/app/controllers/card_grants_controller.rb b/app/controllers/card_grants_controller.rb index 6fac2a34de..9719faa5c5 100644 --- a/app/controllers/card_grants_controller.rb +++ b/app/controllers/card_grants_controller.rb @@ -154,10 +154,6 @@ def bulk_upload_template disposition: "attachment" end - def edit_overview - authorize @card_grant - end - def edit_purpose authorize @card_grant end diff --git a/app/policies/card_grant_policy.rb b/app/policies/card_grant_policy.rb index c355d5ad84..f67b0e9c66 100644 --- a/app/policies/card_grant_policy.rb +++ b/app/policies/card_grant_policy.rb @@ -21,17 +21,16 @@ def spending? record.event.is_public? || user&.auditor? || user_in_event? end - def edit_overview? + def edit_purpose? auditor_or_member? && record.active? end - alias_method :edit_actions?, :edit_overview? - alias_method :edit_usage_restrictions?, :edit_overview? - alias_method :edit_expiration?, :edit_overview? - alias_method :edit_purpose?, :edit_overview? - alias_method :edit_balance?, :edit_overview? - alias_method :edit_topup?, :edit_overview? - alias_method :edit_withdraw?, :edit_overview? + alias_method :edit_actions?, :edit_purpose? + alias_method :edit_usage_restrictions?, :edit_purpose? + alias_method :edit_expiration?, :edit_purpose? + alias_method :edit_balance?, :edit_purpose? + alias_method :edit_topup?, :edit_purpose? + alias_method :edit_withdraw?, :edit_purpose? def activate? (user&.admin? || (cardholder? && authorized_to_activate?)) && record.active? diff --git a/app/views/card_grant/pre_authorizations/_screenshot_count.html.erb b/app/views/card_grant/pre_authorizations/_screenshot_count.html.erb index 31a05ac5d0..18b8fdc634 100644 --- a/app/views/card_grant/pre_authorizations/_screenshot_count.html.erb +++ b/app/views/card_grant/pre_authorizations/_screenshot_count.html.erb @@ -1,10 +1,14 @@ +<%# locals: (count:, card_grant:, disabled:) %> + <% url = clear_screenshots_card_grant_pre_authorizations_path(card_grant) %> -
Or drag & drop your screenshot here
-or drag, drop, or paste it here
+No screenshots uploaded yet
<% end %>Before you can get spending, we need to know what you're planning to buy.
-Before you can start spending, tell us what you're planning to buy.
You're all set — your card is ready to spend.
+ <%= link_to "View card details", card_grant_path(@card_grant), class: "btn bg-success mt-3" %> + <% end %> <% end %> <% if @pre_authorization.fraudulent? && @pre_authorization.unauthorized? %> -- <%= inline_icon "important", size: 24, class: "mr2 text-orange" %> - Your card grant has been flagged as fraudulent because it doesn't meet the given requirements. Please reach out to <%= @card_grant.event.name %> for support. + <%= render "application/callout", type: "error", title: "This grant has been flagged" do %> +
+ Your card grant was flagged because it doesn't meet the given requirements. + Please reach out to <%= @card_grant.event.name %> for support.
-Submit a link to the product you're looking to purchase. We'll use this to verify that the item is in line with the purpose of the grant.
- -+ Submit a link to the product you're looking to purchase. We'll use this to verify that the item is in line with the purpose of the grant. +
+ +<%= form_with model: @pre_authorization, id: "pre_authorization_form", url: card_grant_pre_authorizations_path(@card_grant), method: :patch, data: { turbo: true } do |form| %> +Upload a screenshot of your cart showing the item you want to purchase. Make sure it includes the total cost, as well as any taxes, fees, and shipping costs. This helps us verify the total amount you need to spend.
+<%= render "card_grants/section_heading", icon: "2", title: "Screenshot of your cart" %> ++ Upload a screenshot of your cart showing the item you want to purchase. Make sure it includes the total cost, as well as any taxes, fees, and shipping costs. This helps us verify the total amount you need to spend. +
+ +Once you've provided the product link and uploaded the screenshot, our review process will screen your submission and activate your grant.
++ Once you've provided the product link and uploaded the screenshot, our review process will screen your submission and activate your grant. +
-<% if @pre_authorization.draft? %> - -<% elsif @pre_authorization.submitted? %> - -<% end %> + <% if draft %> + + <% elsif @pre_authorization.submitted? %> + + <% end %> +Controls
- <% if organizer_signed_in?(as: :member) %> - <%= render "stripe_cards/actions/freeze", stripe_card: card_grant.stripe_card if card_grant.stripe_card.present? %> - <%= render "card_grants/actions/convert_to_reimbursement_report", card_grant:, label: card_grant.user == current_user ? "Get reimbursed" : "Create reimbursement report" %> - <% if card_grant.user == current_user %> - <%= render "card_grants/actions/return", card_grant: %> - <% else %> - <%= render "card_grants/actions/cancel", card_grant: %> - <% end %> - <%= render "card_grants/actions/support", card_grant: %> - <% elsif card_grant.user == current_user %> - <%= render "stripe_cards/actions/freeze", stripe_card: card_grant.stripe_card if card_grant.stripe_card.present? %> - <%= render "card_grants/actions/convert_to_reimbursement_report", card_grant:, label: "Get reimbursed" %> - <%= render "card_grants/actions/return", card_grant: %> - <%= render "card_grants/actions/support", card_grant: %> + <% own = card_grant.user == current_user %> + <% stripe_card = card_grant.stripe_card %> + + <%# Rendered up front since several hide themselves, and an empty card shouldn't show. %> + <% controls = [] %> + <% if organizer_signed_in?(as: :member) || own %> + <% controls << render("stripe_cards/actions/freeze", stripe_card:, variant: "control", label: "Pause spending instantly", subtitle: "You can unfreeze it yourself at any time") if stripe_card.present? %> + <% controls << render("card_grants/actions/convert_to_reimbursement_report", card_grant:, variant: "control", label: own ? "I paid out of pocket" : "Create reimbursement report", subtitle: "Swap the balance for a reimbursement") %> + <% if own %> + <% controls << render("card_grants/actions/return", card_grant:, label: "I don’t need this money", subtitle: "Send the remaining balance back to #{card_grant.event.name}") %> + <% else %> + <% controls << render("card_grants/actions/cancel", card_grant:, variant: "control", label: "Return the balance to #{card_grant.event.name}") %> <% end %> -- <% if card_grant.converted_to_reimbursement_report? %> - This grant has been canceled and converted into a reimbursement report - <% else %> - This grant has been canceled - <% end %> - <% if canceled_by = card_grant.last_user_change_to(status: :canceled) %> - by <%= user_mention canceled_by, disable_tooltip: true %> - <% end %> - <% if canceled_at = card_grant.last_time_change_to(status: :canceled) %> - - <%= time_ago_in_words canceled_at %> ago - - <% end %> - <% if card_grant.converted_to_reimbursement_report? && policy(card_grant.reimbursement_report).show? %> - • <%= link_to "View reimbursement report", card_grant.reimbursement_report %> - <% end %> -
-Here's all the info you might need:
- -<%= empty %>
+ <% elsif items.size <= inline_limit %> +<%= items.to_sentence %>
+ <% else %> + + <% end %> +
- This is information about your grant:
-
-
- If there are any issues with your grant amount, spend by, or allowed merchants,
- please reach out to the <%= card_grant.event.name %> team. HCB staff are unable
- to provide assistance with these requests.
-
-
- - Spend by - - <%= inline_icon "important", size: 18, style: "position: unset;" %> - - - <%= format_date card_grant.expiration_at %> -
+ <% if card_grant.keyword_lock.present? %> +<%= card_grant.keyword_lock %>
+- - One time use - - <%= inline_icon "info", size: 18, style: "position: unset;" %> - - - <%= card_grant.one_time_use ? "Yes" : "No" %> -
- <% end %> +<%= card_grant.one_time_use ? "Yes" : "No" %>
+- Allowed merchants - <% if card_grant.allowed_merchants.any? %> - <%= card_grant.allowed_merchant_names.to_sentence %> - <% else %> - All merchants allowed - <% end %> -
+Return to <%= card_grant.event.name %>
+- Merchant IDs - <%= card_grant.allowed_merchants.join(", ") %> -
- <% end %> + <% drawers.each do |label, items| %> + <%= render "card_grants/control_drawer", id: "grant-#{label.parameterize}", label:, items: %> + <% end %> -- Allowed categories - <% if (allowed_categories = card_grant.allowed_category_names.join(", ").presence) %> - <%= allowed_categories %> - <% else %> - All categories allowed - <% end %> -
+ <% if !own && card_grant.allowed_merchants.any? && organizer_signed_in? %> +Merchant IDs: <%= card_grant.allowed_merchants.join(", ") %>
+ <% end %> - <% if card_grant.keyword_lock.present? %> -- Merchant keyword lock - <%= card_grant.keyword_lock %> -
- <% end %> -+ <%= card_grant.event.name %> sets these limits, not HCB. +
++ To change these restrictions, message the <%= card_grant.event.name %> team. + HCB cannot change your grant limits. +
- <%= status_badge card_grant.status_badge_type %> - <%= card_grant.state_text %> - <% if card_grant.purpose.present? %> - • <%= card_grant.purpose %> - <% end %> -
-- Sent by - <%= user_mention card_grant.sent_by %> -
-- Funded by - <%= link_to card_grant.event.name, card_grant.event %> -
-- Sent - - <%= time_ago_in_words card_grant.created_at %> ago - -
-+ <%= status_badge state %> + <%= card_grant.state_text %> + <% if card_grant.purpose.present? %> + • <%= card_grant.purpose %> + <% end %> +
+ <% if show_actions && organizer_signed_in?(as: :member) %> ++ <%= remaining == total ? "to spend" : "left of #{render_money total}" %> +
+ ++ <% if card_grant.canceled? %> + Canceled on + <% elsif card_grant.expired? %> + Expired on + <% else %> + <%= own ? "Spend it by" : "Expires on" %> + <% end %> +
+<%= format_date(ended ? ended_at : card_grant.expiration_at) %>
+ + <% if days_left.positive? %> + <%= pluralize days_left, "day" %> left + <% elsif days_left.zero? %> + today + <% else %> + <%= pluralize days_left.abs, "day" %> ago + <% end %> + +- Before you can start spending, you need to agree to the Card Issuing Terms and pledge to use the card responsibly. -
- <%= form_with url: activate_card_grant_path(card_grant) do |form| %> -+ One more step before you can start spending: agree to the following and we'll issue your card. +
+ + <%= form_with url: activate_card_grant_path(card_grant), class: "card grant-pledge" do |form| %> + <%= form.label :purpose, class: "grant-pledge__row" do %> + <%= form.check_box :purpose, required: true %> + + I will only use this card for the intended purpose + <% if card_grant.purpose.present? %> + (<%= card_grant.purpose %>) <% end %> -+ Converted <%= canceled_when_by %>. The card no longer works; the money is paid out through the report instead. +
+ <% if policy(card_grant.reimbursement_report).show? %> + <%= link_to "View reimbursement report", card_grant.reimbursement_report, class: "btn mt-3" %> + <% end %> + <% end %> + +<% elsif card_grant.canceled? %> + <%= render "application/callout", type: "info", color: "muted", icon: "forbidden", title: "This grant has been canceled" do %> ++ Canceled <%= canceled_when_by %>. + <% if stripe_card.nil? %> + It was canceled before the card was activated, so nothing was ever charged to it. + <% else %> + The card no longer works, and the unspent balance went back to <%= event_name %>. + <% if own %>Any purchases you already made still need receipts, which you can upload below.<% end %> + <% end %> +
+ <% end %> + +<% elsif card_grant.expired? %> + <%= render "application/callout", type: "info", color: "muted", icon: "clock", title: "This grant expired on #{format_date card_grant.expiration_at}" do %> ++ <% if stripe_card.nil? %> + It expired before the card was activated, so nothing was ever charged to it. + <% else %> + The card no longer works, and the unspent balance went back to <%= event_name %>. + <% if own %>Any purchases you already made still need receipts, which you can upload below.<% end %> + <% end %> +
+ <% end %> + +<% elsif card_grant.pending_invite? && !own %> + <%= render "application/callout", type: "info", icon: "email", title: "Waiting for #{card_grant.user.name} to activate this grant" do %> ++ We emailed an invitation to <%= card_grant.email %>. The virtual card is issued as soon as they accept it, and nothing can be spent until then. +
+ <% end %> + +<% elsif stripe_card&.frozen? %> + <% frozen_by = stripe_card.last_frozen_by if stripe_card.last_frozen_by && stripe_card.last_frozen_by != User.system_user %> + <%= render "application/callout", type: "warning", icon: "freeze", title: "This card is frozen" do %> ++ <% if frozen_by %> + Frozen by <%= user_mention frozen_by, disable_tooltip: true %>. + <% end %> + Nothing can be charged to it until it's defrosted. + <% if policy(stripe_card).defrost? %> + You can defrost it from the controls below. + <% elsif card_grant.event.financially_frozen? || card_grant.user.locked? %> + Only the HCB team can defrost it. <%= help_message %> + <% elsif frozen_by && frozen_by != current_user %> + To use it again, ask <%= frozen_by.name %> or a manager of <%= event_name %> to defrost it. + <% else %> + To use it again, ask a manager of <%= event_name %> to defrost it. + <% end %> +
+ <%# Grantees can't defrost a card someone else froze, so point them straight at whoever can. + The support modal lives in the controls, which only render for these viewers. %> + <% if (own || organizer_signed_in?(as: :member)) && !policy(stripe_card).defrost? && !card_grant.event.financially_frozen? && !card_grant.user.locked? %> + <% if card_grant.setting.support_message.present? %> + + <% elsif card_grant.setting.support_url.present? %> + <%= link_to card_grant.setting.support_url, target: "_blank", rel: "noopener", class: "btn mt-3" do %> + <%= inline_icon card_grant.setting.slack_support? ? "slack" : (card_grant.setting.email_support? ? "email" : "support") %> + <% if card_grant.setting.slack_support? %> + Contact <%= event_name %> on Slack + <% elsif card_grant.setting.email_support? %> + Email <%= event_name %> + <% else %> + Contact <%= event_name %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> + +<% elsif stripe_card&.inactive? && card_grant.pre_authorization.nil? %> + <%= render "application/callout", type: "warning", icon: "important", title: "This card isn't active yet" do %> +Nothing can be charged to it until it's activated.
+ <% end %> +<% end %> diff --git a/app/views/card_grants/_transactions.html.erb b/app/views/card_grants/_transactions.html.erb index d67a357836..20e7d0c57a 100644 --- a/app/views/card_grants/_transactions.html.erb +++ b/app/views/card_grants/_transactions.html.erb @@ -1,6 +1,8 @@ -<%# locals: (hcb_codes: nil, is_public: false, ledger:, items:) %> +<%# locals: (hcb_codes: nil, is_public: false, ledger:, items:, heading: true) %> -<%= label.presence || cancel_tooltip %>
-<%= label.presence || cancel_tooltip %>
+<%= subtitle.presence || reimbursement_tooltip %>
-<%= subtitle.presence || reimbursement_tooltip %>
+<%= return_tooltip %>
-Get support
-- <% if card_grant.setting.slack_support? %> - Contact <%= card_grant.event.name %> on Slack - <% elsif card_grant.setting.email_support? %> - Email <%= card_grant.event.name %> for support - <% else %> - Get in touch with <%= card_grant.event.name %> for support - <% end %> -
-- Grant sent to - <%= mail_to @card_grant.user.email %> (<%= copy_to_clipboard(@card_grant.user.public_id, class: "w-fit", tooltip_direction: "e") %>) -
- -- Expires on - <%= format_date @card_grant.expiration_at %> -
- -- One time use? - <%= @card_grant.one_time_use ? "Yes" : "No" %> -
- -- Allowed merchants - <% if @card_grant.allowed_merchants.any? %> - <%= @card_grant.allowed_merchant_names.to_sentence %> - <% else %> - All merchants allowed - <% end %> -
- - <% if @card_grant.allowed_merchants.any? %> -- Merchant IDs - <%= @card_grant.allowed_merchants.join(", ") %> -
- <% end %> -<%= link_to "https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet#table-of-contents", target: "_blank", class: "flex items-center" do %> <%= inline_icon "markdown", size: 32 %> Styling with Markdown is supported diff --git a/app/views/card_grants/show.html.erb b/app/views/card_grants/show.html.erb index 452384fcc7..a00267c127 100644 --- a/app/views/card_grants/show.html.erb +++ b/app/views/card_grants/show.html.erb @@ -5,86 +5,69 @@ <%= render "users/nav", selected: :cards %> <% end %> -<% footer = nil %> -<% if @card_grant.user == current_user && @card_grant.pending_invite? %> - <% footer = capture do %> -
We just need one more thing before you can start spending your grant.
-- <%= MarkdownService.instance.render(@card_grant.instructions) %> -
+<%# Everything that explains the grant's state sits together under the hero. %> +<% notices = capture do %> + <%= render partial: "card_grants/status_callout", locals: { card_grant: @card_grant } %> + + <%= render partial: "card_grant/pre_authorizations/organizer_info", locals: { pre_authorization: @card_grant.pre_authorization, link_to_pre_authorization: true } if @card_grant.pre_authorization.present? && organizer_signed_in? %> + + <% if @card_grant.instructions.present? %> + <%= render "application/callout", title: "Instructions from #{@card_grant.event.name}", type: "info" do %> ++ <%= MarkdownService.instance.render(@card_grant.instructions) %> +
+ <% end %> <% end %> <% end %> +<% if notices.present? %> +- <%= link_to "View reimbursement report", @card_grant.reimbursement_report %> -
- <% end %> -<%= policy(stripe_card).defrost? ? defrost_tooltip : "You don't have permission to perform this action" %>
-<%= defrost_subtitle %>
+<%= policy(stripe_card).freeze? ? freeze_tooltip : "You don't have permission to perform this action" %>
-<%= freeze_subtitle %>
+
+
++ Comments + <%= comments.size %> +
+