create-freakpunch 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +24 -23
  2. package/bin/create-freakpunch.mjs +6 -6
  3. package/files/README.md +19 -15
  4. package/files/app.config.ts +50 -58
  5. package/files/locales.ts +10 -0
  6. package/files/registry.ts +17 -0
  7. package/package.json +23 -23
  8. package/template/BACKEND.md +108 -86
  9. package/template/CLAUDE.md +15 -10
  10. package/template/design/COMPONENTS.md +1 -1
  11. package/template/design/DESIGN.md +3 -3
  12. package/template/design/PATTERNS.md +5 -5
  13. package/template/index.html +1 -1
  14. package/template/src/app.config.ts +50 -61
  15. package/template/src/app.roles.ts +24 -24
  16. package/template/src/core/api/demo.ts +8 -0
  17. package/template/src/core/audit/audit.service.ts +3 -34
  18. package/template/src/core/auth/auth.service.ts +25 -14
  19. package/template/src/core/auth/local-credentials.ts +46 -0
  20. package/template/src/core/auth/permissions.ts +1 -1
  21. package/template/src/core/company/company.service.ts +14 -12
  22. package/template/src/core/company/use-app-name.ts +7 -0
  23. package/template/src/core/components/page.tsx +4 -3
  24. package/template/src/core/config/runtime.ts +16 -14
  25. package/template/src/core/config/types.ts +3 -2
  26. package/template/src/core/layout/app-sidebar.tsx +19 -2
  27. package/template/src/core/notifications/notifications.service.ts +10 -115
  28. package/template/src/core/notifications/types.ts +5 -0
  29. package/template/src/core/setup/setup-gate.tsx +20 -0
  30. package/template/src/core/setup/setup.service.ts +101 -0
  31. package/template/src/core/users/users.service.ts +2 -2
  32. package/template/src/locales/en/auth.ts +2 -1
  33. package/template/src/locales/en/common.ts +1 -0
  34. package/template/src/locales/en/company.ts +3 -1
  35. package/template/src/locales/en/dashboard.ts +5 -0
  36. package/template/src/locales/en/setup.ts +45 -0
  37. package/template/src/locales/index.ts +4 -4
  38. package/template/src/locales/tr/auth.ts +2 -1
  39. package/template/src/locales/tr/common.ts +1 -0
  40. package/template/src/locales/tr/company.ts +3 -1
  41. package/template/src/locales/tr/dashboard.ts +5 -0
  42. package/template/src/locales/tr/setup.ts +49 -0
  43. package/template/src/modules/locales.ts +0 -4
  44. package/template/src/modules/registry.ts +17 -19
  45. package/template/src/pages/dashboard/dashboard-page.tsx +28 -243
  46. package/template/src/pages/dashboard/quick-actions.tsx +37 -0
  47. package/template/src/pages/login-page.tsx +26 -14
  48. package/template/src/pages/not-found-page.tsx +1 -2
  49. package/template/src/pages/settings/account/account.service.ts +22 -41
  50. package/template/src/pages/settings/account/notifications-page.tsx +3 -3
  51. package/template/src/pages/settings/company/general-page.tsx +6 -4
  52. package/template/src/pages/settings/company/integrations/integrations.service.ts +8 -28
  53. package/template/src/pages/settings/company/modules-page.tsx +10 -2
  54. package/template/src/pages/settings/company/roles-page.tsx +2 -1
  55. package/template/src/pages/setup-page.tsx +387 -0
  56. package/template/src/router.tsx +9 -4
  57. package/template/tools/new-module.mjs +90 -90
  58. package/template/tools/templates/module/index.ts.tpl +44 -37
  59. package/template/tools/templates/module/locales/en.ts.tpl +51 -45
  60. package/template/tools/templates/module/locales/tr.ts.tpl +55 -49
  61. package/template/{src/modules/products/pages/product-detail-page.tsx → tools/templates/module/pages/__id__-detail-page.tsx.tpl} +103 -108
  62. package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -121
  63. package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -237
  64. package/template/vite.config.ts +6 -3
  65. package/template/src/core/users/users.mock.ts +0 -24
  66. package/template/src/locales/en/landing.ts +0 -15
  67. package/template/src/locales/tr/landing.ts +0 -19
  68. package/template/src/modules/products/api/products.mock.ts +0 -46
  69. package/template/src/modules/products/api/products.service.ts +0 -84
  70. package/template/src/modules/products/components/product-status.tsx +0 -21
  71. package/template/src/modules/products/hooks.ts +0 -73
  72. package/template/src/modules/products/index.ts +0 -50
  73. package/template/src/modules/products/locales/en.ts +0 -78
  74. package/template/src/modules/products/locales/tr.ts +0 -82
  75. package/template/src/modules/products/pages/product-form-page.tsx +0 -211
  76. package/template/src/modules/products/pages/products-list-page.tsx +0 -286
  77. package/template/src/modules/products/types.ts +0 -61
  78. package/template/src/pages/dashboard/dashboard.service.ts +0 -92
  79. package/template/src/pages/landing-page.tsx +0 -79
@@ -1,84 +0,0 @@
1
- import { createMockCollection, delay, notFound, paginate } from '@/core/api/mock'
2
- import type { ListParams, Paginated } from '@/core/api/types'
3
- import { productFormSchema, type Product, type ProductFilters, type ProductInput, type ProductStats } from '../types'
4
- import { createProductSeed } from './products.mock'
5
-
6
- /**
7
- * The only contract screens depend on. To connect a real backend, implement
8
- * this interface with `http` from '@/core/api/http' and export it below, e.g.
9
- *
10
- * const restProductsService: ProductsService = {
11
- * list: (p) => http.get('/products', { query: toQuery(p) }),
12
- * get: (id) => http.get(`/products/${id}`),
13
- * ...
14
- * }
15
- */
16
- export interface ProductsService {
17
- list(params: ListParams<ProductFilters>): Promise<Paginated<Product>>
18
- get(id: string): Promise<Product>
19
- create(input: ProductInput): Promise<Product>
20
- update(id: string, input: ProductInput): Promise<Product>
21
- remove(id: string): Promise<void>
22
- stats(): Promise<ProductStats>
23
- }
24
-
25
- const LOW_STOCK = 10
26
-
27
- function createMockProductsService(): ProductsService {
28
- const db = createMockCollection('products', createProductSeed)
29
- const find = (id: string) => db.find(id) ?? notFound('Product', id)
30
- const nextId = () => `p_${String(Math.max(0, ...db.all().map((p) => Number(p.id.slice(2)))) + 1).padStart(4, '0')}`
31
-
32
- return {
33
- async list(params) {
34
- await delay()
35
- const { status, category } = params.filters ?? {}
36
- const filtered = db.all().filter(
37
- (p) => (!status || p.status === status) && (!category || p.category === category),
38
- )
39
- return paginate(filtered, params, ['name', 'sku', 'category'])
40
- },
41
-
42
- async get(id) {
43
- await delay(150, 350)
44
- return find(id)
45
- },
46
-
47
- async create(input) {
48
- await delay()
49
- const data = productFormSchema.parse(input)
50
- const now = new Date().toISOString()
51
- const product: Product = { ...data, id: nextId(), createdAt: now, updatedAt: now }
52
- db.save([product, ...db.all()])
53
- return product
54
- },
55
-
56
- async update(id, input) {
57
- await delay()
58
- const data = productFormSchema.parse(input)
59
- const updated: Product = { ...find(id), ...data, updatedAt: new Date().toISOString() }
60
- db.save(db.all().map((p) => (p.id === id ? updated : p)))
61
- return updated
62
- },
63
-
64
- async remove(id) {
65
- await delay()
66
- find(id)
67
- db.save(db.all().filter((p) => p.id !== id))
68
- },
69
-
70
- async stats() {
71
- await delay(150, 300)
72
- const rows = db.all()
73
- return {
74
- total: rows.length,
75
- active: rows.filter((p) => p.status === 'active').length,
76
- lowStock: rows.filter((p) => p.status === 'active' && p.stock < LOW_STOCK).length,
77
- inventoryValue: rows.reduce((sum, p) => sum + p.price * p.stock, 0),
78
- }
79
- },
80
- }
81
- }
82
-
83
- export const productsService: ProductsService = createMockProductsService()
84
- export { LOW_STOCK }
@@ -1,21 +0,0 @@
1
- import { useTranslation } from 'react-i18next'
2
-
3
- import { StatusBadge, type StatusTone } from '@/core/components/status-badge'
4
- import { useFormat } from '@/core/i18n/use-format'
5
- import { LOW_STOCK } from '../api/products.service'
6
- import type { ProductStatus } from '../types'
7
-
8
- const tones: Record<ProductStatus, StatusTone> = { active: 'success', draft: 'neutral', archived: 'warning' }
9
-
10
- export function ProductStatusBadge({ status }: { status: ProductStatus }) {
11
- const { t } = useTranslation('products')
12
- return <StatusBadge tone={tones[status]}>{t(`status.${status}`)}</StatusBadge>
13
- }
14
-
15
- export function StockLevel({ stock }: { stock: number }) {
16
- const { t } = useTranslation('products')
17
- const fmt = useFormat()
18
- if (stock === 0) return <span className="text-danger">{t('stock.out')}</span>
19
- if (stock < LOW_STOCK) return <span className="text-warning">{t('stock.left', { count: stock })}</span>
20
- return <span>{fmt.number(stock)}</span>
21
- }
@@ -1,73 +0,0 @@
1
- import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
-
3
- import type { ListParams } from '@/core/api/types'
4
- import { productsService } from './api/products.service'
5
- import type { Product, ProductFilters, ProductInput } from './types'
6
-
7
- /** Query keys: invalidate `productKeys.all` after any write. */
8
- export const productKeys = {
9
- all: ['products'] as const,
10
- list: (params: ListParams<ProductFilters>) => [...productKeys.all, 'list', params] as const,
11
- detail: (id: string) => [...productKeys.all, 'detail', id] as const,
12
- stats: () => [...productKeys.all, 'stats'] as const,
13
- }
14
-
15
- export function useProducts(params: ListParams<ProductFilters>) {
16
- return useQuery({
17
- queryKey: productKeys.list(params),
18
- queryFn: () => productsService.list(params),
19
- placeholderData: keepPreviousData,
20
- })
21
- }
22
-
23
- export function useProduct(id: string | undefined) {
24
- return useQuery({
25
- queryKey: productKeys.detail(id ?? ''),
26
- queryFn: () => productsService.get(id!),
27
- enabled: Boolean(id),
28
- })
29
- }
30
-
31
- /** Every row matching the current search and filters, for CSV export. */
32
- export function useExportProducts(params: ListParams<ProductFilters>) {
33
- return async () => (await productsService.list({ ...params, page: 1, pageSize: 10_000 })).items
34
- }
35
-
36
- export function useProductStats() {
37
- return useQuery({ queryKey: productKeys.stats(), queryFn: () => productsService.stats() })
38
- }
39
-
40
- /** Sidebar badge: active products running low on stock. */
41
- export function useLowStockCount() {
42
- return useProductStats().data?.lowStock
43
- }
44
-
45
- export function useCreateProduct() {
46
- const qc = useQueryClient()
47
- return useMutation({
48
- mutationFn: (input: ProductInput) => productsService.create(input),
49
- onSuccess: () => qc.invalidateQueries({ queryKey: productKeys.all }),
50
- })
51
- }
52
-
53
- export function useUpdateProduct(id: string) {
54
- const qc = useQueryClient()
55
- return useMutation({
56
- mutationFn: (input: ProductInput) => productsService.update(id, input),
57
- onSuccess: (product: Product) => {
58
- qc.setQueryData(productKeys.detail(id), product)
59
- return qc.invalidateQueries({ queryKey: productKeys.all })
60
- },
61
- })
62
- }
63
-
64
- export function useDeleteProduct() {
65
- const qc = useQueryClient()
66
- return useMutation({
67
- mutationFn: (id: string) => productsService.remove(id),
68
- onSuccess: (_, id) => {
69
- qc.removeQueries({ queryKey: productKeys.detail(id) })
70
- return qc.invalidateQueries({ queryKey: productKeys.all })
71
- },
72
- })
73
- }
@@ -1,50 +0,0 @@
1
- import { Package, PackagePlus } from 'lucide-react'
2
-
3
- import { lazyPage } from '@/core/modules/lazy-page'
4
- import type { AppModule } from '@/core/modules/types'
5
- import { useLowStockCount } from './hooks'
6
-
7
- export const productsModule: AppModule = {
8
- id: 'products',
9
- nav: [
10
- {
11
- label: 'products:nav',
12
- path: '/app/products',
13
- icon: Package,
14
- group: 'catalog',
15
- useBadge: useLowStockCount,
16
- permission: 'products.view',
17
- },
18
- ],
19
- quickActions: [
20
- {
21
- label: 'products:quickActions.new',
22
- description: 'products:quickActions.newDescription',
23
- path: '/app/products/new',
24
- icon: PackagePlus,
25
- permission: 'products.create',
26
- },
27
- ],
28
- routes: [
29
- {
30
- path: 'products',
31
- handle: { crumb: 'products:crumbs.list', permission: 'products.view' },
32
- children: [
33
- { index: true, lazy: lazyPage(() => import('./pages/products-list-page')) },
34
- { path: 'new', handle: { crumb: 'products:crumbs.new', permission: 'products.create' }, lazy: lazyPage(() => import('./pages/product-form-page')) },
35
- {
36
- path: ':id',
37
- handle: { crumb: 'products:crumbs.details' },
38
- children: [
39
- { index: true, lazy: lazyPage(() => import('./pages/product-detail-page')) },
40
- {
41
- path: 'edit',
42
- handle: { crumb: 'products:crumbs.edit', permission: 'products.edit' },
43
- lazy: lazyPage(() => import('./pages/product-form-page')),
44
- },
45
- ],
46
- },
47
- ],
48
- },
49
- ],
50
- }
@@ -1,78 +0,0 @@
1
- /** Products module strings (namespace `products`). */
2
- export default {
3
- nav: 'Products',
4
- crumbs: { list: 'Products', new: 'New', details: 'Details', edit: 'Edit' },
5
- quickActions: { new: 'New product', newDescription: 'Add to your catalog' },
6
- noun: 'products',
7
- status: { active: 'Active', draft: 'Draft', archived: 'Archived' },
8
- categories: { Lighting: 'Lighting', Furniture: 'Furniture', Decor: 'Decor', Kitchen: 'Kitchen', Textiles: 'Textiles' },
9
- stock: { out: 'Out of stock', left: '{{count}} left' },
10
- list: {
11
- title: 'Products',
12
- description: 'Manage your catalog, pricing and stock.',
13
- new: 'New product',
14
- export: 'Export CSV',
15
- search: 'Search name or SKU…',
16
- filterStatus: 'Status',
17
- allStatuses: 'All statuses',
18
- filterCategory: 'Category',
19
- allCategories: 'All categories',
20
- columns: { product: 'Product', category: 'Category', status: 'Status', stock: 'Stock', price: 'Price', updated: 'Updated' },
21
- copySku: 'Copy SKU',
22
- skuCopied: 'SKU copied',
23
- noMatchesTitle: 'No matching products',
24
- noMatchesDescription: 'Try a different search or clear the filters.',
25
- emptyTitle: 'No products yet',
26
- emptyDescription: 'Create your first product to start selling.',
27
- },
28
- delete: {
29
- title: 'Delete “{{name}}”?',
30
- description: 'This product will be removed from your catalog. This cannot be undone.',
31
- deleting: 'Deleting…',
32
- deleted: '{{name}} deleted',
33
- bulkTitle_one: 'Delete {{count}} product?',
34
- bulkTitle_other: 'Delete {{count}} products?',
35
- bulkDescription: 'They will be removed from your catalog. This cannot be undone.',
36
- bulkDeleted_one: '{{count}} product deleted',
37
- bulkDeleted_other: '{{count}} products deleted',
38
- },
39
- detail: {
40
- back: 'Back to products',
41
- overview: 'Overview',
42
- summary: 'Summary',
43
- noDescription: 'No description.',
44
- fields: { status: 'Status', price: 'Price', stock: 'Stock', category: 'Category', created: 'Created', updated: 'Updated' },
45
- },
46
- form: {
47
- newTitle: 'New product',
48
- editTitle: 'Edit {{name}}',
49
- newDescription: 'Add a product to your catalog.',
50
- editDescription: 'Update details, pricing and inventory.',
51
- create: 'Create product',
52
- created: 'Product created',
53
- saved: 'Changes saved',
54
- saveFailed: 'Could not save product',
55
- sections: {
56
- details: 'Details',
57
- detailsDescription: 'Name and description shown to customers.',
58
- organization: 'Organization',
59
- organizationDescription: 'How the product is grouped and whether it is visible.',
60
- pricing: 'Pricing & inventory',
61
- },
62
- fields: {
63
- name: 'Name',
64
- description: 'Description',
65
- descriptionHint: 'Up to 500 characters.',
66
- category: 'Category',
67
- status: 'Status',
68
- sku: 'SKU',
69
- price: 'Price',
70
- stock: 'Stock',
71
- },
72
- errors: {
73
- sku: 'Use 3–20 uppercase letters, numbers or dashes',
74
- price: 'Enter a price',
75
- stock: 'Enter a stock level',
76
- },
77
- },
78
- }
@@ -1,82 +0,0 @@
1
- import type { Messages } from '@/core/i18n/types'
2
- import type en from './en'
3
-
4
- const products: Messages<typeof en> = {
5
- nav: 'Ürünler',
6
- crumbs: { list: 'Ürünler', new: 'Yeni', details: 'Ayrıntılar', edit: 'Düzenle' },
7
- quickActions: { new: 'Yeni ürün', newDescription: 'Kataloğunuza ekleyin' },
8
- noun: 'ürün',
9
- status: { active: 'Aktif', draft: 'Taslak', archived: 'Arşivlendi' },
10
- categories: { Lighting: 'Aydınlatma', Furniture: 'Mobilya', Decor: 'Dekorasyon', Kitchen: 'Mutfak', Textiles: 'Tekstil' },
11
- stock: { out: 'Stokta yok', left: '{{count}} adet kaldı' },
12
- list: {
13
- title: 'Ürünler',
14
- description: 'Kataloğunuzu, fiyatlarınızı ve stoklarınızı yönetin.',
15
- new: 'Yeni ürün',
16
- export: 'CSV olarak dışa aktar',
17
- search: 'Ad veya SKU ara…',
18
- filterStatus: 'Durum',
19
- allStatuses: 'Tüm durumlar',
20
- filterCategory: 'Kategori',
21
- allCategories: 'Tüm kategoriler',
22
- columns: { product: 'Ürün', category: 'Kategori', status: 'Durum', stock: 'Stok', price: 'Fiyat', updated: 'Güncellendi' },
23
- copySku: 'SKU’yu kopyala',
24
- skuCopied: 'SKU kopyalandı',
25
- noMatchesTitle: 'Eşleşen ürün yok',
26
- noMatchesDescription: 'Farklı bir arama deneyin veya filtreleri temizleyin.',
27
- emptyTitle: 'Henüz ürün yok',
28
- emptyDescription: 'Satışa başlamak için ilk ürününüzü oluşturun.',
29
- },
30
- delete: {
31
- title: '“{{name}}” silinsin mi?',
32
- description: 'Bu ürün kataloğunuzdan kaldırılacak. Bu işlem geri alınamaz.',
33
- deleting: 'Siliniyor…',
34
- deleted: '{{name}} silindi',
35
- bulkTitle_one: '{{count}} ürün silinsin mi?',
36
- bulkTitle_other: '{{count}} ürün silinsin mi?',
37
- bulkDescription: 'Kataloğunuzdan kaldırılacaklar. Bu işlem geri alınamaz.',
38
- bulkDeleted_one: '{{count}} ürün silindi',
39
- bulkDeleted_other: '{{count}} ürün silindi',
40
- },
41
- detail: {
42
- back: 'Ürünlere dön',
43
- overview: 'Genel bakış',
44
- summary: 'Özet',
45
- noDescription: 'Açıklama yok.',
46
- fields: { status: 'Durum', price: 'Fiyat', stock: 'Stok', category: 'Kategori', created: 'Oluşturuldu', updated: 'Güncellendi' },
47
- },
48
- form: {
49
- newTitle: 'Yeni ürün',
50
- editTitle: '{{name}} ürününü düzenle',
51
- newDescription: 'Kataloğunuza bir ürün ekleyin.',
52
- editDescription: 'Ayrıntıları, fiyatı ve stoğu güncelleyin.',
53
- create: 'Ürün oluştur',
54
- created: 'Ürün oluşturuldu',
55
- saved: 'Değişiklikler kaydedildi',
56
- saveFailed: 'Ürün kaydedilemedi',
57
- sections: {
58
- details: 'Ayrıntılar',
59
- detailsDescription: 'Müşterilere gösterilen ad ve açıklama.',
60
- organization: 'Düzenleme',
61
- organizationDescription: 'Ürünün nasıl gruplandığı ve görünür olup olmadığı.',
62
- pricing: 'Fiyat ve stok',
63
- },
64
- fields: {
65
- name: 'Ad',
66
- description: 'Açıklama',
67
- descriptionHint: 'En fazla 500 karakter.',
68
- category: 'Kategori',
69
- status: 'Durum',
70
- sku: 'SKU',
71
- price: 'Fiyat',
72
- stock: 'Stok',
73
- },
74
- errors: {
75
- sku: '3–20 büyük harf, rakam veya tire kullanın',
76
- price: 'Bir fiyat girin',
77
- stock: 'Bir stok miktarı girin',
78
- },
79
- },
80
- }
81
-
82
- export default products
@@ -1,211 +0,0 @@
1
- import { Controller, useForm } from 'react-hook-form'
2
- import { zodResolver } from '@hookform/resolvers/zod'
3
- import { useTranslation } from 'react-i18next'
4
- import { useNavigate, useParams } from 'react-router'
5
- import { toast } from 'sonner'
6
-
7
- import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
8
- import { Input } from '@/components/ui/input'
9
- import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '@/components/ui/input-group'
10
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
11
- import { Textarea } from '@/components/ui/textarea'
12
- import { ButtonLink } from '@/core/components/button-link'
13
- import { FormFieldError } from '@/core/components/form-error'
14
- import { FormLayout, FormSection } from '@/core/components/form-page'
15
- import { Page } from '@/core/components/page'
16
- import { ErrorState, LoadingState } from '@/core/components/states'
17
- import { useFormat } from '@/core/i18n/use-format'
18
- import { useCreateProduct, useProduct, useUpdateProduct } from '../hooks'
19
- import { productCategories, productFormSchema, productStatuses, type Product, type ProductInput } from '../types'
20
-
21
- function BackToList() {
22
- const { t } = useTranslation(['products', 'common'])
23
- return (
24
- <ButtonLink to="/app/products" variant="outline">
25
- {t('detail.back')}
26
- </ButtonLink>
27
- )
28
- }
29
-
30
- const emptyProduct: ProductInput = {
31
- name: '',
32
- sku: '',
33
- category: 'Lighting',
34
- price: 0,
35
- stock: 0,
36
- status: 'draft',
37
- description: '',
38
- }
39
-
40
- const toInput = ({ name, sku, category, price, stock, status, description }: Product): ProductInput => ({
41
- name,
42
- sku,
43
- category,
44
- price,
45
- stock,
46
- status,
47
- description,
48
- })
49
-
50
- /** /app/products/new and /app/products/:id/edit */
51
- export default function ProductFormPage() {
52
- const { id } = useParams()
53
- const query = useProduct(id)
54
-
55
- if (!id) return <ProductForm />
56
- if (query.isPending) return <LoadingState />
57
- if (query.error) return <ErrorState error={query.error} onRetry={() => query.refetch()} notFoundAction={<BackToList />} />
58
- return <ProductForm key={query.data.id} product={query.data} />
59
- }
60
-
61
- function ProductForm({ product }: { product?: Product }) {
62
- const navigate = useNavigate()
63
- const create = useCreateProduct()
64
- const update = useUpdateProduct(product?.id ?? '')
65
- const { t } = useTranslation(['products', 'common'])
66
- const fmt = useFormat()
67
- const isEdit = Boolean(product)
68
-
69
- const form = useForm<ProductInput>({
70
- resolver: zodResolver(productFormSchema),
71
- defaultValues: product ? toInput(product) : emptyProduct,
72
- })
73
- const { register, control, handleSubmit, formState } = form
74
- const { errors } = formState
75
-
76
- const onSubmit = handleSubmit(async (values) => {
77
- try {
78
- const saved = isEdit ? await update.mutateAsync(values) : await create.mutateAsync(values)
79
- toast.success(isEdit ? t('form.saved') : t('form.created'), { description: saved.name })
80
- navigate(`/app/products/${saved.id}`)
81
- } catch (e) {
82
- toast.error(t('form.saveFailed'), { description: e instanceof Error ? e.message : undefined })
83
- }
84
- })
85
-
86
- const categoryItems = productCategories.map((c) => ({ value: c, label: t(`categories.${c}`) }))
87
- const statusItems = productStatuses.map((s) => ({ value: s, label: t(`status.${s}`) }))
88
-
89
- return (
90
- <Page
91
- title={isEdit ? t('form.editTitle', { name: product!.name }) : t('form.newTitle')}
92
- description={isEdit ? t('form.editDescription') : t('form.newDescription')}
93
- >
94
- <FormLayout
95
- onSubmit={onSubmit}
96
- submitLabel={isEdit ? t('common:actions.saveChanges') : t('form.create')}
97
- isSubmitting={formState.isSubmitting}
98
- isDirty={!isEdit || formState.isDirty}
99
- onCancel={() => navigate(-1)}
100
- >
101
- <FormSection title={t('form.sections.details')} description={t('form.sections.detailsDescription')}>
102
- <Field data-invalid={!!errors.name}>
103
- <FieldLabel htmlFor="name">{t('form.fields.name')}</FieldLabel>
104
- <Input id="name" autoFocus={!isEdit} aria-invalid={!!errors.name} {...register('name')} />
105
- <FormFieldError error={errors.name} />
106
- </Field>
107
- <Field data-invalid={!!errors.description}>
108
- <FieldLabel htmlFor="description">{t('form.fields.description')}</FieldLabel>
109
- <Textarea id="description" rows={4} aria-invalid={!!errors.description} {...register('description')} />
110
- <FieldDescription>{t('form.fields.descriptionHint')}</FieldDescription>
111
- <FormFieldError error={errors.description} />
112
- </Field>
113
- </FormSection>
114
-
115
- <FormSection title={t('form.sections.organization')} description={t('form.sections.organizationDescription')}>
116
- <div className="grid gap-4 sm:grid-cols-2">
117
- <Field data-invalid={!!errors.category}>
118
- <FieldLabel htmlFor="category">{t('form.fields.category')}</FieldLabel>
119
- <Controller
120
- control={control}
121
- name="category"
122
- render={({ field }) => (
123
- <Select items={categoryItems} value={field.value} onValueChange={(v) => v && field.onChange(v)}>
124
- <SelectTrigger id="category" className="w-full" onBlur={field.onBlur}>
125
- <SelectValue />
126
- </SelectTrigger>
127
- <SelectContent>
128
- {categoryItems.map((c) => (
129
- <SelectItem key={c.value} value={c.value}>
130
- {c.label}
131
- </SelectItem>
132
- ))}
133
- </SelectContent>
134
- </Select>
135
- )}
136
- />
137
- <FormFieldError error={errors.category} />
138
- </Field>
139
- <Field data-invalid={!!errors.status}>
140
- <FieldLabel htmlFor="status">{t('form.fields.status')}</FieldLabel>
141
- <Controller
142
- control={control}
143
- name="status"
144
- render={({ field }) => (
145
- <Select items={statusItems} value={field.value} onValueChange={(v) => v && field.onChange(v)}>
146
- <SelectTrigger id="status" className="w-full" onBlur={field.onBlur}>
147
- <SelectValue />
148
- </SelectTrigger>
149
- <SelectContent>
150
- {statusItems.map((s) => (
151
- <SelectItem key={s.value} value={s.value}>
152
- {s.label}
153
- </SelectItem>
154
- ))}
155
- </SelectContent>
156
- </Select>
157
- )}
158
- />
159
- <FormFieldError error={errors.status} />
160
- </Field>
161
- </div>
162
- <Field data-invalid={!!errors.sku}>
163
- <FieldLabel htmlFor="sku">{t('form.fields.sku')}</FieldLabel>
164
- <Input
165
- id="sku"
166
- className="tabular-nums uppercase"
167
- aria-invalid={!!errors.sku}
168
- {...register('sku', { setValueAs: (v: string) => v.toUpperCase() })}
169
- />
170
- <FormFieldError error={errors.sku} />
171
- </Field>
172
- </FormSection>
173
-
174
- <FormSection title={t('form.sections.pricing')}>
175
- <div className="grid gap-4 sm:grid-cols-2">
176
- <Field data-invalid={!!errors.price}>
177
- <FieldLabel htmlFor="price">{t('form.fields.price')}</FieldLabel>
178
- <InputGroup>
179
- <InputGroupAddon>
180
- <InputGroupText>{fmt.currencySymbol}</InputGroupText>
181
- </InputGroupAddon>
182
- <InputGroupInput
183
- id="price"
184
- type="number"
185
- inputMode="decimal"
186
- step="0.01"
187
- min={0}
188
- aria-invalid={!!errors.price}
189
- {...register('price', { valueAsNumber: true })}
190
- />
191
- </InputGroup>
192
- <FormFieldError error={errors.price} />
193
- </Field>
194
- <Field data-invalid={!!errors.stock}>
195
- <FieldLabel htmlFor="stock">{t('form.fields.stock')}</FieldLabel>
196
- <Input
197
- id="stock"
198
- type="number"
199
- inputMode="numeric"
200
- min={0}
201
- aria-invalid={!!errors.stock}
202
- {...register('stock', { valueAsNumber: true })}
203
- />
204
- <FormFieldError error={errors.stock} />
205
- </Field>
206
- </div>
207
- </FormSection>
208
- </FormLayout>
209
- </Page>
210
- )
211
- }