fix(shangwutong): 完善访客资料同步
This commit is contained in:
@@ -51,7 +51,9 @@ func (c *Contact) syncLocationAndCountryCode() {
|
||||
if city, ok := attrs["city"].(string); ok && city != "" {
|
||||
c.Location = city
|
||||
}
|
||||
if country, ok := attrs["country"].(string); ok && country != "" {
|
||||
if countryCode, ok := attrs["country_code"].(string); ok && countryCode != "" {
|
||||
c.CountryCode = countryCode
|
||||
} else if country, ok := attrs["country"].(string); ok && country != "" {
|
||||
c.CountryCode = country
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
func TestContactSyncLocationAndCountryCodePrefersISOCode(t *testing.T) {
|
||||
contact := Contact{
|
||||
AdditionalAttributes: datatypes.JSON([]byte(`{"city":"贵州贵阳","country":"China","country_code":"CN"}`)),
|
||||
}
|
||||
|
||||
contact.syncLocationAndCountryCode()
|
||||
|
||||
if contact.Location != "贵州贵阳" || contact.CountryCode != "CN" {
|
||||
t.Fatalf("location=%q country_code=%q", contact.Location, contact.CountryCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
UPDATE custom_attribute_definitions
|
||||
SET
|
||||
attribute_display_name = 'Custom Priority',
|
||||
description = 'Custom ticket priority',
|
||||
attribute_values = '["Low", "Medium", "High"]'::jsonb,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE attribute_name = 'custom_priority'
|
||||
AND attribute_model = 'conversation_attribute'
|
||||
AND attribute_display_name = '自定义优先级'
|
||||
AND description = '自定义工单优先级'
|
||||
AND attribute_values = '["低", "中", "高"]'::jsonb;
|
||||
|
||||
UPDATE conversations
|
||||
SET
|
||||
custom_attributes = jsonb_set(
|
||||
custom_attributes,
|
||||
'{custom_priority}',
|
||||
to_jsonb(
|
||||
CASE custom_attributes->>'custom_priority'
|
||||
WHEN '低' THEN 'Low'
|
||||
WHEN '中' THEN 'Medium'
|
||||
WHEN '高' THEN 'High'
|
||||
END
|
||||
)
|
||||
),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE custom_attributes->>'custom_priority' IN ('低', '中', '高');
|
||||
@@ -0,0 +1,27 @@
|
||||
UPDATE custom_attribute_definitions
|
||||
SET
|
||||
attribute_display_name = '自定义优先级',
|
||||
description = '自定义工单优先级',
|
||||
attribute_values = '["低", "中", "高"]'::jsonb,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE attribute_name = 'custom_priority'
|
||||
AND attribute_model = 'conversation_attribute'
|
||||
AND attribute_display_name = 'Custom Priority'
|
||||
AND description = 'Custom ticket priority'
|
||||
AND attribute_values = '["Low", "Medium", "High"]'::jsonb;
|
||||
|
||||
UPDATE conversations
|
||||
SET
|
||||
custom_attributes = jsonb_set(
|
||||
custom_attributes,
|
||||
'{custom_priority}',
|
||||
to_jsonb(
|
||||
CASE custom_attributes->>'custom_priority'
|
||||
WHEN 'Low' THEN '低'
|
||||
WHEN 'Medium' THEN '中'
|
||||
WHEN 'High' THEN '高'
|
||||
END
|
||||
)
|
||||
),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE custom_attributes->>'custom_priority' IN ('Low', 'Medium', 'High');
|
||||
@@ -0,0 +1,12 @@
|
||||
DELETE FROM custom_attribute_definitions
|
||||
WHERE attribute_model = 'contact_attribute'
|
||||
AND attribute_name IN (
|
||||
'swt_ip', 'swt_ip_location', 'swt_isp', 'swt_resolution',
|
||||
'swt_language', 'swt_timezone', 'swt_os', 'swt_browser',
|
||||
'swt_browser_version', 'swt_user_agent', 'swt_device',
|
||||
'swt_query_title', 'swt_query_word', 'swt_traffic_source',
|
||||
'swt_profile_channel', 'swt_site_id'
|
||||
);
|
||||
|
||||
-- Contact values are intentionally retained in custom_attributes. Removing
|
||||
-- them would discard visitor diagnostics that may have arrived after upgrade.
|
||||
@@ -0,0 +1,83 @@
|
||||
INSERT INTO custom_attribute_definitions (
|
||||
account_id,
|
||||
attribute_name,
|
||||
attribute_display_name,
|
||||
attribute_type,
|
||||
attribute_model,
|
||||
description,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
accounts.id,
|
||||
definitions.attribute_name,
|
||||
definitions.attribute_display_name,
|
||||
'text',
|
||||
'contact_attribute',
|
||||
definitions.description,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM accounts
|
||||
CROSS JOIN (VALUES
|
||||
('swt_ip', '访客 IP', '商务通访客 IP 地址'),
|
||||
('swt_ip_location', 'IP 归属地', '商务通解析的访客 IP 归属地'),
|
||||
('swt_isp', '网络运营商', '商务通访客网络运营商'),
|
||||
('swt_resolution', '屏幕分辨率', '商务通访客设备屏幕分辨率'),
|
||||
('swt_language', '浏览器语言', '商务通访客浏览器语言'),
|
||||
('swt_timezone', '访客时区', '商务通访客设备时区'),
|
||||
('swt_os', '操作系统', '商务通访客设备操作系统'),
|
||||
('swt_browser', '浏览器', '商务通访客浏览器名称'),
|
||||
('swt_browser_version', '浏览器版本', '商务通访客浏览器版本'),
|
||||
('swt_user_agent', 'User Agent', '商务通访客浏览器 User Agent'),
|
||||
('swt_device', '访客设备', '商务通访客设备信息'),
|
||||
('swt_query_title', '搜索主题', '商务通访客进入页面时的搜索主题'),
|
||||
('swt_query_word', '搜索关键词', '商务通访客进入页面时的搜索关键词'),
|
||||
('swt_traffic_source', '流量来源', '商务通访客流量来源'),
|
||||
('swt_profile_channel', '来源渠道', '商务通访客来源渠道'),
|
||||
('swt_site_id', '商务通站点 ID', '商务通站点标识')
|
||||
) AS definitions(attribute_name, attribute_display_name, description)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM custom_attribute_definitions existing
|
||||
WHERE existing.account_id = accounts.id
|
||||
AND existing.attribute_name = definitions.attribute_name
|
||||
AND existing.attribute_model = 'contact_attribute'
|
||||
AND existing.deleted_at IS NULL
|
||||
);
|
||||
|
||||
UPDATE contacts
|
||||
SET
|
||||
custom_attributes = COALESCE(custom_attributes, '{}'::jsonb)
|
||||
|| jsonb_strip_nulls(jsonb_build_object(
|
||||
'swt_ip', additional_attributes->'swt_ip',
|
||||
'swt_ip_location', additional_attributes->'swt_ip_location',
|
||||
'swt_isp', additional_attributes->'swt_isp',
|
||||
'swt_resolution', additional_attributes->'swt_resolution',
|
||||
'swt_language', additional_attributes->'swt_language',
|
||||
'swt_timezone', additional_attributes->'swt_timezone',
|
||||
'swt_os', additional_attributes->'swt_os',
|
||||
'swt_browser', additional_attributes->'swt_browser',
|
||||
'swt_browser_version', additional_attributes->'swt_browser_version',
|
||||
'swt_user_agent', additional_attributes->'swt_user_agent',
|
||||
'swt_device', additional_attributes->'swt_device',
|
||||
'swt_query_title', additional_attributes->'swt_query_title',
|
||||
'swt_query_word', additional_attributes->'swt_query_word',
|
||||
'swt_traffic_source', additional_attributes->'swt_traffic_source',
|
||||
'swt_profile_channel', additional_attributes->'swt_profile_channel',
|
||||
'swt_site_id', additional_attributes->'swt_site_id'
|
||||
)),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM contact_inboxes
|
||||
WHERE contact_inboxes.contact_id = contacts.id
|
||||
AND contact_inboxes.inbox_id IN (
|
||||
SELECT id FROM inboxes WHERE channel_type = 'shangwutong'
|
||||
)
|
||||
)
|
||||
AND COALESCE(additional_attributes, '{}'::jsonb) ?| ARRAY[
|
||||
'swt_ip', 'swt_ip_location', 'swt_isp', 'swt_resolution',
|
||||
'swt_language', 'swt_timezone', 'swt_os', 'swt_browser',
|
||||
'swt_browser_version', 'swt_user_agent', 'swt_device',
|
||||
'swt_query_title', 'swt_query_word', 'swt_traffic_source',
|
||||
'swt_profile_channel', 'swt_site_id'
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Message sender names are denormalized presentation metadata. The original
|
||||
-- hard-coded values cannot be restored reliably after contacts are renamed.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,37 @@
|
||||
WITH latest_names AS (
|
||||
SELECT DISTINCT ON (conversation_id)
|
||||
conversation_id,
|
||||
NULLIF(BTRIM(additional_attributes->>'senderName'), '') AS visitor_name
|
||||
FROM messages
|
||||
WHERE message_type = 'incoming'
|
||||
AND NULLIF(BTRIM(additional_attributes->>'senderName'), '') IS NOT NULL
|
||||
AND BTRIM(additional_attributes->>'senderName') <> '商务通访客'
|
||||
ORDER BY conversation_id, created_at DESC, id DESC
|
||||
)
|
||||
UPDATE contacts
|
||||
SET
|
||||
name = latest_names.visitor_name,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
FROM conversations
|
||||
JOIN latest_names ON latest_names.conversation_id = conversations.id
|
||||
JOIN inboxes ON inboxes.id = conversations.inbox_id
|
||||
WHERE contacts.id = conversations.contact_id
|
||||
AND inboxes.channel_type = 'shangwutong'
|
||||
AND contacts.name <> latest_names.visitor_name;
|
||||
|
||||
-- Existing imported messages used a hard-coded senderName. Contact identity is
|
||||
-- the authoritative sender for incoming messages after this repair.
|
||||
UPDATE messages
|
||||
SET additional_attributes = jsonb_set(
|
||||
COALESCE(messages.additional_attributes, '{}'::jsonb),
|
||||
'{senderName}',
|
||||
to_jsonb(contacts.name)
|
||||
)
|
||||
FROM conversations
|
||||
JOIN contacts ON contacts.id = conversations.contact_id
|
||||
JOIN inboxes ON inboxes.id = conversations.inbox_id
|
||||
WHERE messages.conversation_id = conversations.id
|
||||
AND messages.message_type = 'incoming'
|
||||
AND inboxes.channel_type = 'shangwutong'
|
||||
AND BTRIM(contacts.name) <> ''
|
||||
AND COALESCE(messages.additional_attributes->>'senderName', '') <> contacts.name;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Percent-decoded visitor metadata cannot be losslessly restored to the
|
||||
-- original hex casing and plus-sign representation.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,74 @@
|
||||
CREATE OR REPLACE FUNCTION gochat_percent_decode(value TEXT)
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
STRICT
|
||||
AS $$
|
||||
DECLARE
|
||||
position INTEGER := 1;
|
||||
encoded_bytes TEXT := '';
|
||||
token TEXT;
|
||||
BEGIN
|
||||
WHILE position <= char_length(value) LOOP
|
||||
token := substr(value, position, 1);
|
||||
IF token = '%' AND substr(value, position + 1, 2) ~ '^[0-9A-Fa-f]{2}$' THEN
|
||||
encoded_bytes := encoded_bytes || substr(value, position + 1, 2);
|
||||
position := position + 3;
|
||||
ELSIF token = '+' THEN
|
||||
encoded_bytes := encoded_bytes || '20';
|
||||
position := position + 1;
|
||||
ELSE
|
||||
encoded_bytes := encoded_bytes || encode(convert_to(token, 'UTF8'), 'hex');
|
||||
position := position + 1;
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN convert_from(decode(encoded_bytes, 'hex'), 'UTF8');
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN value;
|
||||
END;
|
||||
$$;
|
||||
|
||||
UPDATE contacts
|
||||
SET
|
||||
custom_attributes = COALESCE(custom_attributes, '{}'::jsonb)
|
||||
|| jsonb_strip_nulls(jsonb_build_object(
|
||||
'swt_ip_location', gochat_percent_decode(custom_attributes->>'swt_ip_location'),
|
||||
'swt_color_depth', gochat_percent_decode(custom_attributes->>'swt_color_depth'),
|
||||
'swt_resolution', gochat_percent_decode(custom_attributes->>'swt_resolution'),
|
||||
'swt_os', gochat_percent_decode(custom_attributes->>'swt_os'),
|
||||
'city', CASE
|
||||
WHEN COALESCE(custom_attributes->>'city', '') = ''
|
||||
THEN gochat_percent_decode(custom_attributes->>'swt_ip_location')
|
||||
END
|
||||
)),
|
||||
additional_attributes = COALESCE(additional_attributes, '{}'::jsonb)
|
||||
|| jsonb_strip_nulls(jsonb_build_object(
|
||||
'swt_ip_location', gochat_percent_decode(additional_attributes->>'swt_ip_location'),
|
||||
'swt_color_depth', gochat_percent_decode(additional_attributes->>'swt_color_depth'),
|
||||
'swt_resolution', gochat_percent_decode(additional_attributes->>'swt_resolution'),
|
||||
'swt_os', gochat_percent_decode(additional_attributes->>'swt_os'),
|
||||
'city', CASE
|
||||
WHEN COALESCE(additional_attributes->>'city', '') = ''
|
||||
THEN gochat_percent_decode(additional_attributes->>'swt_ip_location')
|
||||
END
|
||||
)),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM contact_inboxes
|
||||
JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id
|
||||
WHERE contact_inboxes.contact_id = contacts.id
|
||||
AND inboxes.channel_type = 'shangwutong'
|
||||
)
|
||||
AND (
|
||||
COALESCE(custom_attributes->>'swt_ip_location', '') ~ '%[0-9A-Fa-f]{2}'
|
||||
OR COALESCE(custom_attributes->>'swt_color_depth', '') ~ '%[0-9A-Fa-f]{2}'
|
||||
OR COALESCE(custom_attributes->>'swt_resolution', '') ~ '%[0-9A-Fa-f]{2}'
|
||||
OR COALESCE(custom_attributes->>'swt_os', '') ~ '%[0-9A-Fa-f]{2}'
|
||||
OR COALESCE(additional_attributes->>'swt_ip_location', '') ~ '%[0-9A-Fa-f]{2}'
|
||||
OR COALESCE(additional_attributes->>'swt_color_depth', '') ~ '%[0-9A-Fa-f]{2}'
|
||||
OR COALESCE(additional_attributes->>'swt_resolution', '') ~ '%[0-9A-Fa-f]{2}'
|
||||
OR COALESCE(additional_attributes->>'swt_os', '') ~ '%[0-9A-Fa-f]{2}'
|
||||
);
|
||||
|
||||
DROP FUNCTION gochat_percent_decode(TEXT);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Repaired environment fields cannot be reconstructed to the previous
|
||||
-- misaligned values without reintroducing corrupt contact metadata.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,91 @@
|
||||
CREATE OR REPLACE FUNCTION gochat_percent_decode(value TEXT)
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
STRICT
|
||||
AS $$
|
||||
DECLARE
|
||||
position INTEGER := 1;
|
||||
encoded_bytes TEXT := '';
|
||||
token TEXT;
|
||||
BEGIN
|
||||
WHILE position <= char_length(value) LOOP
|
||||
token := substr(value, position, 1);
|
||||
IF token = '%' AND substr(value, position + 1, 2) ~ '^[0-9A-Fa-f]{2}$' THEN
|
||||
encoded_bytes := encoded_bytes || substr(value, position + 1, 2);
|
||||
position := position + 3;
|
||||
ELSIF token = '+' THEN
|
||||
encoded_bytes := encoded_bytes || '20';
|
||||
position := position + 1;
|
||||
ELSE
|
||||
encoded_bytes := encoded_bytes || encode(convert_to(token, 'UTF8'), 'hex');
|
||||
position := position + 1;
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN convert_from(decode(encoded_bytes, 'hex'), 'UTF8');
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN value;
|
||||
END;
|
||||
$$;
|
||||
|
||||
WITH profiles AS (
|
||||
SELECT DISTINCT ON (contacts.id)
|
||||
contacts.id,
|
||||
split_part(
|
||||
COALESCE(
|
||||
NULLIF(contacts.custom_attributes->>'swt_xst_profile', ''),
|
||||
contacts.additional_attributes->>'swt_xst_profile'
|
||||
),
|
||||
'|||||',
|
||||
1
|
||||
) AS profile
|
||||
FROM contacts
|
||||
JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id
|
||||
JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id
|
||||
WHERE inboxes.channel_type = 'shangwutong'
|
||||
AND COALESCE(
|
||||
NULLIF(contacts.custom_attributes->>'swt_xst_profile', ''),
|
||||
contacts.additional_attributes->>'swt_xst_profile'
|
||||
) IS NOT NULL
|
||||
), decoded AS (
|
||||
SELECT
|
||||
id,
|
||||
NULLIF(gochat_percent_decode(split_part(profile, chr(26), 5)), '') AS location,
|
||||
NULLIF(gochat_percent_decode(split_part(profile, chr(26), 11)), '') AS resolution,
|
||||
NULLIF(gochat_percent_decode(split_part(profile, chr(26), 12)), '') AS user_agent,
|
||||
NULLIF(gochat_percent_decode(split_part(profile, chr(26), 13)), '') AS ip_address
|
||||
FROM profiles
|
||||
)
|
||||
UPDATE contacts
|
||||
SET
|
||||
custom_attributes = COALESCE(contacts.custom_attributes, '{}'::jsonb)
|
||||
|| jsonb_strip_nulls(jsonb_build_object(
|
||||
'swt_ip_location', decoded.location,
|
||||
'city', decoded.location,
|
||||
'swt_resolution', decoded.resolution,
|
||||
'swt_user_agent', decoded.user_agent,
|
||||
'swt_device', decoded.user_agent,
|
||||
'swt_os', decoded.user_agent,
|
||||
'swt_ip', decoded.ip_address
|
||||
)),
|
||||
additional_attributes = COALESCE(contacts.additional_attributes, '{}'::jsonb)
|
||||
|| jsonb_strip_nulls(jsonb_build_object(
|
||||
'swt_ip_location', decoded.location,
|
||||
'city', decoded.location,
|
||||
'swt_resolution', decoded.resolution,
|
||||
'swt_user_agent', decoded.user_agent,
|
||||
'swt_device', decoded.user_agent,
|
||||
'swt_os', decoded.user_agent,
|
||||
'swt_ip', decoded.ip_address
|
||||
)),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
FROM decoded
|
||||
WHERE contacts.id = decoded.id
|
||||
AND (
|
||||
decoded.location IS NOT NULL
|
||||
OR decoded.resolution IS NOT NULL
|
||||
OR decoded.user_agent IS NOT NULL
|
||||
OR decoded.ip_address IS NOT NULL
|
||||
);
|
||||
|
||||
DROP FUNCTION gochat_percent_decode(TEXT);
|
||||
@@ -0,0 +1,6 @@
|
||||
DELETE FROM custom_attribute_definitions
|
||||
WHERE attribute_name = 'swt_environment_version'
|
||||
AND attribute_model = 'contact_attribute';
|
||||
|
||||
-- The previous swt_isp and duplicated UA-derived values were semantically
|
||||
-- incorrect and are intentionally not restored.
|
||||
@@ -0,0 +1,116 @@
|
||||
INSERT INTO custom_attribute_definitions (
|
||||
account_id,
|
||||
attribute_name,
|
||||
attribute_display_name,
|
||||
attribute_type,
|
||||
attribute_model,
|
||||
description,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
accounts.id,
|
||||
'swt_environment_version',
|
||||
'环境协议版本',
|
||||
'text',
|
||||
'contact_attribute',
|
||||
'商务通 kind=7 环境信息格式版本;不是网络运营商',
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM accounts
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM custom_attribute_definitions existing
|
||||
WHERE existing.account_id = accounts.id
|
||||
AND existing.attribute_name = 'swt_environment_version'
|
||||
AND existing.attribute_model = 'contact_attribute'
|
||||
AND existing.deleted_at IS NULL
|
||||
);
|
||||
|
||||
DELETE FROM custom_attribute_definitions
|
||||
WHERE attribute_name = 'swt_isp'
|
||||
AND attribute_model = 'contact_attribute';
|
||||
|
||||
WITH swt_contacts AS (
|
||||
SELECT DISTINCT contacts.id
|
||||
FROM contacts
|
||||
JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id
|
||||
JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id
|
||||
WHERE inboxes.channel_type = 'shangwutong'
|
||||
), source AS (
|
||||
SELECT
|
||||
contacts.id,
|
||||
COALESCE(
|
||||
NULLIF(contacts.custom_attributes->>'swt_user_agent', ''),
|
||||
NULLIF(contacts.additional_attributes->>'swt_user_agent', ''),
|
||||
NULLIF(contacts.custom_attributes->>'swt_os', ''),
|
||||
NULLIF(contacts.additional_attributes->>'swt_os', '')
|
||||
) AS user_agent,
|
||||
COALESCE(
|
||||
NULLIF(contacts.custom_attributes->>'swt_environment_version', ''),
|
||||
NULLIF(contacts.custom_attributes->>'swt_isp', ''),
|
||||
NULLIF(contacts.additional_attributes->>'swt_environment_version', ''),
|
||||
NULLIF(contacts.additional_attributes->>'swt_isp', '')
|
||||
) AS environment_version
|
||||
FROM contacts
|
||||
JOIN swt_contacts ON swt_contacts.id = contacts.id
|
||||
), parsed AS (
|
||||
SELECT
|
||||
id,
|
||||
user_agent,
|
||||
environment_version,
|
||||
CASE
|
||||
WHEN user_agent ~ 'OpenHarmony[[:space:]]+[0-9.]+'
|
||||
THEN 'OpenHarmony ' || substring(user_agent FROM 'OpenHarmony[[:space:]]+([0-9.]+)')
|
||||
WHEN user_agent ~ 'Android[[:space:]]+[^;]+'
|
||||
THEN 'Android ' || BTRIM(substring(user_agent FROM 'Android[[:space:]]+([^;]+)'))
|
||||
WHEN user_agent ~ 'iPhone OS[[:space:]]+[0-9_]+'
|
||||
THEN 'iOS ' || replace(substring(user_agent FROM 'iPhone OS[[:space:]]+([0-9_]+)'), '_', '.')
|
||||
WHEN user_agent LIKE '%Windows NT 10.0%'
|
||||
THEN 'Windows 10/11'
|
||||
WHEN user_agent LIKE '%Windows%'
|
||||
THEN 'Windows'
|
||||
WHEN user_agent ~ 'Mac OS X[[:space:]]+[0-9_]+'
|
||||
THEN 'macOS ' || replace(substring(user_agent FROM 'Mac OS X[[:space:]]+([0-9_]+)'), '_', '.')
|
||||
WHEN user_agent LIKE '%Linux%'
|
||||
THEN 'Linux'
|
||||
END AS operating_system,
|
||||
CASE
|
||||
WHEN user_agent LIKE '%OpenHarmony%'
|
||||
THEN BTRIM(split_part(user_agent, ';', 1))
|
||||
WHEN user_agent ~ 'Android[[:space:]]+[^;]+;[[:space:]]*[^;]+ Build/'
|
||||
THEN BTRIM(substring(user_agent FROM 'Android[[:space:]]+[^;]+;[[:space:]]*([^;]+) Build/'))
|
||||
WHEN user_agent LIKE '%Android%'
|
||||
THEN 'Android 设备'
|
||||
WHEN user_agent LIKE '%iPhone OS%'
|
||||
THEN 'iPhone'
|
||||
WHEN user_agent LIKE '%Windows%'
|
||||
THEN 'Windows PC'
|
||||
WHEN user_agent LIKE '%Mac OS X%'
|
||||
THEN 'Mac'
|
||||
WHEN user_agent LIKE '%Linux%'
|
||||
THEN 'Linux 设备'
|
||||
END AS device
|
||||
FROM source
|
||||
)
|
||||
UPDATE contacts
|
||||
SET
|
||||
custom_attributes = (
|
||||
COALESCE(contacts.custom_attributes, '{}'::jsonb) - 'swt_isp' - 'swt_color_depth'
|
||||
) || jsonb_strip_nulls(jsonb_build_object(
|
||||
'swt_environment_version', parsed.environment_version,
|
||||
'swt_user_agent', parsed.user_agent,
|
||||
'swt_os', parsed.operating_system,
|
||||
'swt_device', parsed.device
|
||||
)),
|
||||
additional_attributes = (
|
||||
COALESCE(contacts.additional_attributes, '{}'::jsonb) - 'swt_isp' - 'swt_color_depth'
|
||||
) || jsonb_strip_nulls(jsonb_build_object(
|
||||
'swt_environment_version', parsed.environment_version,
|
||||
'swt_user_agent', parsed.user_agent,
|
||||
'swt_os', parsed.operating_system,
|
||||
'swt_device', parsed.device
|
||||
)),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
FROM parsed
|
||||
WHERE contacts.id = parsed.id;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- The previous device values duplicated the full User Agent and were invalid.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,75 @@
|
||||
WITH swt_contacts AS (
|
||||
SELECT DISTINCT contacts.id
|
||||
FROM contacts
|
||||
JOIN contact_inboxes ON contact_inboxes.contact_id = contacts.id
|
||||
JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id
|
||||
WHERE inboxes.channel_type = 'shangwutong'
|
||||
), source AS (
|
||||
SELECT
|
||||
contacts.id,
|
||||
COALESCE(
|
||||
NULLIF(contacts.custom_attributes->>'swt_user_agent', ''),
|
||||
NULLIF(contacts.additional_attributes->>'swt_user_agent', ''),
|
||||
NULLIF(contacts.custom_attributes->>'swt_device', ''),
|
||||
NULLIF(contacts.additional_attributes->>'swt_device', '')
|
||||
) AS user_agent
|
||||
FROM contacts
|
||||
JOIN swt_contacts ON swt_contacts.id = contacts.id
|
||||
), parsed AS (
|
||||
SELECT
|
||||
id,
|
||||
user_agent,
|
||||
CASE
|
||||
WHEN user_agent ~ 'OpenHarmony[[:space:]]+[0-9.]+'
|
||||
THEN 'OpenHarmony ' || substring(user_agent FROM 'OpenHarmony[[:space:]]+([0-9.]+)')
|
||||
WHEN user_agent ~ 'Android[[:space:]]+[^;]+'
|
||||
THEN 'Android ' || BTRIM(substring(user_agent FROM 'Android[[:space:]]+([^;]+)'))
|
||||
WHEN user_agent ~ 'iPhone OS[[:space:]]+[0-9_]+'
|
||||
THEN 'iOS ' || replace(substring(user_agent FROM 'iPhone OS[[:space:]]+([0-9_]+)'), '_', '.')
|
||||
WHEN user_agent LIKE '%Windows NT 10.0%'
|
||||
THEN 'Windows 10/11'
|
||||
WHEN user_agent LIKE '%Windows%'
|
||||
THEN 'Windows'
|
||||
WHEN user_agent ~ 'Mac OS X[[:space:]]+[0-9_]+'
|
||||
THEN 'macOS ' || replace(substring(user_agent FROM 'Mac OS X[[:space:]]+([0-9_]+)'), '_', '.')
|
||||
WHEN user_agent LIKE '%Linux%'
|
||||
THEN 'Linux'
|
||||
END AS operating_system,
|
||||
CASE
|
||||
WHEN user_agent LIKE '%OpenHarmony%'
|
||||
THEN BTRIM(split_part(user_agent, ';', 1))
|
||||
WHEN user_agent ~ 'Android[[:space:]]+[^;]+;[[:space:]]*[^;]+ Build/'
|
||||
THEN BTRIM(substring(user_agent FROM 'Android[[:space:]]+[^;]+;[[:space:]]*([^;]+) Build/'))
|
||||
WHEN user_agent ~ 'Android[[:space:]]+[^;]+;[[:space:]]*[^;]+;[[:space:]]*[^;]+ Build/'
|
||||
THEN BTRIM(substring(user_agent FROM 'Android[[:space:]]+[^;]+;[[:space:]]*[^;]+;[[:space:]]*([^;]+) Build/'))
|
||||
WHEN user_agent LIKE '%Android%'
|
||||
THEN 'Android 设备'
|
||||
WHEN user_agent LIKE '%iPhone OS%'
|
||||
THEN 'iPhone'
|
||||
WHEN user_agent LIKE '%Windows%'
|
||||
THEN 'Windows PC'
|
||||
WHEN user_agent LIKE '%Mac OS X%'
|
||||
THEN 'Mac'
|
||||
WHEN user_agent LIKE '%Linux%'
|
||||
THEN 'Linux 设备'
|
||||
END AS device
|
||||
FROM source
|
||||
WHERE user_agent IS NOT NULL
|
||||
)
|
||||
UPDATE contacts
|
||||
SET
|
||||
custom_attributes = COALESCE(contacts.custom_attributes, '{}'::jsonb)
|
||||
|| jsonb_strip_nulls(jsonb_build_object(
|
||||
'swt_user_agent', parsed.user_agent,
|
||||
'swt_device', parsed.device,
|
||||
'swt_os', parsed.operating_system
|
||||
)),
|
||||
additional_attributes = COALESCE(contacts.additional_attributes, '{}'::jsonb)
|
||||
|| jsonb_strip_nulls(jsonb_build_object(
|
||||
'swt_user_agent', parsed.user_agent,
|
||||
'swt_device', parsed.device,
|
||||
'swt_os', parsed.operating_system
|
||||
)),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
FROM parsed
|
||||
WHERE contacts.id = parsed.id;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Country metadata inferred from IP location cannot be safely distinguished from
|
||||
-- user-entered values, so this data repair is intentionally irreversible.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,22 @@
|
||||
UPDATE contacts
|
||||
SET
|
||||
country_code = 'CN',
|
||||
location = CASE
|
||||
WHEN COALESCE(location, '') = '' THEN custom_attributes->>'swt_ip_location'
|
||||
ELSE location
|
||||
END,
|
||||
additional_attributes = COALESCE(additional_attributes, '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'country_code', 'CN',
|
||||
'country', 'China',
|
||||
'city', custom_attributes->>'swt_ip_location'
|
||||
),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM contact_inboxes
|
||||
JOIN inboxes ON inboxes.id = contact_inboxes.inbox_id
|
||||
WHERE contact_inboxes.contact_id = contacts.id
|
||||
AND inboxes.channel_type = 'shangwutong'
|
||||
)
|
||||
AND COALESCE(custom_attributes->>'swt_ip_location', '') ~ '^(北京|天津|上海|重庆|河北|山西|辽宁|吉林|黑龙江|江苏|浙江|安徽|福建|江西|山东|河南|湖北|湖南|广东|海南|四川|贵州|云南|陕西|甘肃|青海|台湾|内蒙古|广西|西藏|宁夏|新疆|香港|澳门)';
|
||||
@@ -152,9 +152,15 @@ func (i *Inbound) ensureResources(ctx context.Context, account *dbgen.Account, e
|
||||
|
||||
contactAttributes := cloneMap(mapped.ContactAttributes)
|
||||
contactAttributes["swt_inbox_id"] = account.GochatInboxID
|
||||
contactAdditional := cloneMap(mapped.ContactAdditional)
|
||||
if inferred := shangwutongLocationAttributes(mapped.ContactAttributes); len(inferred) > 0 {
|
||||
for key, value := range inferred {
|
||||
contactAdditional[key] = value
|
||||
}
|
||||
}
|
||||
contactRequest := gochat.ContactRequest{
|
||||
SourceID: event.SwtSid, Name: mapped.ContactName, PhoneNumber: mapped.ContactPhone,
|
||||
AdditionalAttributes: contactAttributes,
|
||||
CustomAttributes: contactAttributes, AdditionalAttributes: contactAdditional,
|
||||
}
|
||||
needsContactWrite := state.contactID == 0 || mapped.ContactName != "" || mapped.ContactPhone != "" || len(mapped.ContactAttributes) > 0
|
||||
if mapped.RequiresContact && needsContactWrite {
|
||||
|
||||
@@ -21,6 +21,7 @@ type mappedEvent struct {
|
||||
ContactName string
|
||||
ContactPhone string
|
||||
ContactAttributes map[string]any
|
||||
ContactAdditional map[string]any
|
||||
ConversationAttrs map[string]any
|
||||
ConversationStatus string
|
||||
Typing *bool
|
||||
@@ -64,6 +65,7 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
|
||||
mapped.Media = media
|
||||
mapped.Subtype = subtype
|
||||
mapped.Message.AdditionalAttributes["senderName"] = "商务通访客"
|
||||
baseAttributes["swt"].(map[string]any)["operator_name"] = cleanText(operator)
|
||||
case 3:
|
||||
content, subtype, fallback, media := normalizeMessageContent(text)
|
||||
message("outgoing", "text", content, chooseStrategy(fallback, "fallback_text", "native_message"))
|
||||
@@ -124,7 +126,6 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
|
||||
case 7:
|
||||
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
|
||||
mapped.ContactAttributes = parseEnvironment(text)
|
||||
mapped.ContactName = visitorDisplayName(mapped.ContactAttributes)
|
||||
case 8:
|
||||
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "conversation_attributes", false, true
|
||||
mapped.ConversationAttrs = parseSource(text)
|
||||
@@ -132,8 +133,8 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
|
||||
case 11:
|
||||
// Account presence is applied in the heartbeat transaction.
|
||||
case 12:
|
||||
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true
|
||||
mapped.ConversationAttrs = map[string]any{"swt_operator_alias": cleanText(text)}
|
||||
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
|
||||
mapped.ContactName = cleanText(text)
|
||||
case 15:
|
||||
content, subtype, fallback, media := normalizeMessageContent(text)
|
||||
message("incoming", "text", firstText(content, "访客发送了文件"), "fallback_text")
|
||||
@@ -143,6 +144,7 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
|
||||
}
|
||||
case 26:
|
||||
mapped.ContactName, mapped.ContactPhone = callbackIdentity(text)
|
||||
mapped.RequiresContact = true
|
||||
activity("访客请求电话回拨")
|
||||
mapped.Strategy = "activity"
|
||||
case 29:
|
||||
@@ -158,7 +160,10 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
|
||||
case 35:
|
||||
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
|
||||
mapped.ContactAttributes = map[string]any{"swt_third_party_source": truncate(cleanText(text), 2048)}
|
||||
case 41, 61:
|
||||
case 41:
|
||||
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true
|
||||
mapped.ConversationAttrs = map[string]any{"swt_outcome": cleanText(text)}
|
||||
case 61:
|
||||
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
|
||||
mapped.ContactName = cleanText(text)
|
||||
case 52:
|
||||
@@ -172,7 +177,6 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour
|
||||
case 65:
|
||||
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact = "contact_attributes", false, true
|
||||
mapped.ContactAttributes = parseXSTProfile(text)
|
||||
mapped.ContactName = visitorDisplayName(mapped.ContactAttributes)
|
||||
case 66:
|
||||
mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true
|
||||
mapped.ConversationAttrs = parseSearchSource(text)
|
||||
@@ -438,16 +442,16 @@ func cleanText(value string) string {
|
||||
}
|
||||
|
||||
func parseEnvironment(text string) map[string]any {
|
||||
parts := strings.Fields(text)
|
||||
indexes := map[int]string{0: "swt_ip", 1: "swt_ip_location", 2: "swt_isp", 7: "swt_resolution", 8: "swt_color_depth", 9: "swt_language", 10: "swt_timezone", 11: "swt_os", 13: "swt_browser", 14: "swt_browser_version"}
|
||||
parts := strings.Split(text, " ")
|
||||
indexes := map[int]string{0: "swt_ip", 1: "swt_ip_location", 4: "swt_environment_version", 7: "swt_resolution", 9: "swt_language", 16: "swt_timezone", 17: "swt_user_agent"}
|
||||
result := map[string]any{}
|
||||
for index, key := range indexes {
|
||||
if index < len(parts) && parts[index] != "0" && parts[index] != "null" {
|
||||
result[key] = truncate(decodeSWTValue(parts[index]), 512)
|
||||
}
|
||||
}
|
||||
if len(parts) > 19 {
|
||||
result["swt_user_agent"] = truncate(decodeSWTValue(strings.Join(parts[19:], " ")), 1024)
|
||||
if userAgent, ok := result["swt_user_agent"].(string); ok && userAgent != "" {
|
||||
setVisitorUserAgentAttributes(result, userAgent)
|
||||
}
|
||||
if location, ok := result["swt_ip_location"].(string); ok && location != "" {
|
||||
result["city"] = location
|
||||
@@ -455,6 +459,86 @@ func parseEnvironment(text string) map[string]any {
|
||||
return result
|
||||
}
|
||||
|
||||
func setVisitorUserAgentAttributes(attributes map[string]any, userAgent string) {
|
||||
operatingSystem, device := parseVisitorUserAgent(userAgent)
|
||||
if operatingSystem != "" {
|
||||
attributes["swt_os"] = operatingSystem
|
||||
}
|
||||
if device != "" {
|
||||
attributes["swt_device"] = device
|
||||
}
|
||||
}
|
||||
|
||||
func shangwutongLocationAttributes(attributes map[string]any) map[string]any {
|
||||
location, _ := attributes["swt_ip_location"].(string)
|
||||
location = strings.TrimSpace(location)
|
||||
if location == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"city": location,
|
||||
"country": "China",
|
||||
"country_code": "CN",
|
||||
}
|
||||
}
|
||||
|
||||
func parseVisitorUserAgent(userAgent string) (operatingSystem, device string) {
|
||||
parts := strings.Split(userAgent, ";")
|
||||
for index := range parts {
|
||||
parts[index] = strings.TrimSpace(parts[index])
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(userAgent, "OpenHarmony"):
|
||||
match := regexp.MustCompile(`OpenHarmony\s+([\d.]+)`).FindStringSubmatch(userAgent)
|
||||
operatingSystem = "OpenHarmony"
|
||||
if len(match) > 1 {
|
||||
operatingSystem += " " + match[1]
|
||||
}
|
||||
device = firstText(firstEnvironmentPart(parts), "HarmonyOS 设备")
|
||||
case strings.Contains(userAgent, "Android"):
|
||||
match := regexp.MustCompile(`Android\s+([^;]+)`).FindStringSubmatch(userAgent)
|
||||
operatingSystem = "Android"
|
||||
if len(match) > 1 {
|
||||
operatingSystem += " " + strings.TrimSpace(match[1])
|
||||
}
|
||||
for _, part := range parts {
|
||||
if strings.Contains(part, " Build/") {
|
||||
device = strings.TrimSpace(strings.SplitN(part, " Build/", 2)[0])
|
||||
break
|
||||
}
|
||||
}
|
||||
if device == "" {
|
||||
device = firstText(last(parts), "Android 设备")
|
||||
}
|
||||
case strings.Contains(userAgent, "iPhone OS"):
|
||||
match := regexp.MustCompile(`iPhone OS\s+([\d_]+)`).FindStringSubmatch(userAgent)
|
||||
operatingSystem, device = "iOS", "iPhone"
|
||||
if len(match) > 1 {
|
||||
operatingSystem += " " + strings.ReplaceAll(match[1], "_", ".")
|
||||
}
|
||||
case strings.Contains(userAgent, "Windows NT 10.0"):
|
||||
operatingSystem, device = "Windows 10/11", "Windows PC"
|
||||
case strings.Contains(userAgent, "Windows"):
|
||||
operatingSystem, device = "Windows", "Windows PC"
|
||||
case strings.Contains(userAgent, "Mac OS X"):
|
||||
match := regexp.MustCompile(`Mac OS X\s+([\d_]+)`).FindStringSubmatch(userAgent)
|
||||
operatingSystem, device = "macOS", "Mac"
|
||||
if len(match) > 1 {
|
||||
operatingSystem += " " + strings.ReplaceAll(match[1], "_", ".")
|
||||
}
|
||||
case strings.Contains(userAgent, "Linux"):
|
||||
operatingSystem, device = "Linux", "Linux 设备"
|
||||
}
|
||||
return operatingSystem, device
|
||||
}
|
||||
|
||||
func firstEnvironmentPart(parts []string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func parseXSTProfile(text string) map[string]any {
|
||||
result := map[string]any{"swt_xst_profile": truncate(cleanText(text), 2048)}
|
||||
profiles := strings.Split(text, "|||||")
|
||||
@@ -465,7 +549,7 @@ func parseXSTProfile(text string) map[string]any {
|
||||
fields := map[int]string{
|
||||
0: "swt_profile_channel", 1: "swt_visitor_nickname", 2: "swt_query_title",
|
||||
3: "swt_query_word", 4: "swt_profile_location", 5: "swt_site_id",
|
||||
8: "swt_traffic_mode", 10: "swt_resolution", 11: "swt_device",
|
||||
8: "swt_traffic_mode", 10: "swt_resolution", 11: "swt_user_agent",
|
||||
12: "swt_ip", 13: "swt_traffic_source",
|
||||
}
|
||||
for index, key := range fields {
|
||||
@@ -480,34 +564,12 @@ func parseXSTProfile(text string) map[string]any {
|
||||
if location, ok := result["swt_profile_location"].(string); ok && location != "" {
|
||||
result["city"] = location
|
||||
}
|
||||
if userAgent, ok := result["swt_user_agent"].(string); ok && userAgent != "" {
|
||||
setVisitorUserAgentAttributes(result, userAgent)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func visitorDisplayName(attributes map[string]any) string {
|
||||
for _, key := range []string{"swt_visitor_nickname", "swt_ip_location", "swt_profile_location", "swt_ip"} {
|
||||
if value, ok := attributes[key].(string); ok && usableVisitorLabel(key, value) {
|
||||
return truncate(strings.TrimSpace(value), 64)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func usableVisitorLabel(key, value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
if key != "swt_visitor_nickname" {
|
||||
return true
|
||||
}
|
||||
// XST may put an opaque tracking token in the nickname slot. Never expose
|
||||
// that token as the contact name; fall back to location or IP instead.
|
||||
if len([]rune(value)) > 32 || regexp.MustCompile(`^[A-Za-z0-9_-]{24,}$`).MatchString(value) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeSWTValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if decoded, err := url.QueryUnescape(value); err == nil {
|
||||
@@ -554,7 +616,7 @@ func callbackIdentity(text string) (string, string) {
|
||||
if phone != "" && !strings.HasPrefix(phone, "+") {
|
||||
phone = "+86" + strings.TrimPrefix(phone, "86")
|
||||
}
|
||||
name := strings.TrimSpace(strings.Replace(cleaned, phonePattern.FindString(cleaned), "", 1))
|
||||
name := strings.Trim(strings.TrimSpace(strings.Replace(cleaned, phonePattern.FindString(cleaned), "", 1)), "|,,::;-_")
|
||||
return truncate(name, 255), phone
|
||||
}
|
||||
|
||||
|
||||
@@ -39,19 +39,57 @@ func TestMapInboundEventCoreStrategies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKind2UsesSequenceAsExternalMessageID(t *testing.T) {
|
||||
mapped := mapInboundEvent(2, 98765, "hello", "", "", "swt:10:sid:2:98765:0", 10, time.Now())
|
||||
func TestKind2UsesSequenceAsExternalMessageIDWithoutOverwritingContactName(t *testing.T) {
|
||||
mapped := mapInboundEvent(2, 98765, "hello", "商务通客服名称", "", "swt:10:sid:2:98765:0", 10, time.Now())
|
||||
if mapped.Message == nil || mapped.Message.ExternalSourceIDs["shangwutong"] != "98765" {
|
||||
t.Fatalf("external source IDs = %#v", mapped.Message)
|
||||
}
|
||||
if mapped.ContactName != "" || mapped.Message.AdditionalAttributes["senderName"] != "商务通访客" {
|
||||
t.Fatalf("visitor identity = %#v", mapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKind2FallsBackToGenericNameWhenMessageHasNoVisitorName(t *testing.T) {
|
||||
mapped := mapInboundEvent(2, 98765, "hello", "", "", "swt:10:sid:2:98765:0", 10, time.Now())
|
||||
if mapped.ContactName != "" || mapped.Message.AdditionalAttributes["senderName"] != "商务通访客" {
|
||||
t.Fatalf("visitor identity = %#v", mapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKind12UpdatesContactNickname(t *testing.T) {
|
||||
mapped := mapInboundEvent(12, 42, "贵州贵阳客人1219", "", "", "source", 10, time.Now())
|
||||
if mapped.ContactName != "贵州贵阳客人1219" || !mapped.RequiresContact || mapped.RequiresConversation {
|
||||
t.Fatalf("visitor nickname mapping = %#v", mapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKind26UpdatesCurrentContactPhone(t *testing.T) {
|
||||
mapped := mapInboundEvent(26, 42, "张三|13800138000", "", "", "source", 10, time.Now())
|
||||
if mapped.ContactName != "张三" || mapped.ContactPhone != "+8613800138000" || !mapped.RequiresContact {
|
||||
t.Fatalf("callback identity mapping = %#v", mapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKind41StoresConversationOutcomeWithoutOverwritingContactName(t *testing.T) {
|
||||
mapped := mapInboundEvent(41, 42, "留联", "", "", "source", 10, time.Now())
|
||||
if mapped.ContactName != "" || !mapped.RequiresConversation || mapped.ConversationAttrs["swt_outcome"] != "留联" {
|
||||
t.Fatalf("conversation outcome mapping = %#v", mapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisitorProfileMapsContactNameAndAttributes(t *testing.T) {
|
||||
environment := mapInboundEvent(7, 1, "220.197.4.178 %e8%b4%b5%e5%b7%9e%e8%b4%b5%e9%98%b3 ISP 0 0 0 0 393x798 24 zh-CN +8 iPhone 0 Safari 18", "", "", "source", 10, time.Now())
|
||||
if environment.ContactName != "贵州贵阳" {
|
||||
environment := mapInboundEvent(7, 1, "220.197.4.178 %e8%b4%b5%e5%b7%9e%e8%b4%b5%e9%98%b3 5.6 1 1 393x798 zh-CN iPhone iPhone%3b+CPU+iPhone+OS+18_7+like+Mac+OS+X ", "", "", "source", 10, time.Now())
|
||||
if environment.ContactName != "" {
|
||||
t.Fatalf("environment contact name = %q", environment.ContactName)
|
||||
}
|
||||
if environment.ContactAttributes["swt_ip"] != "220.197.4.178" || environment.ContactAttributes["city"] != "贵州贵阳" {
|
||||
if environment.ContactAttributes["swt_ip"] != "220.197.4.178" ||
|
||||
environment.ContactAttributes["swt_ip_location"] != "贵州贵阳" ||
|
||||
environment.ContactAttributes["city"] != "贵州贵阳" ||
|
||||
environment.ContactAttributes["swt_resolution"] != "393x798" ||
|
||||
environment.ContactAttributes["swt_environment_version"] != "5.6" ||
|
||||
environment.ContactAttributes["swt_user_agent"] != "iPhone; CPU iPhone OS 18_7 like Mac OS X" ||
|
||||
environment.ContactAttributes["swt_os"] != "iOS 18.7" ||
|
||||
environment.ContactAttributes["swt_device"] != "iPhone" {
|
||||
t.Fatalf("environment attributes = %#v", environment.ContactAttributes)
|
||||
}
|
||||
|
||||
@@ -60,19 +98,42 @@ func TestVisitorProfileMapsContactNameAndAttributes(t *testing.T) {
|
||||
"xst%7csbox%7czhinengzx", "医院", "fc", "1", "393x798", "iPhone%3b+CPU+iPhone+OS+18_7", "220.197.4.178", "百度搜索推广",
|
||||
}, "\x1a")
|
||||
profile := mapInboundEvent(65, 2, profileText, "", "", "source", 10, time.Now())
|
||||
if profile.ContactName != "访客昵称" {
|
||||
if profile.ContactName != "" {
|
||||
t.Fatalf("profile contact name = %q", profile.ContactName)
|
||||
}
|
||||
if profile.ContactAttributes["swt_query_word"] != "甘油三酯高怎么办" || profile.ContactAttributes["swt_device"] != "iPhone; CPU iPhone OS 18_7" {
|
||||
if profile.ContactAttributes["swt_query_word"] != "甘油三酯高怎么办" ||
|
||||
profile.ContactAttributes["swt_user_agent"] != "iPhone; CPU iPhone OS 18_7" ||
|
||||
profile.ContactAttributes["swt_device"] != "iPhone" ||
|
||||
profile.ContactAttributes["swt_os"] != "iOS 18.7" {
|
||||
t.Fatalf("profile attributes = %#v", profile.ContactAttributes)
|
||||
}
|
||||
|
||||
profileText = strings.Join([]string{
|
||||
"xst|sbox|zhinengzx", "TLnqPW6zPjDdPH9BPAc1nh7WPhfsuy79uH9-nHwBmy7WmHRKnHTvPjmsrjD1ns", "胆固醇高", "甘油三酯高怎么办", "贵州贵阳", "48989266",
|
||||
}, "\x1a")
|
||||
profile = mapInboundEvent(65, 3, profileText, "", "", "source", 10, time.Now())
|
||||
if profile.ContactName != "贵州贵阳" {
|
||||
t.Fatalf("opaque nickname fallback contact name = %q", profile.ContactName)
|
||||
}
|
||||
|
||||
func TestParseVisitorUserAgent(t *testing.T) {
|
||||
tests := []struct {
|
||||
userAgent string
|
||||
os string
|
||||
device string
|
||||
}{
|
||||
{"Linux; Android 16; V2302A Build/BP2A.250605.031.A3; wv", "Android 16", "V2302A"},
|
||||
{"Phone; OpenHarmony 6.1", "OpenHarmony 6.1", "Phone"},
|
||||
{"iPhone; CPU iPhone OS 18_7 like Mac OS X", "iOS 18.7", "iPhone"},
|
||||
{"Windows NT 10.0; Win64; x64", "Windows 10/11", "Windows PC"},
|
||||
{"Macintosh; Intel Mac OS X 10_15_7", "macOS 10.15.7", "Mac"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
operatingSystem, device := parseVisitorUserAgent(test.userAgent)
|
||||
if operatingSystem != test.os || device != test.device {
|
||||
t.Fatalf("parseVisitorUserAgent(%q) = %q, %q", test.userAgent, operatingSystem, device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShangwutongLocationAttributesDefaultToChina(t *testing.T) {
|
||||
attributes := shangwutongLocationAttributes(map[string]any{"swt_ip_location": "贵州贵阳"})
|
||||
if attributes["country_code"] != "CN" || attributes["country"] != "China" || attributes["city"] != "贵州贵阳" {
|
||||
t.Fatalf("location attributes = %#v", attributes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,12 +157,12 @@ func TestKnownKindMappingMatrix(t *testing.T) {
|
||||
{3, "hello", "native_message", false}, {5, "unverified", "raw_only", true},
|
||||
{7, "127.0.0.1 Beijing ISP 0 0 0 0 1920x1080 24 zh-CN +8 Windows 0 Chrome 149", "contact_attributes", false},
|
||||
{8, "https://example.test 0 Landing 0 0 0 friendlink", "conversation_attributes", false},
|
||||
{11, "3", "raw_only", true}, {12, "alias", "conversation_attributes", false},
|
||||
{11, "3", "raw_only", true}, {12, "alias", "contact_attributes", false},
|
||||
{15, "filemsg|name|https://media.example/file", "native_message", false},
|
||||
{26, "张三|13800138000", "activity", false}, {29, "red", "conversation_attributes", false},
|
||||
{30, "system", "activity", false}, {31, "guest_open_chat", "activity", false},
|
||||
{34, "active", "conversation_attributes", false}, {35, "source", "contact_attributes", false},
|
||||
{39, "unverified", "raw_only", true}, {41, "访客", "contact_attributes", false},
|
||||
{39, "unverified", "raw_only", true}, {41, "留联", "conversation_attributes", false},
|
||||
{52, "unverified#history", "raw_only", true}, {56, "web", "conversation_attributes", false},
|
||||
{58, "left", "activity", false}, {61, "访客", "contact_attributes", false},
|
||||
{65, "profile", "contact_attributes", false}, {66, "keyword|referrer|engine", "conversation_attributes", false},
|
||||
|
||||
+463
-415
@@ -1,8 +1,8 @@
|
||||
<script>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import {
|
||||
DuplicateContactException,
|
||||
ExceptionWithMessage,
|
||||
DuplicateContactException,
|
||||
ExceptionWithMessage,
|
||||
} from 'shared/helpers/CustomErrors';
|
||||
import { required, email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
@@ -14,424 +14,472 @@ import Avatar from 'next/avatar/Avatar.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
Avatar,
|
||||
ComboBox,
|
||||
},
|
||||
props: {
|
||||
contact: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
inProgress: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onSubmit: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['cancel', 'success'],
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
countries: countries,
|
||||
companyName: '',
|
||||
description: '',
|
||||
email: '',
|
||||
name: '',
|
||||
phoneNumber: '',
|
||||
activeDialCode: '',
|
||||
avatarFile: null,
|
||||
avatarUrl: '',
|
||||
country: {
|
||||
id: '',
|
||||
name: '',
|
||||
},
|
||||
city: '',
|
||||
socialProfileUserNames: {
|
||||
facebook: '',
|
||||
twitter: '',
|
||||
linkedin: '',
|
||||
github: '',
|
||||
telegram: '',
|
||||
},
|
||||
socialProfileKeys: [
|
||||
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
|
||||
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
|
||||
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
|
||||
{ key: 'github', prefixURL: 'https://github.com/' },
|
||||
{ key: 'telegram', prefixURL: 'https://t.me/' },
|
||||
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
|
||||
],
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
name: {
|
||||
required,
|
||||
},
|
||||
description: {},
|
||||
email: {
|
||||
email,
|
||||
},
|
||||
companyName: {},
|
||||
phoneNumber: {},
|
||||
bio: {},
|
||||
},
|
||||
computed: {
|
||||
parsePhoneNumber() {
|
||||
return parsePhoneNumber(this.phoneNumber);
|
||||
},
|
||||
isPhoneNumberNotValid() {
|
||||
if (this.phoneNumber !== '') {
|
||||
return (
|
||||
!isPhoneNumberValid(this.phoneNumber, this.activeDialCode) ||
|
||||
(this.phoneNumber !== '' ? this.activeDialCode === '' : false)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
phoneNumberError() {
|
||||
if (this.activeDialCode === '') {
|
||||
return this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DIAL_CODE_ERROR');
|
||||
}
|
||||
if (!isPhoneNumberValid(this.phoneNumber, this.activeDialCode)) {
|
||||
return this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.ERROR');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
setPhoneNumber() {
|
||||
if (this.parsePhoneNumber && this.parsePhoneNumber.countryCallingCode) {
|
||||
return this.phoneNumber;
|
||||
}
|
||||
if (this.phoneNumber === '' && this.activeDialCode !== '') {
|
||||
return '';
|
||||
}
|
||||
return this.activeDialCode
|
||||
? `${this.activeDialCode}${this.phoneNumber}`
|
||||
: '';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
contact() {
|
||||
this.setContactObject();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setContactObject();
|
||||
this.setDialCode();
|
||||
},
|
||||
methods: {
|
||||
onCancel() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
onSuccess() {
|
||||
this.$emit('success');
|
||||
},
|
||||
countryNameWithCode({ name, id }) {
|
||||
if (!id) return name;
|
||||
if (!name && !id) return '';
|
||||
return `${name} (${id})`;
|
||||
},
|
||||
onCountryChange(value) {
|
||||
const selected = this.countries.find(c => c.id === value);
|
||||
this.country = selected
|
||||
? { id: selected.id, name: selected.name }
|
||||
: { id: '', name: '' };
|
||||
},
|
||||
setDialCode() {
|
||||
if (
|
||||
this.phoneNumber !== '' &&
|
||||
this.parsePhoneNumber &&
|
||||
this.parsePhoneNumber.countryCallingCode
|
||||
) {
|
||||
const dialCode = this.parsePhoneNumber.countryCallingCode;
|
||||
this.activeDialCode = `+${dialCode}`;
|
||||
}
|
||||
},
|
||||
setContactObject() {
|
||||
const {
|
||||
email: emailAddress,
|
||||
phone_number: phoneNumber,
|
||||
name,
|
||||
} = this.contact;
|
||||
const additionalAttributes = this.contact.additional_attributes || {};
|
||||
components: {
|
||||
NextButton,
|
||||
Avatar,
|
||||
ComboBox,
|
||||
},
|
||||
props: {
|
||||
contact: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
inProgress: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onSubmit: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['cancel', 'success'],
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
countries: countries,
|
||||
companyName: '',
|
||||
description: '',
|
||||
email: '',
|
||||
name: '',
|
||||
phoneNumber: '',
|
||||
activeDialCode: '',
|
||||
avatarFile: null,
|
||||
avatarUrl: '',
|
||||
country: {
|
||||
id: '',
|
||||
name: '',
|
||||
},
|
||||
city: '',
|
||||
socialProfileUserNames: {
|
||||
facebook: '',
|
||||
twitter: '',
|
||||
linkedin: '',
|
||||
github: '',
|
||||
telegram: '',
|
||||
},
|
||||
socialProfileKeys: [
|
||||
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
|
||||
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
|
||||
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
|
||||
{ key: 'github', prefixURL: 'https://github.com/' },
|
||||
{ key: 'telegram', prefixURL: 'https://t.me/' },
|
||||
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
|
||||
],
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
name: {
|
||||
required,
|
||||
},
|
||||
description: {},
|
||||
email: {
|
||||
email,
|
||||
},
|
||||
companyName: {},
|
||||
phoneNumber: {},
|
||||
bio: {},
|
||||
},
|
||||
computed: {
|
||||
parsePhoneNumber() {
|
||||
return parsePhoneNumber(this.phoneNumber);
|
||||
},
|
||||
isPhoneNumberNotValid() {
|
||||
if (this.phoneNumber !== '') {
|
||||
return (
|
||||
!isPhoneNumberValid(
|
||||
this.phoneNumber,
|
||||
this.activeDialCode
|
||||
) ||
|
||||
(this.phoneNumber !== ''
|
||||
? this.activeDialCode === ''
|
||||
: false)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
phoneNumberError() {
|
||||
if (this.activeDialCode === '') {
|
||||
return this.$t(
|
||||
'CONTACT_FORM.FORM.PHONE_NUMBER.DIAL_CODE_ERROR'
|
||||
);
|
||||
}
|
||||
if (!isPhoneNumberValid(this.phoneNumber, this.activeDialCode)) {
|
||||
return this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.ERROR');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
setPhoneNumber() {
|
||||
if (
|
||||
this.parsePhoneNumber &&
|
||||
this.parsePhoneNumber.countryCallingCode
|
||||
) {
|
||||
return this.phoneNumber;
|
||||
}
|
||||
if (this.phoneNumber === '' && this.activeDialCode !== '') {
|
||||
return '';
|
||||
}
|
||||
return this.activeDialCode
|
||||
? `${this.activeDialCode}${this.phoneNumber}`
|
||||
: '';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
contact() {
|
||||
this.setContactObject();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setContactObject();
|
||||
this.setDialCode();
|
||||
},
|
||||
methods: {
|
||||
onCancel() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
onSuccess() {
|
||||
this.$emit('success');
|
||||
},
|
||||
countryNameWithCode({ name, id }) {
|
||||
if (!id) return name;
|
||||
if (!name && !id) return '';
|
||||
return `${name} (${id})`;
|
||||
},
|
||||
onCountryChange(value) {
|
||||
const selected = this.countries.find(c => c.id === value);
|
||||
this.country = selected
|
||||
? { id: selected.id, name: selected.name }
|
||||
: { id: '', name: '' };
|
||||
this.setPhoneCode(selected?.dial_code || '');
|
||||
},
|
||||
setDialCode() {
|
||||
if (
|
||||
this.phoneNumber !== '' &&
|
||||
this.parsePhoneNumber &&
|
||||
this.parsePhoneNumber.countryCallingCode
|
||||
) {
|
||||
const dialCode = this.parsePhoneNumber.countryCallingCode;
|
||||
this.activeDialCode = `+${dialCode}`;
|
||||
}
|
||||
},
|
||||
setContactObject() {
|
||||
const {
|
||||
email: emailAddress,
|
||||
phone_number: phoneNumber,
|
||||
name,
|
||||
} = this.contact;
|
||||
const additionalAttributes =
|
||||
this.contact.additional_attributes || {};
|
||||
|
||||
this.name = name || '';
|
||||
this.email = emailAddress || '';
|
||||
this.phoneNumber = phoneNumber || '';
|
||||
this.companyName = additionalAttributes.company_name || '';
|
||||
this.country = {
|
||||
id: additionalAttributes.country_code || '',
|
||||
name:
|
||||
additionalAttributes.country ||
|
||||
this.$t('CONTACT_FORM.FORM.COUNTRY.SELECT_COUNTRY'),
|
||||
};
|
||||
this.city = additionalAttributes.city || '';
|
||||
this.description = additionalAttributes.description || '';
|
||||
this.avatarUrl = this.contact.thumbnail || '';
|
||||
const {
|
||||
social_profiles: socialProfiles = {},
|
||||
screen_name: twitterScreenName,
|
||||
social_telegram_user_name: telegramUserName,
|
||||
} = additionalAttributes;
|
||||
this.socialProfileUserNames = {
|
||||
twitter: socialProfiles.twitter || twitterScreenName || '',
|
||||
facebook: socialProfiles.facebook || '',
|
||||
linkedin: socialProfiles.linkedin || '',
|
||||
github: socialProfiles.github || '',
|
||||
telegram: socialProfiles.telegram || telegramUserName || '',
|
||||
instagram: socialProfiles.instagram || '',
|
||||
tiktok: socialProfiles.tiktok || '',
|
||||
};
|
||||
},
|
||||
getContactObject() {
|
||||
if (this.country === null) {
|
||||
this.country = {
|
||||
id: '',
|
||||
name: '',
|
||||
};
|
||||
}
|
||||
const contactObject = {
|
||||
id: this.contact.id,
|
||||
name: this.name,
|
||||
email: this.email,
|
||||
phone_number: this.setPhoneNumber,
|
||||
additional_attributes: {
|
||||
...this.contact.additional_attributes,
|
||||
description: this.description,
|
||||
company_name: this.companyName,
|
||||
country_code: this.country.id,
|
||||
country:
|
||||
this.country.name ===
|
||||
this.$t('CONTACT_FORM.FORM.COUNTRY.SELECT_COUNTRY')
|
||||
? ''
|
||||
: this.country.name,
|
||||
city: this.city,
|
||||
social_profiles: this.socialProfileUserNames,
|
||||
},
|
||||
};
|
||||
if (this.avatarFile) {
|
||||
contactObject.avatar = this.avatarFile;
|
||||
contactObject.isFormData = true;
|
||||
}
|
||||
return contactObject;
|
||||
},
|
||||
setPhoneCode(code) {
|
||||
if (this.phoneNumber !== '' && this.parsePhoneNumber) {
|
||||
const dialCode = this.parsePhoneNumber.countryCallingCode;
|
||||
if (dialCode === code) {
|
||||
return;
|
||||
}
|
||||
this.activeDialCode = `+${dialCode}`;
|
||||
const newPhoneNumber = this.phoneNumber.replace(
|
||||
`+${dialCode}`,
|
||||
`${code}`
|
||||
);
|
||||
this.phoneNumber = newPhoneNumber;
|
||||
} else {
|
||||
this.activeDialCode = code;
|
||||
}
|
||||
},
|
||||
async handleSubmit() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid || this.isPhoneNumberNotValid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.onSubmit(this.getContactObject());
|
||||
this.onSuccess();
|
||||
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateContactException) {
|
||||
if (error.data.includes('email')) {
|
||||
useAlert(this.$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE'));
|
||||
} else if (error.data.includes('phone_number')) {
|
||||
useAlert(this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
|
||||
}
|
||||
} else if (error instanceof ExceptionWithMessage) {
|
||||
useAlert(error.data);
|
||||
} else {
|
||||
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
|
||||
}
|
||||
}
|
||||
},
|
||||
handleImageUpload({ file, url }) {
|
||||
this.avatarFile = file;
|
||||
this.avatarUrl = url;
|
||||
},
|
||||
async handleAvatarDelete() {
|
||||
try {
|
||||
if (this.contact && this.contact.id) {
|
||||
await this.$store.dispatch('contacts/deleteAvatar', this.contact.id);
|
||||
useAlert(this.$t('CONTACT_FORM.DELETE_AVATAR.API.SUCCESS_MESSAGE'));
|
||||
}
|
||||
this.avatarFile = null;
|
||||
this.avatarUrl = '';
|
||||
this.activeDialCode = '';
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message
|
||||
? error.message
|
||||
: this.$t('CONTACT_FORM.DELETE_AVATAR.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
this.name = name || '';
|
||||
this.email = emailAddress || '';
|
||||
this.phoneNumber = phoneNumber || '';
|
||||
this.companyName = additionalAttributes.company_name || '';
|
||||
this.country = {
|
||||
id:
|
||||
additionalAttributes.country_code ||
|
||||
this.contact.country_code ||
|
||||
'',
|
||||
name:
|
||||
additionalAttributes.country ||
|
||||
this.countries.find(
|
||||
country =>
|
||||
country.id ===
|
||||
(additionalAttributes.country_code ||
|
||||
this.contact.country_code)
|
||||
)?.name ||
|
||||
this.$t('CONTACT_FORM.FORM.COUNTRY.SELECT_COUNTRY'),
|
||||
};
|
||||
if (!this.phoneNumber && this.country.id) {
|
||||
this.activeDialCode =
|
||||
this.countries.find(
|
||||
country => country.id === this.country.id
|
||||
)?.dial_code || '';
|
||||
}
|
||||
this.city = additionalAttributes.city || '';
|
||||
this.description = additionalAttributes.description || '';
|
||||
this.avatarUrl = this.contact.thumbnail || '';
|
||||
const {
|
||||
social_profiles: socialProfiles = {},
|
||||
screen_name: twitterScreenName,
|
||||
social_telegram_user_name: telegramUserName,
|
||||
} = additionalAttributes;
|
||||
this.socialProfileUserNames = {
|
||||
twitter: socialProfiles.twitter || twitterScreenName || '',
|
||||
facebook: socialProfiles.facebook || '',
|
||||
linkedin: socialProfiles.linkedin || '',
|
||||
github: socialProfiles.github || '',
|
||||
telegram: socialProfiles.telegram || telegramUserName || '',
|
||||
instagram: socialProfiles.instagram || '',
|
||||
tiktok: socialProfiles.tiktok || '',
|
||||
};
|
||||
},
|
||||
getContactObject() {
|
||||
if (this.country === null) {
|
||||
this.country = {
|
||||
id: '',
|
||||
name: '',
|
||||
};
|
||||
}
|
||||
const contactObject = {
|
||||
id: this.contact.id,
|
||||
name: this.name,
|
||||
email: this.email,
|
||||
phone_number: this.setPhoneNumber,
|
||||
additional_attributes: {
|
||||
...this.contact.additional_attributes,
|
||||
description: this.description,
|
||||
company_name: this.companyName,
|
||||
country_code: this.country.id,
|
||||
country:
|
||||
this.country.name ===
|
||||
this.$t('CONTACT_FORM.FORM.COUNTRY.SELECT_COUNTRY')
|
||||
? ''
|
||||
: this.country.name,
|
||||
city: this.city,
|
||||
social_profiles: this.socialProfileUserNames,
|
||||
},
|
||||
};
|
||||
if (this.avatarFile) {
|
||||
contactObject.avatar = this.avatarFile;
|
||||
contactObject.isFormData = true;
|
||||
}
|
||||
return contactObject;
|
||||
},
|
||||
setPhoneCode(code) {
|
||||
if (this.phoneNumber !== '' && this.parsePhoneNumber) {
|
||||
const dialCode = this.parsePhoneNumber.countryCallingCode;
|
||||
if (dialCode === code) {
|
||||
return;
|
||||
}
|
||||
this.activeDialCode = `+${dialCode}`;
|
||||
const newPhoneNumber = this.phoneNumber.replace(
|
||||
`+${dialCode}`,
|
||||
`${code}`
|
||||
);
|
||||
this.phoneNumber = newPhoneNumber;
|
||||
} else {
|
||||
this.activeDialCode = code;
|
||||
}
|
||||
},
|
||||
async handleSubmit() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid || this.isPhoneNumberNotValid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.onSubmit(this.getContactObject());
|
||||
this.onSuccess();
|
||||
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateContactException) {
|
||||
if (error.data.includes('email')) {
|
||||
useAlert(
|
||||
this.$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE')
|
||||
);
|
||||
} else if (error.data.includes('phone_number')) {
|
||||
useAlert(
|
||||
this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE')
|
||||
);
|
||||
}
|
||||
} else if (error instanceof ExceptionWithMessage) {
|
||||
useAlert(error.data);
|
||||
} else {
|
||||
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
|
||||
}
|
||||
}
|
||||
},
|
||||
handleImageUpload({ file, url }) {
|
||||
this.avatarFile = file;
|
||||
this.avatarUrl = url;
|
||||
},
|
||||
async handleAvatarDelete() {
|
||||
try {
|
||||
if (this.contact && this.contact.id) {
|
||||
await this.$store.dispatch(
|
||||
'contacts/deleteAvatar',
|
||||
this.contact.id
|
||||
);
|
||||
useAlert(
|
||||
this.$t(
|
||||
'CONTACT_FORM.DELETE_AVATAR.API.SUCCESS_MESSAGE'
|
||||
)
|
||||
);
|
||||
}
|
||||
this.avatarFile = null;
|
||||
this.avatarUrl = '';
|
||||
this.activeDialCode = '';
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message
|
||||
? error.message
|
||||
: this.$t(
|
||||
'CONTACT_FORM.DELETE_AVATAR.API.ERROR_MESSAGE'
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="w-full px-8 pt-6 pb-8 contact--form"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<div class="flex flex-col mb-4 items-start gap-1 w-full">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('CONTACT_FORM.FORM.AVATAR.LABEL') }}
|
||||
</label>
|
||||
<Avatar
|
||||
:src="avatarUrl"
|
||||
:size="72"
|
||||
:name="contact.name"
|
||||
allow-upload
|
||||
rounded-full
|
||||
@upload="handleImageUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w-full">
|
||||
<label :class="{ error: v$.name.$error }">
|
||||
{{ $t('CONTACT_FORM.FORM.NAME.LABEL') }}
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.NAME.PLACEHOLDER')"
|
||||
@input="v$.name.$touch"
|
||||
/>
|
||||
</label>
|
||||
<form
|
||||
class="w-full px-8 pt-6 pb-8 contact--form"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<div class="flex flex-col mb-4 items-start gap-1 w-full">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('CONTACT_FORM.FORM.AVATAR.LABEL') }}
|
||||
</label>
|
||||
<Avatar
|
||||
:src="avatarUrl"
|
||||
:size="72"
|
||||
:name="contact.name"
|
||||
allow-upload
|
||||
rounded-full
|
||||
@upload="handleImageUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w-full">
|
||||
<label :class="{ error: v$.name.$error }">
|
||||
{{ $t('CONTACT_FORM.FORM.NAME.LABEL') }}
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.NAME.PLACEHOLDER')"
|
||||
@input="v$.name.$touch"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label :class="{ error: v$.email.$error }">
|
||||
{{ $t('CONTACT_FORM.FORM.EMAIL_ADDRESS.LABEL') }}
|
||||
<input
|
||||
v-model="email"
|
||||
type="text"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.PLACEHOLDER')"
|
||||
@input="v$.email.$touch"
|
||||
/>
|
||||
<span v-if="v$.email.$error" class="message">
|
||||
{{ $t('CONTACT_FORM.FORM.EMAIL_ADDRESS.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<label :class="{ error: v$.description.$error }">
|
||||
{{ $t('CONTACT_FORM.FORM.BIO.LABEL') }}
|
||||
<textarea
|
||||
v-model="description"
|
||||
type="text"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.BIO.PLACEHOLDER')"
|
||||
@input="v$.description.$touch"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w-full">
|
||||
<label
|
||||
:class="{
|
||||
error: isPhoneNumberNotValid,
|
||||
}"
|
||||
>
|
||||
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.LABEL') }}
|
||||
<woot-phone-input
|
||||
v-model="phoneNumber"
|
||||
:value="phoneNumber"
|
||||
:error="isPhoneNumberNotValid"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.PHONE_NUMBER.PLACEHOLDER')"
|
||||
@blur="v$.phoneNumber.$touch"
|
||||
@set-code="setPhoneCode"
|
||||
/>
|
||||
<span v-if="isPhoneNumberNotValid" class="message">
|
||||
{{ phoneNumberError }}
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="isPhoneNumberNotValid || !phoneNumber"
|
||||
class="relative mx-0 mt-0 mb-2.5 p-2 rounded-md text-sm border border-solid border-n-amber-5 text-n-amber-12 bg-n-amber-3"
|
||||
>
|
||||
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.HELP') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<woot-input
|
||||
v-model="companyName"
|
||||
class="w-full"
|
||||
:label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="w-full mb-4">
|
||||
<label>
|
||||
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
:model-value="country.id"
|
||||
:options="
|
||||
countries.map(c => ({
|
||||
value: c.id,
|
||||
label: countryNameWithCode(c),
|
||||
}))
|
||||
"
|
||||
class="[&>div>button]:!bg-n-alpha-black2"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
|
||||
:search-placeholder="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')"
|
||||
@update:model-value="onCountryChange"
|
||||
/>
|
||||
</div>
|
||||
<woot-input
|
||||
v-model="city"
|
||||
class="w-full"
|
||||
:label="$t('CONTACT_FORM.FORM.CITY.LABEL')"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.CITY.PLACEHOLDER')"
|
||||
/>
|
||||
<label :class="{ error: v$.email.$error }">
|
||||
{{ $t('CONTACT_FORM.FORM.EMAIL_ADDRESS.LABEL') }}
|
||||
<input
|
||||
v-model="email"
|
||||
type="text"
|
||||
:placeholder="
|
||||
$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.PLACEHOLDER')
|
||||
"
|
||||
@input="v$.email.$touch"
|
||||
/>
|
||||
<span v-if="v$.email.$error" class="message">
|
||||
{{ $t('CONTACT_FORM.FORM.EMAIL_ADDRESS.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<label :class="{ error: v$.description.$error }">
|
||||
{{ $t('CONTACT_FORM.FORM.BIO.LABEL') }}
|
||||
<textarea
|
||||
v-model="description"
|
||||
type="text"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.BIO.PLACEHOLDER')"
|
||||
@input="v$.description.$touch"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w-full">
|
||||
<label
|
||||
:class="{
|
||||
error: isPhoneNumberNotValid,
|
||||
}"
|
||||
>
|
||||
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.LABEL') }}
|
||||
<woot-phone-input
|
||||
v-model="phoneNumber"
|
||||
:value="phoneNumber"
|
||||
:error="isPhoneNumberNotValid"
|
||||
:placeholder="
|
||||
$t('CONTACT_FORM.FORM.PHONE_NUMBER.PLACEHOLDER')
|
||||
"
|
||||
@blur="v$.phoneNumber.$touch"
|
||||
@set-code="setPhoneCode"
|
||||
/>
|
||||
<span v-if="isPhoneNumberNotValid" class="message">
|
||||
{{ phoneNumberError }}
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="isPhoneNumberNotValid || !phoneNumber"
|
||||
class="relative mx-0 mt-0 mb-2.5 p-2 rounded-md text-sm border border-solid border-n-amber-5 text-n-amber-12 bg-n-amber-3"
|
||||
>
|
||||
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.HELP') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<woot-input
|
||||
v-model="companyName"
|
||||
class="w-full"
|
||||
:label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="w-full mb-4">
|
||||
<label>
|
||||
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
:model-value="country.id"
|
||||
:options="
|
||||
countries.map(c => ({
|
||||
value: c.id,
|
||||
label: countryNameWithCode(c),
|
||||
}))
|
||||
"
|
||||
class="[&>div>button]:!bg-n-alpha-black2"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
|
||||
:search-placeholder="
|
||||
$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')
|
||||
"
|
||||
@update:model-value="onCountryChange"
|
||||
/>
|
||||
</div>
|
||||
<woot-input
|
||||
v-model="city"
|
||||
class="w-full"
|
||||
:label="$t('CONTACT_FORM.FORM.CITY.LABEL')"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.CITY.PLACEHOLDER')"
|
||||
/>
|
||||
|
||||
<div class="w-full">
|
||||
<label>{{ $t('CONTACTS_PAGE.LIST.TABLE_HEADER.SOCIAL_PROFILES') }}</label>
|
||||
<div
|
||||
v-for="socialProfile in socialProfileKeys"
|
||||
:key="socialProfile.key"
|
||||
class="flex items-stretch w-full mb-4"
|
||||
>
|
||||
<span
|
||||
class="flex items-center h-10 px-2 text-sm border-solid border-y ltr:border-l rtl:border-r ltr:rounded-l-md rtl:rounded-r-md bg-n-solid-3 text-n-slate-11 border-n-weak"
|
||||
>
|
||||
{{ socialProfile.prefixURL }}
|
||||
</span>
|
||||
<input
|
||||
v-model="socialProfileUserNames[socialProfile.key]"
|
||||
class="input-group-field ltr:!rounded-l-none rtl:!rounded-r-none !mb-0"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('CONTACT_FORM.FORM.SUBMIT')"
|
||||
:is-loading="inProgress"
|
||||
/>
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('CONTACT_FORM.FORM.CANCEL')"
|
||||
@click.prevent="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div class="w-full">
|
||||
<label>{{
|
||||
$t('CONTACTS_PAGE.LIST.TABLE_HEADER.SOCIAL_PROFILES')
|
||||
}}</label>
|
||||
<div
|
||||
v-for="socialProfile in socialProfileKeys"
|
||||
:key="socialProfile.key"
|
||||
class="flex items-stretch w-full mb-4"
|
||||
>
|
||||
<span
|
||||
class="flex items-center h-10 px-2 text-sm border-solid border-y ltr:border-l rtl:border-r ltr:rounded-l-md rtl:rounded-r-md bg-n-solid-3 text-n-slate-11 border-n-weak"
|
||||
>
|
||||
{{ socialProfile.prefixURL }}
|
||||
</span>
|
||||
<input
|
||||
v-model="socialProfileUserNames[socialProfile.key]"
|
||||
class="input-group-field ltr:!rounded-l-none rtl:!rounded-r-none !mb-0"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('CONTACT_FORM.FORM.SUBMIT')"
|
||||
:is-loading="inProgress"
|
||||
/>
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('CONTACT_FORM.FORM.CANCEL')"
|
||||
@click.prevent="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
+391
-329
@@ -2,8 +2,8 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import {
|
||||
DuplicateContactException,
|
||||
ExceptionWithMessage,
|
||||
DuplicateContactException,
|
||||
ExceptionWithMessage,
|
||||
} from 'shared/helpers/CustomErrors';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
@@ -19,341 +19,403 @@ import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
ContactInfoRow,
|
||||
EditContact,
|
||||
Avatar,
|
||||
ComposeConversation,
|
||||
SocialIcons,
|
||||
ContactMergeModal,
|
||||
ContactDeleteModal,
|
||||
VoiceCallButton,
|
||||
InlineInput,
|
||||
},
|
||||
props: {
|
||||
contact: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
showAvatar: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['panelClose'],
|
||||
setup() {
|
||||
const { isAdmin } = useAdmin();
|
||||
return {
|
||||
isAdmin,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showEditModal: false,
|
||||
isEditingName: false,
|
||||
editName: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
|
||||
contactProfileLink() {
|
||||
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
|
||||
},
|
||||
additionalAttributes() {
|
||||
return this.contact.additional_attributes || {};
|
||||
},
|
||||
location() {
|
||||
const {
|
||||
country = '',
|
||||
city = '',
|
||||
country_code: countryCode,
|
||||
} = this.additionalAttributes;
|
||||
const cityAndCountry = [city, country].filter(item => !!item).join(', ');
|
||||
components: {
|
||||
NextButton,
|
||||
ContactInfoRow,
|
||||
EditContact,
|
||||
Avatar,
|
||||
ComposeConversation,
|
||||
SocialIcons,
|
||||
ContactMergeModal,
|
||||
ContactDeleteModal,
|
||||
VoiceCallButton,
|
||||
InlineInput,
|
||||
},
|
||||
props: {
|
||||
contact: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
showAvatar: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['panelClose'],
|
||||
setup() {
|
||||
const { isAdmin } = useAdmin();
|
||||
return {
|
||||
isAdmin,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showEditModal: false,
|
||||
isEditingName: false,
|
||||
editName: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
|
||||
contactProfileLink() {
|
||||
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
|
||||
},
|
||||
additionalAttributes() {
|
||||
return this.contact.additional_attributes || {};
|
||||
},
|
||||
quickPhonePrefix() {
|
||||
const countryCode = (
|
||||
this.additionalAttributes.country_code ||
|
||||
this.contact.country_code ||
|
||||
''
|
||||
).toUpperCase();
|
||||
const country = this.additionalAttributes.country || '';
|
||||
return countryCode === 'CN' || country === 'China' ? '+86' : '';
|
||||
},
|
||||
location() {
|
||||
const {
|
||||
country = '',
|
||||
city = '',
|
||||
country_code: countryCode,
|
||||
} = this.additionalAttributes;
|
||||
const cityAndCountry = [city, country]
|
||||
.filter(item => !!item)
|
||||
.join(', ');
|
||||
|
||||
if (!cityAndCountry) {
|
||||
return '';
|
||||
}
|
||||
return this.findCountryFlag(countryCode, cityAndCountry);
|
||||
},
|
||||
socialProfiles() {
|
||||
const {
|
||||
social_profiles: socialProfiles,
|
||||
screen_name: twitterScreenName,
|
||||
social_telegram_user_name: telegramUsername,
|
||||
} = this.additionalAttributes;
|
||||
if (!cityAndCountry) {
|
||||
return '';
|
||||
}
|
||||
return this.findCountryFlag(countryCode, cityAndCountry);
|
||||
},
|
||||
socialProfiles() {
|
||||
const {
|
||||
social_profiles: socialProfiles,
|
||||
screen_name: twitterScreenName,
|
||||
social_telegram_user_name: telegramUsername,
|
||||
} = this.additionalAttributes;
|
||||
|
||||
const telegram = socialProfiles?.telegram || telegramUsername || '';
|
||||
const twitter = socialProfiles?.twitter || twitterScreenName || '';
|
||||
const telegram = socialProfiles?.telegram || telegramUsername || '';
|
||||
const twitter = socialProfiles?.twitter || twitterScreenName || '';
|
||||
|
||||
return {
|
||||
...(socialProfiles || {}),
|
||||
twitter,
|
||||
telegram,
|
||||
};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'contact.id': {
|
||||
handler(id) {
|
||||
this.$store.dispatch('contacts/fetchContactableInbox', id);
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
dynamicTime,
|
||||
toggleEditModal() {
|
||||
this.showEditModal = !this.showEditModal;
|
||||
},
|
||||
findCountryFlag(countryCode, cityAndCountry) {
|
||||
try {
|
||||
if (!countryCode) {
|
||||
return `${cityAndCountry} 🌎`;
|
||||
}
|
||||
return {
|
||||
...(socialProfiles || {}),
|
||||
twitter,
|
||||
telegram,
|
||||
};
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'contact.id': {
|
||||
handler(id) {
|
||||
this.$store.dispatch('contacts/fetchContactableInbox', id);
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
dynamicTime,
|
||||
toggleEditModal() {
|
||||
this.showEditModal = !this.showEditModal;
|
||||
},
|
||||
findCountryFlag(countryCode, cityAndCountry) {
|
||||
try {
|
||||
if (!countryCode) {
|
||||
return `${cityAndCountry} 🌎`;
|
||||
}
|
||||
|
||||
const code = countryCode?.toLowerCase();
|
||||
return `${cityAndCountry} <span class="fi fi-${code} size-3.5"></span>`;
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
startEditingName() {
|
||||
this.editName = this.contact.name || '';
|
||||
this.isEditingName = true;
|
||||
this.$nextTick(() => {
|
||||
this.$refs.nameInput?.focus();
|
||||
});
|
||||
},
|
||||
saveNameEdit() {
|
||||
if (!this.isEditingName) return;
|
||||
this.isEditingName = false;
|
||||
const trimmed = this.editName.trim();
|
||||
if (trimmed && trimmed !== this.contact.name) {
|
||||
this.updateContactField({ name: trimmed });
|
||||
}
|
||||
},
|
||||
cancelNameEdit() {
|
||||
this.isEditingName = false;
|
||||
},
|
||||
onFieldUpdate(field, value) {
|
||||
this.updateContactField({ [field]: value });
|
||||
},
|
||||
async updateContactField(attrs) {
|
||||
const contactId = this.contact.id;
|
||||
try {
|
||||
await this.$store.dispatch('contacts/update', {
|
||||
id: contactId,
|
||||
...attrs,
|
||||
});
|
||||
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
|
||||
await this.$store.dispatch('contacts/fetchContactableInbox', contactId);
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateContactException) {
|
||||
const detail = error.contactErrorDetail;
|
||||
if (detail) {
|
||||
useAlert(detail);
|
||||
} else {
|
||||
const invalidAttrs = Array.isArray(error.data) ? error.data : [];
|
||||
if (invalidAttrs.includes('email')) {
|
||||
useAlert(this.$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE'));
|
||||
} else if (invalidAttrs.includes('phone_number')) {
|
||||
useAlert(this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
|
||||
} else {
|
||||
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
|
||||
}
|
||||
}
|
||||
} else if (error instanceof ExceptionWithMessage) {
|
||||
useAlert(error.data);
|
||||
} else {
|
||||
useAlert(error.message || this.$t('CONTACT_FORM.ERROR_MESSAGE'));
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
const code = countryCode?.toLowerCase();
|
||||
return `${cityAndCountry} <span class="fi fi-${code} size-3.5"></span>`;
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
startEditingName() {
|
||||
this.editName = this.contact.name || '';
|
||||
this.isEditingName = true;
|
||||
this.$nextTick(() => {
|
||||
this.$refs.nameInput?.focus();
|
||||
});
|
||||
},
|
||||
saveNameEdit() {
|
||||
if (!this.isEditingName) return;
|
||||
this.isEditingName = false;
|
||||
const trimmed = this.editName.trim();
|
||||
if (trimmed && trimmed !== this.contact.name) {
|
||||
this.updateContactField({ name: trimmed });
|
||||
}
|
||||
},
|
||||
cancelNameEdit() {
|
||||
this.isEditingName = false;
|
||||
},
|
||||
onFieldUpdate(field, value) {
|
||||
const normalizedValue =
|
||||
field === 'phone_number'
|
||||
? this.normalizeQuickPhone(value)
|
||||
: value;
|
||||
this.updateContactField({ [field]: normalizedValue });
|
||||
},
|
||||
normalizeQuickPhone(value) {
|
||||
const phone = value.trim().replace(/[\s()-]/g, '');
|
||||
if (!phone || phone.startsWith('+')) return phone;
|
||||
|
||||
const countryCode = (
|
||||
this.additionalAttributes.country_code ||
|
||||
this.contact.country_code ||
|
||||
''
|
||||
).toUpperCase();
|
||||
const country = this.additionalAttributes.country || '';
|
||||
if (countryCode === 'CN' || country === 'China') {
|
||||
return `+86${phone.replace(/^0+/, '')}`;
|
||||
}
|
||||
return phone;
|
||||
},
|
||||
async updateContactField(attrs) {
|
||||
const contactId = this.contact.id;
|
||||
try {
|
||||
await this.$store.dispatch('contacts/update', {
|
||||
id: contactId,
|
||||
...attrs,
|
||||
});
|
||||
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
|
||||
await this.$store.dispatch(
|
||||
'contacts/fetchContactableInbox',
|
||||
contactId
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateContactException) {
|
||||
const detail = error.contactErrorDetail;
|
||||
if (detail) {
|
||||
useAlert(detail);
|
||||
} else {
|
||||
const invalidAttrs = Array.isArray(error.data)
|
||||
? error.data
|
||||
: [];
|
||||
if (invalidAttrs.includes('email')) {
|
||||
useAlert(
|
||||
this.$t(
|
||||
'CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE'
|
||||
)
|
||||
);
|
||||
} else if (invalidAttrs.includes('phone_number')) {
|
||||
useAlert(
|
||||
this.$t(
|
||||
'CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
|
||||
}
|
||||
}
|
||||
} else if (error instanceof ExceptionWithMessage) {
|
||||
useAlert(error.data);
|
||||
} else {
|
||||
useAlert(
|
||||
error.message || this.$t('CONTACT_FORM.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative items-center w-full p-4">
|
||||
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
|
||||
<div class="flex flex-row justify-between">
|
||||
<Avatar
|
||||
v-if="showAvatar"
|
||||
:src="contact.thumbnail"
|
||||
:name="contact.name"
|
||||
:status="contact.availability_status"
|
||||
:size="48"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="relative items-center w-full p-4">
|
||||
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
|
||||
<div class="flex flex-row justify-between">
|
||||
<Avatar
|
||||
v-if="showAvatar"
|
||||
:src="contact.thumbnail"
|
||||
:name="contact.name"
|
||||
:status="contact.availability_status"
|
||||
:size="48"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-1.5 min-w-0 w-full">
|
||||
<div v-if="showAvatar" class="flex items-center w-full min-w-0 gap-3">
|
||||
<InlineInput
|
||||
v-if="isEditingName"
|
||||
ref="nameInput"
|
||||
v-model="editName"
|
||||
custom-input-class="!text-base !font-medium"
|
||||
class="!w-fit"
|
||||
@enter-press="saveNameEdit"
|
||||
@escape-press="cancelNameEdit"
|
||||
@blur="saveNameEdit"
|
||||
/>
|
||||
<h3
|
||||
v-else
|
||||
class="group/name flex-shrink max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12 cursor-pointer hover:text-n-slate-12/80"
|
||||
:title="$t('CONTACT_PANEL.CLICK_TO_EDIT')"
|
||||
@click="startEditingName"
|
||||
>
|
||||
{{ contact.name }}
|
||||
<span
|
||||
class="i-lucide-pencil text-xs text-n-slate-10 opacity-0 group-hover/name:opacity-100 transition-opacity ml-1 align-middle"
|
||||
/>
|
||||
</h3>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span
|
||||
v-if="contact.created_at"
|
||||
v-tooltip.left="
|
||||
`${$t('CONTACT_PANEL.CREATED_AT_LABEL')} ${dynamicTime(
|
||||
contact.created_at
|
||||
)}`
|
||||
"
|
||||
class="i-lucide-info text-sm text-n-slate-10"
|
||||
/>
|
||||
<a
|
||||
:href="contactProfileLink"
|
||||
target="_blank"
|
||||
rel="noopener nofollow noreferrer"
|
||||
class="leading-3"
|
||||
>
|
||||
<span class="i-lucide-external-link text-sm text-n-slate-10" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-1.5 min-w-0 w-full">
|
||||
<div
|
||||
v-if="showAvatar"
|
||||
class="flex items-center w-full min-w-0 gap-3"
|
||||
>
|
||||
<InlineInput
|
||||
v-if="isEditingName"
|
||||
ref="nameInput"
|
||||
v-model="editName"
|
||||
custom-input-class="!text-base !font-medium"
|
||||
class="!w-fit"
|
||||
@enter-press="saveNameEdit"
|
||||
@escape-press="cancelNameEdit"
|
||||
@blur="saveNameEdit"
|
||||
/>
|
||||
<h3
|
||||
v-else
|
||||
class="group/name flex-shrink max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12 cursor-pointer hover:text-n-slate-12/80"
|
||||
:title="$t('CONTACT_PANEL.CLICK_TO_EDIT')"
|
||||
@click="startEditingName"
|
||||
>
|
||||
{{ contact.name }}
|
||||
<span
|
||||
class="i-lucide-pencil text-xs text-n-slate-10 opacity-0 group-hover/name:opacity-100 transition-opacity ml-1 align-middle"
|
||||
/>
|
||||
</h3>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span
|
||||
v-if="contact.created_at"
|
||||
v-tooltip.left="
|
||||
`${$t('CONTACT_PANEL.CREATED_AT_LABEL')} ${dynamicTime(
|
||||
contact.created_at
|
||||
)}`
|
||||
"
|
||||
class="i-lucide-info text-sm text-n-slate-10"
|
||||
/>
|
||||
<a
|
||||
:href="contactProfileLink"
|
||||
target="_blank"
|
||||
rel="noopener nofollow noreferrer"
|
||||
class="leading-3"
|
||||
>
|
||||
<span
|
||||
class="i-lucide-external-link text-sm text-n-slate-10"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="additionalAttributes.description" class="break-words mb-0.5">
|
||||
{{ additionalAttributes.description }}
|
||||
</p>
|
||||
<div class="flex flex-col items-start w-full gap-2">
|
||||
<ContactInfoRow
|
||||
:href="contact.email ? `mailto:${contact.email}` : ''"
|
||||
:value="contact.email"
|
||||
icon="mail"
|
||||
emoji="✉️"
|
||||
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
|
||||
show-copy
|
||||
editable
|
||||
@update="value => onFieldUpdate('email', value)"
|
||||
/>
|
||||
<ContactInfoRow
|
||||
:href="contact.phone_number ? `tel:${contact.phone_number}` : ''"
|
||||
:value="contact.phone_number"
|
||||
icon="call"
|
||||
emoji="📞"
|
||||
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
|
||||
show-copy
|
||||
editable
|
||||
@update="value => onFieldUpdate('phone_number', value)"
|
||||
/>
|
||||
<ContactInfoRow
|
||||
v-if="contact.identifier"
|
||||
:value="contact.identifier"
|
||||
icon="contact-identify"
|
||||
emoji="🪪"
|
||||
:title="$t('CONTACT_PANEL.IDENTIFIER')"
|
||||
/>
|
||||
<ContactInfoRow
|
||||
:value="additionalAttributes.company_name"
|
||||
icon="building-bank"
|
||||
emoji="🏢"
|
||||
:title="$t('CONTACT_PANEL.COMPANY')"
|
||||
editable
|
||||
@update="
|
||||
value =>
|
||||
updateContactField({
|
||||
additional_attributes: {
|
||||
...additionalAttributes,
|
||||
company_name: value,
|
||||
},
|
||||
})
|
||||
"
|
||||
/>
|
||||
<ContactInfoRow
|
||||
v-if="location || additionalAttributes.location"
|
||||
:value="location || additionalAttributes.location"
|
||||
icon="map"
|
||||
emoji="🌍"
|
||||
:title="$t('CONTACT_PANEL.LOCATION')"
|
||||
/>
|
||||
<SocialIcons :social-profiles="socialProfiles" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center w-full mt-0.5 gap-2">
|
||||
<ComposeConversation :contact-id="String(contact.id)">
|
||||
<template #trigger>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
|
||||
icon="i-ph-chat-circle-dots"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
<VoiceCallButton
|
||||
:phone="contact.phone_number"
|
||||
:contact-id="contact.id"
|
||||
icon="i-ri-phone-fill"
|
||||
size="sm"
|
||||
:tooltip-label="$t('CONTACT_PANEL.CALL')"
|
||||
slate
|
||||
faded
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
|
||||
icon="i-ph-pencil-simple"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="toggleEditModal"
|
||||
/>
|
||||
<ContactMergeModal :primary-contact="contact">
|
||||
<template #trigger>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('CONTACT_PANEL.MERGE_CONTACT')"
|
||||
icon="i-ph-arrows-merge"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
:disabled="uiFlags.isMerging"
|
||||
/>
|
||||
</template>
|
||||
</ContactMergeModal>
|
||||
<ContactDeleteModal
|
||||
v-if="isAdmin"
|
||||
:contact="contact"
|
||||
@deleted="$emit('panelClose')"
|
||||
>
|
||||
<template #trigger>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('DELETE_CONTACT.BUTTON_LABEL')"
|
||||
icon="i-ph-trash"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
ruby
|
||||
:disabled="uiFlags.isDeleting"
|
||||
/>
|
||||
</template>
|
||||
</ContactDeleteModal>
|
||||
</div>
|
||||
<EditContact
|
||||
:show="showEditModal"
|
||||
:contact="contact"
|
||||
@cancel="toggleEditModal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="additionalAttributes.description"
|
||||
class="break-words mb-0.5"
|
||||
>
|
||||
{{ additionalAttributes.description }}
|
||||
</p>
|
||||
<div class="flex flex-col items-start w-full gap-2">
|
||||
<ContactInfoRow
|
||||
:href="contact.email ? `mailto:${contact.email}` : ''"
|
||||
:value="contact.email"
|
||||
icon="mail"
|
||||
emoji="✉️"
|
||||
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
|
||||
show-copy
|
||||
editable
|
||||
@update="value => onFieldUpdate('email', value)"
|
||||
/>
|
||||
<ContactInfoRow
|
||||
:href="
|
||||
contact.phone_number
|
||||
? `tel:${contact.phone_number}`
|
||||
: ''
|
||||
"
|
||||
:value="contact.phone_number"
|
||||
:edit-prefix="quickPhonePrefix"
|
||||
icon="call"
|
||||
emoji="📞"
|
||||
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
|
||||
show-copy
|
||||
editable
|
||||
@update="value => onFieldUpdate('phone_number', value)"
|
||||
/>
|
||||
<ContactInfoRow
|
||||
v-if="contact.identifier"
|
||||
:value="contact.identifier"
|
||||
icon="contact-identify"
|
||||
emoji="🪪"
|
||||
:title="$t('CONTACT_PANEL.IDENTIFIER')"
|
||||
/>
|
||||
<ContactInfoRow
|
||||
:value="additionalAttributes.company_name"
|
||||
icon="building-bank"
|
||||
emoji="🏢"
|
||||
:title="$t('CONTACT_PANEL.COMPANY')"
|
||||
editable
|
||||
@update="
|
||||
value =>
|
||||
updateContactField({
|
||||
additional_attributes: {
|
||||
...additionalAttributes,
|
||||
company_name: value,
|
||||
},
|
||||
})
|
||||
"
|
||||
/>
|
||||
<ContactInfoRow
|
||||
v-if="location || additionalAttributes.location"
|
||||
:value="location || additionalAttributes.location"
|
||||
icon="map"
|
||||
emoji="🌍"
|
||||
:title="$t('CONTACT_PANEL.LOCATION')"
|
||||
/>
|
||||
<SocialIcons :social-profiles="socialProfiles" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center w-full mt-0.5 gap-2">
|
||||
<ComposeConversation :contact-id="String(contact.id)">
|
||||
<template #trigger>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
|
||||
icon="i-ph-chat-circle-dots"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
<VoiceCallButton
|
||||
:phone="contact.phone_number"
|
||||
:contact-id="contact.id"
|
||||
icon="i-ri-phone-fill"
|
||||
size="sm"
|
||||
:tooltip-label="$t('CONTACT_PANEL.CALL')"
|
||||
slate
|
||||
faded
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
|
||||
icon="i-ph-pencil-simple"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="toggleEditModal"
|
||||
/>
|
||||
<ContactMergeModal :primary-contact="contact">
|
||||
<template #trigger>
|
||||
<NextButton
|
||||
v-tooltip.top-end="
|
||||
$t('CONTACT_PANEL.MERGE_CONTACT')
|
||||
"
|
||||
icon="i-ph-arrows-merge"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
:disabled="uiFlags.isMerging"
|
||||
/>
|
||||
</template>
|
||||
</ContactMergeModal>
|
||||
<ContactDeleteModal
|
||||
v-if="isAdmin"
|
||||
:contact="contact"
|
||||
@deleted="$emit('panelClose')"
|
||||
>
|
||||
<template #trigger>
|
||||
<NextButton
|
||||
v-tooltip.top-end="
|
||||
$t('DELETE_CONTACT.BUTTON_LABEL')
|
||||
"
|
||||
icon="i-ph-trash"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
ruby
|
||||
:disabled="uiFlags.isDeleting"
|
||||
/>
|
||||
</template>
|
||||
</ContactDeleteModal>
|
||||
</div>
|
||||
<EditContact
|
||||
:show="showEditModal"
|
||||
:contact="contact"
|
||||
@cancel="toggleEditModal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+159
-155
@@ -6,165 +6,169 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EmojiOrIcon,
|
||||
NextButton,
|
||||
InlineInput,
|
||||
},
|
||||
props: {
|
||||
href: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
emoji: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showCopy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['update'],
|
||||
data() {
|
||||
return {
|
||||
isEditing: false,
|
||||
editValue: '',
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async onCopy(e) {
|
||||
e.preventDefault();
|
||||
await copyTextToClipboard(this.value);
|
||||
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
|
||||
},
|
||||
startEditing() {
|
||||
if (!this.editable) return;
|
||||
this.editValue = this.value || '';
|
||||
this.isEditing = true;
|
||||
this.$nextTick(() => {
|
||||
this.$refs.editInput?.focus();
|
||||
});
|
||||
},
|
||||
saveEdit() {
|
||||
if (!this.isEditing) return;
|
||||
this.isEditing = false;
|
||||
const trimmed = this.editValue.trim();
|
||||
if (trimmed !== (this.value || '')) {
|
||||
this.$emit('update', trimmed);
|
||||
}
|
||||
},
|
||||
cancelEdit() {
|
||||
this.isEditing = false;
|
||||
},
|
||||
},
|
||||
components: {
|
||||
EmojiOrIcon,
|
||||
NextButton,
|
||||
InlineInput,
|
||||
},
|
||||
props: {
|
||||
href: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
emoji: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showCopy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
editPrefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['update'],
|
||||
data() {
|
||||
return {
|
||||
isEditing: false,
|
||||
editValue: '',
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async onCopy(e) {
|
||||
e.preventDefault();
|
||||
await copyTextToClipboard(this.value);
|
||||
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
|
||||
},
|
||||
startEditing() {
|
||||
if (!this.editable) return;
|
||||
this.editValue = this.value || this.editPrefix;
|
||||
this.isEditing = true;
|
||||
this.$nextTick(() => {
|
||||
this.$refs.editInput?.focus();
|
||||
});
|
||||
},
|
||||
saveEdit() {
|
||||
if (!this.isEditing) return;
|
||||
this.isEditing = false;
|
||||
const trimmed = this.editValue.trim();
|
||||
if (trimmed !== (this.value || '')) {
|
||||
this.$emit('update', trimmed);
|
||||
}
|
||||
},
|
||||
cancelEdit() {
|
||||
this.isEditing = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group/row w-full h-5 ltr:-ml-1 rtl:-mr-1">
|
||||
<!-- Inline edit mode -->
|
||||
<div v-if="isEditing" class="flex items-center gap-2">
|
||||
<EmojiOrIcon
|
||||
:icon="icon"
|
||||
:emoji="emoji"
|
||||
icon-size="14"
|
||||
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
|
||||
/>
|
||||
<InlineInput
|
||||
ref="editInput"
|
||||
v-model="editValue"
|
||||
:placeholder="title"
|
||||
class="!w-fit"
|
||||
@enter-press="saveEdit"
|
||||
@escape-press="cancelEdit"
|
||||
@blur="saveEdit"
|
||||
/>
|
||||
</div>
|
||||
<div class="group/row w-full h-5 ltr:-ml-1 rtl:-mr-1">
|
||||
<!-- Inline edit mode -->
|
||||
<div v-if="isEditing" class="flex items-center gap-2">
|
||||
<EmojiOrIcon
|
||||
:icon="icon"
|
||||
:emoji="emoji"
|
||||
icon-size="14"
|
||||
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
|
||||
/>
|
||||
<InlineInput
|
||||
ref="editInput"
|
||||
v-model="editValue"
|
||||
:placeholder="title"
|
||||
class="!w-fit"
|
||||
@enter-press="saveEdit"
|
||||
@escape-press="cancelEdit"
|
||||
@blur="saveEdit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Read mode with link -->
|
||||
<a
|
||||
v-else-if="href"
|
||||
:href="href"
|
||||
class="flex items-center gap-2 text-n-slate-11 hover:underline"
|
||||
>
|
||||
<EmojiOrIcon
|
||||
:icon="icon"
|
||||
:emoji="emoji"
|
||||
icon-size="14"
|
||||
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
|
||||
/>
|
||||
<span
|
||||
v-if="value"
|
||||
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
|
||||
:title="value"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-n-slate-11">
|
||||
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
|
||||
</span>
|
||||
<NextButton
|
||||
v-if="showCopy"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="ltr:-ml-1 rtl:-mr-1"
|
||||
icon="i-lucide-clipboard"
|
||||
@click="onCopy"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="editable"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
|
||||
icon="i-lucide-pencil"
|
||||
@click.prevent="startEditing"
|
||||
/>
|
||||
</a>
|
||||
<!-- Read mode with link -->
|
||||
<a
|
||||
v-else-if="href"
|
||||
:href="href"
|
||||
class="flex items-center gap-2 text-n-slate-11 hover:underline"
|
||||
>
|
||||
<EmojiOrIcon
|
||||
:icon="icon"
|
||||
:emoji="emoji"
|
||||
icon-size="14"
|
||||
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
|
||||
/>
|
||||
<span
|
||||
v-if="value"
|
||||
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
|
||||
:title="value"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-n-slate-11">
|
||||
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
|
||||
</span>
|
||||
<NextButton
|
||||
v-if="showCopy"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="ltr:-ml-1 rtl:-mr-1"
|
||||
icon="i-lucide-clipboard"
|
||||
@click="onCopy"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="editable"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
|
||||
icon="i-lucide-pencil"
|
||||
@click.prevent="startEditing"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<!-- Read mode without link -->
|
||||
<div v-else class="flex items-center gap-2 text-n-slate-11">
|
||||
<EmojiOrIcon
|
||||
:icon="icon"
|
||||
:emoji="emoji"
|
||||
icon-size="14"
|
||||
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
|
||||
/>
|
||||
<span
|
||||
v-if="value"
|
||||
v-dompurify-html="value"
|
||||
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
|
||||
/>
|
||||
<span v-else class="text-sm text-n-slate-11">
|
||||
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
|
||||
</span>
|
||||
<NextButton
|
||||
v-if="editable"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
|
||||
icon="i-lucide-pencil"
|
||||
@click="startEditing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Read mode without link -->
|
||||
<div v-else class="flex items-center gap-2 text-n-slate-11">
|
||||
<EmojiOrIcon
|
||||
:icon="icon"
|
||||
:emoji="emoji"
|
||||
icon-size="14"
|
||||
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
|
||||
/>
|
||||
<span
|
||||
v-if="value"
|
||||
v-dompurify-html="value"
|
||||
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
|
||||
/>
|
||||
<span v-else class="text-sm text-n-slate-11">
|
||||
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
|
||||
</span>
|
||||
<NextButton
|
||||
v-if="editable"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
|
||||
icon="i-lucide-pencil"
|
||||
@click="startEditing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user