Преглед изворни кода

feat: connect customer pages to real customer apis

ChenYu пре 4 месеци
родитељ
комит
798c2e00ac

+ 3 - 3
frontend/src/pages/dashboard/customers/CustomerFormPage.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react'
+import { useEffect, useState, type FormEvent } from 'react'
 import { Link, useParams } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { createCustomer, getCustomerById, updateCustomer } from '../../../services/api/customers'
@@ -44,14 +44,14 @@ export function CustomerFormPage({ mode }: { mode: 'new' | 'edit' }) {
   const pageCopy = copy.customerFormPage
   const modeCopy = mode === 'edit' ? pageCopy.edit : pageCopy.new
 
-  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
+  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
     event.preventDefault()
     if (!form.name || !form.phone || submitting) return
     setError('')
     setSubmitting(true)
     try {
       let image: string | undefined = existing?.image
-      if (photoFile) image = (await uploadMedia(photoFile)).url
+      if (photoFile) image = (await uploadMedia(photoFile, 'avatar')).url
       const payload = formValuesToPayload(form, image)
       if (mode === 'edit' && existing) {
         await updateCustomer(existing.id, payload)

+ 70 - 54
frontend/src/pages/dashboard/customers/CustomerListPage.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
 import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import {
@@ -18,41 +18,76 @@ const emptySummary: CustomerDashboardSummary = {
   weeklyRecords: 0,
 }
 
-const STYLE_API_MAP: Record<string, string> = {
-  '短发': 'Short',
-  '中长发': 'Medium',
-  '长发': 'Long',
-  'ショート': 'Short',
-  'ミディアム': 'Medium',
-  'ロング': 'Long',
+const VISIT_STATUS_API_MAP: Record<string, string | undefined> = {
+  '全部': undefined,
+  '今天到店': 'TODAY',
+  '本周到店': 'THIS_WEEK',
+  '30日以上未到店': 'OVER_30_DAYS_NO_VISIT',
+  '全て': undefined,
+  '本日来店': 'TODAY',
+  '今週来店': 'THIS_WEEK',
+  '30日以上未来店': 'OVER_30_DAYS_NO_VISIT',
 }
 
-function parseDate(value?: string): Date | null {
-  if (!value) return null
-  const normalized = value.replace(/\//g, '-')
-  const date = new Date(normalized)
-  return Number.isNaN(date.getTime()) ? null : date
+const FOLLOW_STATUS_API_MAP: Record<string, string | undefined> = {
+  '全部': undefined,
+  '需跟进': 'NEED_FOLLOW_UP',
+  '已发送': 'SENT',
+  '全て': undefined,
+  '要フォロー': 'NEED_FOLLOW_UP',
+  '送信済み': 'SENT',
 }
 
-function daysBetween(a: Date, b: Date): number {
-  const ms = Math.abs(a.getTime() - b.getTime())
-  return Math.floor(ms / (1000 * 60 * 60 * 24))
+const HAIR_LENGTH_API_MAP: Record<string, string | undefined> = {
+  '全部': undefined,
+  '短发': 'SHORT',
+  '中长发': 'MEDIUM',
+  '长发': 'LONG',
+  '全て': undefined,
+  'ショート': 'SHORT',
+  'ミディアム': 'MEDIUM',
+  'ロング': 'LONG',
 }
 
+function getFollowStatusLabel(status: string | undefined, pageCopy: CustomerListPageCopy) {
+  if (status === 'NEED_FOLLOW_UP') return pageCopy.filters.followOptions[1]
+  if (status === 'SENT') return pageCopy.filters.followOptions[2]
+  return '—'
+}
+
+type CustomerListPageCopy = ReturnType<typeof usePageContext>['copy']['customerListPage']
+
 export function CustomerListPage() {
   const { locale, path, copy } = usePageContext()
   const [items, setItems] = useState<CustomerEntity[]>([])
   const [summary, setSummary] = useState<CustomerDashboardSummary>(emptySummary)
   const [query, setQuery] = useState('')
+  const [page] = useState(1)
+  const [pageSize] = useState(20)
+  const [total, setTotal] = useState(0)
   const [visitFilter, setVisitFilter] = useState(copy.customerListPage.filters.visitOptions[0])
   const [followFilter, setFollowFilter] = useState(copy.customerListPage.filters.followOptions[0])
   const [styleFilter, setStyleFilter] = useState(copy.customerListPage.filters.styleOptions[0])
   const pageCopy = copy.customerListPage
 
   const loadCustomers = useCallback(() => {
-    const style = STYLE_API_MAP[styleFilter] || undefined
-    listCustomers({ q: query.trim() || undefined, style }).then(setItems).catch(() => setItems([]))
-  }, [query, styleFilter])
+    listCustomers({
+      page,
+      pageSize,
+      q: query.trim() || undefined,
+      visitStatus: VISIT_STATUS_API_MAP[visitFilter],
+      followStatus: FOLLOW_STATUS_API_MAP[followFilter],
+      hairLength: HAIR_LENGTH_API_MAP[styleFilter],
+    })
+      .then((result) => {
+        setItems(result.items)
+        setTotal(result.total)
+      })
+      .catch(() => {
+        setItems([])
+        setTotal(0)
+      })
+  }, [followFilter, page, pageSize, query, styleFilter, visitFilter])
 
   useEffect(() => {
     const timer = setTimeout(loadCustomers, 300)
@@ -63,35 +98,6 @@ export function CustomerListPage() {
     getCustomerDashboardSummary().then(setSummary).catch(() => setSummary(emptySummary))
   }, [])
 
-  const filteredItems = useMemo(() => {
-    const today = new Date()
-    return items.filter((customer) => {
-      if (visitFilter !== pageCopy.filters.visitOptions[0]) {
-        const lastVisit = parseDate(customer.lastVisit)
-        if (!lastVisit) return false
-        const days = daysBetween(today, lastVisit)
-        if (visitFilter === pageCopy.filters.visitOptions[1]) {
-          if (days !== 0) return false
-        } else if (visitFilter === pageCopy.filters.visitOptions[2]) {
-          if (days > 7) return false
-        } else if (visitFilter === pageCopy.filters.visitOptions[3]) {
-          if (days < 30) return false
-        }
-      }
-
-      if (followFilter !== pageCopy.filters.followOptions[0]) {
-        const tags = customer.tags || []
-        const needsFollow = tags.some((tag) => tag.includes('跟进') || tag.includes('フォロー'))
-          || (customer.followStatus && customer.followStatus.includes('计划'))
-        const sent = customer.followStatus?.includes('已发送') || customer.followStatus?.includes('送信')
-        if (followFilter === pageCopy.filters.followOptions[1] && !needsFollow) return false
-        if (followFilter === pageCopy.filters.followOptions[2] && !sent) return false
-      }
-
-      return true
-    })
-  }, [items, visitFilter, followFilter, pageCopy.filters])
-
   return (
     <DashboardLayout>
       <Helmet>
@@ -159,7 +165,7 @@ export function CustomerListPage() {
             <div className="d-flex align-items-center justify-content-between mb-25px">
               <div>
                 <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.resultBadge}</span>
-                <h2 className="fw-700 text-medium-gray fs-26 mb-0">{pageCopy.resultTitle.replace('{{count}}', String(filteredItems.length))}</h2>
+                <h2 className="fw-700 text-medium-gray fs-26 mb-0">{pageCopy.resultTitle.replace('{{count}}', String(total))}</h2>
               </div>
             </div>
 
@@ -167,20 +173,30 @@ export function CustomerListPage() {
               <div className="customer-row customer-head">
                 {pageCopy.tableHeaders.map((label: string) => <span key={label}>{label}</span>)}
               </div>
-              {filteredItems.length === 0 ? (
+              {items.length === 0 ? (
                 <p className="fs-15 text-medium-gray mb-0 mt-3">{copy.common.empty}</p>
-              ) : filteredItems.map((customer, index) => (
+              ) : items.map((customer, index) => (
                 <div key={customer.id} className="customer-row">
                   <div className="customer-profile">
                     <img src={customer.image || '/images/aitori-before-photo.png'} alt={customer.name} className="customer-avatar" />
                     <div>
                       <span className="fw-700 text-dark-gray d-block">{customer.name}</span>
-                      <span className="fs-14 text-medium-gray">{customer.phone} / {customer.hairLength === 'Medium' ? pageCopy.hairLengthMap.medium : customer.hairLength === 'Long' ? pageCopy.hairLengthMap.long : pageCopy.hairLengthMap.short}</span>
+                      <span className="fs-14 text-medium-gray">
+                        {customer.phone}
+                        {' / '}
+                        {customer.hairLength === 'Medium'
+                          ? pageCopy.hairLengthMap.medium
+                          : customer.hairLength === 'Long'
+                            ? pageCopy.hairLengthMap.long
+                            : customer.hairLength === 'Short'
+                              ? pageCopy.hairLengthMap.short
+                              : '—'}
+                      </span>
                     </div>
                   </div>
                   <span>{customer.lastVisit || '—'}</span>
-                  <span>{customer.tags?.[0] || customer.concern || '—'}</span>
-                  <span><span className="workbench-badge">{customer.followStatus || pageCopy.rowStatuses[index] || pageCopy.rowStatuses[5]}</span></span>
+                  <span>{customer.concern || '—'}</span>
+                  <span><span className="workbench-badge">{getFollowStatusLabel(customer.followStatus, pageCopy)}</span></span>
                   <Link to={path(`/customers/${customer.id}`)} className={`btn btn-very-small btn-rounded ${index === 0 ? 'btn-base-color' : 'btn-transparent-light-gray border border-1 border-color-dark-gray'}`}>
                     {pageCopy.detail}
                   </Link>

+ 171 - 0
frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx

@@ -0,0 +1,171 @@
+import { HelmetProvider } from 'react-helmet-async'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import type { ReactNode } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { CustomerFormPage } from '../CustomerFormPage'
+
+const { uploadMedia, createCustomer, navigate } = vi.hoisted(() => ({
+  uploadMedia: vi.fn(),
+  createCustomer: vi.fn(),
+  navigate: vi.fn(),
+}))
+
+vi.mock('../../../../layouts/DashboardLayout', () => ({
+  DashboardLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
+}))
+
+vi.mock('../../../../services/api/media', () => ({
+  uploadMedia,
+}))
+
+vi.mock('../../../../services/api/customers', () => ({
+  createCustomer,
+  getCustomerById: vi.fn(),
+  updateCustomer: vi.fn(),
+}))
+
+vi.mock('../../../../shared/pageContext', () => ({
+  usePageContext: () => ({
+    locale: 'zh',
+    navigate,
+    path: (path = '') => `/zh${path}`,
+    copy: {
+      common: { cancel: '取消' },
+      customerFormPage: {
+        photo: { badge: '照片', title: '顾客头像', change: '上传头像' },
+        nextStep: { badge: '下一步', title: '后续', steps: ['完善详情'] },
+        basic: {
+          badge: '基本信息',
+          title: '基本资料',
+          name: '姓名',
+          namePlaceholder: '请输入姓名',
+          kana: '片假名',
+          kanaPlaceholder: '请输入片假名',
+          phone: '电话号码',
+          email: '邮箱',
+          birthday: '生日',
+          contactNew: '联系方式',
+          contactEdit: 'LINE 状态',
+          contactNewOptions: ['LINE', '电话', '邮箱'],
+          contactEditOptions: ['已关联', '未关联'],
+        },
+        consultation: {
+          badgeNew: '咨询信息',
+          badgeEdit: '咨询信息',
+          titleNew: '咨询信息',
+          titleEdit: '咨询信息',
+          visitDateNew: '最近到店',
+          visitDateEdit: '最近到店',
+          visitPurposeNew: '到店周期',
+          visitPurposeEdit: '到店周期',
+          visitPurposeOptions: ['首次'],
+          visitCycleOptions: ['30天'],
+          concernNew: '关注点',
+          concernEdit: '关注点',
+          concernPlaceholder: '请输入关注点',
+          styleNew: '期望发型',
+          styleEdit: '期望发型',
+          stylePlaceholder: '请输入期望发型',
+        },
+        hair: {
+          badgeNew: '发质信息',
+          badgeEdit: '发质信息',
+          titleNew: '发质信息',
+          titleEdit: '发质信息',
+          fieldNewLeft: '发长',
+          fieldNewRight: '发质',
+          fieldEditLeft: '配方备注',
+          fieldEditRight: '过敏备注',
+          lengthOptions: ['短发', '中长发', '长发'],
+          hairTypeOptions: ['自然卷'],
+          formulaDefault: '请输入配方备注',
+          staffPlaceholder: '请输入过敏备注',
+          staffSecond: '内部备注',
+          staffSecondPlaceholder: '请输入内部备注',
+        },
+        follow: {
+          badge: '跟进',
+          titleNew: '跟进信息',
+          titleEdit: '跟进信息',
+          statusNew: '待设置',
+          statusEdit: '待设置',
+          timingNew: '跟进时间',
+          timingEdit: '跟进时间',
+          timingOptions: ['今天'],
+          nextVisitNew: '下次到店',
+          nextVisitEdit: '跟进状态',
+          statusOptions: ['需跟进', '已发送'],
+          templateNew: '跟进模板',
+          templateEdit: '联系方式',
+          contactOptions: ['LINE', '电话', '邮箱'],
+          templateOptions: ['需跟进', '已发送'],
+          messageNew: '跟进内容',
+          messageEdit: '跟进内容',
+        },
+        submit: {
+          titleNew: '保存顾客',
+          titleEdit: '保存顾客',
+          leadNew: '完成新建顾客',
+          leadEdit: '完成编辑顾客',
+        },
+        new: {
+          title: '新建顾客',
+          description: 'desc',
+          badge: 'badge',
+          heading: '新建顾客',
+          lead: '填写顾客资料',
+          submit: '登记顾客',
+          back: '返回顾客列表',
+        },
+        edit: {
+          title: '编辑顾客',
+          description: 'desc',
+          badge: 'badge',
+          heading: '编辑顾客',
+          lead: '编辑 {{name}}',
+          submit: '保存顾客',
+          back: '返回顾客详情',
+        },
+      },
+    },
+  }),
+  usePreviewUrl: (_file: File | null, fallback: string) => fallback,
+}))
+
+describe('CustomerFormPage', () => {
+  it('uploads avatar images with avatar type before create', async () => {
+    uploadMedia.mockResolvedValue({ url: 'https://example.com/avatar.png' })
+    createCustomer.mockResolvedValue({ id: '1' })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/customers/new']}>
+          <Routes>
+            <Route path="/:locale/customers/new" element={<CustomerFormPage mode="new" />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    fireEvent.change(screen.getByLabelText(/姓名/i), { target: { value: 'Sato Misaki' } })
+    fireEvent.change(screen.getByLabelText(/电话号码/i), { target: { value: '090-0000-0000' } })
+
+    const file = new File(['avatar'], 'avatar.png', { type: 'image/png' })
+    const input = document.querySelector('input[type="file"]') as HTMLInputElement
+    fireEvent.change(input, { target: { files: [file] } })
+
+    fireEvent.click(screen.getAllByRole('button', { name: /登记顾客/i })[0])
+
+    await waitFor(() => {
+      expect(uploadMedia).toHaveBeenCalledWith(file, 'avatar')
+    })
+    expect(createCustomer).toHaveBeenCalledWith(
+      expect.objectContaining({
+        name: 'Sato Misaki',
+        phone: '090-0000-0000',
+        image: 'https://example.com/avatar.png',
+      }),
+    )
+  })
+})

+ 88 - 0
frontend/src/pages/dashboard/customers/__tests__/CustomerListPage.test.tsx

@@ -0,0 +1,88 @@
+import { HelmetProvider } from 'react-helmet-async'
+import { render, screen, waitFor } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import type { ReactNode } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { CustomerListPage } from '../CustomerListPage'
+
+vi.mock('../../../../layouts/DashboardLayout', () => ({
+  DashboardLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
+}))
+
+vi.mock('../../../../services/api/customers', () => ({
+  listCustomers: vi.fn().mockResolvedValue({
+    items: [],
+    total: 42,
+    page: 1,
+    pageSize: 20,
+  }),
+  getCustomerDashboardSummary: vi.fn().mockResolvedValue({
+    total: 42,
+    today: 3,
+    followUp: 4,
+    simulated: 2,
+  }),
+}))
+
+vi.mock('../../../../shared/pageContext', () => ({
+  usePageContext: () => ({
+    locale: 'zh',
+    path: (path = '') => `/zh${path}`,
+    copy: {
+      common: { empty: '暂无数据' },
+      customerListPage: {
+        title: '顾客列表',
+        description: 'desc',
+        badge: 'badge',
+        lead: 'lead',
+        newCustomer: '新建顾客',
+        filters: {
+          searchLabel: '搜索',
+          searchPlaceholder: '输入关键词',
+          visitLabel: '到店状态',
+          visitOptions: ['全部', '今天到店', '本周到店', '30日以上未到店'],
+          followLabel: '跟进状态',
+          followOptions: ['全部', '需跟进', '已发送'],
+          styleLabel: '发长',
+          styleOptions: ['全部', '短发', '中长发', '长发'],
+        },
+        stats: {
+          total: '顾客总数',
+          today: '今天到店',
+          followUp: '需跟进',
+          simulated: '已模拟',
+        },
+        resultBadge: '搜索结果',
+        resultTitle: '搜索结果 {{count}}件',
+        tableHeaders: ['顾客', '最近到店', '备注', '状态', '操作'],
+        hairLengthMap: {
+          short: '短发',
+          medium: '中长发',
+          long: '长发',
+        },
+        detail: '查看详情',
+      },
+    },
+  }),
+}))
+
+describe('CustomerListPage', () => {
+  it('renders backend summary and total search count', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <CustomerListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await waitFor(() => {
+      expect(screen.getByText('搜索结果 42件')).toBeInTheDocument()
+    })
+
+    expect(screen.getByText('42')).toBeInTheDocument()
+    expect(screen.getByText('3')).toBeInTheDocument()
+    expect(screen.getByText('4')).toBeInTheDocument()
+    expect(screen.getByText('2')).toBeInTheDocument()
+  })
+})

+ 55 - 0
frontend/src/services/api/__tests__/customers.test.ts

@@ -0,0 +1,55 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { getCustomerDashboardSummary, listCustomers } from '../customers'
+import { http } from '../../http/client'
+
+vi.mock('../../http/client', () => ({
+  http: {
+    get: vi.fn(),
+    post: vi.fn(),
+    patch: vi.fn(),
+  },
+  ApiError: class ApiError extends Error {
+    status = 500
+  },
+}))
+
+describe('customers api', () => {
+  beforeEach(() => {
+    vi.mocked(http.get).mockReset()
+  })
+
+  it('serializes pagination and filter query params', async () => {
+    vi.mocked(http.get).mockResolvedValue({
+      items: [],
+      total: 0,
+      page: 1,
+      pageSize: 20,
+    })
+
+    await listCustomers({
+      page: 1,
+      pageSize: 20,
+      q: 'Sato',
+      visitStatus: 'TODAY',
+      followStatus: 'NEED_FOLLOW_UP',
+      hairLength: 'MEDIUM',
+    })
+
+    expect(http.get).toHaveBeenCalledWith(
+      '/customers?page=1&pageSize=20&q=Sato&visitStatus=TODAY&followStatus=NEED_FOLLOW_UP&hairLength=MEDIUM',
+    )
+  })
+
+  it('uses the customer summary route', async () => {
+    vi.mocked(http.get).mockResolvedValue({
+      total: 0,
+      today: 0,
+      followUp: 0,
+      simulated: 0,
+    })
+
+    await getCustomerDashboardSummary()
+
+    expect(http.get).toHaveBeenCalledWith('/customers/summary')
+  })
+})

+ 8 - 1
frontend/src/services/api/contracts.ts

@@ -84,7 +84,7 @@ export type CustomerDashboardSummary = {
   today: number
   followUp: number
   simulated: number
-  weeklyRecords: number
+  weeklyRecords?: number
 }
 
 export type TemplateDashboardSummary = {
@@ -112,3 +112,10 @@ export type ListResponse<T> = {
   items: T[]
   total: number
 }
+
+export type PaginatedCustomerList = {
+  items: CustomerEntity[]
+  total: number
+  page: number
+  pageSize: number
+}

+ 29 - 9
frontend/src/services/api/customers.ts

@@ -1,17 +1,25 @@
 import { http, ApiError, type ListEnvelope } from '../http/client'
-import type { CustomerDashboardSummary, CustomerEntity, GetCustomerByIdParams } from './contracts'
-export type { CustomerDashboardSummary, CustomerEntity, GetCustomerByIdParams } from './contracts'
+import type {
+  CustomerDashboardSummary,
+  CustomerEntity,
+  GetCustomerByIdParams,
+  PaginatedCustomerList,
+} from './contracts'
+export type {
+  CustomerDashboardSummary,
+  CustomerEntity,
+  GetCustomerByIdParams,
+  PaginatedCustomerList,
+} from './contracts'
 
 export type CustomerUpsertInput = {
   name: string
   phone: string
   kana?: string
   email?: string
-  summary?: string
   concern?: string
   hairLength?: string
   contact?: string
-  tags?: string[]
   image?: string
   lastVisit?: string
   nextVisit?: string
@@ -42,13 +50,25 @@ export type CustomerHistoryItem = {
   simulationType?: string | null
 }
 
-export async function listCustomers(params: { q?: string; style?: string } = {}): Promise<CustomerEntity[]> {
+export async function listCustomers(
+  params: {
+    page?: number
+    pageSize?: number
+    q?: string
+    visitStatus?: string
+    followStatus?: string
+    hairLength?: string
+  } = {},
+): Promise<PaginatedCustomerList> {
   const query = new URLSearchParams()
+  if (params.page) query.set('page', String(params.page))
+  if (params.pageSize) query.set('pageSize', String(params.pageSize))
   if (params.q) query.set('q', params.q)
-  if (params.style) query.set('style', params.style)
+  if (params.visitStatus) query.set('visitStatus', params.visitStatus)
+  if (params.followStatus) query.set('followStatus', params.followStatus)
+  if (params.hairLength) query.set('hairLength', params.hairLength)
   const qs = query.toString()
-  const data = await http.get<ListEnvelope<CustomerEntity>>(`/customers${qs ? `?${qs}` : ''}`)
-  return data.items
+  return http.get<PaginatedCustomerList>(`/customers${qs ? `?${qs}` : ''}`)
 }
 
 export async function getCustomerById({ customerId }: GetCustomerByIdParams = {}): Promise<CustomerEntity | null> {
@@ -84,5 +104,5 @@ export async function sendReminder(
 }
 
 export async function getCustomerDashboardSummary(): Promise<CustomerDashboardSummary> {
-  return http.get<CustomerDashboardSummary>('/stylist/dashboard/customer-summary')
+  return http.get<CustomerDashboardSummary>('/customers/summary')
 }

+ 10 - 3
frontend/src/services/api/media.ts

@@ -1,9 +1,16 @@
 import { http } from '../http/client'
 
-export type UploadedMedia = { id: number; url: string }
+export type UploadedMedia = {
+  url: string
+  objectKey: string
+  bucket: string
+  contentType: string
+  size: number
+}
 
-export async function uploadMedia(file: File): Promise<UploadedMedia> {
+export async function uploadMedia(file: File, type = 'avatar'): Promise<UploadedMedia> {
   const form = new FormData()
   form.append('file', file)
-  return http.upload<UploadedMedia>('/media/uploads', form)
+  form.append('type', type)
+  return http.upload<UploadedMedia>('/storage/upload', form)
 }