mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
Exploration spike following expert-panel synthesis. Codifies the 3-tier gating model (instance / per-user / per-family) without adding a framework. Investments is the first family-scoped module. - Family#disabled_modules: string[] column, opt-out semantics. No per-module DB column. Existing families default to [] = enabled. - Family::AVAILABLE_MODULES = %w[investments] (the registry). - ModuleGateable concern (auto-included in ApplicationController): require_module! class macro + module_enabled? helper. - Api::V1::BaseController#require_module!: 403 feature_disabled JSON, mirrors require_ai_enabled. - NavigationHelper extracts mobile/desktop nav into a single source with module: key support; mobile shrinks via justify-around, never auto-fills empty slots. - Settings → Preferences gains a family-scoped module toggle card. - 4 HTML controllers (Investments, Holdings, Trades, Securities) + 3 API controllers gated. Investment/Crypto account types hidden in the new-account modal when off. - docs/feature-gating.md codifies the rule for future modules. Background-job layer not wired (no Investments-specific scheduled job to gate; flagged as TODO in docs). Run db:migrate before bin/rails test. No PR yet — awaiting decisions in open-questions list.
201 lines
9.0 KiB
Plaintext
201 lines
9.0 KiB
Plaintext
<% intro_mode = Current.user&.ui_layout_intro? %>
|
|
<% home_path = intro_mode ? chats_path : root_path %>
|
|
<% mobile_nav_items = mobile_nav_items(intro_mode: intro_mode) %>
|
|
<% desktop_nav_items = desktop_nav_items(intro_mode: intro_mode) %>
|
|
<% expanded_sidebar_class = "w-full" %>
|
|
<% collapsed_sidebar_class = "w-0 overflow-hidden" %>
|
|
|
|
<%= render "layouts/shared/htmldoc" do %>
|
|
<div
|
|
class="flex flex-col lg:flex-row h-full bg-surface"
|
|
data-controller="app-layout privacy-mode"
|
|
data-app-layout-expanded-sidebar-class="<%= expanded_sidebar_class %>"
|
|
data-app-layout-collapsed-sidebar-class="<%= collapsed_sidebar_class %>"
|
|
data-app-layout-user-id-value="<%= Current.user.id %>">
|
|
<%= link_to t("layouts.application.skip_to_main"), "#main",
|
|
class: "sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:px-3 focus:py-2 focus:rounded-lg focus:bg-container focus:text-primary focus:shadow-border-xs" %>
|
|
|
|
<div
|
|
class="hidden fixed inset-0 bg-surface z-20 h-full w-full pt-[calc(env(safe-area-inset-top)+0.75rem)] pr-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] pl-3 overflow-y-auto transition-all duration-300"
|
|
data-app-layout-target="mobileSidebar">
|
|
<% unless intro_mode %>
|
|
<div class="mb-2">
|
|
<%= icon("x", as_button: true, data: { action: "app-layout#closeMobileSidebar" }) %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= render(
|
|
"accounts/account_sidebar_tabs",
|
|
family: Current.family,
|
|
active_tab: @account_group_tab,
|
|
mobile: true
|
|
) %>
|
|
</div>
|
|
|
|
<%# MOBILE - Top nav %>
|
|
<nav class="lg:hidden flex justify-between items-center p-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
|
|
<% if intro_mode %>
|
|
<% else %>
|
|
<%= icon("panel-left", as_button: true, data: { action: "app-layout#openMobileSidebar"}) %>
|
|
<% end %>
|
|
|
|
<%= link_to home_path, class: "block" do %>
|
|
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
|
|
<% end %>
|
|
|
|
<div class="flex items-center gap-1">
|
|
<button type="button"
|
|
class="inline-flex items-center justify-center w-8 h-8 rounded-lg text-secondary hover:text-primary transition-colors"
|
|
data-action="click->privacy-mode#toggle"
|
|
data-privacy-mode-target="toggle"
|
|
title="<%= t("layouts.application.privacy_mode") %>"
|
|
aria-label="<%= t("layouts.application.privacy_mode") %>"
|
|
aria-pressed="false">
|
|
<%= icon("eye-off", size: "sm", data: { privacy_mode_target: "iconOff" }) %>
|
|
<%= icon("eye", size: "sm", class: "hidden", data: { privacy_mode_target: "iconOn" }) %>
|
|
</button>
|
|
<%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12, intro_mode: intro_mode %>
|
|
</div>
|
|
</nav>
|
|
|
|
<%# DESKTOP - Left navbar %>
|
|
<div class="hidden lg:block border-r border-divider">
|
|
<nav class="h-full flex flex-col shrink-0 w-[84px] py-4">
|
|
<div class="pl-2 mb-3">
|
|
<%= link_to home_path, class: "block" do %>
|
|
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
|
|
<% end %>
|
|
</div>
|
|
|
|
<ul class="space-y-0.5">
|
|
<% desktop_nav_items.reject { |item| item[:mobile_only] }.each do |nav_item| %>
|
|
<li>
|
|
<%= render "layouts/shared/nav_item", **nav_item %>
|
|
</li>
|
|
<% end %>
|
|
</ul>
|
|
|
|
<div class="pl-2 mt-auto mx-auto flex flex-col gap-2">
|
|
<%= render DS::Link.new(
|
|
variant: "icon",
|
|
icon: "message-circle-question",
|
|
href: "https://discord.gg/36ZGBsxYEK",
|
|
target: "_blank"
|
|
) %>
|
|
|
|
<%= render "users/user_menu", user: Current.user, intro_mode: intro_mode %>
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
|
|
<%# DESKTOP - Left sidebar %>
|
|
<%= tag.div class: class_names(
|
|
"hidden lg:block py-4 shrink-0 max-w-[320px] transition-all duration-300 border-divider",
|
|
Current.user.show_sidebar? ? [expanded_sidebar_class, "border-r"] : [collapsed_sidebar_class, "border-r-0"],
|
|
),
|
|
inert: !Current.user.show_sidebar?,
|
|
data: { app_layout_target: "leftSidebar" } do %>
|
|
<div class="px-4 h-full flex flex-col overflow-y-auto">
|
|
<% if content_for?(:sidebar) %>
|
|
<%= yield :sidebar %>
|
|
<% else %>
|
|
<div class="grow">
|
|
<%= render "accounts/account_sidebar_tabs", family: Current.family, active_tab: @account_group_tab %>
|
|
</div>
|
|
|
|
<% if Current.family.trialing? && !self_hosted? %>
|
|
<div class="px-4 py-3 space-y-4 bg-container shadow-border-xs rounded-xl">
|
|
<div class="flex items-start justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium text-primary"><%= t("layouts.trial.open_demo") %></p>
|
|
<p class="text-sm text-secondary"><%= t("layouts.trial.data_deleted_in_days", days: Current.family.days_left_in_trial) %></p>
|
|
</div>
|
|
|
|
<%= render DS::Link.new(
|
|
text: t("layouts.trial.contribute"),
|
|
href: upgrade_subscription_path,
|
|
) %>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-0.5 h-1.5">
|
|
<div class="h-full bg-warning rounded-full" style="width: <%= Current.family.percentage_of_trial_completed %>%"></div>
|
|
<div class="h-full bg-surface-inset rounded-full" style="width: <%= Current.family.percentage_of_trial_remaining %>%"></div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%# SHARED - Main content %>
|
|
<%= tag.main id: "main", class: class_names("grow overflow-y-auto px-3 lg:px-10 w-full mx-auto pb-[calc(5rem+env(safe-area-inset-bottom))] lg:pb-0"), data: { app_layout_target: "content", viewport_target: "content" } do %>
|
|
<% unless intro_mode %>
|
|
<div class="hidden lg:flex gap-2 items-center justify-between mb-6 sticky top-0 z-10 -mx-3 lg:-mx-10 px-3 lg:px-10 py-4 bg-surface border-b border-tertiary">
|
|
<div class="flex items-center gap-2">
|
|
<%= icon("panel-left", as_button: true, data: { action: "app-layout#toggleLeftSidebar" }) %>
|
|
|
|
<% if content_for?(:breadcrumbs) %>
|
|
<%= yield :breadcrumbs %>
|
|
<% else %>
|
|
<%= render "layouts/shared/breadcrumbs", breadcrumbs: breadcrumbs %>
|
|
<% end %>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<button type="button"
|
|
class="inline-flex items-center justify-center w-8 h-8 rounded-lg text-secondary hover:text-primary hover:bg-surface-hover transition-colors"
|
|
data-action="click->privacy-mode#toggle"
|
|
data-privacy-mode-target="toggle"
|
|
title="<%= t("layouts.application.privacy_mode") %>"
|
|
aria-label="<%= t("layouts.application.privacy_mode") %>"
|
|
aria-pressed="false">
|
|
<%= icon("eye-off", size: "sm", data: { privacy_mode_target: "iconOff" }) %>
|
|
<%= icon("eye", size: "sm", class: "hidden", data: { privacy_mode_target: "iconOn" }) %>
|
|
</button>
|
|
|
|
<%= icon("panel-right", as_button: true, data: { action: "app-layout#toggleRightSidebar" }) %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<% if content_for?(:page_header) %>
|
|
<%= yield :page_header %>
|
|
<% end %>
|
|
|
|
<%= yield %>
|
|
<% end %>
|
|
|
|
<%# DESKTOP - Right sidebar %>
|
|
<%= tag.div class: class_names(
|
|
"hidden lg:block h-full shrink-0 max-w-[400px] transition-all duration-300 border-divider",
|
|
Current.user.show_ai_sidebar? ? [expanded_sidebar_class, "border-l"] : [collapsed_sidebar_class, "border-l-0"],
|
|
),
|
|
inert: !Current.user.show_ai_sidebar?,
|
|
data: { app_layout_target: "rightSidebar" } do %>
|
|
<%= tag.div id: "chat-container", class: "relative h-full px-4 overflow-y-auto", data: { controller: "chat hotkey", turbo_permanent: true } do %>
|
|
<div class="flex flex-col h-full justify-between shrink-0">
|
|
<%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>
|
|
<div class="flex justify-center items-center h-full">
|
|
<%= icon("loader-circle", class: "animate-spin") %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<% unless Current.user.ai_enabled? %>
|
|
<div class="absolute backdrop-blur-lg inset-0 h-full w-full flex flex-col justify-center items-center px-4">
|
|
<%= render "chats/ai_consent" %>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
<% end %>
|
|
|
|
<%# MOBILE - Bottom Nav %>
|
|
<%= tag.nav class: "lg:hidden fixed bottom-0 left-0 right-0 bg-surface z-10 border-t border-tertiary flex justify-around pb-[env(safe-area-inset-bottom)]",
|
|
data: { viewport_target: "bottomNav" } do %>
|
|
<% mobile_nav_items.each do |nav_item| %>
|
|
<%= render "layouts/shared/nav_item", **nav_item %>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|