diff --git a/.env.example b/.env.example
index 6e2b7fe56..b7ba0920d 100644
--- a/.env.example
+++ b/.env.example
@@ -155,10 +155,6 @@ TWITTER_ENVIRONMENT=
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
-#Linear Integration
-LINEAR_CLIENT_ID=
-LINEAR_CLIENT_SECRET=
-
# Google OAuth
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
diff --git a/.github/dashboard-screen.png b/.github/dashboard-screen.png
deleted file mode 100644
index 847ae1582..000000000
Binary files a/.github/dashboard-screen.png and /dev/null differ
diff --git a/.github/screenshots/dashboard-dark.png b/.github/screenshots/dashboard-dark.png
new file mode 100644
index 000000000..4d08b52b9
Binary files /dev/null and b/.github/screenshots/dashboard-dark.png differ
diff --git a/.github/screenshots/dashboard.png b/.github/screenshots/dashboard.png
new file mode 100644
index 000000000..b8b99be49
Binary files /dev/null and b/.github/screenshots/dashboard.png differ
diff --git a/.github/screenshots/header-dark.png b/.github/screenshots/header-dark.png
new file mode 100644
index 000000000..84931aee3
Binary files /dev/null and b/.github/screenshots/header-dark.png differ
diff --git a/.github/screenshots/header.png b/.github/screenshots/header.png
new file mode 100644
index 000000000..f10ca0faf
Binary files /dev/null and b/.github/screenshots/header.png differ
diff --git a/.github/workflows/nightly_installer.yml b/.github/workflows/nightly_installer.yml
index d11fe6401..a01ba1093 100644
--- a/.github/workflows/nightly_installer.yml
+++ b/.github/workflows/nightly_installer.yml
@@ -16,7 +16,7 @@ on:
jobs:
nightly:
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-24.04
steps:
- name: get installer
diff --git a/.scss-lint.yml b/.scss-lint.yml
index 1cc029441..2477dfffb 100644
--- a/.scss-lint.yml
+++ b/.scss-lint.yml
@@ -283,3 +283,4 @@ exclude:
- 'app/javascript/widget/assets/scss/sdk.css'
- 'app/assets/stylesheets/administrate/reset/_normalize.scss'
- 'app/javascript/shared/assets/stylesheets/*.scss'
+ - 'app/javascript/dashboard/assets/scss/_woot.scss'
diff --git a/Gemfile b/Gemfile
index 937aef4af..1e8605379 100644
--- a/Gemfile
+++ b/Gemfile
@@ -173,8 +173,11 @@ gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
+gem 'iso-639'
gem 'ruby-openai'
+gem 'shopify_api'
+
### Gems required only in specific deployment environments ###
##############################################################
diff --git a/Gemfile.lock b/Gemfile.lock
index e9a573816..bfe4b1970 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -352,6 +352,7 @@ GEM
ruby2ruby (~> 2.4)
ruby_parser (~> 3.10)
hana (1.3.7)
+ hash_diff (1.1.1)
hashdiff (1.1.0)
hashie (5.0.0)
html2text (0.4.0)
@@ -377,6 +378,8 @@ GEM
io-console (0.6.0)
irb (1.7.2)
reline (>= 0.3.6)
+ iso-639 (0.3.8)
+ csv
jbuilder (2.11.5)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
@@ -520,6 +523,9 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
+ oj (3.16.10)
+ bigdecimal (>= 3.0)
+ ostruct (>= 0.2)
omniauth (2.1.2)
hashie (>= 3.4.6)
rack (>= 2.2.3)
@@ -561,7 +567,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
- rack (2.2.11)
+ rack (2.2.13)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -711,6 +717,7 @@ GEM
parser
scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5)
+ securerandom (0.4.1)
seed_dump (3.3.1)
activerecord (>= 4)
activesupport (>= 4)
@@ -725,6 +732,17 @@ GEM
sentry-ruby (~> 5.19.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
+ shopify_api (14.8.0)
+ activesupport
+ concurrent-ruby
+ hash_diff
+ httparty
+ jwt
+ oj
+ openssl
+ securerandom
+ sorbet-runtime
+ zeitwerk (~> 2.5)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.3.1)
@@ -757,6 +775,7 @@ GEM
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
+ sorbet-runtime (0.5.11934)
spring (4.1.1)
spring-watcher-listen (2.1.0)
listen (>= 2.7, < 4.0)
@@ -799,7 +818,7 @@ GEM
unf_ext (0.0.8.2)
unicode-display_width (2.4.2)
uniform_notifier (1.16.0)
- uri (0.13.0)
+ uri (1.0.3)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -896,6 +915,7 @@ DEPENDENCIES
hashie
html2text
image_processing
+ iso-639
jbuilder
json_refs
json_schemer
@@ -950,6 +970,7 @@ DEPENDENCIES
sentry-rails (>= 5.19.0)
sentry-ruby
sentry-sidekiq (>= 5.19.0)
+ shopify_api
shoulda-matchers
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
diff --git a/README.md b/README.md
index f09bd5698..21316b422 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,11 @@
-## 🚨 Note: This branch is unstable. For the stable branch's source code, please use the branch [3.x](https://github.com/chatwoot/chatwoot/tree/3.x)
-
-
-
-
+
+
___
# Chatwoot
-Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
-
@@ -31,41 +20,71 @@ Customer engagement suite, an open-source alternative to Intercom, Zendesk, Sale
-
-Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile.
+
-## Features
+
+
-Chatwoot supports the following conversation channels:
+---
- - **Website**: Talk to your customers using our live chat widget and make use of our SDK to identify a user and provide contextual support.
- - **Facebook**: Connect your Facebook pages and start replying to the direct messages to your page.
- - **Instagram**: Connect your Instagram profile and start replying to the direct messages.
- - **Twitter**: Connect your Twitter profiles and reply to direct messages or the tweets where you are mentioned.
- - **Telegram**: Connect your Telegram bot and reply to your customers right from a single dashboard.
- - **WhatsApp**: Connect your WhatsApp business account and manage the conversation in Chatwoot.
- - **Line**: Connect your Line account and manage the conversations in Chatwoot.
- - **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot.
- - **API Channel**: Build custom communication channels using our API channel.
- - **Email**: Forward all your email queries to Chatwoot and view it in our integrated dashboard.
+Chatwoot is the modern, open-source, and self-hosted customer support platform designed to help businesses deliver exceptional customer support experience. Built for scale and flexibility, Chatwoot gives you full control over your customer data while providing powerful tools to manage conversations across channels.
-And more.
+### ✨ Captain – AI Agent for Support
-Other features include:
+Supercharge your support with Captain, Chatwoot’s AI agent. Captain helps automate responses, handle common queries, and reduce agent workload—ensuring customers get instant, accurate answers. With Captain, your team can focus on complex conversations while routine questions are resolved automatically. Read more about Captain [here](https://chwt.app/captain-docs).
+
+### 💬 Omnichannel Support Desk
+
+Chatwoot centralizes all customer conversations into one powerful inbox, no matter where your customers reach out from. It supports live chat on your website, email, Facebook, Instagram, Twitter, WhatsApp, Telegram, Line, SMS etc.
+
+### 📚 Help center portal
+
+Publish help articles, FAQs, and guides through the built-in Help Center Portal. Enable customers to find answers on their own, reduce repetitive queries, and keep your support team focused on more complex issues.
+
+### 🗂️ Other features
+
+#### Collaboration & Productivity
+
+- Private Notes and @mentions for internal team discussions.
+- Labels to organize and categorize conversations.
+- Keyboard Shortcuts and a Command Bar for quick navigation.
+- Canned Responses to reply faster to frequently asked questions.
+- Auto-Assignment to route conversations based on agent availability.
+- Multi-lingual Support to serve customers in multiple languages.
+- Custom Views and Filters for better inbox organization.
+- Business Hours and Auto-Responders to manage response expectations.
+- Teams and Automation tools for scaling support workflows.
+- Agent Capacity Management to balance workload across the team.
+
+#### Customer Data & Segmentation
+- Contact Management with profiles and interaction history.
+- Contact Segments and Notes for targeted communication.
+- Campaigns to proactively engage customers.
+- Custom Attributes for storing additional customer data.
+- Pre-Chat Forms to collect user information before starting conversations.
+
+#### Integrations
+- Slack Integration to manage conversations directly from Slack.
+- Dialogflow Integration for chatbot automation.
+- Dashboard Apps to embed internal tools within Chatwoot.
+- Shopify Integration to view and manage customer orders right within Chatwoot.
+- Use Google Translate to translate messages from your customers in realtime.
+- Create and manage Linear tickets within Chatwoot.
+
+#### Reports & Insights
+- Live View of ongoing conversations for real-time monitoring.
+- Conversation, Agent, Inbox, Label, and Team Reports for operational visibility.
+- CSAT Reports to measure customer satisfaction.
+- Downloadable Reports for offline analysis and reporting.
-- **CRM**: Save all your customer information right inside Chatwoot, use contact notes to log emails, phone calls, or meeting notes.
-- **Custom Attributes**: Define custom attribute attributes to store information about a contact or a conversation and extend the product to match your workflow.
-- **Shared multi-brand inboxes**: Manage multiple brands or pages using a shared inbox.
-- **Private notes**: Use @mentions and private notes to communicate internally about a conversation.
-- **Canned responses (Saved replies)**: Improve the response rate by adding saved replies for frequently asked questions.
-- **Conversation Labels**: Use conversation labels to create custom workflows.
-- **Auto assignment**: Chatwoot intelligently assigns a ticket to the agents who have access to the inbox depending on their availability and load.
-- **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot will send an email to the customer under the agent name so that the user can continue the conversation over the email.
-- **Multi-lingual support**: Chatwoot supports 10+ languages.
-- **Powerful API & Webhooks**: Extend the capability of the software using Chatwoot’s webhooks and APIs.
-- **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard.
## Documentation
@@ -107,13 +126,11 @@ For other supported options, checkout our [deployment page](https://chatwoot.com
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
-
-## Community? Questions? Support ?
+## Community
If you need help or just want to hang out, come, say hi on our [Discord](https://discord.gg/cJXdrwS) server.
-
-## Contributors ✨
+## Contributors
Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors):
diff --git a/app/assets/stylesheets/administrate/components/_buttons.scss b/app/assets/stylesheets/administrate/components/_buttons.scss
index 7b2f62045..1fceddc1f 100644
--- a/app/assets/stylesheets/administrate/components/_buttons.scss
+++ b/app/assets/stylesheets/administrate/components/_buttons.scss
@@ -1,15 +1,15 @@
-button,
-input[type="button"],
-input[type="reset"],
-input[type="submit"],
-.button {
+button:not(.reset-base),
+input[type='button']:not(.reset-base),
+input[type='reset']:not(.reset-base),
+input[type='submit']:not(.reset-base),
+.button:not(.reset-base) {
appearance: none;
background-color: $color-woot;
border: 0;
border-radius: $base-border-radius;
color: $white;
cursor: pointer;
- display: inline-block;
+ display: inline-flex;
font-size: $font-size-small;
-webkit-font-smoothing: antialiased;
font-weight: $font-weight-medium;
diff --git a/app/assets/stylesheets/administrate/custom_styles.scss b/app/assets/stylesheets/administrate/custom_styles.scss
index 5e6d803d8..00f1a058c 100644
--- a/app/assets/stylesheets/administrate/custom_styles.scss
+++ b/app/assets/stylesheets/administrate/custom_styles.scss
@@ -10,7 +10,6 @@
.icon-container {
margin-right: 2px;
-
}
.value-container {
diff --git a/app/builders/contact_inbox_builder.rb b/app/builders/contact_inbox_builder.rb
index 8fcd2b158..4d51700a8 100644
--- a/app/builders/contact_inbox_builder.rb
+++ b/app/builders/contact_inbox_builder.rb
@@ -12,50 +12,11 @@ class ContactInboxBuilder
private
def generate_source_id
- case @inbox.channel_type
- when 'Channel::TwilioSms'
- twilio_source_id
- when 'Channel::Whatsapp'
- wa_source_id
- when 'Channel::Email'
- email_source_id
- when 'Channel::Sms'
- phone_source_id
- when 'Channel::Api', 'Channel::WebWidget'
- SecureRandom.uuid
- else
- raise "Unsupported operation for this channel: #{@inbox.channel_type}"
- end
- end
-
- def email_source_id
- raise ActionController::ParameterMissing, 'contact email' unless @contact.email
-
- @contact.email
- end
-
- def phone_source_id
- raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
-
- @contact.phone_number
- end
-
- def wa_source_id
- raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
-
- # whatsapp doesn't want the + in e164 format
- @contact.phone_number.delete('+').to_s
- end
-
- def twilio_source_id
- raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
-
- case @inbox.channel.medium
- when 'sms'
- @contact.phone_number
- when 'whatsapp'
- "whatsapp:#{@contact.phone_number}"
- end
+ ContactInbox::SourceIdService.new(
+ contact: @contact,
+ channel_type: @inbox.channel_type,
+ medium: @inbox.channel.try(:medium)
+ ).generate
end
def create_contact_inbox
@@ -64,5 +25,40 @@ class ContactInboxBuilder
inbox_id: @inbox.id,
source_id: @source_id
)
+ rescue ActiveRecord::RecordNotUnique
+ Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
+ update_old_contact_inbox
+ retry
+ end
+
+ def update_old_contact_inbox
+ # The race condition occurs when there’s a contact inbox with the
+ # same source ID but linked to a different contact. This can happen
+ # if the agent updates the contact’s email or phone number, or
+ # if the contact is merged with another.
+ #
+ # We update the old contact inbox source_id to a random value to
+ # avoid disrupting the current flow. However, the root cause of
+ # this issue is a flaw in the contact inbox model design.
+ # Contact inbox is essentially tracking a session and is not
+ # needed for non-live chat channels.
+ raise ActiveRecord::RecordNotUnique unless allowed_channels?
+
+ contact_inbox = ::ContactInbox.find_by(inbox_id: @inbox.id, source_id: @source_id)
+ return if contact_inbox.blank?
+
+ contact_inbox.update!(source_id: new_source_id)
+ end
+
+ def new_source_id
+ if @inbox.whatsapp? || @inbox.sms? || @inbox.twilio?
+ "#{@source_id}#{rand(100)}"
+ else
+ "#{rand(10)}#{@source_id}"
+ end
+ end
+
+ def allowed_channels?
+ @inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
end
end
diff --git a/app/channels/room_channel.rb b/app/channels/room_channel.rb
index 0680f1458..629078229 100644
--- a/app/channels/room_channel.rb
+++ b/app/channels/room_channel.rb
@@ -2,10 +2,9 @@ class RoomChannel < ApplicationCable::Channel
def subscribed
# TODO: should we only do ensure stream if current account is present?
# for now going ahead with guard clauses in update_subscription and broadcast_presence
-
- ensure_stream
current_user
current_account
+ ensure_stream
update_subscription
broadcast_presence
end
@@ -22,12 +21,12 @@ class RoomChannel < ApplicationCable::Channel
data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) }
data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User
- ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data })
+ ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
end
def ensure_stream
- @pubsub_token = params[:pubsub_token]
- stream_from @pubsub_token
+ stream_from pubsub_token
+ stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
end
def update_subscription
@@ -36,11 +35,15 @@ class RoomChannel < ApplicationCable::Channel
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
end
+ def pubsub_token
+ @pubsub_token ||= params[:pubsub_token]
+ end
+
def current_user
@current_user ||= if params[:user_id].blank?
- ContactInbox.find_by!(pubsub_token: @pubsub_token).contact
+ ContactInbox.find_by!(pubsub_token: pubsub_token).contact
else
- User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
+ User.find_by!(pubsub_token: pubsub_token, id: params[:user_id])
end
end
diff --git a/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb b/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb
index d985c8a73..bde9a4f0c 100644
--- a/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb
@@ -9,6 +9,8 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts:
source_id: params[:source_id],
hmac_verified: hmac_verified?
).perform
+ rescue ArgumentError => e
+ render json: { error: e.message }, status: :unprocessable_entity
end
private
diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb
index 2cd5281ff..138c2bd68 100644
--- a/app/controllers/api/v1/accounts/conversations_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations_controller.rb
@@ -6,6 +6,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :inbox, :contact, :contact_inbox, only: [:create]
+ ATTACHMENT_RESULTS_PER_PAGE = 100
+
def index
result = conversation_finder.perform
@conversations = result[:conversations]
@@ -24,7 +26,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def attachments
+ @attachments_count = @conversation.attachments.count
@attachments = @conversation.attachments
+ .includes(:message)
+ .order(created_at: :desc)
+ .page(attachment_params[:page])
+ .per(ATTACHMENT_RESULTS_PER_PAGE)
end
def show; end
@@ -124,6 +131,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
params.permit(:priority)
end
+ def attachment_params
+ params.permit(:page)
+ end
+
def update_last_seen_on_conversation(last_seen_at, update_assignee)
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, last_seen_at)
diff --git a/app/controllers/api/v1/accounts/integrations/shopify_controller.rb b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb
new file mode 100644
index 000000000..7fe31889b
--- /dev/null
+++ b/app/controllers/api/v1/accounts/integrations/shopify_controller.rb
@@ -0,0 +1,111 @@
+class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
+ include Shopify::IntegrationHelper
+ before_action :setup_shopify_context, only: [:orders]
+ before_action :fetch_hook, except: [:auth]
+ before_action :validate_contact, only: [:orders]
+
+ def auth
+ shop_domain = params[:shop_domain]
+ return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank?
+
+ state = generate_shopify_token(Current.account.id)
+
+ auth_url = "https://#{shop_domain}/admin/oauth/authorize?"
+ auth_url += URI.encode_www_form(
+ client_id: client_id,
+ scope: REQUIRED_SCOPES.join(','),
+ redirect_uri: redirect_uri,
+ state: state
+ )
+
+ render json: { redirect_url: auth_url }
+ end
+
+ def orders
+ customers = fetch_customers
+ return render json: { orders: [] } if customers.empty?
+
+ orders = fetch_orders(customers.first['id'])
+ render json: { orders: orders }
+ rescue ShopifyAPI::Errors::HttpResponseError => e
+ render json: { error: e.message }, status: :unprocessable_entity
+ end
+
+ def destroy
+ @hook.destroy!
+ head :ok
+ rescue StandardError => e
+ render json: { error: e.message }, status: :unprocessable_entity
+ end
+
+ private
+
+ def redirect_uri
+ "#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback"
+ end
+
+ def contact
+ @contact ||= Current.account.contacts.find_by(id: params[:contact_id])
+ end
+
+ def fetch_hook
+ @hook = Integrations::Hook.find_by!(account: Current.account, app_id: 'shopify')
+ end
+
+ def fetch_customers
+ query = []
+ query << "email:#{contact.email}" if contact.email.present?
+ query << "phone:#{contact.phone_number}" if contact.phone_number.present?
+
+ shopify_client.get(
+ path: 'customers/search.json',
+ query: {
+ query: query.join(' OR '),
+ fields: 'id,email,phone'
+ }
+ ).body['customers'] || []
+ end
+
+ def fetch_orders(customer_id)
+ orders = shopify_client.get(
+ path: 'orders.json',
+ query: {
+ customer_id: customer_id,
+ status: 'any',
+ fields: 'id,email,created_at,total_price,currency,fulfillment_status,financial_status'
+ }
+ ).body['orders'] || []
+
+ orders.map do |order|
+ order.merge('admin_url' => "https://#{@hook.reference_id}/admin/orders/#{order['id']}")
+ end
+ end
+
+ def setup_shopify_context
+ return if client_id.blank? || client_secret.blank?
+
+ ShopifyAPI::Context.setup(
+ api_key: client_id,
+ api_secret_key: client_secret,
+ api_version: '2025-01'.freeze,
+ scope: REQUIRED_SCOPES.join(','),
+ is_embedded: true,
+ is_private: false
+ )
+ end
+
+ def shopify_session
+ ShopifyAPI::Auth::Session.new(shop: @hook.reference_id, access_token: @hook.access_token)
+ end
+
+ def shopify_client
+ @shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session)
+ end
+
+ def validate_contact
+ return unless contact.blank? || (contact.email.blank? && contact.phone_number.blank?)
+
+ render json: { error: 'Contact information missing' },
+ status: :unprocessable_entity
+ end
+end
diff --git a/app/controllers/api/v1/widget/campaigns_controller.rb b/app/controllers/api/v1/widget/campaigns_controller.rb
index cb9b96a38..10a73aa62 100644
--- a/app/controllers/api/v1/widget/campaigns_controller.rb
+++ b/app/controllers/api/v1/widget/campaigns_controller.rb
@@ -2,6 +2,10 @@ class Api::V1::Widget::CampaignsController < Api::V1::Widget::BaseController
skip_before_action :set_contact
def index
- @campaigns = @web_widget.inbox.campaigns.where(enabled: true)
+ @campaigns = @web_widget
+ .inbox
+ .campaigns
+ .where(enabled: true, account_id: @web_widget.inbox.account_id)
+ .includes(:sender)
end
end
diff --git a/app/controllers/api/v1/widget/inbox_members_controller.rb b/app/controllers/api/v1/widget/inbox_members_controller.rb
index c4bc377ea..22934388b 100644
--- a/app/controllers/api/v1/widget/inbox_members_controller.rb
+++ b/app/controllers/api/v1/widget/inbox_members_controller.rb
@@ -2,6 +2,6 @@ class Api::V1::Widget::InboxMembersController < Api::V1::Widget::BaseController
skip_before_action :set_contact
def index
- @inbox_members = @web_widget.inbox.inbox_members.includes(:user)
+ @inbox_members = @web_widget.inbox.inbox_members.includes(user: { avatar_attachment: :blob })
end
end
diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb
index 3013ff3cc..a8ea8ae05 100644
--- a/app/controllers/concerns/switch_locale.rb
+++ b/app/controllers/concerns/switch_locale.rb
@@ -5,10 +5,11 @@ module SwitchLocale
def switch_locale(&)
# priority is for locale set in query string (mostly for widget/from js sdk)
- locale ||= locale_from_params
+ locale ||= params[:locale]
+
locale ||= locale_from_custom_domain
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
- locale ||= locale_from_env_variable
+ locale ||= ENV.fetch('DEFAULT_LOCALE', nil)
set_locale(locale, &)
end
@@ -32,26 +33,30 @@ module SwitchLocale
end
def set_locale(locale, &)
- # if locale is empty, use default_locale
- locale ||= I18n.default_locale
+ safe_locale = validate_and_get_locale(locale)
# Ensure locale won't bleed into other requests
# https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests
- I18n.with_locale(locale, &)
+ I18n.with_locale(safe_locale, &)
end
- def locale_from_params
- I18n.available_locales.map(&:to_s).include?(params[:locale]) ? params[:locale] : nil
+ def validate_and_get_locale(locale)
+ return I18n.default_locale.to_s if locale.blank?
+
+ available_locales = I18n.available_locales.map(&:to_s)
+ locale_without_variant = locale.split('_')[0]
+
+ if available_locales.include?(locale)
+ locale
+ elsif available_locales.include?(locale_without_variant)
+ locale_without_variant
+ else
+ I18n.default_locale.to_s
+ end
end
def locale_from_account(account)
return unless account
- I18n.available_locales.map(&:to_s).include?(account.locale) ? account.locale : nil
- end
-
- def locale_from_env_variable
- return unless ENV.fetch('DEFAULT_LOCALE', nil)
-
- I18n.available_locales.map(&:to_s).include?(ENV.fetch('DEFAULT_LOCALE')) ? ENV.fetch('DEFAULT_LOCALE') : nil
+ account.locale
end
end
diff --git a/app/controllers/linear/callbacks_controller.rb b/app/controllers/linear/callbacks_controller.rb
index c0688cefc..2eea49333 100644
--- a/app/controllers/linear/callbacks_controller.rb
+++ b/app/controllers/linear/callbacks_controller.rb
@@ -16,9 +16,12 @@ class Linear::CallbacksController < ApplicationController
private
def oauth_client
+ app_id = GlobalConfigService.load('LINEAR_CLIENT_ID', nil)
+ app_secret = GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
+
OAuth2::Client.new(
- ENV.fetch('LINEAR_CLIENT_ID', nil),
- ENV.fetch('LINEAR_CLIENT_SECRET', nil),
+ app_id,
+ app_secret,
{
site: 'https://api.linear.app',
token_url: '/oauth/token',
diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb
index f0fcf403d..d07dbcb9d 100644
--- a/app/controllers/public/api/v1/portals/articles_controller.rb
+++ b/app/controllers/public/api/v1/portals/articles_controller.rb
@@ -6,17 +6,25 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
layout 'portal'
def index
- @articles = @portal.articles.published
+ @articles = @portal.articles.published.includes(:category, :author)
@articles_count = @articles.count
search_articles
order_by_sort_param
- @articles = @articles.page(list_params[:page]) if list_params[:page].present?
+ limit_results
end
def show; end
private
+ def limit_results
+ return if list_params[:per_page].blank?
+
+ per_page = [list_params[:per_page].to_i, 100].min
+ per_page = 25 if per_page < 1
+ @articles = @articles.page(list_params[:page]).per(per_page)
+ end
+
def search_articles
@articles = @articles.search(list_params) if list_params.present?
end
@@ -45,7 +53,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def list_params
- params.permit(:query, :locale, :sort, :status, :page)
+ params.permit(:query, :locale, :sort, :status, :page, :per_page)
end
def permitted_params
diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb
index f6c10f7c4..66b052b1e 100644
--- a/app/controllers/public/api/v1/portals/base_controller.rb
+++ b/app/controllers/public/api/v1/portals/base_controller.rb
@@ -1,4 +1,6 @@
class Public::Api::V1::Portals::BaseController < PublicController
+ include SwitchLocale
+
before_action :show_plain_layout
before_action :set_color_scheme
before_action :set_global_config
@@ -27,14 +29,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def switch_locale_with_portal(&)
- locale_without_variant = params[:locale].split('_')[0]
- is_locale_available = I18n.available_locales.map(&:to_s).include?(params[:locale])
- is_locale_variant_available = I18n.available_locales.map(&:to_s).include?(locale_without_variant)
- if is_locale_available
- @locale = params[:locale]
- elsif is_locale_variant_available
- @locale = locale_without_variant
- end
+ @locale = validate_and_get_locale(params[:locale])
I18n.with_locale(@locale, &)
end
@@ -44,12 +39,12 @@ class Public::Api::V1::Portals::BaseController < PublicController
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank?
- @locale = if article.category.present?
- article.category.locale
- else
- article.portal.default_locale
- end
-
+ article_locale = if article.category.present?
+ article.category.locale
+ else
+ article.portal.default_locale
+ end
+ @locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
end
diff --git a/app/controllers/shopify/callbacks_controller.rb b/app/controllers/shopify/callbacks_controller.rb
new file mode 100644
index 000000000..7fb8b5a47
--- /dev/null
+++ b/app/controllers/shopify/callbacks_controller.rb
@@ -0,0 +1,72 @@
+class Shopify::CallbacksController < ApplicationController
+ include Shopify::IntegrationHelper
+
+ def show
+ verify_account!
+
+ @response = oauth_client.auth_code.get_token(
+ params[:code],
+ redirect_uri: '/shopify/callback'
+ )
+
+ handle_response
+ rescue StandardError => e
+ Rails.logger.error("Shopify callback error: #{e.message}")
+ redirect_to "#{redirect_uri}?error=true"
+ end
+
+ private
+
+ def verify_account!
+ @account_id = verify_shopify_token(params[:state])
+ raise StandardError, 'Invalid state parameter' if account.blank?
+ end
+
+ def handle_response
+ account.hooks.create!(
+ app_id: 'shopify',
+ access_token: parsed_body['access_token'],
+ status: 'enabled',
+ reference_id: params[:shop],
+ settings: {
+ scope: parsed_body['scope']
+ }
+ )
+
+ redirect_to shopify_integration_url
+ end
+
+ def parsed_body
+ @parsed_body ||= @response.response.parsed
+ end
+
+ def oauth_client
+ OAuth2::Client.new(
+ client_id,
+ client_secret,
+ {
+ site: "https://#{params[:shop]}",
+ authorize_url: '/admin/oauth/authorize',
+ token_url: '/admin/oauth/access_token'
+ }
+ )
+ end
+
+ def account
+ @account ||= Account.find(@account_id)
+ end
+
+ def account_id
+ @account_id ||= params[:state].split('_').first
+ end
+
+ def shopify_integration_url
+ "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/shopify"
+ end
+
+ def redirect_uri
+ return shopify_integration_url if account
+
+ ENV.fetch('FRONTEND_URL', nil)
+ end
+end
diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb
index b8f3bd9a9..3e17a7369 100644
--- a/app/controllers/super_admin/app_configs_controller.rb
+++ b/app/controllers/super_admin/app_configs_controller.rb
@@ -35,10 +35,14 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
+ when 'shopify'
+ %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET]
when 'microsoft'
%w[AZURE_APP_ID AZURE_APP_SECRET]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
+ when 'linear'
+ %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
else
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end
diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb
index 7723e5dd2..ff5db952d 100644
--- a/app/controllers/twilio/callback_controller.rb
+++ b/app/controllers/twilio/callback_controller.rb
@@ -1,6 +1,6 @@
class Twilio::CallbackController < ApplicationController
def create
- ::Twilio::IncomingMessageService.new(params: permitted_params).perform
+ Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash)
head :no_content
end
diff --git a/app/controllers/twilio/delivery_status_controller.rb b/app/controllers/twilio/delivery_status_controller.rb
index cc7afb0fc..1c756a1c2 100644
--- a/app/controllers/twilio/delivery_status_controller.rb
+++ b/app/controllers/twilio/delivery_status_controller.rb
@@ -1,6 +1,6 @@
class Twilio::DeliveryStatusController < ApplicationController
def create
- ::Twilio::DeliveryStatusService.new(params: permitted_params).perform
+ Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash)
head :no_content
end
diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb
index 8f408d2b0..c4c376e5c 100644
--- a/app/controllers/webhooks/whatsapp_controller.rb
+++ b/app/controllers/webhooks/whatsapp_controller.rb
@@ -2,6 +2,12 @@ class Webhooks::WhatsappController < ActionController::API
include MetaTokenVerifyConcern
def process_payload
+ if inactive_whatsapp_number?
+ Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
+ render json: { error: 'Inactive WhatsApp number' }, status: :unprocessable_entity
+ return
+ end
+
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
head :ok
end
@@ -13,4 +19,15 @@ class Webhooks::WhatsappController < ActionController::API
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
end
+
+ def inactive_whatsapp_number?
+ phone_number = params[:phone_number]
+ return false if phone_number.blank?
+
+ inactive_numbers = GlobalConfig.get_value('INACTIVE_WHATSAPP_NUMBERS').to_s
+ return false if inactive_numbers.blank?
+
+ inactive_numbers_array = inactive_numbers.split(',').map(&:strip)
+ inactive_numbers_array.include?(phone_number)
+ end
end
diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb
index b566551f4..f7b04a167 100644
--- a/app/dashboards/account_dashboard.rb
+++ b/app/dashboards/account_dashboard.rb
@@ -78,7 +78,11 @@ class AccountDashboard < Administrate::BaseDashboard
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
- COLLECTION_FILTERS = {}.freeze
+ COLLECTION_FILTERS = {
+ active: ->(resources) { resources.where(status: :active) },
+ suspended: ->(resources) { resources.where(status: :suspended) },
+ recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
+ }.freeze
# Overwrite this method to customize how accounts are displayed
# across all pages of the admin dashboard.
diff --git a/app/dashboards/user_dashboard.rb b/app/dashboards/user_dashboard.rb
index 6b2129eed..8abdefd1a 100644
--- a/app/dashboards/user_dashboard.rb
+++ b/app/dashboards/user_dashboard.rb
@@ -94,7 +94,12 @@ class UserDashboard < Administrate::BaseDashboard
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
- COLLECTION_FILTERS = {}.freeze
+ COLLECTION_FILTERS = {
+ super_admin: ->(resources) { resources.where(type: 'SuperAdmin') },
+ confirmed: ->(resources) { resources.where.not(confirmed_at: nil) },
+ unconfirmed: ->(resources) { resources.where(confirmed_at: nil) },
+ recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
+ }.freeze
# Overwrite this method to customize how users are displayed
# across all pages of the admin dashboard.
diff --git a/app/helpers/filter_helper.rb b/app/helpers/filters/filter_helper.rb
similarity index 90%
rename from app/helpers/filter_helper.rb
rename to app/helpers/filters/filter_helper.rb
index 9b5cac684..fe03dae28 100644
--- a/app/helpers/filter_helper.rb
+++ b/app/helpers/filters/filter_helper.rb
@@ -1,4 +1,4 @@
-module FilterHelper
+module Filters::FilterHelper
def build_condition_query(model_filters, query_hash, current_index)
current_filter = model_filters[query_hash['attribute_key']]
@@ -89,4 +89,18 @@ module FilterHelper
operator = condition['query_operator'].upcase
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
end
+
+ def conversation_status_values(values)
+ return Conversation.statuses.values if values.include?('all')
+
+ values.map { |x| Conversation.statuses[x.to_sym] }
+ end
+
+ def conversation_priority_values(values)
+ values.map { |x| Conversation.priorities[x.to_sym] }
+ end
+
+ def message_type_values(values)
+ values.map { |x| Message.message_types[x.to_sym] }
+ end
end
diff --git a/app/helpers/linear/integration_helper.rb b/app/helpers/linear/integration_helper.rb
index 19f16832d..67df836ce 100644
--- a/app/helpers/linear/integration_helper.rb
+++ b/app/helpers/linear/integration_helper.rb
@@ -32,7 +32,7 @@ module Linear::IntegrationHelper
private
def client_secret
- @client_secret ||= ENV.fetch('LINEAR_CLIENT_SECRET', nil)
+ @client_secret ||= GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
end
def decode_token(token, secret)
diff --git a/app/helpers/shopify/integration_helper.rb b/app/helpers/shopify/integration_helper.rb
new file mode 100644
index 000000000..6aad93211
--- /dev/null
+++ b/app/helpers/shopify/integration_helper.rb
@@ -0,0 +1,58 @@
+module Shopify::IntegrationHelper
+ REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments].freeze
+
+ # Generates a signed JWT token for Shopify integration
+ #
+ # @param account_id [Integer] The account ID to encode in the token
+ # @return [String, nil] The encoded JWT token or nil if client secret is missing
+ def generate_shopify_token(account_id)
+ return if client_secret.blank?
+
+ JWT.encode(token_payload(account_id), client_secret, 'HS256')
+ rescue StandardError => e
+ Rails.logger.error("Failed to generate Shopify token: #{e.message}")
+ nil
+ end
+
+ def token_payload(account_id)
+ {
+ sub: account_id,
+ iat: Time.current.to_i
+ }
+ end
+
+ # Verifies and decodes a Shopify JWT token
+ #
+ # @param token [String] The JWT token to verify
+ # @return [Integer, nil] The account ID from the token or nil if invalid
+ def verify_shopify_token(token)
+ return if token.blank? || client_secret.blank?
+
+ decode_token(token, client_secret)
+ end
+
+ private
+
+ def client_id
+ @client_id ||= GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil)
+ end
+
+ def client_secret
+ @client_secret ||= GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
+ end
+
+ def decode_token(token, secret)
+ JWT.decode(
+ token,
+ secret,
+ true,
+ {
+ algorithm: 'HS256',
+ verify_expiration: true
+ }
+ ).first['sub']
+ rescue StandardError => e
+ Rails.logger.error("Unexpected error verifying Shopify token: #{e.message}")
+ nil
+ end
+end
diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js
index 8b9eacf3f..39546096f 100644
--- a/app/javascript/dashboard/api/inbox/conversation.js
+++ b/app/javascript/dashboard/api/inbox/conversation.js
@@ -137,6 +137,10 @@ class ConversationApi extends ApiClient {
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
+
+ getInboxAssistant(conversationId) {
+ return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
+ }
}
export default new ConversationApi();
diff --git a/app/javascript/dashboard/api/integrations.js b/app/javascript/dashboard/api/integrations.js
index 2b816e603..d4ffcbca3 100644
--- a/app/javascript/dashboard/api/integrations.js
+++ b/app/javascript/dashboard/api/integrations.js
@@ -32,6 +32,12 @@ class IntegrationsAPI extends ApiClient {
deleteHook(hookId) {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
+
+ connectShopify({ shopDomain }) {
+ return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, {
+ shop_domain: shopDomain,
+ });
+ }
}
export default new IntegrationsAPI();
diff --git a/app/javascript/dashboard/api/integrations/shopify.js b/app/javascript/dashboard/api/integrations/shopify.js
new file mode 100644
index 000000000..0b6ce8ec1
--- /dev/null
+++ b/app/javascript/dashboard/api/integrations/shopify.js
@@ -0,0 +1,17 @@
+/* global axios */
+
+import ApiClient from '../ApiClient';
+
+class ShopifyAPI extends ApiClient {
+ constructor() {
+ super('integrations/shopify', { accountScoped: true });
+ }
+
+ getOrders(contactId) {
+ return axios.get(`${this.url}/orders`, {
+ params: { contact_id: contactId },
+ });
+ }
+}
+
+export default new ShopifyAPI();
diff --git a/app/javascript/dashboard/api/liveReports.js b/app/javascript/dashboard/api/liveReports.js
new file mode 100644
index 000000000..1435da258
--- /dev/null
+++ b/app/javascript/dashboard/api/liveReports.js
@@ -0,0 +1,20 @@
+/* global axios */
+import ApiClient from './ApiClient';
+
+class LiveReportsAPI extends ApiClient {
+ constructor() {
+ super('live_reports', { accountScoped: true, apiVersion: 'v2' });
+ }
+
+ getConversationMetric(params = {}) {
+ return axios.get(`${this.url}/conversation_metrics`, { params });
+ }
+
+ getGroupedConversations({ groupBy } = { groupBy: 'assignee_id' }) {
+ return axios.get(`${this.url}/grouped_conversation_metrics`, {
+ params: { group_by: groupBy },
+ });
+ }
+}
+
+export default new LiveReportsAPI();
diff --git a/app/javascript/dashboard/assets/scss/_date-picker.scss b/app/javascript/dashboard/assets/scss/_date-picker.scss
index 2fa577673..b22381ea0 100644
--- a/app/javascript/dashboard/assets/scss/_date-picker.scss
+++ b/app/javascript/dashboard/assets/scss/_date-picker.scss
@@ -10,7 +10,7 @@
&.no-margin {
.mx-input {
- @apply mb-0;
+ margin-bottom: 0 !important;
}
}
diff --git a/app/javascript/dashboard/assets/scss/_helper-classes.scss b/app/javascript/dashboard/assets/scss/_helper-classes.scss
index 48ee1918b..229606254 100644
--- a/app/javascript/dashboard/assets/scss/_helper-classes.scss
+++ b/app/javascript/dashboard/assets/scss/_helper-classes.scss
@@ -4,7 +4,6 @@
@apply inline-block h-6 py-0 px-6 relative align-middle w-6;
&.message {
- @include normal-shadow;
@apply bg-white dark:bg-slate-800 rounded-full left-0 my-3 mx-auto p-4 top-0;
&::before {
diff --git a/app/javascript/dashboard/assets/scss/_mixins.scss b/app/javascript/dashboard/assets/scss/_mixins.scss
index 9ca87c859..fc742d24f 100644
--- a/app/javascript/dashboard/assets/scss/_mixins.scss
+++ b/app/javascript/dashboard/assets/scss/_mixins.scss
@@ -1,79 +1,7 @@
@import 'dashboard/assets/scss/variables';
-@import 'widget/assets/scss/mixins';
$spinner-before-border-color: rgba(255, 255, 255, 0.7);
-//borders
-@mixin border-nil() {
- border-color: transparent;
- border: 0;
-}
-
-@mixin thin-border($color) {
- border: 1px solid $color;
-}
-
-@mixin custom-border-bottom($size, $color) {
- border-bottom: $size solid $color;
-}
-
-@mixin custom-border-top($size, $color) {
- border-top: $size solid $color;
-}
-
-@mixin border-normal() {
- @apply border border-slate-50 dark:border-slate-700;
-}
-
-@mixin border-normal-left() {
- @apply border-l border-slate-50 dark:border-slate-700;
-}
-
-@mixin border-normal-top() {
- @apply border-t border-slate-50 dark:border-slate-700;
-}
-
-@mixin border-normal-right() {
- @apply border-r border-slate-50 dark:border-slate-700;
-}
-
-@mixin border-normal-bottom() {
- @apply border-b border-slate-50 dark:border-slate-700;
-}
-
-@mixin border-light() {
- @apply border border-slate-25 dark:border-slate-700;
-}
-
-@mixin border-light-left() {
- @apply border-l border-slate-25 dark:border-slate-700;
-}
-
-@mixin border-light-top() {
- @apply border-t border-slate-25 dark:border-slate-700;
-}
-
-@mixin border-light-right() {
- @apply border-r border-slate-25 dark:border-slate-700;
-}
-
-@mixin border-light-bottom() {
- @apply border-b border-slate-25 dark:border-slate-700;
-}
-
-// background
-@mixin background-gray() {
- background: $color-background;
-}
-
-@mixin background-light() {
- @apply bg-slate-50 dark:bg-slate-800;
-}
-
-@mixin background-white() {
- @apply bg-white dark:bg-slate-900;
-}
-
// input form
@mixin ghost-input() {
box-shadow: none;
@@ -87,65 +15,6 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7);
}
}
-// flex-layout
-@mixin space-between() {
- display: flex;
- justify-content: space-between;
-}
-
-@mixin space-between-column() {
- @include space-between;
- flex-direction: column;
-}
-
-@mixin space-between-row() {
- @include space-between;
- flex-direction: row;
-}
-
-@mixin flex-shrink() {
- flex: 0 0 auto;
- max-width: 100%;
-}
-
-@mixin flex-weight($value) {
- // Grab flex-grow for older browsers.
- $flex-grow: nth($value, 1);
-
- // 2009
- @include prefixer(box-flex, $flex-grow, webkit moz spec);
-
- // 2011 (IE 10), 2012
- @include prefixer(flex, $value, webkit moz ms spec);
-}
-
-// full height
-@mixin full-height() {
- height: 100%;
-}
-
-@mixin round-corner() {
- border-radius: 1000px;
-}
-
-@mixin scroll-on-hover() {
- overflow: hidden;
-
- &:hover {
- overflow-y: auto;
- }
-}
-
-
-@mixin horizontal-scroll() {
- overflow-y: auto;
-}
-
-@mixin elegant-card() {
- @include normal-shadow;
- border-radius: $space-small;
-}
-
@mixin color-spinner() {
@keyframes spinner {
to {
@@ -230,17 +99,3 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7);
border-left: $size solid transparent;
}
}
-
-@mixin text-ellipsis {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-@mixin three-column-grid($column-one-width: 16rem,
- $column-three-width: 16rem) {
- width: 100%;
- height: 100%;
- display: grid;
- grid-template-columns: minmax($column-one-width, 6fr) 10fr minmax($column-three-width, 6fr);
-}
diff --git a/app/javascript/dashboard/assets/scss/_next-colors.scss b/app/javascript/dashboard/assets/scss/_next-colors.scss
new file mode 100644
index 000000000..48cfce921
--- /dev/null
+++ b/app/javascript/dashboard/assets/scss/_next-colors.scss
@@ -0,0 +1,208 @@
+// scss-lint:disable PropertySortOrder
+@layer base {
+ // NEXT COLORS START
+ :root {
+ // slate
+ --slate-1: 252 252 253;
+ --slate-2: 249 249 251;
+ --slate-3: 240 240 243;
+ --slate-4: 232 232 236;
+ --slate-5: 224 225 230;
+ --slate-6: 217 217 224;
+ --slate-7: 205 206 214;
+ --slate-8: 185 187 198;
+ --slate-9: 139 141 152;
+ --slate-10: 128 131 141;
+ --slate-11: 96 100 108;
+ --slate-12: 28 32 36;
+
+ --iris-1: 253 253 255;
+ --iris-2: 248 248 255;
+ --iris-3: 240 241 254;
+ --iris-4: 230 231 255;
+ --iris-5: 218 220 255;
+ --iris-6: 203 205 255;
+ --iris-7: 184 186 248;
+ --iris-8: 155 158 240;
+ --iris-9: 91 91 214;
+ --iris-10: 81 81 205;
+ --iris-11: 87 83 198;
+ --iris-12: 39 41 98;
+
+ --ruby-1: 255 252 253;
+ --ruby-2: 255 247 248;
+ --ruby-3: 254 234 237;
+ --ruby-4: 255 220 225;
+ --ruby-5: 255 206 214;
+ --ruby-6: 248 191 200;
+ --ruby-7: 239 172 184;
+ --ruby-8: 229 146 163;
+ --ruby-9: 229 70 102;
+ --ruby-10: 220 59 93;
+ --ruby-11: 202 36 77;
+ --ruby-12: 100 23 43;
+
+ --amber-1: 254 253 251;
+ --amber-2: 254 251 233;
+ --amber-3: 255 247 194;
+ --amber-4: 255 238 156;
+ --amber-5: 251 229 119;
+ --amber-6: 243 214 115;
+ --amber-7: 233 193 98;
+ --amber-8: 226 163 54;
+ --amber-9: 255 197 61;
+ --amber-10: 255 186 24;
+ --amber-11: 171 100 0;
+ --amber-12: 79 52 34;
+
+ --teal-1: 250 254 253;
+ --teal-2: 243 251 249;
+ --teal-3: 224 248 243;
+ --teal-4: 204 243 234;
+ --teal-5: 184 234 224;
+ --teal-6: 161 222 210;
+ --teal-7: 131 205 193;
+ --teal-8: 83 185 171;
+ --teal-9: 18 165 148;
+ --teal-10: 13 155 138;
+ --teal-11: 0 133 115;
+ --teal-12: 13 61 56;
+
+ --gray-1: 252 252 252;
+ --gray-2: 249 249 249;
+ --gray-3: 240 240 240;
+ --gray-4: 232 232 232;
+ --gray-5: 224 224 224;
+ --gray-6: 217 217 217;
+ --gray-7: 206 206 206;
+ --gray-8: 187 187 187;
+ --gray-9: 141 141 141;
+ --gray-10: 131 131 131;
+ --gray-11: 100 100 100;
+ --gray-12: 32 32 32;
+
+ --background-color: 253 253 253;
+ --text-blue: 8 109 224;
+ --border-container: 236 236 236;
+ --border-strong: 235 235 235;
+ --border-weak: 234 234 234;
+ --solid-1: 255 255 255;
+ --solid-2: 255 255 255;
+ --solid-3: 255 255 255;
+ --solid-active: 255 255 255;
+ --solid-amber: 252 232 193;
+ --solid-blue: 218 236 255;
+ --solid-iris: 230 231 255;
+
+ --alpha-1: 67, 67, 67, 0.06;
+ --alpha-2: 201, 202, 207, 0.15;
+ --alpha-3: 255, 255, 255, 0.96;
+ --black-alpha-1: 0, 0, 0, 0.12;
+ --black-alpha-2: 0, 0, 0, 0.04;
+ --border-blue: 39, 129, 246, 0.5;
+ --white-alpha: 255, 255, 255, 0.8;
+ }
+
+ .dark {
+ // slate
+ --slate-1: 17 17 19;
+ --slate-2: 24 25 27;
+ --slate-3: 33 34 37;
+ --slate-4: 39 42 45;
+ --slate-5: 46 49 53;
+ --slate-6: 54 58 63;
+ --slate-7: 67 72 78;
+ --slate-8: 90 97 105;
+ --slate-9: 105 110 119;
+ --slate-10: 119 123 132;
+ --slate-11: 176 180 186;
+ --slate-12: 237 238 240;
+
+ --iris-1: 19 19 30;
+ --iris-2: 23 22 37;
+ --iris-3: 32 34 72;
+ --iris-4: 38 42 101;
+ --iris-5: 48 51 116;
+ --iris-6: 61 62 130;
+ --iris-7: 74 74 149;
+ --iris-8: 89 88 177;
+ --iris-9: 91 91 214;
+ --iris-10: 84 114 228;
+ --iris-11: 158 177 255;
+ --iris-12: 224 223 254;
+
+ --ruby-1: 25 17 19;
+ --ruby-2: 30 21 23;
+ --ruby-3: 58 20 30;
+ --ruby-4: 78 19 37;
+ --ruby-5: 94 26 46;
+ --ruby-6: 111 37 57;
+ --ruby-7: 136 52 71;
+ --ruby-8: 179 68 90;
+ --ruby-9: 229 70 102;
+ --ruby-10: 236 90 114;
+ --ruby-11: 255 148 157;
+ --ruby-12: 254 210 225;
+
+ --amber-1: 22 18 12;
+ --amber-2: 29 24 15;
+ --amber-3: 48 32 8;
+ --amber-4: 63 39 0;
+ --amber-5: 77 48 0;
+ --amber-6: 92 61 5;
+ --amber-7: 113 79 25;
+ --amber-8: 143 100 36;
+ --amber-9: 255 197 61;
+ --amber-10: 255 214 10;
+ --amber-11: 255 202 22;
+ --amber-12: 255 231 179;
+
+ --teal-1: 13 21 20;
+ --teal-2: 17 28 27;
+ --teal-3: 13 45 42;
+ --teal-4: 2 59 55;
+ --teal-5: 8 72 67;
+ --teal-6: 20 87 80;
+ --teal-7: 28 105 97;
+ --teal-8: 32 126 115;
+ --teal-9: 18 165 148;
+ --teal-10: 14 179 158;
+ --teal-11: 11 216 182;
+ --teal-12: 173 240 221;
+
+ --gray-1: 17 17 17;
+ --gray-2: 25 25 25;
+ --gray-3: 34 34 34;
+ --gray-4: 42 42 42;
+ --gray-5: 49 49 49;
+ --gray-6: 58 58 58;
+ --gray-7: 72 72 72;
+ --gray-8: 96 96 96;
+ --gray-9: 110 110 110;
+ --gray-10: 123 123 123;
+ --gray-11: 180 180 180;
+ --gray-12: 238 238 238;
+
+ --background-color: 18 18 19;
+ --border-strong: 52 52 52;
+ --border-weak: 38 38 42;
+ --solid-1: 23 23 26;
+ --solid-2: 29 30 36;
+ --solid-3: 44 45 54;
+ --solid-active: 53 57 66;
+ --solid-amber: 42 37 30;
+ --solid-blue: 16 49 91;
+ --solid-iris: 38 42 101;
+ --text-blue: 126 182 255;
+
+ --alpha-1: 36, 36, 36, 0.8;
+ --alpha-2: 139, 147, 182, 0.15;
+ --alpha-3: 36, 38, 45, 0.9;
+ --black-alpha-1: 0, 0, 0, 0.3;
+ --black-alpha-2: 0, 0, 0, 0.2;
+ --border-blue: 39, 129, 246, 0.5;
+ --border-container: 236, 236, 236, 0;
+ --white-alpha: 255, 255, 255, 0.1;
+ }
+}
+// NEXT COLORS END
diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss
index d736d7b5a..004d8bf59 100644
--- a/app/javascript/dashboard/assets/scss/_woot.scss
+++ b/app/javascript/dashboard/assets/scss/_woot.scss
@@ -5,6 +5,9 @@
@import 'shared/assets/fonts/InterDisplay/inter-display';
@import 'shared/assets/fonts/inter';
+// Next Colors
+@import 'next-colors';
+
@import 'shared/assets/stylesheets/animations';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@@ -54,214 +57,19 @@
@apply text-n-blue-text;
}
+.custom-dashed-border {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23E2E3E7' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 100% 100%;
+}
+
+.dark .custom-dashed-border {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23343434' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
+}
+
// scss-lint:disable PropertySortOrder
@layer base {
- /* NEXT COLORS START */
- :root {
- /* slate */
- --slate-1: 252 252 253;
- --slate-2: 249 249 251;
- --slate-3: 240 240 243;
- --slate-4: 232 232 236;
- --slate-5: 224 225 230;
- --slate-6: 217 217 224;
- --slate-7: 205 206 214;
- --slate-8: 185 187 198;
- --slate-9: 139 141 152;
- --slate-10: 128 131 141;
- --slate-11: 96 100 108;
- --slate-12: 28 32 36;
-
- --iris-1: 253 253 255;
- --iris-2: 248 248 255;
- --iris-3: 240 241 254;
- --iris-4: 230 231 255;
- --iris-5: 218 220 255;
- --iris-6: 203 205 255;
- --iris-7: 184 186 248;
- --iris-8: 155 158 240;
- --iris-9: 91 91 214;
- --iris-10: 81 81 205;
- --iris-11: 87 83 198;
- --iris-12: 39 41 98;
-
- --ruby-1: 255 252 253;
- --ruby-2: 255 247 248;
- --ruby-3: 254 234 237;
- --ruby-4: 255 220 225;
- --ruby-5: 255 206 214;
- --ruby-6: 248 191 200;
- --ruby-7: 239 172 184;
- --ruby-8: 229 146 163;
- --ruby-9: 229 70 102;
- --ruby-10: 220 59 93;
- --ruby-11: 202 36 77;
- --ruby-12: 100 23 43;
-
- --amber-1: 254 253 251;
- --amber-2: 254 251 233;
- --amber-3: 255 247 194;
- --amber-4: 255 238 156;
- --amber-5: 251 229 119;
- --amber-6: 243 214 115;
- --amber-7: 233 193 98;
- --amber-8: 226 163 54;
- --amber-9: 255 197 61;
- --amber-10: 255 186 24;
- --amber-11: 171 100 0;
- --amber-12: 79 52 34;
-
- --teal-1: 250 254 253;
- --teal-2: 243 251 249;
- --teal-3: 224 248 243;
- --teal-4: 204 243 234;
- --teal-5: 184 234 224;
- --teal-6: 161 222 210;
- --teal-7: 131 205 193;
- --teal-8: 83 185 171;
- --teal-9: 18 165 148;
- --teal-10: 13 155 138;
- --teal-11: 0 133 115;
- --teal-12: 13 61 56;
-
- --gray-1: 252 252 252;
- --gray-2: 249 249 249;
- --gray-3: 240 240 240;
- --gray-4: 232 232 232;
- --gray-5: 224 224 224;
- --gray-6: 217 217 217;
- --gray-7: 206 206 206;
- --gray-8: 187 187 187;
- --gray-9: 141 141 141;
- --gray-10: 131 131 131;
- --gray-11: 100 100 100;
- --gray-12: 32 32 32;
-
- --background-color: 253 253 253;
- --text-blue: 8 109 224;
- --border-container: 236 236 236;
- --border-strong: 235 235 235;
- --border-weak: 234 234 234;
- --solid-1: 255 255 255;
- --solid-2: 255 255 255;
- --solid-3: 255 255 255;
- --solid-active: 255 255 255;
- --solid-amber: 252 232 193;
- --solid-blue: 218 236 255;
- --solid-iris: 230 231 255;
-
- --alpha-1: 67, 67, 67, 0.06;
- --alpha-2: 201, 202, 207, 0.15;
- --alpha-3: 255, 255, 255, 0.96;
- --black-alpha-1: 0, 0, 0, 0.12;
- --black-alpha-2: 0, 0, 0, 0.04;
- --border-blue: 39, 129, 246, 0.5;
- --white-alpha: 255, 255, 255, 0.8;
- }
-
- .dark {
- /* slate */
- --slate-1: 17 17 19;
- --slate-2: 24 25 27;
- --slate-3: 33 34 37;
- --slate-4: 39 42 45;
- --slate-5: 46 49 53;
- --slate-6: 54 58 63;
- --slate-7: 67 72 78;
- --slate-8: 90 97 105;
- --slate-9: 105 110 119;
- --slate-10: 119 123 132;
- --slate-11: 176 180 186;
- --slate-12: 237 238 240;
-
- --iris-1: 19 19 30;
- --iris-2: 23 22 37;
- --iris-3: 32 34 72;
- --iris-4: 38 42 101;
- --iris-5: 48 51 116;
- --iris-6: 61 62 130;
- --iris-7: 74 74 149;
- --iris-8: 89 88 177;
- --iris-9: 91 91 214;
- --iris-10: 84 114 228;
- --iris-11: 158 177 255;
- --iris-12: 224 223 254;
-
- --ruby-1: 25 17 19;
- --ruby-2: 30 21 23;
- --ruby-3: 58 20 30;
- --ruby-4: 78 19 37;
- --ruby-5: 94 26 46;
- --ruby-6: 111 37 57;
- --ruby-7: 136 52 71;
- --ruby-8: 179 68 90;
- --ruby-9: 229 70 102;
- --ruby-10: 236 90 114;
- --ruby-11: 255 148 157;
- --ruby-12: 254 210 225;
-
- --amber-1: 22 18 12;
- --amber-2: 29 24 15;
- --amber-3: 48 32 8;
- --amber-4: 63 39 0;
- --amber-5: 77 48 0;
- --amber-6: 92 61 5;
- --amber-7: 113 79 25;
- --amber-8: 143 100 36;
- --amber-9: 255 197 61;
- --amber-10: 255 214 10;
- --amber-11: 255 202 22;
- --amber-12: 255 231 179;
-
- --teal-1: 13 21 20;
- --teal-2: 17 28 27;
- --teal-3: 13 45 42;
- --teal-4: 2 59 55;
- --teal-5: 8 72 67;
- --teal-6: 20 87 80;
- --teal-7: 28 105 97;
- --teal-8: 32 126 115;
- --teal-9: 18 165 148;
- --teal-10: 14 179 158;
- --teal-11: 11 216 182;
- --teal-12: 173 240 221;
-
- --gray-1: 17 17 17;
- --gray-2: 25 25 25;
- --gray-3: 34 34 34;
- --gray-4: 42 42 42;
- --gray-5: 49 49 49;
- --gray-6: 58 58 58;
- --gray-7: 72 72 72;
- --gray-8: 96 96 96;
- --gray-9: 110 110 110;
- --gray-10: 123 123 123;
- --gray-11: 180 180 180;
- --gray-12: 238 238 238;
-
- --background-color: 18 18 19;
- --border-strong: 52 52 52;
- --border-weak: 38 38 42;
- --solid-1: 23 23 26;
- --solid-2: 29 30 36;
- --solid-3: 44 45 54;
- --solid-active: 53 57 66;
- --solid-amber: 42 37 30;
- --solid-blue: 16 49 91;
- --solid-iris: 38 42 101;
- --text-blue: 126 182 255;
-
- --alpha-1: 36, 36, 36, 0.8;
- --alpha-2: 139, 147, 182, 0.15;
- --alpha-3: 36, 38, 45, 0.9;
- --black-alpha-1: 0, 0, 0, 0.3;
- --black-alpha-2: 0, 0, 0, 0.2;
- --border-blue: 39, 129, 246, 0.5;
- --border-container: 236, 236, 236, 0;
- --white-alpha: 255, 255, 255, 0.1;
- }
- /* NEXT COLORS END */
-
:root {
--color-amber-25: 254 253 251;
--color-amber-50: 255 249 237;
diff --git a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss
index 2f9c35c51..ffcfca545 100644
--- a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss
+++ b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss
@@ -1,13 +1,13 @@
@mixin label-multiselect-hover {
&::after {
- @apply text-woot-600 dark:text-woot-600;
+ @apply text-n-brand;
}
&:hover {
- @apply bg-slate-50 dark:bg-slate-700;
+ @apply bg-n-slate-3;
&::after {
- @apply text-woot-500 dark:text-woot-500;
+ @apply text-n-blue-text;
}
}
}
@@ -18,20 +18,16 @@
}
&.multiselect--disabled {
- @apply opacity-50 border border-slate-200 dark:border-slate-600 rounded-md cursor-not-allowed;
+ @apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
.multiselect__select {
- @apply cursor-not-allowed bg-white dark:bg-slate-900 rounded-md;
- }
-
- .multiselect__tags {
- @apply border-0;
+ @apply cursor-not-allowed bg-transparent rounded-lg;
}
}
.multiselect--active {
> .multiselect__tags {
- @apply border-woot-500 dark:border-woot-500;
+ @apply outline-n-blue-border;
}
}
@@ -44,7 +40,7 @@
}
.multiselect__content-wrapper {
- @apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 text-slate-800 dark:text-slate-100;
+ @apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg;
}
.multiselect__content {
@@ -61,15 +57,19 @@
@apply mb-0;
}
+ &::after {
+ @apply bottom-0 flex items-center justify-center text-center;
+ }
+
&.multiselect__option--highlight {
- @apply bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100;
+ @apply bg-n-alpha-black2 text-n-slate-12;
}
&.multiselect__option--highlight:hover {
- @apply bg-woot-50 dark:bg-woot-600 text-slate-800 dark:text-slate-100;
+ @apply bg-n-brand/10 text-n-slate-12;
&::after {
- @apply bg-woot-50 dark:bg-woot-600 text-slate-600 dark:text-slate-200;
+ @apply bg-transparent text-center text-n-slate-12;
}
}
@@ -78,17 +78,17 @@
}
&.multiselect__option--selected {
- @apply bg-woot-50 dark:bg-woot-600 text-slate-800 dark:text-slate-100;
+ @apply bg-n-brand/20 text-n-slate-12;
+
+ &::after {
+ @apply bg-transparent;
+ }
&.multiselect__option--highlight:hover {
- @apply bg-woot-75 dark:bg-woot-600;
-
- &::after {
- @apply bg-transparent;
- }
+ @apply bg-transparent;
&::after:hover {
- @apply text-slate-800 dark:text-slate-100;
+ @apply text-n-slate-12 bg-transparent;
}
}
}
@@ -96,23 +96,27 @@
}
.multiselect__tags {
- @apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 m-0 min-h-[2.875rem] pt-0;
+ @apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0;
input {
- @apply border-0 border-none;
+ @apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10;
}
}
+ .multiselect__spinner {
+ background-color: transparent;
+ }
+
.multiselect__tags-wrap {
@apply inline-block leading-none mt-1;
}
.multiselect__placeholder {
- @apply text-slate-400 dark:text-slate-400 font-normal pt-3;
+ @apply text-n-slate-10 font-normal pt-3;
}
.multiselect__tag {
- @apply bg-slate-50 dark:bg-slate-800 mt-1 text-slate-800 dark:text-slate-100 pr-6 pl-2.5 py-1.5;
+ @apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5;
}
.multiselect__tag-icon {
@@ -125,7 +129,7 @@
}
.multiselect__single {
- @apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
+ @apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
}
}
@@ -143,11 +147,11 @@
}
> .multiselect__tags {
- @apply border-transparent;
+ @apply outline-transparent;
}
&.multiselect--active > .multiselect__tags {
- @apply border-woot-500 dark:border-woot-500;
+ @apply outline-n-blue-border;
}
}
}
@@ -179,7 +183,7 @@
.multiselect__tags,
.multiselect__input,
.multiselect {
- @apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-[5px] text-sm min-h-[2.5rem];
+ @apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
}
.multiselect__input {
@@ -187,7 +191,7 @@
}
.multiselect__single {
- @apply items-center flex m-0 text-sm max-h-[2.375rem] text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900 py-3 px-0.5;
+ @apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5;
}
.multiselect__placeholder {
@@ -208,6 +212,24 @@
}
}
+.multiselect--disabled {
+ background-color: rgba(var(--black-alpha-2)) !important;
+
+ .multiselect__tags {
+ @apply hover:outline-n-weak;
+ }
+}
+
+.multiselect--active {
+ .multiselect__select::before {
+ @apply top-[62%];
+ }
+}
+
+.multiselect__select::before {
+ top: 60% !important;
+}
+
.multiselect-wrap--medium {
.multiselect__tags,
.multiselect__input {
@@ -217,16 +239,16 @@
.multiselect__tags,
.multiselect__input,
.multiselect {
- @apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 text-sm h-12 min-h-[3rem];
+ @apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem];
}
.multiselect__input {
@apply h-[2.875rem] min-h-[2.875rem];
- margin-bottom: 0px !important;
+ margin-bottom: 0 !important;
}
.multiselect__single {
- @apply items-center flex m-0 text-sm py-1 px-0.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900;
+ @apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12;
}
.multiselect__placeholder {
diff --git a/app/javascript/dashboard/assets/scss/widgets/_base.scss b/app/javascript/dashboard/assets/scss/widgets/_base.scss
index 9367e8b2d..b2c271173 100644
--- a/app/javascript/dashboard/assets/scss/widgets/_base.scss
+++ b/app/javascript/dashboard/assets/scss/widgets/_base.scss
@@ -1,22 +1,22 @@
// scss-lint:disable QualifyingElement
// Base typography
+// -------------------------
h1,
h2,
h3,
h4,
h5,
h6 {
- @apply font-medium text-slate-800 dark:text-slate-50;
+ @apply font-medium text-n-slate-12;
}
p {
text-rendering: optimizeLegibility;
-
@apply mb-2 leading-[1.65] text-sm;
a {
- @apply text-woot-500 dark:text-woot-500 cursor-pointer;
+ @apply text-n-brand dark:text-n-brand cursor-pointer;
}
}
@@ -41,64 +41,82 @@ dl:not(.reset-base) {
}
// Form elements
+// -------------------------
label {
- @apply text-slate-800 dark:text-slate-200 block m-0 leading-7 text-sm font-medium;
-
- &.error {
- input {
- @apply mb-1;
- }
- }
+ @apply text-n-slate-12 block m-0 leading-7 text-sm font-medium;
}
.input-wrap,
.help-text {
- @apply text-slate-800 dark:text-slate-100 text-sm font-medium;
-
- .help-text {
- @apply font-normal text-slate-600 dark:text-slate-400;
- }
+ @apply text-n-slate-11 text-sm font-medium;
}
// Focus outline removal
.button,
-textarea,
-input:focus {
+textarea {
outline: none;
}
-// Inputs
-input[type='text']:not(.reset-base),
-input[type='number']:not(.reset-base),
-input[type='password']:not(.reset-base),
-input[type='date']:not(.reset-base),
-input[type='email']:not(.reset-base),
-input[type='url']:not(.reset-base) {
- @apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
+// Field base styles (Input, TextArea, Select)
+@layer components {
+ .field-base {
+ @apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
+ }
- &[disabled] {
- @apply bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-400 border-slate-200 dark:border-slate-600 cursor-not-allowed;
+ .field-disabled {
+ @apply opacity-50 outline-n-weak dark:outline-n-weak cursor-not-allowed;
+ }
+
+ .field-error {
+ @apply outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
}
}
-input[type='file'] {
- @apply bg-white dark:bg-n-solid-1 leading-[1.15] mb-4;
+$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)";
+
+#{$form-input-selector} {
+ @apply field-base h-10;
+
+ &[disabled] {
+ @apply field-disabled;
+ }
+
+ &.error {
+ @apply field-error mb-1;
+ }
+}
+
+input[type='file']:not(.multiselect__input) {
+ @apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
}
// Select
select {
background-image: url("data:image/svg+xml;utf8,");
- background-position: right -1rem center;
background-size: 9px 6px;
- @apply h-10 mx-0 mt-0 mb-4 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 pr-6 pl-2 rounded-md w-full text-base font-normal appearance-none transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
+
+ @apply field-base h-10 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
+
+ &[disabled] {
+ @apply field-disabled;
+ }
}
// Textarea
textarea {
- @apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-16 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
+ @apply field-base h-16;
&[disabled] {
- @apply bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-400 border-slate-200 dark:border-slate-600 cursor-not-allowed;
+ @apply field-disabled;
+ }
+}
+
+// Add mb-1 when .help-text exists within the same label container
+label:has(.help-text) {
+ input,
+ textarea,
+ select {
+ margin-bottom: 0.25rem !important;
}
}
@@ -109,18 +127,41 @@ textarea {
}
}
+// FormKit support
+.formkit-outer[data-invalid='true'] {
+ #{$form-input-selector},
+ textarea,
+ select {
+ @apply field-error;
+ }
+
+ .formkit-message {
+ @apply text-n-ruby-9 dark:text-n-ruby-9 block text-sm mb-2.5 w-full;
+ }
+}
+
.error {
- input,
+ #{$form-input-selector},
input:not([type]),
textarea,
select,
.multiselect > .multiselect__tags,
.multiselect:not(.no-margin) {
- @apply border border-solid border-red-400 dark:border-red-400 mb-1;
+ @apply field-error;
+ }
+
+ // Only add mb-1 when .message exists within the same .error container
+ // And exclude no-margin from the margin-bottom
+ &:has(.message) {
+ input:not(.no-margin),
+ textarea,
+ select {
+ margin-bottom: 0.25rem !important;
+ }
}
.message {
- @apply text-red-400 dark:text-red-400 block text-sm mb-2.5 w-full;
+ @apply text-n-ruby-9 dark:text-n-ruby-9 block text-sm mb-2.5 w-full;
}
}
@@ -130,7 +171,7 @@ textarea {
}
.error {
- @apply border-red-400 dark:border-red-400;
+ @apply text-n-ruby-9 dark:text-n-ruby-9;
}
}
@@ -141,11 +182,11 @@ code {
@apply text-xs border-0;
&.hljs {
- @apply bg-n-slate-3 dark:bg-n-solid-3 text-slate-800 dark:text-slate-50 rounded-lg p-5;
+ @apply bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-12 rounded-lg p-5;
.hljs-number,
.hljs-string {
- @apply text-red-800 dark:text-red-400;
+ @apply text-n-ruby-9 dark:text-n-ruby-9;
}
.hljs-name,
diff --git a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss b/app/javascript/dashboard/assets/scss/widgets/_buttons.scss
index 1213e7c26..133e07bd1 100644
--- a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss
+++ b/app/javascript/dashboard/assets/scss/widgets/_buttons.scss
@@ -42,7 +42,7 @@ button {
}
}
- &:hover:not(.secondary):not(.success):not(.alert):not(.warning):not(
+ &:hover:not(:disabled):not(.success):not(.alert):not(.warning):not(
.clear
):not(.smooth):not(.hollow) {
@apply bg-n-brand/80 dark:bg-n-brand/80;
@@ -117,63 +117,63 @@ button {
}
&.hollow {
- @apply border border-n-brand/40 bg-transparent text-n-blue-text hover:bg-n-brand/20;
+ @apply border border-n-brand/40 bg-transparent text-n-blue-text hover:enabled:bg-n-brand/20;
&.secondary {
- @apply text-n-slate-12 border-n-slate-5 hover:bg-n-slate-5;
+ @apply text-n-slate-12 border-n-slate-5 hover:enabled:bg-n-slate-5;
}
&.success {
- @apply text-n-teal-9 border-n-teal-8 hover:bg-n-teal-5;
+ @apply text-n-teal-9 border-n-teal-8 hover:enabled:bg-n-teal-5;
}
&.alert {
- @apply text-n-ruby-9 border-n-ruby-8 hover:bg-n-ruby-5;
+ @apply text-n-ruby-9 border-n-ruby-8 hover:enabled:bg-n-ruby-5;
}
&.warning {
- @apply text-n-amber-9 border-n-amber-8 hover:bg-n-amber-5;
+ @apply text-n-amber-9 border-n-amber-8 hover:enabled:bg-n-amber-5;
}
}
// Smooth style
&.smooth {
- @apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:bg-n-brand/20 dark:hover:bg-n-brand/40;
+ @apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:enabled:bg-n-brand/20 dark:hover:enabled:bg-n-brand/40;
&.secondary {
- @apply bg-n-slate-4 text-n-slate-11 hover:text-n-slate-11 hover:bg-n-slate-5;
+ @apply bg-n-slate-4 text-n-slate-11 hover:enabled:text-n-slate-11 hover:enabled:bg-n-slate-5;
}
&.success {
- @apply bg-n-teal-4 text-n-teal-11 hover:text-n-teal-11 hover:bg-n-teal-5;
+ @apply bg-n-teal-4 text-n-teal-11 hover:enabled:text-n-teal-11 hover:enabled:bg-n-teal-5;
}
&.alert {
- @apply bg-n-ruby-4 text-n-ruby-11 hover:text-n-ruby-11 hover:bg-n-ruby-5;
+ @apply bg-n-ruby-4 text-n-ruby-11 hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-5;
}
&.warning {
- @apply bg-n-amber-4 text-n-amber-11 hover:text-n-amber-11 hover:bg-n-amber-5;
+ @apply bg-n-amber-4 text-n-amber-11 hover:enabled:text-n-amber-11 hover:enabled:bg-n-amber-5;
}
}
&.clear {
- @apply text-n-blue-text hover:bg-n-brand/10 dark:hover:bg-n-brand/30;
+ @apply text-n-blue-text hover:enabled:bg-n-brand/10 dark:hover:enabled:bg-n-brand/30;
&.secondary {
- @apply text-n-slate-12 hover:bg-n-slate-4;
+ @apply text-n-slate-12 hover:enabled:bg-n-slate-4;
}
&.success {
- @apply text-n-teal-10 hover:bg-n-teal-4;
+ @apply text-n-teal-10 hover:enabled:bg-n-teal-4;
}
&.alert {
- @apply text-n-ruby-11 hover:bg-n-ruby-4;
+ @apply text-n-ruby-11 hover:enabled:bg-n-ruby-4;
}
&.warning {
- @apply text-n-amber-11 hover:bg-n-amber-4;
+ @apply text-n-amber-11 hover:enabled:bg-n-amber-4;
}
&:active {
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue
index 4afca6269..cd8c767b8 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue
@@ -247,10 +247,9 @@ defineExpose({
:placeholder="item.placeholder"
class="[&>div>button]:h-8"
:class="{
- '[&>div>button]:bg-n-alpha-black2 [&>div>button]:!outline-transparent':
+ '[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
!isDetailsView,
- '[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2':
- isDetailsView,
+ '[&>div>button]:!bg-n-alpha-black2': isDetailsView,
}"
@update:model-value="handleCountrySelection"
/>
@@ -266,7 +265,9 @@ defineExpose({
:placeholder="item.placeholder"
:message-type="getMessageType(item.key)"
:custom-input-class="`h-8 !pt-1 !pb-1 ${
- !isDetailsView ? '[&:not(.error,.focus)]:!border-transparent' : ''
+ !isDetailsView
+ ? '[&:not(.error,.focus)]:!outline-transparent'
+ : ''
}`"
class="w-full"
@input="
@@ -303,7 +304,7 @@ defineExpose({
v-model="
state.additionalAttributes.socialProfiles[item.key.toLowerCase()]
"
- class="w-auto min-w-[100px] text-sm bg-transparent reset-base text-n-slate-12 dark:text-n-slate-12 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10"
+ class="w-auto min-w-[100px] text-sm bg-transparent outline-none reset-base text-n-slate-12 dark:text-n-slate-12 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10"
:placeholder="item.placeholder"
:size="item.placeholder.length"
@input="emit('update', state)"
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue
index 588f1712e..14d944b3e 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue
@@ -55,13 +55,13 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
@click="closeDialog"
/>
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue
index 4964668df..039d2c709 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue
@@ -135,7 +135,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
:placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
"
- class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
+ class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>