Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions dashboard/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ declare module 'vue' {
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
FooterEditor: typeof import('./src/components/storefront/FooterEditor.vue')['default']
FooterLinkDialog: typeof import('./src/components/storefront/FooterLinkDialog.vue')['default']
GatewayCard: typeof import('./src/components/settings/GatewayCard.vue')['default']
GatewayConfig: typeof import('./src/components/settings/GatewayConfig.vue')['default']
ImagesStep: typeof import('./src/components/import/steps/ImagesStep.vue')['default']
ImportDialog: typeof import('./src/components/import/ImportDialog.vue')['default']
ImportStepNav: typeof import('./src/components/import/ImportStepNav.vue')['default']
IntegrationCard: typeof import('./src/components/settings/IntegrationCard.vue')['default']
IntegrationConfig: typeof import('./src/components/settings/IntegrationConfig.vue')['default']
IntegrationsPanel: typeof import('./src/components/settings/IntegrationsPanel.vue')['default']
ListPagination: typeof import('./src/components/ListPagination.vue')['default']
MapStep: typeof import('./src/components/import/steps/MapStep.vue')['default']
NavigationEditor: typeof import('./src/components/storefront/NavigationEditor.vue')['default']
Expand Down
120 changes: 69 additions & 51 deletions dashboard/src/components/AddProductDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,33 @@ function addCollection() {
})
}

// Every product this store sells is built on its own Color/Size pair — Size has to keep that
// exact spelling (generate_variants() depends on it; see catalog.create_product's Trap 2 guard),
// so the option axis is any OTHER attribute and Size is fixed, not picked.
// The option axis is any attribute EXCEPT Size — Size has to keep that exact spelling
// (generate_variants() depends on it; see catalog.create_product's Trap 2 guard), so it is fixed
// rather than picked. Both axes are optional here: a book has neither, and create_product fills
// them with a hidden value rather than refusing the product.
const attributesRequest = useAdminRead('catalog.get_attributes')
const attributes = computed(() => attributesRequest.data ?? [])
const sizeAttributeExists = computed(() => attributes.value.some((a) => a.name === 'Size'))
const optionAttributeOptions = computed(() =>
attributes.value.filter((a) => a.name !== 'Size').map((a) => ({ label: a.name, value: a.name })),
)

const createAttributeAction = useAdminAction('catalog.create_attribute')
function addAttribute() {
dialog.prompt({
title: 'New option',
fields: [
{ name: 'title', label: 'Name', required: true, placeholder: 'Format' },
{ name: 'values', label: 'Values', placeholder: 'Paperback, Hardcover' },
],
onConfirm: async ({ values }) => {
await createAttributeAction.submit({ name: values.title, values: values.values || undefined })
if (createAttributeAction.error) return
await attributesRequest.reload()
optionAttribute.value = values.title
toast.success(`"${values.title}" created`)
},
})
}
watch(attributes, (list) => {
if (!list.some((a) => a.name === optionAttribute.value)) {
optionAttribute.value = list.find((a) => a.name === 'Color')?.name ?? list.find((a) => a.name !== 'Size')?.name ?? ''
Expand All @@ -93,29 +111,40 @@ watch(optionAttribute, () => {

const optionSizes = computed(() => buildOptionSizes(colors.value, sizes.value, excludedPairs.value))
const variantCount = computed(() => optionSizes.value.reduce((total, row) => total + row.sizes.length, 0))
const emptyOption = computed(() => optionSizes.value.find((row) => !row.sizes.length)?.option)
const gridError = computed(() => (emptyOption.value ? `Pick at least one size for ${emptyOption.value}` : ''))

// Every option sizeless is a book, and allowed. Some sized and some not is a half-filled grid, and
// still the owner forgetting a row — create_product refuses that pairing for the same reason.
const sizelessOptions = computed(() => optionSizes.value.filter((row) => !row.sizes.length))
const mixedOption = computed(() =>
sizelessOptions.value.length && sizelessOptions.value.length < optionSizes.value.length
? sizelessOptions.value[0].option
: '',
)
const gridError = computed(() => (mixedOption.value ? `Pick at least one size for ${mixedOption.value}` : ''))

const canSubmit = computed(
() =>
Boolean(title.value.trim()) &&
Boolean(collection.value) &&
Boolean(optionAttribute.value) &&
sizeAttributeExists.value &&
variantCount.value > 0 &&
!emptyOption.value,
() => Boolean(title.value.trim()) && Boolean(collection.value) && !mixedOption.value,
)

// What the owner is about to get, in their words rather than ERPNext's.
const summary = computed(() => {
const optionCount = colors.value.length
const optionWord = optionCount === 1 ? 'option' : 'options'
if (variantCount.value) {
const variantWord = variantCount.value === 1 ? 'variant' : 'variants'
return `${optionCount} ${optionWord} · ${variantCount.value} ${variantWord} will be created`
}
if (optionCount) return `${optionCount} ${optionWord} · no sizes, so each one sells as a single item`
return 'No options — this sells as a single item, the way a book does'
})

const createAction = useAdminAction('catalog.create_product')

// A disabled button with no reason reads as a broken screen, so the first thing still missing is named.
const submitHint = computed(() => {
if (canSubmit.value || createAction.loading) return ''
if (!title.value.trim()) return 'Add a title to continue.'
if (!collection.value) return 'Pick a collection to continue.'
if (!sizeAttributeExists.value) return 'This store needs a "Size" attribute first.'
if (!colors.value.length) return `Pick at least one ${(optionAttribute.value || 'option').toLowerCase()}.`
if (!sizes.value.length) return 'Pick at least one size.'
return ''
})

Expand All @@ -125,7 +154,7 @@ async function submit() {
await createAction.submit({
title: title.value.trim(),
collection: collection.value,
option_attribute: optionAttribute.value,
option_attribute: colors.value.length ? optionAttribute.value : undefined,
size_attribute: 'Size',
option_sizes: optionSizes.value,
price: compareAt.value || undefined,
Expand Down Expand Up @@ -166,43 +195,35 @@ async function submit() {
</div>
</div>

<FormControl
v-if="optionAttributeOptions.length > 1"
v-model="optionAttribute"
type="select"
label="Option"
:options="optionAttributeOptions"
description="Sizes are added below and apply to every option."
/>
<div>
<span class="mb-1.5 block text-base text-ink-gray-6">Option</span>
<div class="flex gap-2">
<Select v-model="optionAttribute" class="min-w-0 flex-1" :options="optionAttributeOptions" />
<Button label="New" icon-left="lucide-plus" @click="addAttribute" />
</div>
<p class="mt-1.5 text-p-sm text-ink-gray-5">
What this product varies by. Sizes are added below and apply to every option.
</p>
</div>

<AttributeMultiSelect
v-if="optionAttribute"
v-model="colors"
v-model:open="colorsOpen"
:attribute="optionAttribute"
:label="optionAttribute || 'Options'"
:placeholder="`Pick or type a ${(optionAttribute || 'option').toLowerCase()}`"
:description="`Type a new ${(optionAttribute || 'option').toLowerCase()} to add it`"
required
:label="optionAttribute"
:placeholder="`Pick or type a ${optionAttribute.toLowerCase()}`"
:description="`Type a new ${optionAttribute.toLowerCase()} to add it — leave empty if this product has none`"
/>

<div>
<AttributeMultiSelect
v-model="sizes"
v-model:open="sizesOpen"
attribute="Size"
label="Sizes"
placeholder="Pick or type a size"
description="Type a new size to add it"
required
/>
<Alert
v-if="!sizeAttributeExists"
class="mt-2"
theme="red"
title='This store has no "Size" attribute yet'
description="Create it from the Attributes screen first, then come back to add products."
/>
</div>
<AttributeMultiSelect
v-model="sizes"
v-model:open="sizesOpen"
attribute="Size"
label="Sizes"
placeholder="Pick or type a size"
description="Leave empty for a product that has no sizes, like a book"
/>

<OptionSizeGrid
v-if="colors.length && sizes.length"
Expand All @@ -212,10 +233,7 @@ async function submit() {
:option-label="optionAttribute || 'Option'"
/>

<p v-if="variantCount" class="text-sm text-ink-gray-5">
{{ colors.length }} {{ colors.length === 1 ? 'option' : 'options' }} ·
{{ variantCount }} {{ variantCount === 1 ? 'variant' : 'variants' }} will be created
</p>
<p class="text-sm text-ink-gray-5">{{ summary }}</p>
<ErrorMessage :message="gridError" />

<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
Expand Down
155 changes: 35 additions & 120 deletions dashboard/src/components/settings/AppSettingsDialog.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup>
import { computed, reactive, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import {
Badge,
Button,
Expand All @@ -19,50 +19,37 @@ import {
toast,
} from 'frappe-ui'
import BrandMark from './BrandMark.vue'
import GatewayCard from './GatewayCard.vue'
import GatewayConfig from './GatewayConfig.vue'
import { appIntegrations, paymentGateways } from '../../data/integrations'
import { locations } from '../../data/mock'
import IntegrationsPanel from './IntegrationsPanel.vue'
import { paymentIntegrations, shippingIntegrations } from '../../data/integrations'
import { appIntegrations, locations } from '../../data/mock'
import { company, erpnextLink } from '../../data/erpnext'
import { settings } from '../../ia/settings'

const defaultGateway = ref('razorpay')
const taxInclusive = ref(true)
const autoFulfil = ref(false)
const weightUnit = ref('g')
const notifyOrders = ref(true)
const notifyLowStock = ref(true)
const notifyPayouts = ref(false)

// One record per gateway: several can be enabled at once, each with its own
// environment and keys, so nothing here is exclusive except the checkout
// default below.
const config = reactive(
Object.fromEntries(
paymentGateways.map((g) => [
g.id,
{
enabled: g.connected,
mode: g.mode,
configured: g.connected,
captureManually: false,
values: Object.fromEntries(g.fields.map((f) => [f.key, ''])),
},
]),
),
)

const enabledGateways = computed(() => paymentGateways.filter((g) => config[g.id].enabled))
const needsKeys = (id) => config[id].enabled && !config[id].configured
const incomplete = computed(() => enabledGateways.value.filter((g) => needsKeys(g.id)))

// Configuring one gateway swaps the panel for its own screen.
const configuring = ref(null)
const current = computed(() => paymentGateways.find((g) => g.id === configuring.value) ?? null)

const connectedCount = computed(() => enabledGateways.value.length)
// The counts beside the sidebar entries are the server's answer, not a local tally, so
// they cannot claim a provider is live when the site says otherwise.
const connectedCount = paymentIntegrations.connectedCount
const shippingConnected = shippingIntegrations.connectedCount
const appsConnected = computed(() => appIntegrations.filter((a) => a.connected).length)

// Both registries load when the dialog opens, not when their tab is first shown: the
// counts sit in the sidebar from the start, and an unread registry counts zero, which
// reads as "nothing is connected" rather than "not looked yet".
watch(
() => settings.open,
(isOpen) => {
if (!isOpen) return
paymentIntegrations.loadOnce()
shippingIntegrations.loadOnce()
},
{ immediate: true },
)

const appsByCategory = computed(() => {
const groups = new Map()
for (const app of appIntegrations) {
Expand Down Expand Up @@ -94,11 +81,6 @@ function inviteUser() {
onConfirm: ({ values }) => toast.success(`Invite sent to ${values.email}`),
})
}

function makeDefault(id) {
defaultGateway.value = id
toast.success(`${paymentGateways.find((g) => g.id === id).name} is now the checkout default`)
}
</script>

<template>
Expand Down Expand Up @@ -136,6 +118,9 @@ function makeDefault(id) {
<SettingsNavItem value="shipping">
<template #prefix><span class="lucide-truck size-4" aria-hidden="true" /></template>
Shipping
<template #suffix>
<span class="text-sm text-ink-gray-5 tabular-nums">{{ shippingConnected }}</span>
</template>
</SettingsNavItem>
<SettingsNavItem value="taxes">
<template #prefix><span class="lucide-receipt size-4" aria-hidden="true" /></template>
Expand Down Expand Up @@ -267,91 +252,21 @@ function makeDefault(id) {
</SettingsPanel>

<SettingsPanel value="payments">
<template v-if="!current">
<SettingsHeader
title="Payments"
description="Turn on as many providers as you like. Each keeps its own keys and environment."
>
<template #actions>
<Badge
v-if="incomplete.length"
:label="`${incomplete.length} ${incomplete.length === 1 ? 'needs' : 'need'} keys`"
theme="orange"
variant="subtle"
/>
<Button
label="Payout report"
icon-left="lucide-download"
@click="toast.info('Payout report queued')"
/>
</template>
</SettingsHeader>
<SettingsBody>
<div class="divide-y divide-outline-gray-1">
<GatewayCard
v-for="gateway in paymentGateways"
:key="gateway.id"
:gateway="gateway"
:config="config[gateway.id]"
:is-default="defaultGateway === gateway.id"
:needs-keys="needsKeys(gateway.id)"
@configure="configuring = $event"
/>
</div>

<div class="mt-4 divide-y divide-outline-gray-1 border-t border-outline-gray-1">
<SettingsRow
title="Default at checkout"
description="Preselected for the customer. The rest still show as alternatives."
>
<Select
v-model="defaultGateway"
class="w-56"
:options="enabledGateways.map((g) => ({ label: g.name, value: g.id }))"
:disabled="enabledGateways.length < 2"
/>
</SettingsRow>
</div>
</SettingsBody>
</template>

<!-- One gateway, its own screen. -->
<GatewayConfig
v-else
:gateway="current"
:config="config[current.id]"
:is-default="defaultGateway === current.id"
@back="configuring = null"
@make-default="makeDefault"
<IntegrationsPanel
:store="paymentIntegrations"
:active="settings.tab === 'payments'"
title="Payments"
description="Turn on as many providers as you like. Each keeps its own keys."
/>
</SettingsPanel>

<SettingsPanel value="shipping">
<SettingsHeader title="Shipping" description="Rates offered at checkout." />
<SettingsBody>
<div class="divide-y divide-outline-gray-1">
<div class="flex items-center justify-between py-3">
<div>
<p class="text-base text-ink-gray-8">Standard — India</p>
<p class="mt-1 text-sm text-ink-gray-5">₹79, free over ₹3,000, 3–5 days</p>
</div>
<Button label="Edit" variant="ghost" />
</div>
<div class="flex items-center justify-between py-3">
<div>
<p class="text-base text-ink-gray-8">Express — metros</p>
<p class="mt-1 text-sm text-ink-gray-5">₹199, 1–2 days</p>
</div>
<Button label="Edit" variant="ghost" />
</div>
<SettingsRow
title="Auto-fulfil digital products"
description="Sends the download link as soon as payment clears."
>
<Switch v-model="autoFulfil" size="sm" />
</SettingsRow>
</div>
</SettingsBody>
<IntegrationsPanel
:store="shippingIntegrations"
:active="settings.tab === 'shipping'"
title="Shipping"
description="Carriers this store books with. Each quotes its own rates at checkout."
/>
</SettingsPanel>

<SettingsPanel value="taxes">
Expand Down
Loading
Loading