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/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 5af4857e0..45ff25203 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -10,7 +10,7 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/nightly_installer.yml b/.github/workflows/nightly_installer.yml index a01ba1093..beef5727c 100644 --- a/.github/workflows/nightly_installer.yml +++ b/.github/workflows/nightly_installer.yml @@ -2,7 +2,7 @@ # # # # Linux nightly installer action # # This action will try to install and setup -# # chatwoot on an Ubuntu 20.04 machine using +# # chatwoot on an Ubuntu 22.04 machine using # # the linux installer script. # # # # This is set to run daily at midnight. diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 5a9d35d0b..385feddfc 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -9,7 +9,7 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 services: postgres: image: pgvector/pgvector:pg15 diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 724f69ec3..9be79da05 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -7,7 +7,7 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 77c4a4740..53deb62a8 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,6 @@ yarn-debug.log* # Vite uses dotenv and suggests to ignore local-only env files. See # https://vitejs.dev/guide/env-and-mode.html#env-files *.local + +# Claude.ai config file +CLAUDE.md 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 fc1e483f6..fe180439f 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 729319f31..2baf17aa1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -371,6 +371,7 @@ GEM ruby2ruby (~> 2.4) ruby_parser (~> 3.10) hana (1.3.7) + hash_diff (1.1.1) hashdiff (1.1.2) hashie (5.0.0) html2text (0.4.0) @@ -401,6 +402,8 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) + iso-639 (0.3.8) + csv jbuilder (2.13.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) @@ -552,6 +555,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.3) hashie (>= 3.4.6) rack (>= 2.2.3) @@ -784,6 +790,17 @@ GEM sentry-ruby (~> 5.22.4) sidekiq (>= 3.0) sexp_processor (4.17.3) + shopify_api (14.9.0) + activesupport + concurrent-ruby + hash_diff + httparty + jwt + oj + openssl + securerandom + sorbet-runtime + zeitwerk (~> 2.5) shoulda-matchers (6.4.0) activesupport (>= 5.2.0) sidekiq (8.0.1) @@ -818,6 +835,7 @@ GEM snaky_hash (2.0.1) hashie version_gem (~> 1.1, >= 1.1.1) + sorbet-runtime (0.5.12024) spring (4.2.1) spring-watcher-listen (2.1.0) listen (>= 2.7, < 4.0) @@ -962,6 +980,7 @@ DEPENDENCIES hashie html2text image_processing + iso-639 jbuilder json_refs json_schemer @@ -1018,6 +1037,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) - - -Chat dashboard dark mode -Chat dashboard +Header light mode +Header dark mode ___ # Chatwoot -Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc. -

- - Deploy - - - Deploy to DO - -

+The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.

Maintainability @@ -31,41 +20,71 @@ Customer engagement suite, an open-source alternative to Intercom, Zendesk, Sale Artifact HUB

-Chat dashboard dark mode -Chat dashboard -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. +

+ + Deploy + + + Deploy to DO + +

-## Features +Chat dashboard dark mode +Chat dashboard -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 a0c3699ba..1fceddc1f 100644 --- a/app/assets/stylesheets/administrate/components/_buttons.scss +++ b/app/assets/stylesheets/administrate/components/_buttons.scss @@ -9,7 +9,7 @@ input[type='submit']:not(.reset-base), 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/builders/contact_inbox_builder.rb b/app/builders/contact_inbox_builder.rb index 8fcd2b158..ffa45db2e 100644 --- a/app/builders/contact_inbox_builder.rb +++ b/app/builders/contact_inbox_builder.rb @@ -64,5 +64,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? + "whatsapp:#{@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/builders/contact_inbox_with_contact_builder.rb b/app/builders/contact_inbox_with_contact_builder.rb index 2f30093db..2c0e6087e 100644 --- a/app/builders/contact_inbox_with_contact_builder.rb +++ b/app/builders/contact_inbox_with_contact_builder.rb @@ -63,9 +63,33 @@ class ContactInboxWithContactBuilder contact = find_contact_by_identifier(contact_attributes[:identifier]) contact ||= find_contact_by_email(contact_attributes[:email]) contact ||= find_contact_by_phone_number(contact_attributes[:phone_number]) + contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel? + contact end + def instagram_channel? + inbox.channel_type == 'Channel::Instagram' + end + + # There might be existing contact_inboxes created through Channel::FacebookPage + # with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes + # while still reusing contacts if found in Facebook channels so that we can create + # new conversations with the same contact. + def find_contact_by_instagram_source_id(instagram_id) + return if instagram_id.blank? + + existing_contact_inbox = ContactInbox.joins(:inbox) + .where(source_id: instagram_id) + .where( + 'inboxes.channel_type = ? AND inboxes.account_id = ?', + 'Channel::FacebookPage', + account.id + ).first + + existing_contact_inbox&.contact + end + def find_contact_by_identifier(identifier) return if identifier.blank? diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb new file mode 100644 index 000000000..767115bc6 --- /dev/null +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -0,0 +1,178 @@ +class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder + attr_reader :messaging + + def initialize(messaging, inbox, outgoing_echo: false) + super() + @messaging = messaging + @inbox = inbox + @outgoing_echo = outgoing_echo + end + + def perform + return if @inbox.channel.reauthorization_required? + + ActiveRecord::Base.transaction do + build_message + end + rescue StandardError => e + handle_error(e) + end + + private + + def attachments + @messaging[:message][:attachments] || {} + end + + def message_type + @outgoing_echo ? :outgoing : :incoming + end + + def message_identifier + message[:mid] + end + + def message_source_id + @outgoing_echo ? recipient_id : sender_id + end + + def message_is_unsupported? + message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true + end + + def sender_id + @messaging[:sender][:id] + end + + def recipient_id + @messaging[:recipient][:id] + end + + def message + @messaging[:message] + end + + def contact + @contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact + end + + def conversation + @conversation ||= set_conversation_based_on_inbox_config + end + + def set_conversation_based_on_inbox_config + if @inbox.lock_to_single_conversation + find_conversation_scope.order(created_at: :desc).first || build_conversation + else + find_or_build_for_multiple_conversations + end + end + + def find_conversation_scope + Conversation.where(conversation_params) + end + + def find_or_build_for_multiple_conversations + last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first + return build_conversation if last_conversation.nil? + + last_conversation + end + + def message_content + @messaging[:message][:text] + end + + def story_reply_attributes + message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present? + end + + def message_reply_attributes + message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present? + end + + def build_message + # Duplicate webhook events may be sent for the same message + # when a user is connected to the Instagram account through both Messenger and Instagram login. + # There is chance for echo events to be sent for the same message. + # Therefore, we need to check if the message already exists before creating it. + return if message_already_exists? + + return if message_content.blank? && all_unsupported_files? + + @message = conversation.messages.create!(message_params) + save_story_id + + attachments.each do |attachment| + process_attachment(attachment) + end + end + + def save_story_id + return if story_reply_attributes.blank? + + @message.save_story_info(story_reply_attributes) + end + + def build_conversation + @contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id) + Conversation.create!(conversation_params.merge( + contact_inbox_id: @contact_inbox.id, + additional_attributes: additional_conversation_attributes + )) + end + + def additional_conversation_attributes + {} + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: contact.id + } + end + + def message_params + params = { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: message_type, + source_id: message_identifier, + content: message_content, + sender: @outgoing_echo ? nil : contact, + content_attributes: { + in_reply_to_external_id: message_reply_attributes + } + } + + params[:content_attributes][:is_unsupported] = true if message_is_unsupported? + params + end + + def message_already_exists? + cw_message = conversation.messages.where( + source_id: @messaging[:message][:mid] + ).first + + cw_message.present? + end + + def all_unsupported_files? + return if attachments.empty? + + attachments_type = attachments.pluck(:type).uniq.first + unsupported_file_type?(attachments_type) + end + + def handle_error(error) + ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception + true + end + + # Abstract methods to be implemented by subclasses + def get_story_object_from_source_id(source_id) + raise NotImplementedError + end +end diff --git a/app/builders/messages/instagram/message_builder.rb b/app/builders/messages/instagram/message_builder.rb index 7f1b9cab2..4e7150894 100644 --- a/app/builders/messages/instagram/message_builder.rb +++ b/app/builders/messages/instagram/message_builder.rb @@ -1,200 +1,42 @@ -# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo` -# Assumptions -# 1. Incase of an outgoing message which is echo, source_id will NOT be nil, -# based on this we are showing "not sent from chatwoot" message in frontend -# Hence there is no need to set user_id in message for outgoing echo messages. - -class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder - attr_reader :messaging - +class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder def initialize(messaging, inbox, outgoing_echo: false) - super() - @messaging = messaging - @inbox = inbox - @outgoing_echo = outgoing_echo - end - - def perform - return if @inbox.channel.reauthorization_required? - - ActiveRecord::Base.transaction do - build_message - end - rescue Koala::Facebook::AuthenticationError => e - Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}") - Rails.logger.error e - @inbox.channel.authorization_error! - raise - rescue StandardError => e - ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception - true + super(messaging, inbox, outgoing_echo: outgoing_echo) end private - def attachments - @messaging[:message][:attachments] || {} + def get_story_object_from_source_id(source_id) + url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}" + + response = HTTParty.get(url) + + return JSON.parse(response.body).with_indifferent_access if response.success? + + # Create message first if it doesn't exist + @message ||= conversation.messages.create!(message_params) + handle_error_response(response) + nil end - def message_type - @outgoing_echo ? :outgoing : :incoming - end + def handle_error_response(response) + parsed_response = JSON.parse(response.body) + error_code = parsed_response.dig('error', 'code') - def message_identifier - message[:mid] - end + # https://developers.facebook.com/docs/messenger-platform/error-codes + # Access token has expired or become invalid. + channel.authorization_error! if error_code == 190 - def message_source_id - @outgoing_echo ? recipient_id : sender_id - end - - def message_is_unsupported? - message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true - end - - def sender_id - @messaging[:sender][:id] - end - - def recipient_id - @messaging[:recipient][:id] - end - - def message - @messaging[:message] - end - - def contact - @contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact - end - - def conversation - @conversation ||= set_conversation_based_on_inbox_config - end - - def instagram_direct_message_conversation - Conversation.where(conversation_params) - .where("additional_attributes ->> 'type' = 'instagram_direct_message'") - end - - def set_conversation_based_on_inbox_config - if @inbox.lock_to_single_conversation - instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation - else - find_or_build_for_multiple_conversations + # There was a problem scraping data from the provided link. + # https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005 + if error_code == 1_609_005 + @message.attachments.destroy_all + @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) end + + Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}") end - def find_or_build_for_multiple_conversations - last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first - - return build_conversation if last_conversation.nil? - - last_conversation + def base_uri + "https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}" end - - def message_content - @messaging[:message][:text] - end - - def story_reply_attributes - message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present? - end - - def message_reply_attributes - message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present? - end - - def build_message - return if @outgoing_echo && already_sent_from_chatwoot? - return if message_content.blank? && all_unsupported_files? - - @message = conversation.messages.create!(message_params) - save_story_id - - attachments.each do |attachment| - process_attachment(attachment) - end - end - - def save_story_id - return if story_reply_attributes.blank? - - @message.save_story_info(story_reply_attributes) - end - - def build_conversation - @contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id) - - Conversation.create!(conversation_params.merge( - contact_inbox_id: @contact_inbox.id, - additional_attributes: { type: 'instagram_direct_message' } - )) - end - - def conversation_params - { - account_id: @inbox.account_id, - inbox_id: @inbox.id, - contact_id: contact.id - } - end - - def message_params - params = { - account_id: conversation.account_id, - inbox_id: conversation.inbox_id, - message_type: message_type, - source_id: message_identifier, - content: message_content, - sender: @outgoing_echo ? nil : contact, - content_attributes: { - in_reply_to_external_id: message_reply_attributes - } - } - - params[:content_attributes][:is_unsupported] = true if message_is_unsupported? - params - end - - def already_sent_from_chatwoot? - cw_message = conversation.messages.where( - source_id: @messaging[:message][:mid] - ).first - - cw_message.present? - end - - def all_unsupported_files? - return if attachments.empty? - - attachments_type = attachments.pluck(:type).uniq.first - unsupported_file_type?(attachments_type) - end - - ### Sample response - # { - # "object": "instagram", - # "entry": [ - # { - # "id": "",// ig id of the business - # "time": 1569262486134, - # "messaging": [ - # { - # "sender": { - # "id": "" - # }, - # "recipient": { - # "id": "" - # }, - # "timestamp": 1569262485349, - # "message": { - # "mid": "", - # "text": "" - # } - # } - # ] - # } - # ], - # } end diff --git a/app/builders/messages/instagram/messenger/message_builder.rb b/app/builders/messages/instagram/messenger/message_builder.rb new file mode 100644 index 000000000..1263dee90 --- /dev/null +++ b/app/builders/messages/instagram/messenger/message_builder.rb @@ -0,0 +1,33 @@ +class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder + def initialize(messaging, inbox, outgoing_echo: false) + super(messaging, inbox, outgoing_echo: outgoing_echo) + end + + private + + def get_story_object_from_source_id(source_id) + k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? + k.get_object(source_id, fields: %w[story from]) || {} + rescue Koala::Facebook::AuthenticationError + @inbox.channel.authorization_error! + raise + rescue Koala::Facebook::ClientError => e + # The exception occurs when we are trying fetch the deleted story or blocked story. + @message.attachments.destroy_all + @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) + Rails.logger.error e + {} + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception + {} + end + + def find_conversation_scope + Conversation.where(conversation_params) + .where("additional_attributes ->> 'type' = 'instagram_direct_message'") + end + + def additional_conversation_attributes + { type: 'instagram_direct_message' } + end +end diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index f01a236c9..4e7f2849d 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -68,20 +68,8 @@ class Messages::Messenger::MessageBuilder message.save! end - def get_story_object_from_source_id(source_id) - k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? - k.get_object(source_id, fields: %w[story from]) || {} - rescue Koala::Facebook::AuthenticationError - @inbox.channel.authorization_error! - raise - rescue Koala::Facebook::ClientError => e - # The exception occurs when we are trying fetch the deleted story or blocked story. - @message.attachments.destroy_all - @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) - Rails.logger.error e - {} - rescue StandardError => e - ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception + # This is a placeholder method to be overridden by child classes + def get_story_object_from_source_id(_source_id) {} end diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index 43bce17bc..1422beea1 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController end def permitted_params - params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content]) + params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {}) end def process_avatar_from_url diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 01aa42620..438944f04 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -72,7 +72,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController end def allowed_agent_params - [:name, :email, :name, :role, :availability, :auto_offline] + [:name, :email, :role, :availability, :auto_offline] end def agent_params diff --git a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb index de0ac4db9..fda19b8c2 100644 --- a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb @@ -1,17 +1,21 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController def index - @conversations = Current.account.conversations.includes( + # Start with all conversations for this contact + conversations = Current.account.conversations.includes( :assignee, :contact, :inbox, :taggings - ).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20) - end + ).where(contact_id: @contact.id) - private + # Apply permission-based filtering using the existing service + conversations = Conversations::PermissionFilterService.new( + conversations, + Current.user, + Current.account + ).perform - def inbox_ids - if Current.user.administrator? || Current.user.agent? - Current.user.assigned_inboxes.pluck(:id) - else - [] - end + # Only allow conversations from inboxes the user has access to + inbox_ids = Current.user.assigned_inboxes.pluck(:id) + conversations = conversations.where(inbox_id: inbox_ids) + + @conversations = conversations.order(last_activity_at: :desc).limit(20) end end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 138c2bd68..8753918fc 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -48,7 +48,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro end def filter - result = ::Conversations::FilterService.new(params.permit!, current_user).perform + result = ::Conversations::FilterService.new(params.permit!, current_user, current_account).perform @conversations = result[:conversations] @conversations_count = result[:count] rescue CustomExceptions::CustomFilter::InvalidAttribute, diff --git a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb new file mode 100644 index 000000000..eace4411a --- /dev/null +++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb @@ -0,0 +1,30 @@ +class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController + include InstagramConcern + include Instagram::IntegrationHelper + before_action :check_authorization + + def create + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization + redirect_url = instagram_client.auth_code.authorize_url( + { + redirect_uri: "#{base_url}/instagram/callback", + scope: REQUIRED_SCOPES.join(','), + enable_fb_login: '0', + force_authentication: '1', + response_type: 'code', + state: generate_instagram_token(Current.account.id) + } + ) + if redirect_url + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end + + private + + def check_authorization + raise Pundit::NotAuthorizedError unless Current.account_user.administrator? + end +end 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/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index fe9a03ef5..6cfed161f 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -9,11 +9,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController @portals = Current.account.portals end - def add_members - agents = Current.account.agents.where(id: portal_member_params[:member_ids]) - @portal.members << agents - end - def show @all_articles = @portal.articles @articles = @all_articles.search(locale: params[:locale]) @@ -85,10 +80,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController { channel_web_widget_id: inbox.channel.id } end - def portal_member_params - params.require(:portal).permit(:account_id, member_ids: []) - end - def set_current_page @current_page = params[:page] || 1 end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index af3655e59..6e2d0ff4c 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -66,9 +66,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController end def check_authorization - return if Current.account_user.administrator? - - raise Pundit::NotAuthorizedError + authorize :report, :view? end def common_params @@ -137,5 +135,3 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics end end - -Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController') diff --git a/app/controllers/concerns/instagram_concern.rb b/app/controllers/concerns/instagram_concern.rb new file mode 100644 index 000000000..04de9e63c --- /dev/null +++ b/app/controllers/concerns/instagram_concern.rb @@ -0,0 +1,74 @@ +module InstagramConcern + extend ActiveSupport::Concern + + def instagram_client + ::OAuth2::Client.new( + client_id, + client_secret, + { + site: 'https://api.instagram.com', + authorize_url: 'https://api.instagram.com/oauth/authorize', + token_url: 'https://api.instagram.com/oauth/access_token', + auth_scheme: :request_body, + token_method: :post + } + ) + end + + private + + def client_id + GlobalConfigService.load('INSTAGRAM_APP_ID', nil) + end + + def client_secret + GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil) + end + + def exchange_for_long_lived_token(short_lived_token) + endpoint = 'https://graph.instagram.com/access_token' + params = { + grant_type: 'ig_exchange_token', + client_secret: client_secret, + access_token: short_lived_token, + client_id: client_id + } + + make_api_request(endpoint, params, 'Failed to exchange token') + end + + def fetch_instagram_user_details(access_token) + endpoint = 'https://graph.instagram.com/v22.0/me' + params = { + fields: 'id,username,user_id,name,profile_picture_url,account_type', + access_token: access_token + } + + make_api_request(endpoint, params, 'Failed to fetch Instagram user details') + end + + def make_api_request(endpoint, params, error_prefix) + response = HTTParty.get( + endpoint, + query: params, + headers: { 'Accept' => 'application/json' } + ) + + unless response.success? + Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}" + raise "#{error_prefix}: #{response.body}" + end + + begin + JSON.parse(response.body) + rescue JSON::ParserError => e + ChatwootExceptionTracker.new(e).capture_exception + Rails.logger.error "Invalid JSON response: #{response.body}" + raise e + end + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 0f915ab8a..a2cb466f1 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -36,7 +36,7 @@ class DashboardController < ActionController::Base 'LOGOUT_REDIRECT_LINK', 'DISABLE_USER_PROFILE_UPDATE', 'DEPLOYMENT_ENV', - 'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN' + 'INSTALLATION_PRICING_PLAN' ).merge(app_config) end @@ -65,6 +65,7 @@ class DashboardController < ActionController::Base VAPID_PUBLIC_KEY: VapidService.public_key, ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), + INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'), IS_ENTERPRISE: ChatwootApp.enterprise?, AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index e1cf76d6b..2b3ea9067 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -55,7 +55,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa def validate_business_account? # return true if the user is a business account, false if it is a gmail account - auth_hash['info']['email'].exclude?('@gmail.com') + auth_hash['info']['email'].downcase.exclude?('@gmail.com') end def create_account_for_user diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb new file mode 100644 index 000000000..4dc8ece1c --- /dev/null +++ b/app/controllers/instagram/callbacks_controller.rb @@ -0,0 +1,163 @@ +class Instagram::CallbacksController < ApplicationController + include InstagramConcern + include Instagram::IntegrationHelper + + def show + # Check if Instagram redirected with an error (user canceled authorization) + # See: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization + if params[:error].present? + handle_authorization_error + return + end + + process_successful_authorization + rescue StandardError => e + handle_error(e) + end + + private + + # Process the authorization code and create inbox + def process_successful_authorization + @response = instagram_client.auth_code.get_token( + oauth_code, + redirect_uri: "#{base_url}/#{provider_name}/callback", + grant_type: 'authorization_code' + ) + + @long_lived_token_response = exchange_for_long_lived_token(@response.token) + inbox, already_exists = find_or_create_inbox + + if already_exists + redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) + else + redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id) + end + end + + # Handle all errors that might occur during authorization + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#sample-rejected-response + def handle_error(error) + Rails.logger.error("Instagram Channel creation Error: #{error.message}") + ChatwootExceptionTracker.new(error).capture_exception + + error_info = extract_error_info(error) + redirect_to_error_page(error_info) + end + + # Extract error details from the exception + def extract_error_info(error) + if error.is_a?(OAuth2::Error) + begin + # Instagram returns JSON error response which we parse to extract error details + JSON.parse(error.message) + rescue JSON::ParseError + # Fall back to a generic OAuth error if JSON parsing fails + { 'error_type' => 'OAuthException', 'code' => 400, 'error_message' => error.message } + end + else + # For other unexpected errors + { 'error_type' => error.class.name, 'code' => 500, 'error_message' => error.message } + end + end + + # Handles the case when a user denies permissions or cancels the authorization flow + # Error parameters are documented at: + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization + def handle_authorization_error + error_info = { + 'error_type' => params[:error] || 'authorization_error', + 'code' => 400, + 'error_message' => params[:error_description] || 'Authorization was denied' + } + + Rails.logger.error("Instagram Authorization Error: #{error_info['error_message']}") + redirect_to_error_page(error_info) + end + + # Centralized method to redirect to error page with appropriate parameters + # This ensures consistent error handling across different error scenarios + # Frontend will handle the error page based on the error_type + def redirect_to_error_page(error_info) + redirect_to app_new_instagram_inbox_url( + account_id: account_id, + error_type: error_info['error_type'], + code: error_info['code'], + error_message: error_info['error_message'] + ) + end + + def find_or_create_inbox + user_details = fetch_instagram_user_details(@long_lived_token_response['access_token']) + channel_instagram = find_channel_by_instagram_id(user_details['user_id'].to_s) + channel_exists = channel_instagram.present? + + if channel_instagram + update_channel(channel_instagram, user_details) + else + channel_instagram = create_channel_with_inbox(user_details) + end + + # reauthorize channel, this code path only triggers when instagram auth is successful + # reauthorized will also update cache keys for the associated inbox + channel_instagram.reauthorized! + + [channel_instagram.inbox, channel_exists] + end + + def find_channel_by_instagram_id(instagram_id) + Channel::Instagram.find_by(instagram_id: instagram_id, account: account) + end + + def update_channel(channel_instagram, user_details) + expires_at = Time.current + @long_lived_token_response['expires_in'].seconds + + channel_instagram.update!( + access_token: @long_lived_token_response['access_token'], + expires_at: expires_at + ) + + # Update inbox name if username changed + channel_instagram.inbox.update!(name: user_details['username']) + channel_instagram + end + + def create_channel_with_inbox(user_details) + ActiveRecord::Base.transaction do + expires_at = Time.current + @long_lived_token_response['expires_in'].seconds + + channel_instagram = Channel::Instagram.create!( + access_token: @long_lived_token_response['access_token'], + instagram_id: user_details['user_id'].to_s, + account: account, + expires_at: expires_at + ) + + account.inboxes.create!( + account: account, + channel: channel_instagram, + name: user_details['username'] + ) + + channel_instagram + end + end + + def account_id + return unless params[:state] + + verify_instagram_token(params[:state]) + end + + def oauth_code + params[:code] + end + + def account + @account ||= Account.find(account_id) + end + + def provider_name + 'instagram' + end +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 43157fa0e..550b6c893 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -35,12 +35,16 @@ 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] + when 'instagram' + %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] else %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS] end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index b658915ed..3d46334ca 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -15,6 +15,9 @@ class Webhooks::InstagramController < ActionController::API private def valid_token?(token) - token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') + # Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and + # INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login) + token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') || + token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '') end end diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index f7b04a167..0bf4e44ca 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -81,7 +81,8 @@ class AccountDashboard < Administrate::BaseDashboard COLLECTION_FILTERS = { active: ->(resources) { resources.where(status: :active) }, suspended: ->(resources) { resources.where(status: :suspended) }, - recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) } + recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }, + marked_for_deletion: ->(resources) { resources.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL") } }.freeze # Overwrite this method to customize how accounts are displayed diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 44592c201..1694199ba 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -32,6 +32,7 @@ class ConversationFinder def initialize(current_user, params) @current_user = current_user @current_account = current_user.account + @is_admin = current_account.account_users.find_by(user_id: current_user.id)&.administrator? @params = params end @@ -85,8 +86,19 @@ class ConversationFinder @team = current_account.teams.find(params[:team_id]) if params[:team_id] end + def find_conversation_by_inbox + @conversations = current_account.conversations + @conversations = @conversations.where(inbox_id: @inbox_ids) unless params[:inbox_id].blank? && @is_admin + end + def find_all_conversations - @conversations = current_account.conversations.where(inbox_id: @inbox_ids) + find_conversation_by_inbox + # Apply permission-based filtering + @conversations = Conversations::PermissionFilterService.new( + @conversations, + current_user, + current_account + ).perform filter_by_conversation_type if params[:conversation_type] @conversations end diff --git a/app/helpers/billing_helper.rb b/app/helpers/billing_helper.rb index 26669e38d..e2ada7e86 100644 --- a/app/helpers/billing_helper.rb +++ b/app/helpers/billing_helper.rb @@ -18,4 +18,8 @@ module BillingHelper def non_web_inboxes(account) account.inboxes.where.not(channel_type: Channel::WebWidget.to_s).count end + + def agents(account) + account.users.count + end end diff --git a/app/helpers/instagram/integration_helper.rb b/app/helpers/instagram/integration_helper.rb new file mode 100644 index 000000000..8ba57bf95 --- /dev/null +++ b/app/helpers/instagram/integration_helper.rb @@ -0,0 +1,49 @@ +module Instagram::IntegrationHelper + REQUIRED_SCOPES = %w[instagram_business_basic instagram_business_manage_messages].freeze + + # Generates a signed JWT token for Instagram 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_instagram_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 Instagram token: #{e.message}") + nil + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + # Verifies and decodes a Instagram 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_instagram_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_secret + @client_secret ||= GlobalConfigService.load('INSTAGRAM_APP_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 Instagram token: #{e.message}") + nil + end +end 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/App.vue b/app/javascript/dashboard/App.vue index ae50055fb..e51958e9e 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -4,7 +4,6 @@ import AddAccountModal from '../dashboard/components/layout/sidebarComponents/Ad import LoadingState from './components/widgets/LoadingState.vue'; import NetworkNotification from './components/NetworkNotification.vue'; import UpdateBanner from './components/app/UpdateBanner.vue'; -import UpgradeBanner from './components/app/UpgradeBanner.vue'; import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue'; import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue'; import vueActionCable from './helper/actionCable'; @@ -31,7 +30,6 @@ export default { UpdateBanner, PaymentPendingBanner, WootSnackbarBox, - UpgradeBanner, PendingEmailVerificationBanner, }, setup() { @@ -146,7 +144,6 @@ export default { diff --git a/app/javascript/dashboard/api/agentBots.js b/app/javascript/dashboard/api/agentBots.js index 4de6fcee0..6e59f38d3 100644 --- a/app/javascript/dashboard/api/agentBots.js +++ b/app/javascript/dashboard/api/agentBots.js @@ -1,9 +1,26 @@ +/* global axios */ import ApiClient from './ApiClient'; class AgentBotsAPI extends ApiClient { constructor() { super('agent_bots', { accountScoped: true }); } + + create(data) { + return axios.post(this.url, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + + update(id, data) { + return axios.patch(`${this.url}/${id}`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + + deleteAgentBotAvatar(botId) { + return axios.delete(`${this.url}/${botId}/avatar`); + } } export default new AgentBotsAPI(); diff --git a/app/javascript/dashboard/api/channel/instagramClient.js b/app/javascript/dashboard/api/channel/instagramClient.js new file mode 100644 index 000000000..51ae26448 --- /dev/null +++ b/app/javascript/dashboard/api/channel/instagramClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class InstagramChannel extends ApiClient { + constructor() { + super('instagram', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new InstagramChannel(); diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index bb95335ad..3f12dc007 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -17,6 +17,12 @@ class EnterpriseAccountAPI extends ApiClient { getLimits() { return axios.get(`${this.url}limits`); } + + toggleDeletion(action) { + return axios.post(`${this.url}toggle_deletion`, { + action_type: action, + }); + } } export default new EnterpriseAccountAPI(); diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js index 4fb1bd0ee..9c65b0b67 100644 --- a/app/javascript/dashboard/api/enterprise/specs/account.spec.js +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -10,6 +10,7 @@ describe('#enterpriseAccountAPI', () => { expect(accountAPI).toHaveProperty('update'); expect(accountAPI).toHaveProperty('delete'); expect(accountAPI).toHaveProperty('checkout'); + expect(accountAPI).toHaveProperty('toggleDeletion'); }); describe('API calls', () => { @@ -42,5 +43,21 @@ describe('#enterpriseAccountAPI', () => { '/enterprise/api/v1/subscription' ); }); + + it('#toggleDeletion with delete action', () => { + accountAPI.toggleDeletion('delete'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/toggle_deletion', + { action_type: 'delete' } + ); + }); + + it('#toggleDeletion with undelete action', () => { + accountAPI.toggleDeletion('undelete'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/toggle_deletion', + { action_type: 'undelete' } + ); + }); }); }); 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/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..f23c01d42 --- /dev/null +++ b/app/javascript/dashboard/assets/scss/_next-colors.scss @@ -0,0 +1,234 @@ +// 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; + + --blue-1: 251 253 255; + --blue-2: 245 249 255; + --blue-3: 233 243 255; + --blue-4: 218 236 255; + --blue-5: 201 226 255; + --blue-6: 181 213 255; + --blue-7: 155 195 252; + --blue-8: 117 171 247; + --blue-9: 39 129 246; + --blue-10: 16 115 233; + --blue-11: 8 109 224; + --blue-12: 11 50 101; + + --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; + + --blue-1: 10 17 28; + --blue-2: 15 24 38; + --blue-3: 15 39 72; + --blue-4: 10 49 99; + --blue-5: 18 61 117; + --blue-6: 29 84 134; + --blue-7: 40 89 156; + --blue-8: 48 106 186; + --blue-9: 39 129 246; + --blue-10: 21 116 231; + --blue-11: 126 182 255; + --blue-12: 205 227 255; + + --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/_rtl.scss b/app/javascript/dashboard/assets/scss/_rtl.scss index 36679fc60..4f5f4b843 100644 --- a/app/javascript/dashboard/assets/scss/_rtl.scss +++ b/app/javascript/dashboard/assets/scss/_rtl.scss @@ -81,28 +81,6 @@ margin-left: var(--space-small); } } - - // Conversation sidebar close button - .close-button--rtl { - transform: rotate(180deg); - } - - // Resolve actions button - .resolve-actions { - .button-group .button:first-child { - border-bottom-left-radius: 0; - border-bottom-right-radius: var(--border-radius-normal); - border-top-left-radius: 0; - border-top-right-radius: var(--border-radius-normal); - } - - .button-group .button:last-child { - border-bottom-left-radius: var(--border-radius-normal); - border-bottom-right-radius: 0; - border-top-left-radius: var(--border-radius-normal); - border-top-right-radius: 0; - } - } } // Conversation list @@ -177,71 +155,6 @@ } } - // Help center - .article-container .row--article-block { - td:last-child { - direction: initial; - } - } - - .portal-popover__container .portal { - .actions-container { - margin-left: unset; - margin-right: var(--space-one); - } - } - - .edit-article--container { - .header-right--wrap { - .button-group .button:first-child { - border-bottom-left-radius: 0; - border-bottom-right-radius: var(--border-radius-normal); - border-top-left-radius: 0; - border-top-right-radius: var(--border-radius-normal); - } - - .button-group .button:last-child { - border-bottom-left-radius: var(--border-radius-normal); - border-bottom-right-radius: 0; - border-top-left-radius: var(--border-radius-normal); - border-top-right-radius: 0; - } - } - - .header-left--wrap { - .back-button { - direction: initial; - } - } - - .article--buttons { - .dropdown-pane { - left: 0; - position: absolute; - right: unset; - } - } - - .sidebar-button { - transform: rotate(180deg); - } - } - - .article-settings--container { - border-left: 0; - border-right: 1px solid var(--color-border-light); - flex-direction: row-reverse; - margin-left: 0; - margin-right: var(--space-normal); - padding-left: 0; - padding-right: var(--space-normal); - } - - .category-list--container .header-left--wrap { - direction: initial; - justify-content: flex-end; - } - // Toggle switch .toggle-button { &.small { @@ -264,11 +177,6 @@ } } - // Widget builder - .widget-builder-container .widget-preview { - direction: initial; - } - // Modal .modal-container { text-align: right; @@ -282,7 +190,6 @@ } // Other changes - .colorpicker--chrome { direction: initial; } @@ -291,14 +198,6 @@ direction: initial; } - .contact--details .contact--bio { - direction: ltr; - } - - .merge-contacts .child-contact-wrap { - direction: ltr; - } - .contact--form .input-group { direction: initial; } diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index d736d7b5a..622eb4085 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'; @@ -26,7 +29,6 @@ @import 'rtl'; @import 'widgets/base'; -@import 'widgets/buttons'; @import 'widgets/conversation-view'; @import 'widgets/tabs'; @import 'widgets/woot-tables'; @@ -54,214 +56,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 a8da23f3b..9c4ccf126 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; } } @@ -40,65 +40,89 @@ dl:not(.reset-base) { @apply mb-0; } -// Form elements -label { - @apply text-slate-800 dark:text-slate-200 block m-0 leading-7 text-sm font-medium; +// Button base +button { + font-family: inherit; + @apply inline-block text-center align-middle cursor-pointer text-sm m-0 py-1 px-2.5 transition-all duration-200 ease-in-out border-0 border-none rounded-lg disabled:opacity-50; +} - &.error { - input { - @apply mb-1; - } - } +// Form elements +// ------------------------- +label { + @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-n-background 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 +133,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 +177,7 @@ textarea { } .error { - @apply border-red-400 dark:border-red-400; + @apply text-n-ruby-9 dark:text-n-ruby-9; } } @@ -141,11 +188,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 deleted file mode 100644 index 1213e7c26..000000000 --- a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss +++ /dev/null @@ -1,228 +0,0 @@ -// scss-lint:disable SpaceAfterPropertyColon -// scss-lint:disable MergeableSelector -button { - font-family: inherit; - transition: - background-color 0.25s ease-out, - color 0.25s ease-out; - @apply inline-block items-center mb-0 text-center align-middle cursor-pointer text-sm mt-0 mx-0 py-1 px-2.5 border border-solid border-transparent dark:border-transparent rounded-[0.3125rem]; - - &:disabled, - &.disabled { - @apply opacity-40 cursor-not-allowed; - } -} - -.button-group { - @apply mb-0 flex flex-nowrap items-stretch; - - .button { - flex: 0 0 auto; - @apply m-0 text-sm rounded-none first:rounded-tl-[0.3125rem] first:rounded-bl-[0.3125rem] last:rounded-tr-[0.3125rem] last:rounded-br-[0.3125rem] rtl:space-x-reverse; - } - - .button--only-icon { - @apply w-10 justify-center pl-0 pr-0; - } -} - -.back-button { - @apply m-0; -} - -.button { - @apply items-center bg-n-brand px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium; - - .button__content { - @apply w-full whitespace-nowrap overflow-hidden text-ellipsis; - - img, - svg { - @apply inline-block; - } - } - - &:hover:not(.secondary):not(.success):not(.alert):not(.warning):not( - .clear - ):not(.smooth):not(.hollow) { - @apply bg-n-brand/80 dark:bg-n-brand/80; - } - - &:disabled, - &.disabled { - @apply opacity-40 cursor-not-allowed; - } - - &.success { - @apply bg-n-teal-9 text-white dark:text-white; - } - - &.secondary { - @apply bg-n-solid-3 text-white dark:text-white; - } - - &.primary { - @apply bg-n-brand text-white dark:text-white; - } - - &.clear { - @apply text-n-blue-text dark:text-n-blue-text bg-transparent dark:bg-transparent; - } - - &.alert { - @apply bg-n-ruby-9 text-white dark:text-white; - - &.clear { - @apply bg-transparent dark:bg-transparent; - } - } - - &.warning { - @apply bg-n-amber-9 text-white dark:text-white; - - &.clear { - @apply bg-transparent dark:bg-transparent; - } - } - - &.tiny { - @apply h-6 text-[10px]; - } - - &.small { - @apply h-8 text-xs; - } - - .spinner { - @apply px-2 py-0; - } - - // @TODDO - Remove after moving all buttons to woot-button - .icon + .button__content { - @apply w-auto; - } - - &.expanded { - @apply flex justify-center text-center; - } - - &.round { - @apply rounded-full; - } - - // @TODO Use with link - - &.compact { - @apply pb-0 pt-0; - } - - &.hollow { - @apply border border-n-brand/40 bg-transparent text-n-blue-text hover:bg-n-brand/20; - - &.secondary { - @apply text-n-slate-12 border-n-slate-5 hover:bg-n-slate-5; - } - - &.success { - @apply text-n-teal-9 border-n-teal-8 hover:bg-n-teal-5; - } - - &.alert { - @apply text-n-ruby-9 border-n-ruby-8 hover:bg-n-ruby-5; - } - - &.warning { - @apply text-n-amber-9 border-n-amber-8 hover: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; - - &.secondary { - @apply bg-n-slate-4 text-n-slate-11 hover:text-n-slate-11 hover:bg-n-slate-5; - } - - &.success { - @apply bg-n-teal-4 text-n-teal-11 hover:text-n-teal-11 hover:bg-n-teal-5; - } - - &.alert { - @apply bg-n-ruby-4 text-n-ruby-11 hover:text-n-ruby-11 hover:bg-n-ruby-5; - } - - &.warning { - @apply bg-n-amber-4 text-n-amber-11 hover:text-n-amber-11 hover:bg-n-amber-5; - } - } - - &.clear { - @apply text-n-blue-text hover:bg-n-brand/10 dark:hover:bg-n-brand/30; - - &.secondary { - @apply text-n-slate-12 hover:bg-n-slate-4; - } - - &.success { - @apply text-n-teal-10 hover:bg-n-teal-4; - } - - &.alert { - @apply text-n-ruby-11 hover:bg-n-ruby-4; - } - - &.warning { - @apply text-n-amber-11 hover:bg-n-amber-4; - } - - &:active { - &.secondary { - @apply active:bg-n-slate-3 dark:active:bg-n-slate-7; - } - } - - &:focus { - &.secondary { - @apply focus:bg-n-slate-4 dark:focus:bg-n-slate-6; - } - } - } - - // Sizes - &.tiny { - @apply h-6; - } - - &.small { - @apply h-8 pb-1 pt-1; - } - - &.large { - @apply h-12; - } - - &.button--only-icon { - @apply justify-center pl-0 pr-0 w-10; - - &.tiny { - @apply w-6; - } - - &.small { - @apply w-8; - } - - &.large { - @apply w-12; - } - } - - &.link { - @apply h-auto m-0 p-0; - - &:hover { - @apply underline; - } - } -} 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..75400692e 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue @@ -51,17 +51,18 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });