Merge pull request #695 from frappe/develop

chore: Merge develop to main
This commit is contained in:
Shariq Ansari 2025-03-25 14:09:47 +05:30 committed by GitHub
commit f020368c9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 621 additions and 483 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

@ -1 +1 @@
Subproject commit b6efd25b2122c2c1f3beeea1702788c5e6e5555f Subproject commit 3423aa5b5c38d3a1b143ae8ab08cbde7360f9a7c

View File

@ -150,8 +150,7 @@ declare module 'vue' {
MobileSidebar: typeof import('./src/components/Mobile/MobileSidebar.vue')['default'] MobileSidebar: typeof import('./src/components/Mobile/MobileSidebar.vue')['default']
MoneyIcon: typeof import('./src/components/Icons/MoneyIcon.vue')['default'] MoneyIcon: typeof import('./src/components/Icons/MoneyIcon.vue')['default']
MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default'] MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default']
MultiselectInput: typeof import('./src/components/Controls/MultiselectInput.vue')['default'] MultiSelectEmailInput: typeof import('./src/components/Controls/MultiSelectEmailInput.vue')['default']
MultiValueInput: typeof import('./src/components/Controls/MultiValueInput.vue')['default']
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default'] MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
NestedPopover: typeof import('./src/components/NestedPopover.vue')['default'] NestedPopover: typeof import('./src/components/NestedPopover.vue')['default']
NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default'] NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default']
@ -193,7 +192,6 @@ declare module 'vue' {
SidePanelLayout: typeof import('./src/components/SidePanelLayout.vue')['default'] SidePanelLayout: typeof import('./src/components/SidePanelLayout.vue')['default']
SidePanelLayoutEditor: typeof import('./src/components/SidePanelLayoutEditor.vue')['default'] SidePanelLayoutEditor: typeof import('./src/components/SidePanelLayoutEditor.vue')['default']
SidePanelModal: typeof import('./src/components/Modals/SidePanelModal.vue')['default'] SidePanelModal: typeof import('./src/components/Modals/SidePanelModal.vue')['default']
SignupBanner: typeof import('./src/components/SignupBanner.vue')['default']
SLASection: typeof import('./src/components/SLASection.vue')['default'] SLASection: typeof import('./src/components/SLASection.vue')['default']
SmileIcon: typeof import('./src/components/Icons/SmileIcon.vue')['default'] SmileIcon: typeof import('./src/components/Icons/SmileIcon.vue')['default']
SortBy: typeof import('./src/components/SortBy.vue')['default'] SortBy: typeof import('./src/components/SortBy.vue')['default']

View File

@ -9,19 +9,15 @@
"serve": "vite preview" "serve": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tiptap/extension-paragraph": "^2.4.0",
"@twilio/voice-sdk": "^2.10.2", "@twilio/voice-sdk": "^2.10.2",
"@vueuse/core": "^10.3.0",
"@vueuse/integrations": "^10.3.0", "@vueuse/integrations": "^10.3.0",
"feather-icons": "^4.28.0", "frappe-ui": "^0.1.121",
"frappe-ui": "^0.1.118",
"gemoji": "^8.1.0", "gemoji": "^8.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mime": "^4.0.1", "mime": "^4.0.1",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",
"sortablejs": "^1.15.0", "sortablejs": "^1.15.0",
"tailwindcss": "^3.3.3",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.2.2", "vue-router": "^4.2.2",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
@ -31,6 +27,7 @@
"@vitejs/plugin-vue-jsx": "^3.0.1", "@vitejs/plugin-vue-jsx": "^3.0.1",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"postcss": "^8.4.5", "postcss": "^8.4.5",
"tailwindcss": "^3.4.15",
"vite": "^4.4.9", "vite": "^4.4.9",
"vite-plugin-pwa": "^0.15.0" "vite-plugin-pwa": "^0.15.0"
} }

View File

@ -43,25 +43,29 @@
attachment.is_private ? __('Make public') : __('Make private') attachment.is_private ? __('Make public') : __('Make private')
" "
> >
<Button <div>
class="!size-5" <Button
@click.stop=" class="!size-5"
togglePrivate(attachment.name, attachment.is_private) @click.stop="
" togglePrivate(attachment.name, attachment.is_private)
> "
<FeatherIcon >
:name="attachment.is_private ? 'lock' : 'unlock'" <FeatherIcon
class="size-3 text-ink-gray-7" :name="attachment.is_private ? 'lock' : 'unlock'"
/> class="size-3 text-ink-gray-7"
</Button> />
</Button>
</div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Delete attachment')"> <Tooltip :text="__('Delete attachment')">
<Button <div>
class="!size-5" <Button
@click.stop="() => deleteAttachment(attachment.name)" class="!size-5"
> @click.stop="() => deleteAttachment(attachment.name)"
<FeatherIcon name="trash-2" class="size-3 text-ink-gray-7" /> >
</Button> <FeatherIcon name="trash-2" class="size-3 text-ink-gray-7" />
</Button>
</div>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>

View File

@ -1,9 +1,9 @@
<template> <template>
<div <div
class="activity group flex h-48 cursor-pointer flex-col justify-between gap-2 rounded-md bg-surface-menu-bar px-4 py-3 hover:bg-surface-gray-2" class="activity group flex h-48 cursor-pointer flex-col justify-between gap-2 rounded-md bg-surface-gray-1 px-4 py-3 hover:bg-surface-gray-2"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="truncate text-lg font-medium"> <div class="truncate text-lg font-medium text-ink-gray-8">
{{ note.title }} {{ note.title }}
</div> </div>
<Dropdown <Dropdown

View File

@ -42,9 +42,11 @@
@click.stop @click.stop
> >
<Tooltip :text="__('Change Status')"> <Tooltip :text="__('Change Status')">
<Button variant="ghosted" class="hover:bg-surface-gray-4"> <div>
<TaskStatusIcon :status="task.status" /> <Button variant="ghosted" class="hover:bg-surface-gray-4">
</Button> <TaskStatusIcon :status="task.status" />
</Button>
</div>
</Tooltip> </Tooltip>
</Dropdown> </Dropdown>
<Dropdown <Dropdown

View File

@ -175,7 +175,11 @@ const commentEmpty = computed(() => {
}) })
const emailEmpty = computed(() => { const emailEmpty = computed(() => {
return !newEmail.value || newEmail.value === '<p></p>' return (
!newEmail.value ||
newEmail.value === '<p></p>' ||
!newEmailEditor.value?.toEmails?.length
)
}) })
async function sendMail() { async function sendMail() {

View File

@ -8,7 +8,10 @@
:label="value" :label="value"
theme="gray" theme="gray"
variant="subtle" variant="subtle"
class="rounded" :class="{
'rounded bg-surface-white hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
variant === 'subtle',
}"
@keydown.delete.capture.stop="removeLastValue" @keydown.delete.capture.stop="removeLastValue"
> >
<template #suffix> <template #suffix>
@ -25,7 +28,14 @@
<template #target="{ togglePopover }"> <template #target="{ togglePopover }">
<ComboboxInput <ComboboxInput
ref="search" ref="search"
class="search-input form-input w-full border-none bg-surface-white hover:bg-surface-white focus:border-none focus:!shadow-none focus-visible:!ring-0" class="search-input form-input w-full border-none focus:border-none focus:!shadow-none focus-visible:!ring-0"
:class="[
variant == 'ghost'
? 'bg-surface-white hover:bg-surface-white'
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
inputClass,
]"
:placeholder="placeholder"
type="text" type="text"
:value="query" :value="query"
@change=" @change="
@ -84,6 +94,12 @@
</div> </div>
</div> </div>
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
<div
v-if="info"
class="whitespace-pre-line text-sm text-ink-blue-3 mt-2 pl-2"
>
{{ info }}
</div>
</div> </div>
</template> </template>
@ -105,6 +121,18 @@ const props = defineProps({
type: Function, type: Function,
default: null, default: null,
}, },
variant: {
type: String,
default: 'subtle',
},
placeholder: {
type: String,
default: '',
},
inputClass: {
type: String,
default: '',
},
errorMessage: { errorMessage: {
type: Function, type: Function,
default: (value) => `${value} is an Invalid value`, default: (value) => `${value} is an Invalid value`,
@ -116,6 +144,7 @@ const values = defineModel()
const emails = ref([]) const emails = ref([])
const search = ref(null) const search = ref(null)
const error = ref(null) const error = ref(null)
const info = ref(null)
const query = ref('') const query = ref('')
const text = ref('') const text = ref('')
const showOptions = ref(false) const showOptions = ref(false)
@ -181,6 +210,7 @@ function reload(val) {
const addValue = (value) => { const addValue = (value) => {
error.value = null error.value = null
info.value = null
if (value) { if (value) {
const splitValues = value.split(',') const splitValues = value.split(',')
splitValues.forEach((value) => { splitValues.forEach((value) => {
@ -191,6 +221,7 @@ const addValue = (value) => {
// check if value is valid // check if value is valid
if (value && props.validate && !props.validate(value)) { if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value) error.value = props.errorMessage(value)
query.value = value
return return
} }
// add value to values array // add value to values array
@ -200,6 +231,8 @@ const addValue = (value) => {
values.value.push(value) values.value.push(value)
} }
value = value.replace(value, '') value = value.replace(value, '')
} else {
info.value = __('email already exists')
} }
} }
}) })

View File

@ -1,126 +0,0 @@
<template>
<div>
<div
class="group flex flex-wrap gap-1 min-h-20 p-1.5 cursor-text rounded h-7 text-base bg-surface-gray-2 hover:bg-surface-gray-3 focus:border-outline-gray-4 focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full"
@click="setFocus"
>
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
class="rounded bg-surface-white hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeValue(value)"
/>
</template>
</Button>
<div class="flex-1">
<input
ref="search"
class="w-full border-none h-7 text-base bg-surface-gray-2 group-hover:bg-surface-gray-3 focus:border-none focus:!shadow-none focus-visible:!ring-0 transition-colors"
type="text"
v-model="query"
placeholder="example@email.com"
@keydown.enter.capture.stop="addValue()"
@keydown.delete.capture.stop="removeLastValue"
/>
</div>
</div>
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
<p v-if="description" class="text-xs text-ink-gray-5 mt-1.5">
{{ description }}
</p>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
const props = defineProps({
validate: {
type: Function,
default: null,
},
description: {
type: String,
default: '',
},
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
})
const values = defineModel()
const emails = ref([])
const search = ref(null)
const error = ref(null)
const query = ref('')
const addValue = () => {
let value = query.value
error.value = null
if (value) {
const splitValues = value.split(',')
splitValues.forEach((value) => {
value = value.trim()
if (value) {
// check if value is not already in the values array
if (!values.value?.includes(value)) {
// check if value is valid
if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value)
return
}
// add value to values array
if (!values.value) {
values.value = [value]
} else {
values.value.push(value)
}
value = value.replace(value, '')
}
}
})
!error.value && (query.value = '')
}
}
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value)
}
const removeLastValue = () => {
if (query.value) return
let emailRef = emails.value[emails.value.length - 1]?.$el
if (document.activeElement === emailRef) {
values.value.pop()
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].$el
emailRef?.focus()
} else {
setFocus()
}
})
} else {
emailRef?.focus()
}
}
function setFocus() {
search.value.focus()
}
defineExpose({ setFocus })
</script>

View File

@ -20,8 +20,9 @@
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="sm:mx-10 mx-4 flex items-center gap-2 border-t pt-2.5"> <div class="sm:mx-10 mx-4 flex items-center gap-2 border-t pt-2.5">
<span class="text-xs text-ink-gray-4">{{ __('TO') }}:</span> <span class="text-xs text-ink-gray-4">{{ __('TO') }}:</span>
<MultiselectInput <MultiSelectEmailInput
class="flex-1" class="flex-1"
variant="ghost"
v-model="toEmails" v-model="toEmails"
:validate="validateEmail" :validate="validateEmail"
:error-message=" :error-message="
@ -53,9 +54,10 @@
</div> </div>
<div v-if="cc" class="sm:mx-10 mx-4 flex items-center gap-2"> <div v-if="cc" class="sm:mx-10 mx-4 flex items-center gap-2">
<span class="text-xs text-ink-gray-4">{{ __('CC') }}:</span> <span class="text-xs text-ink-gray-4">{{ __('CC') }}:</span>
<MultiselectInput <MultiSelectEmailInput
ref="ccInput" ref="ccInput"
class="flex-1" class="flex-1"
variant="ghost"
v-model="ccEmails" v-model="ccEmails"
:validate="validateEmail" :validate="validateEmail"
:error-message=" :error-message="
@ -65,9 +67,10 @@
</div> </div>
<div v-if="bcc" class="sm:mx-10 mx-4 flex items-center gap-2"> <div v-if="bcc" class="sm:mx-10 mx-4 flex items-center gap-2">
<span class="text-xs text-ink-gray-4">{{ __('BCC') }}:</span> <span class="text-xs text-ink-gray-4">{{ __('BCC') }}:</span>
<MultiselectInput <MultiSelectEmailInput
ref="bccInput" ref="bccInput"
class="flex-1" class="flex-1"
variant="ghost"
v-model="bccEmails" v-model="bccEmails"
:validate="validateEmail" :validate="validateEmail"
:error-message=" :error-message="
@ -176,7 +179,7 @@ import SmileIcon from '@/components/Icons/SmileIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue' import Email2Icon from '@/components/Icons/Email2Icon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue' import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import AttachmentItem from '@/components/AttachmentItem.vue' import AttachmentItem from '@/components/AttachmentItem.vue'
import MultiselectInput from '@/components/Controls/MultiselectInput.vue' import MultiSelectEmailInput from '@/components/Controls/MultiSelectEmailInput.vue'
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue' import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
import { TextEditorBubbleMenu, TextEditor, FileUploader, call } from 'frappe-ui' import { TextEditorBubbleMenu, TextEditor, FileUploader, call } from 'frappe-ui'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'

View File

@ -72,14 +72,24 @@
</div> </div>
</div> </div>
<div class="m-2 flex flex-col gap-1"> <div class="m-2 flex flex-col gap-1">
<SignupBanner :isSidebarCollapsed="isSidebarCollapsed" /> <div class="flex flex-col gap-2 mb-1">
<TrialBanner v-if="isFCSite" :isSidebarCollapsed="isSidebarCollapsed" /> <SignupBanner
<GettingStartedBanner v-if="isDemoSite"
v-if="!isOnboardingStepsCompleted" :isSidebarCollapsed="isSidebarCollapsed"
:isSidebarCollapsed="isSidebarCollapsed" :afterSignup="() => capture('signup_from_demo_site')"
/> />
<TrialBanner
v-if="isFCSite"
:isSidebarCollapsed="isSidebarCollapsed"
:afterUpgrade="() => capture('upgrade_plan_from_trial_banner')"
/>
<GettingStartedBanner
v-if="!isOnboardingStepsCompleted"
:isSidebarCollapsed="isSidebarCollapsed"
/>
</div>
<SidebarLink <SidebarLink
v-else v-if="isOnboardingStepsCompleted"
:label="__('Help')" :label="__('Help')"
:isCollapsed="isSidebarCollapsed" :isCollapsed="isSidebarCollapsed"
@click=" @click="
@ -118,9 +128,14 @@
:logo="CRMLogo" :logo="CRMLogo"
:afterSkip="(step) => capture('onboarding_step_skipped_' + step)" :afterSkip="(step) => capture('onboarding_step_skipped_' + step)"
:afterSkipAll="() => capture('onboarding_steps_skipped')" :afterSkipAll="() => capture('onboarding_steps_skipped')"
:afterReset="() => capture('onboarding_steps_reset')" :afterReset="(step) => capture('onboarding_step_reset_' + step)"
:afterResetAll="() => capture('onboarding_steps_reset')"
docsLink="https://docs.frappe.io/crm" docsLink="https://docs.frappe.io/crm"
/> />
<IntermediateStepModal
v-model="showIntermediateModal"
:currentStep="currentStep"
/>
</div> </div>
</template> </template>
@ -148,7 +163,6 @@ import HelpIcon from '@/components/Icons/HelpIcon.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import Notifications from '@/components/Notifications.vue' import Notifications from '@/components/Notifications.vue'
import Settings from '@/components/Settings/Settings.vue' import Settings from '@/components/Settings/Settings.vue'
import SignupBanner from '@/components/SignupBanner.vue'
import { viewsStore } from '@/stores/views' import { viewsStore } from '@/stores/views'
import { import {
unreadNotificationsCount, unreadNotificationsCount,
@ -157,12 +171,14 @@ import {
import { showSettings, activeSettingsPage } from '@/composables/settings' import { showSettings, activeSettingsPage } from '@/composables/settings'
import { FeatherIcon, call } from 'frappe-ui' import { FeatherIcon, call } from 'frappe-ui'
import { import {
SignupBanner,
TrialBanner, TrialBanner,
HelpModal, HelpModal,
GettingStartedBanner, GettingStartedBanner,
useOnboarding, useOnboarding,
showHelpModal, showHelpModal,
minimize, minimize,
IntermediateStepModal,
} from 'frappe-ui/frappe' } from 'frappe-ui/frappe'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import router from '@/router' import router from '@/router'
@ -175,6 +191,7 @@ const { toggle: toggleNotificationPanel } = notificationsStore()
const isSidebarCollapsed = useStorage('isSidebarCollapsed', false) const isSidebarCollapsed = useStorage('isSidebarCollapsed', false)
const isFCSite = ref(window.is_fc_site) const isFCSite = ref(window.is_fc_site)
const isDemoSite = ref(window.is_demo_site)
const links = [ const links = [
{ {
@ -284,19 +301,21 @@ function getIcon(routeName, icon) {
// onboarding // onboarding
const { isOnboardingStepsCompleted, setUp } = useOnboarding('frappecrm') const { isOnboardingStepsCompleted, setUp } = useOnboarding('frappecrm')
const firstLead = ref('')
const firstDeal = ref('')
async function getFirstLead() { async function getFirstLead() {
if (firstLead.value) return firstLead.value let firstLead = localStorage.getItem('firstLead')
if (firstLead) return firstLead
return await call('crm.api.onboarding.get_first_lead') return await call('crm.api.onboarding.get_first_lead')
} }
async function getFirstDeal() { async function getFirstDeal() {
if (firstDeal.value) return firstDeal.value let firstDeal = localStorage.getItem('firstDeal')
if (firstDeal) return firstDeal
return await call('crm.api.onboarding.get_first_deal') return await call('crm.api.onboarding.get_first_deal')
} }
const showIntermediateModal = ref(false)
const currentStep = ref({})
const steps = reactive([ const steps = reactive([
{ {
name: 'create_first_lead', name: 'create_first_lead',
@ -327,13 +346,23 @@ const steps = reactive([
onClick: async () => { onClick: async () => {
minimize.value = true minimize.value = true
let lead = await getFirstLead() currentStep.value = {
title: __('Convert lead to deal'),
buttonLabel: __('Convert'),
videoURL: '/assets/crm/videos/convertToDeal.mov',
onClick: async () => {
showIntermediateModal.value = false
currentStep.value = {}
if (lead) { let lead = await getFirstLead()
router.push({ name: 'Lead', params: { leadId: lead } }) if (lead) {
} else { router.push({ name: 'Lead', params: { leadId: lead } })
router.push({ name: 'Leads' }) } else {
router.push({ name: 'Leads' })
}
},
} }
showIntermediateModal.value = true
}, },
}, },
{ {
@ -423,17 +452,28 @@ const steps = reactive([
completed: false, completed: false,
onClick: async () => { onClick: async () => {
minimize.value = true minimize.value = true
let deal = await getFirstDeal()
if (deal) { currentStep.value = {
router.push({ title: __('Change deal status'),
name: 'Deal', buttonLabel: __('Change'),
params: { dealId: deal }, videoURL: '/assets/crm/videos/changeDealStatus.mov',
hash: '#activity', onClick: async () => {
}) showIntermediateModal.value = false
} else { currentStep.value = {}
router.push({ name: 'Leads' })
let deal = await getFirstDeal()
if (deal) {
router.push({
name: 'Deal',
params: { dealId: deal },
hash: '#activity',
})
} else {
router.push({ name: 'Leads' })
}
},
} }
showIntermediateModal.value = true
}, },
}, },
]) ])
@ -474,6 +514,13 @@ const articles = ref([
{ name: 'email-template', title: __('Email template') }, { name: 'email-template', title: __('Email template') },
], ],
}, },
{
title: __('Capturing leads'),
opened: false,
subArticles: [
{ name: 'web-form', title: __('Web form') },
],
},
{ {
title: __('Views'), title: __('Views'),
opened: false, opened: false,

View File

@ -52,22 +52,21 @@
v-for="assignee in assignees" v-for="assignee in assignees"
:key="assignee.name" :key="assignee.name"
> >
<Button <div>
:label="getUser(assignee.name).full_name" <Button :label="getUser(assignee.name).full_name" theme="gray">
theme="gray" <template #prefix>
> <UserAvatar :user="assignee.name" size="sm" />
<template #prefix> </template>
<UserAvatar :user="assignee.name" size="sm" /> <template #suffix>
</template> <FeatherIcon
<template #suffix> v-if="assignee.name !== owner"
<FeatherIcon class="h-3.5"
v-if="assignee.name !== owner" name="x"
class="h-3.5" @click.stop="removeValue(assignee.name)"
name="x" />
@click.stop="removeValue(assignee.name)" </template>
/> </Button>
</template> </div>
</Button>
</Tooltip> </Tooltip>
</div> </div>
<ErrorMessage class="mt-2" v-if="error" :message="__(error)" /> <ErrorMessage class="mt-2" v-if="error" :message="__(error)" />

View File

@ -168,7 +168,9 @@ function createNewLead() {
isLeadCreating.value = false isLeadCreating.value = false
show.value = false show.value = false
router.push({ name: 'Lead', params: { leadId: data.name } }) router.push({ name: 'Lead', params: { leadId: data.name } })
updateOnboardingStep('create_first_lead') updateOnboardingStep('create_first_lead', true, false, () => {
localStorage.setItem('firstLead', data.name)
})
}, },
onError(err) { onError(err) {
isLeadCreating.value = false isLeadCreating.value = false

View File

@ -8,14 +8,20 @@
<label class="block text-xs text-ink-gray-5 mb-1.5"> <label class="block text-xs text-ink-gray-5 mb-1.5">
{{ __('Invite by email') }} {{ __('Invite by email') }}
</label> </label>
<MultiValueInput <div
v-model="invitees" class="p-2 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded"
:validate="validateEmail" >
:error-message=" <MultiSelectEmailInput
(value) => __('{0} is an invalid email address', [value]) class="flex-1"
" inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3"
:description="__('Press enter to add email')" :placeholder="__('john@doe.com')"
/> v-model="invitees"
:validate="validateEmail"
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
/>
</div>
<FormControl <FormControl
type="select" type="select"
class="mt-4" class="mt-4"
@ -51,15 +57,17 @@
</div> </div>
<div> <div>
<Tooltip text="Delete Invitation"> <Tooltip text="Delete Invitation">
<Button <div>
icon="x" <Button
variant="ghost" icon="x"
:loading=" variant="ghost"
pendingInvitations.delete.loading && :loading="
pendingInvitations.delete.params.name === user.name pendingInvitations.delete.loading &&
" pendingInvitations.delete.params.name === user.name
@click="pendingInvitations.delete.submit(user.name)" "
/> @click="pendingInvitations.delete.submit(user.name)"
/>
</div>
</Tooltip> </Tooltip>
</div> </div>
</li> </li>
@ -72,6 +80,7 @@
<Button <Button
:label="__('Send Invites')" :label="__('Send Invites')"
variant="solid" variant="solid"
:disabled="!invitees.length"
@click="inviteByEmail.submit()" @click="inviteByEmail.submit()"
:loading="inviteByEmail.loading" :loading="inviteByEmail.loading"
/> />
@ -79,7 +88,7 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import MultiValueInput from '@/components/Controls/MultiValueInput.vue' import MultiSelectEmailInput from '@/components/Controls/MultiSelectEmailInput.vue'
import { validateEmail, convertArrayToString } from '@/utils' import { validateEmail, convertArrayToString } from '@/utils'
import { import {
createListResource, createListResource,

View File

@ -1,41 +0,0 @@
<template>
<div
v-if="!isSidebarCollapsed && showBanner"
class="m-2 flex flex-col gap-3 shadow-sm rounded-lg py-2.5 px-3 bg-surface-white text-base"
>
<div class="flex flex-col gap-1">
<div class="inline-flex gap-2 items-center font-medium">
<FeatherIcon class="h-4" name="info" />
{{ __('Loved the demo?') }}
</div>
<div class="text-ink-gray-7 text-p-sm">
{{ __('Try Frappe CRM for free with a 14-day trial.') }}
</div>
</div>
<Button :label="__('Sign up now')" theme="blue" @click="signupNow">
<template #prefix>
<LightningIcon class="size-4" />
</template>
</Button>
</div>
</template>
<script setup>
import LightningIcon from '@/components/Icons/LightningIcon.vue'
import { capture } from '@/telemetry'
import { createResource } from 'frappe-ui'
import { ref } from 'vue'
const props = defineProps({
isSidebarCollapsed: {
type: Boolean,
default: false,
},
})
const showBanner = ref(window.is_demo_site)
function signupNow() {
capture('signup_from_demo_site')
window.open('https://frappecloud.com/crm/signup', '_blank')
}
</script>

View File

@ -2,11 +2,22 @@
<div class="flex items-center"> <div class="flex items-center">
<router-link <router-link
:to="{ name: routeName }" :to="{ name: routeName }"
class="px-0.5 py-1 text-lg font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-5 hover:text-ink-gray-7" class="px-0.5 py-1 text-lg font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3"
:class="[
viewControls
? 'text-ink-gray-5 hover:text-ink-gray-7'
: 'text-ink-gray-7',
]"
> >
{{ __(routeName) }} {{ __(routeName) }}
</router-link> </router-link>
<span class="mx-0.5 text-base text-ink-gray-4" aria-hidden="true"> / </span> <span
v-if="viewControls"
class="mx-0.5 text-base text-ink-gray-4"
aria-hidden="true"
>
/
</span>
<Dropdown v-if="viewControls" :options="viewControls.viewsDropdownOptions"> <Dropdown v-if="viewControls" :options="viewControls.viewsDropdownOptions">
<template #default="{ open }"> <template #default="{ open }">
<Button <Button

View File

@ -1,29 +1,24 @@
@import 'frappe-ui/src/fonts/Inter/inter.css'; @import "frappe-ui/src/style.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components { @layer components {
.prose-f { .prose-f {
@apply @apply break-all
break-all max-w-none
max-w-none prose
prose prose-code:break-all
prose-code:break-all prose-code:whitespace-pre-wrap
prose-code:whitespace-pre-wrap prose-img:border
prose-img:border prose-img:rounded-lg
prose-img:rounded-lg prose-sm
prose-sm prose-table:table-fixed
prose-table:table-fixed prose-td:border
prose-td:border prose-td:border-outline-gray-2
prose-td:border-outline-gray-2 prose-td:p-2
prose-td:p-2 prose-td:relative
prose-td:relative prose-th:bg-surface-gray-2
prose-th:bg-surface-gray-2 prose-th:border
prose-th:border prose-th:border-outline-gray-2
prose-th:border-outline-gray-2 prose-th:p-2
prose-th:p-2 prose-th:relative;
prose-th:relative
} }
} }

View File

@ -75,38 +75,46 @@
</Tooltip> </Tooltip>
<div class="flex gap-1.5"> <div class="flex gap-1.5">
<Tooltip v-if="callEnabled" :text="__('Make a call')"> <Tooltip v-if="callEnabled" :text="__('Make a call')">
<Button class="h-7 w-7" @click="triggerCall"> <div>
<PhoneIcon class="h-4 w-4" /> <Button class="h-7 w-7" @click="triggerCall">
</Button> <PhoneIcon class="h-4 w-4" />
</Button>
</div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Send an email')"> <Tooltip :text="__('Send an email')">
<Button class="h-7 w-7"> <div>
<Email2Icon <Button class="h-7 w-7">
class="h-4 w-4" <Email2Icon
@click=" class="h-4 w-4"
deal.data.email @click="
? openEmailBox() deal.data.email
: errorMessage(__('No email set')) ? openEmailBox()
" : errorMessage(__('No email set'))
/> "
</Button> />
</Button>
</div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Go to website')"> <Tooltip :text="__('Go to website')">
<Button class="h-7 w-7"> <div>
<LinkIcon <Button class="h-7 w-7">
class="h-4 w-4" <LinkIcon
@click=" class="h-4 w-4"
deal.data.website @click="
? openWebsite(deal.data.website) deal.data.website
: errorMessage(__('No website set')) ? openWebsite(deal.data.website)
" : errorMessage(__('No website set'))
/> "
</Button> />
</Button>
</div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Attach a file')"> <Tooltip :text="__('Attach a file')">
<Button class="size-7" @click="showFilesUploader = true"> <div>
<AttachmentIcon class="size-4" /> <Button class="size-7" @click="showFilesUploader = true">
</Button> <AttachmentIcon class="size-4" />
</Button>
</div>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>

View File

@ -117,46 +117,54 @@
</Tooltip> </Tooltip>
<div class="flex gap-1.5"> <div class="flex gap-1.5">
<Tooltip v-if="callEnabled" :text="__('Make a call')"> <Tooltip v-if="callEnabled" :text="__('Make a call')">
<Button <div>
class="h-7 w-7" <Button
@click=" class="h-7 w-7"
() => @click="
lead.data.mobile_no () =>
? makeCall(lead.data.mobile_no) lead.data.mobile_no
: errorMessage(__('No phone number set')) ? makeCall(lead.data.mobile_no)
" : errorMessage(__('No phone number set'))
> "
<PhoneIcon class="h-4 w-4" /> >
</Button> <PhoneIcon class="h-4 w-4" />
</Button>
</div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Send an email')"> <Tooltip :text="__('Send an email')">
<Button class="h-7 w-7"> <div>
<Email2Icon <Button class="h-7 w-7">
class="h-4 w-4" <Email2Icon
@click=" class="h-4 w-4"
lead.data.email @click="
? openEmailBox() lead.data.email
: errorMessage(__('No email set')) ? openEmailBox()
" : errorMessage(__('No email set'))
/> "
</Button> />
</Button>
</div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Go to website')"> <Tooltip :text="__('Go to website')">
<Button class="h-7 w-7"> <div>
<LinkIcon <Button class="h-7 w-7">
class="h-4 w-4" <LinkIcon
@click=" class="h-4 w-4"
lead.data.website @click="
? openWebsite(lead.data.website) lead.data.website
: errorMessage(__('No website set')) ? openWebsite(lead.data.website)
" : errorMessage(__('No website set'))
/> "
</Button> />
</Button>
</div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Attach a file')"> <Tooltip :text="__('Attach a file')">
<Button class="h-7 w-7" @click="showFilesUploader = true"> <div>
<AttachmentIcon class="h-4 w-4" /> <Button class="h-7 w-7" @click="showFilesUploader = true">
</Button> <AttachmentIcon class="h-4 w-4" />
</Button>
</div>
</Tooltip> </Tooltip>
</div> </div>
<ErrorMessage :message="__(error)" /> <ErrorMessage :message="__(error)" />
@ -649,7 +657,9 @@ async function convertToDeal() {
existingOrganizationChecked.value = false existingOrganizationChecked.value = false
existingContact.value = '' existingContact.value = ''
existingOrganization.value = '' existingOrganization.value = ''
updateOnboardingStep('convert_lead_to_deal') updateOnboardingStep('convert_lead_to_deal', true, false, () => {
localStorage.setItem('firstDeal', _deal)
})
capture('convert_lead_to_deal') capture('convert_lead_to_deal')
router.push({ name: 'Deal', params: { dealId: _deal } }) router.push({ name: 'Deal', params: { dealId: _deal } })
} }

View File

@ -71,8 +71,9 @@ export default defineConfig({
'feather-icons', 'feather-icons',
'showdown', 'showdown',
'tailwind.config.js', 'tailwind.config.js',
'engine.io-client',
'prosemirror-state', 'prosemirror-state',
'prosemirror-view',
'lowlight',
], ],
}, },
}) })

View File

@ -1,9 +1,13 @@
{ {
"private": true, "private": true,
"workspaces": ["frontend", "frappe-ui"], "workspaces": ["frontend", "frappe-ui"],
"scripts": { "scripts": {
"postinstall": "cd frontend && yarn install", "postinstall": "cd frontend && yarn install",
"dev": "cd frontend && yarn dev", "dev": "cd frontend && yarn dev",
"build": "cd frontend && yarn build" "build": "cd frontend && yarn build",
} "disable-workspaces": "sed -i '' 's/\"workspaces\"/\"aworkspaces\"/g' package.json",
"enable-workspaces": "sed -i '' 's/\"aworkspaces\"/\"workspaces\"/g' package.json && rm -rf node_modules ./frontend/node_modules/ frappe-ui/node_modules/ && yarn install",
"upgrade-frappeui": "cd frontend && yarn add frappe-ui@latest && cd ..",
"disable-workspaces-and-upgrade-frappeui": "yarn disable-workspaces && yarn upgrade-frappeui"
}
} }

View File

@ -1357,7 +1357,7 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.10.3.tgz#5ee0feb2f06a59c50e413160840f244215cc4026" resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.10.3.tgz#5ee0feb2f06a59c50e413160840f244215cc4026"
integrity sha512-/SFuEDnbJxy3jvi72LeyiPHWkV+uFc0LUHTUHSh20vwyy+tLrzncJfXohGbTIv5YxYhzExQYZDRD4VbSghKdlw== integrity sha512-/SFuEDnbJxy3jvi72LeyiPHWkV+uFc0LUHTUHSh20vwyy+tLrzncJfXohGbTIv5YxYhzExQYZDRD4VbSghKdlw==
"@tiptap/extension-paragraph@^2.10.3", "@tiptap/extension-paragraph@^2.4.0": "@tiptap/extension-paragraph@^2.10.3":
version "2.10.3" version "2.10.3"
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.10.3.tgz#128c8fcd46d2e854d214c7f566e6212f2ebff6f1" resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.10.3.tgz#128c8fcd46d2e854d214c7f566e6212f2ebff6f1"
integrity sha512-sNkTX/iN+YoleDiTJsrWSBw9D7c4vsYwnW5y/G5ydfuJMIRQMF78pWSIWZFDRNOMkgK5UHkhu9anrbCFYgBfaA== integrity sha512-sNkTX/iN+YoleDiTJsrWSBw9D7c4vsYwnW5y/G5ydfuJMIRQMF78pWSIWZFDRNOMkgK5UHkhu9anrbCFYgBfaA==
@ -1693,7 +1693,7 @@
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.13.tgz#87b309a6379c22b926e696893237826f64339b6f" resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.13.tgz#87b309a6379c22b926e696893237826f64339b6f"
integrity sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ== integrity sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==
"@vueuse/core@10.11.1", "@vueuse/core@^10.11.0", "@vueuse/core@^10.3.0", "@vueuse/core@^10.4.1": "@vueuse/core@10.11.1", "@vueuse/core@^10.11.0", "@vueuse/core@^10.4.1":
version "10.11.1" version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-10.11.1.tgz#15d2c0b6448d2212235b23a7ba29c27173e0c2c6" resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-10.11.1.tgz#15d2c0b6448d2212235b23a7ba29c27173e0c2c6"
integrity sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww== integrity sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==
@ -2542,10 +2542,10 @@ fraction.js@^4.3.7:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
frappe-ui@^0.1.118: frappe-ui@^0.1.121:
version "0.1.118" version "0.1.121"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.118.tgz#36fb108d63194fe9b06078a84576e32af54d06b8" resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.121.tgz#a8d37f300228edfcbb6b4fffb343f0773dcfd933"
integrity sha512-t29fYu22jfPoi/pFAbPX4//bh5XEgqbECHttjsOsPboC8BsnyYUtQSA/pY6PP/M/hMAW74QslnTK6iU/1OuSaQ== integrity sha512-gvtKKZECPD2MU5X4MwPUKr2hSOs1+s1DA9laP3aPnmH0ukJRSFEhDOyjCMfH9k6ZdAe/vZCIbT4XucxLq/fOEA==
dependencies: dependencies:
"@headlessui/vue" "^1.7.14" "@headlessui/vue" "^1.7.14"
"@popperjs/core" "^2.11.2" "@popperjs/core" "^2.11.2"
@ -4357,7 +4357,7 @@ svg-tags@^1.0.0:
resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764" resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764"
integrity sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA== integrity sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==
tailwindcss@^3.3.3: tailwindcss@^3.4.15:
version "3.4.17" version "3.4.17"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.17.tgz#ae8406c0f96696a631c790768ff319d46d5e5a63" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.17.tgz#ae8406c0f96696a631c790768ff319d46d5e5a63"
integrity sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og== integrity sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==