@hed-hog/catalog 0.0.276 → 0.0.279

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.
@@ -1,12 +1,12 @@
1
1
  'use client';
2
-
3
- import { CatalogNav } from '../_components/catalog-nav';
4
2
  import {
5
3
  catalogResourceMap,
6
4
  type CatalogFieldDefinition,
7
5
  type CatalogFilterOptionDefinition,
6
+ getCatalogLocalizedText,
8
7
  getCatalogRecordLabel,
9
8
  } from '../_lib/catalog-resources';
9
+ import { CatalogResourceFormSheet } from '../_components/catalog-resource-form-sheet';
10
10
  import {
11
11
  EntityCard,
12
12
  Page,
@@ -25,24 +25,8 @@ import {
25
25
  AlertDialogHeader,
26
26
  AlertDialogTitle,
27
27
  } from '@/components/ui/alert-dialog';
28
- import { Badge } from '@/components/ui/badge';
29
28
  import { Button } from '@/components/ui/button';
30
- import {
31
- Card,
32
- CardContent,
33
- CardDescription,
34
- CardHeader,
35
- CardTitle,
36
- } from '@/components/ui/card';
37
- import {
38
- Dialog,
39
- DialogContent,
40
- DialogDescription,
41
- DialogFooter,
42
- DialogHeader,
43
- DialogTitle,
44
- } from '@/components/ui/dialog';
45
- import { Label } from '@/components/ui/label';
29
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
46
30
  import {
47
31
  Table,
48
32
  TableBody,
@@ -51,15 +35,13 @@ import {
51
35
  TableHeader,
52
36
  TableRow,
53
37
  } from '@/components/ui/table';
54
- import { Textarea } from '@/components/ui/textarea';
55
38
  import { useDebounce } from '@/hooks/use-debounce';
56
- import { cn } from '@/lib/utils';
57
39
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
58
- import { Eye, Pencil, Plus, RefreshCcw, Search, Trash2 } from 'lucide-react';
40
+ import { Eye, Pencil, Plus, Search, Trash2 } from 'lucide-react';
59
41
  import Link from 'next/link';
60
42
  import { useParams } from 'next/navigation';
61
43
  import { useTranslations } from 'next-intl';
62
- import { useEffect, useMemo, useState } from 'react';
44
+ import { useMemo, useState } from 'react';
63
45
  import { toast } from 'sonner';
64
46
 
65
47
  type PaginationResult<T> = {
@@ -87,24 +69,15 @@ export default function CatalogResourcePage() {
87
69
  const resource = params?.resource ?? '';
88
70
  const config = catalogResourceMap.get(resource);
89
71
  const t = useTranslations('catalog');
90
- const { request, currentLocaleCode } = useApp();
72
+ const { accessToken, request, currentLocaleCode } = useApp();
91
73
  const [search, setSearch] = useState('');
92
74
  const debouncedSearch = useDebounce(search, 400);
93
75
  const [filterValue, setFilterValue] = useState('all');
94
76
  const [page, setPage] = useState(1);
95
77
  const [pageSize, setPageSize] = useState(12);
96
- const [dialogOpen, setDialogOpen] = useState(false);
78
+ const [sheetOpen, setSheetOpen] = useState(false);
97
79
  const [deleteId, setDeleteId] = useState<number | null>(null);
98
- const [editingRecord, setEditingRecord] = useState<CatalogRecord | null>(
99
- null
100
- );
101
- const [payload, setPayload] = useState('{}');
102
- const [isSubmitting, setIsSubmitting] = useState(false);
103
-
104
- const initialPayload = useMemo(
105
- () => JSON.stringify(config?.template ?? {}, null, 2),
106
- [config]
107
- );
80
+ const [editingRecordId, setEditingRecordId] = useState<number | null>(null);
108
81
  const resourceTitle = config
109
82
  ? t(`resources.${config.translationKey}.title`)
110
83
  : t('unsupportedResource');
@@ -122,17 +95,34 @@ export default function CatalogResourcePage() {
122
95
  }),
123
96
  [currentLocaleCode]
124
97
  );
125
-
126
- useEffect(() => {
127
- setPayload(initialPayload);
128
- }, [initialPayload]);
129
-
130
- useEffect(() => {
131
- setPage(1);
132
- }, [debouncedSearch, filterValue]);
98
+ const isAppReady = accessToken.trim().length > 0;
99
+ const translatableValueFields = useMemo(
100
+ () =>
101
+ new Set([
102
+ 'status',
103
+ 'comparison_status',
104
+ 'availability_status',
105
+ 'site_type',
106
+ 'merchant_type',
107
+ 'network_type',
108
+ 'commission_type',
109
+ 'page_type',
110
+ 'canonical_strategy',
111
+ 'source_type',
112
+ 'data_type',
113
+ 'comparison_mode',
114
+ 'facet_mode',
115
+ ]),
116
+ []
117
+ );
133
118
 
134
119
  const { data: stats } = useQuery<ResourceStats>({
135
- queryKey: ['catalog-resource-stats', resource],
120
+ queryKey: [
121
+ 'catalog-resource-stats',
122
+ resource,
123
+ accessToken,
124
+ currentLocaleCode,
125
+ ],
136
126
  queryFn: async () => {
137
127
  if (!resource || !config) {
138
128
  return { total: 0 };
@@ -152,17 +142,19 @@ export default function CatalogResourcePage() {
152
142
  statsData.active !== undefined ? Number(statsData.active) : undefined,
153
143
  };
154
144
  },
155
- initialData: { total: 0 },
145
+ enabled: Boolean(resource && config && isAppReady),
156
146
  });
157
- const resourceStats = stats as ResourceStats;
147
+ const resourceStats = (stats ?? { total: 0 }) as ResourceStats;
158
148
 
159
149
  const { data, refetch } = useQuery<PaginationResult<CatalogRecord>>({
160
150
  queryKey: [
161
- 'catalog-resource',
162
- resource,
163
- debouncedSearch,
164
- filterValue,
165
- page,
151
+ 'catalog-resource',
152
+ resource,
153
+ accessToken,
154
+ currentLocaleCode,
155
+ debouncedSearch,
156
+ filterValue,
157
+ page,
166
158
  pageSize,
167
159
  ],
168
160
  queryFn: async () => {
@@ -199,16 +191,34 @@ export default function CatalogResourcePage() {
199
191
  pageSize: Number(responseData.pageSize ?? pageSize),
200
192
  };
201
193
  },
202
- initialData: { data: [], total: 0, page: 1, pageSize: 12 },
194
+ enabled: Boolean(resource && config && isAppReady),
203
195
  });
204
- const resourceData = data as PaginationResult<CatalogRecord>;
196
+ const resourceData = (data ?? {
197
+ data: [],
198
+ total: 0,
199
+ page: 1,
200
+ pageSize: 12,
201
+ }) as PaginationResult<CatalogRecord>;
205
202
 
206
203
  const safeTranslate = (key: string, fallback: string) => {
207
- try {
208
- return t(key);
209
- } catch {
204
+ const translated = t(key);
205
+
206
+ if (translated === key) {
210
207
  return fallback;
211
208
  }
209
+
210
+ return translated;
211
+ };
212
+
213
+ const resolveDynamicValueLabel = (fieldKey: string, value: string) => {
214
+ if (!translatableValueFields.has(fieldKey)) {
215
+ return value;
216
+ }
217
+
218
+ return safeTranslate(
219
+ `resource.valueLabels.${fieldKey}.${value}`,
220
+ humanizeValue(value)
221
+ );
212
222
  };
213
223
 
214
224
  const getFirstValue = (record: CatalogRecord, keys: string[]) => {
@@ -246,10 +256,7 @@ export default function CatalogResourcePage() {
246
256
  }
247
257
 
248
258
  if (typeof value === 'string') {
249
- return safeTranslate(
250
- `resource.valueLabels.${field?.key}.${value}`,
251
- humanizeValue(value)
252
- );
259
+ return field?.key ? resolveDynamicValueLabel(field.key, value) : value;
253
260
  }
254
261
 
255
262
  return String(value);
@@ -329,7 +336,6 @@ export default function CatalogResourcePage() {
329
336
  { label: t('resource.notFound') },
330
337
  ]}
331
338
  />
332
- <CatalogNav currentHref="/catalog/dashboard" />
333
339
  <Card>
334
340
  <CardHeader>
335
341
  <CardTitle>{t('unsupportedResource')}</CardTitle>
@@ -348,46 +354,13 @@ export default function CatalogResourcePage() {
348
354
  }
349
355
 
350
356
  const openCreate = () => {
351
- setEditingRecord(null);
352
- setPayload(initialPayload);
353
- setDialogOpen(true);
357
+ setEditingRecordId(null);
358
+ setSheetOpen(true);
354
359
  };
355
360
 
356
361
  const openEdit = (record: CatalogRecord) => {
357
- setEditingRecord(record);
358
- setPayload(JSON.stringify(record, null, 2));
359
- setDialogOpen(true);
360
- };
361
-
362
- const submit = async () => {
363
- try {
364
- setIsSubmitting(true);
365
- const body = JSON.parse(payload);
366
-
367
- if (editingRecord?.id) {
368
- await request({
369
- url: `/catalog/${resource}/${editingRecord.id}`,
370
- method: 'PATCH',
371
- data: body,
372
- });
373
- } else {
374
- await request({
375
- url: `/catalog/${resource}`,
376
- method: 'POST',
377
- data: body,
378
- });
379
- }
380
-
381
- toast.success(t('toasts.saveSuccess', { resource: resourceTitle }));
382
- setDialogOpen(false);
383
- await refetch();
384
- } catch (error) {
385
- toast.error(
386
- error instanceof Error ? error.message : t('toasts.saveError')
387
- );
388
- } finally {
389
- setIsSubmitting(false);
390
- }
362
+ setEditingRecordId(Number(record.id));
363
+ setSheetOpen(true);
391
364
  };
392
365
 
393
366
  const handleDelete = async () => {
@@ -473,191 +446,170 @@ export default function CatalogResourcePage() {
473
446
  ]}
474
447
  actions={[
475
448
  {
476
- label: t('refresh'),
449
+ label: getCatalogLocalizedText(
450
+ config.createActionLabel,
451
+ currentLocaleCode
452
+ ),
477
453
  onClick: () => {
478
- void refetch();
454
+ openCreate();
479
455
  },
480
- variant: 'outline',
481
- icon: <RefreshCcw className="size-4" />,
456
+ icon: <Plus className="size-4" />,
482
457
  },
483
458
  ]}
484
459
  />
485
460
 
486
461
  <div className="min-w-0 space-y-4 overflow-x-hidden">
487
- <CatalogNav currentHref={config.href} />
488
-
489
462
  <StatsCards stats={statsCards} className="sm:grid-cols-3" />
490
463
 
491
- <Card className="min-w-0 overflow-hidden">
492
- <CardHeader className="border-b pb-4">
493
- <CardTitle>{t('resource.listTitle')}</CardTitle>
494
- <CardDescription>{t('resource.listDescription')}</CardDescription>
495
- </CardHeader>
496
- <CardContent className="min-w-0 space-y-6 pt-6">
497
- <div className="flex min-w-0 flex-col gap-3 xl:flex-row xl:items-center">
498
- <SearchBar
499
- searchQuery={search}
500
- onSearchChange={setSearch}
501
- onSearch={() => {
502
- setPage(1);
503
- void refetch();
504
- }}
505
- placeholder={t('searchPlaceholder')}
506
- className="min-w-0 flex-1 flex-wrap"
507
- filters={{
508
- value: filterValue,
509
- options: filterOptions,
510
- onChange: setFilterValue,
511
- placeholder: t(
512
- `resource.filters.${config.primaryFilterField}`
513
- ),
514
- }}
515
- />
516
- <Button onClick={openCreate} className="w-full xl:w-auto">
517
- <Plus className="mr-2 size-4" />
518
- {t('newRecord')}
519
- </Button>
464
+ <div className="min-w-0 space-y-4">
465
+ <SearchBar
466
+ searchQuery={search}
467
+ onSearchChange={(value) => {
468
+ setSearch(value);
469
+ setPage(1);
470
+ }}
471
+ onSearch={() => {
472
+ setPage(1);
473
+ void refetch();
474
+ }}
475
+ placeholder={t('searchPlaceholder')}
476
+ className="min-w-0"
477
+ filters={{
478
+ value: filterValue,
479
+ options: filterOptions,
480
+ onChange: (value) => {
481
+ setFilterValue(value);
482
+ setPage(1);
483
+ },
484
+ placeholder: t(`resource.filters.${config.primaryFilterField}`),
485
+ }}
486
+ />
487
+
488
+ {resourceData.data.length === 0 ? (
489
+ <div className="rounded-lg border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">
490
+ {t('resource.emptyState')}
520
491
  </div>
521
-
522
- {resourceData.data.length === 0 ? (
523
- <div className="rounded-lg border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">
524
- {t('resource.emptyState')}
525
- </div>
526
- ) : config.listVariant === 'table' ? (
527
- <div className="min-w-0 space-y-4">
528
- <div className="overflow-x-auto rounded-md border">
529
- <Table>
530
- <TableHeader>
531
- <TableRow>
492
+ ) : config.listVariant === 'table' ? (
493
+ <div className="min-w-0 space-y-4">
494
+ <div className="overflow-x-auto rounded-md border">
495
+ <Table>
496
+ <TableHeader>
497
+ <TableRow>
498
+ {config.tableColumns?.map((column) => (
499
+ <TableHead key={column.key}>
500
+ {t(`resource.fields.${column.labelKey}`)}
501
+ </TableHead>
502
+ ))}
503
+ <TableHead className="w-[110px] text-right">
504
+ {t('resource.fields.actions')}
505
+ </TableHead>
506
+ </TableRow>
507
+ </TableHeader>
508
+ <TableBody>
509
+ {resourceData.data.map((record) => (
510
+ <TableRow key={String(record.id)}>
532
511
  {config.tableColumns?.map((column) => (
533
- <TableHead key={column.key}>
534
- {t(`resource.fields.${column.labelKey}`)}
535
- </TableHead>
512
+ <TableCell key={`${record.id}-${column.key}`}>
513
+ {formatFieldValue(record[column.key], column)}
514
+ </TableCell>
536
515
  ))}
537
- <TableHead className="w-[110px] text-right">
538
- {t('resource.fields.actions')}
539
- </TableHead>
516
+ <TableCell className="text-right">
517
+ <div className="flex justify-end gap-2">
518
+ <Button
519
+ variant="outline"
520
+ size="icon"
521
+ onClick={() => openEdit(record)}
522
+ >
523
+ <Pencil className="size-4" />
524
+ </Button>
525
+ <Button
526
+ variant="destructive"
527
+ size="icon"
528
+ onClick={() => setDeleteId(Number(record.id))}
529
+ >
530
+ <Trash2 className="size-4" />
531
+ </Button>
532
+ </div>
533
+ </TableCell>
540
534
  </TableRow>
541
- </TableHeader>
542
- <TableBody>
543
- {resourceData.data.map((record) => (
544
- <TableRow key={String(record.id)}>
545
- {config.tableColumns?.map((column) => (
546
- <TableCell key={`${record.id}-${column.key}`}>
547
- {formatFieldValue(record[column.key], column)}
548
- </TableCell>
549
- ))}
550
- <TableCell className="text-right">
551
- <div className="flex justify-end gap-2">
552
- <Button
553
- variant="outline"
554
- size="icon"
555
- onClick={() => openEdit(record)}
556
- >
557
- <Pencil className="size-4" />
558
- </Button>
559
- <Button
560
- variant="destructive"
561
- size="icon"
562
- onClick={() => setDeleteId(Number(record.id))}
563
- >
564
- <Trash2 className="size-4" />
565
- </Button>
566
- </div>
567
- </TableCell>
568
- </TableRow>
569
- ))}
570
- </TableBody>
571
- </Table>
572
- </div>
573
-
574
- <PaginationFooter
575
- currentPage={page}
576
- pageSize={pageSize}
577
- totalItems={resourceData.total}
578
- onPageChange={setPage}
579
- onPageSizeChange={(nextPageSize) => {
580
- setPageSize(nextPageSize);
581
- setPage(1);
582
- }}
583
- />
535
+ ))}
536
+ </TableBody>
537
+ </Table>
584
538
  </div>
585
- ) : (
586
- <div className="min-w-0 space-y-4">
587
- <div className="grid min-w-0 gap-4 md:grid-cols-2 2xl:grid-cols-3">
588
- {resourceData.data.map((record) => (
589
- <EntityCard
590
- key={String(record.id)}
591
- title={String(
592
- getFirstValue(record, config.titleFields) ??
593
- getCatalogRecordLabel(record)
594
- )}
595
- description={getCardDescription(record)}
596
- badges={getBadges(record)}
597
- metadata={getCardMetadata(record)}
598
- actions={[
599
- {
600
- label: t('edit'),
601
- onClick: () => openEdit(record),
602
- variant: 'outline',
603
- icon: <Pencil className="size-4" />,
604
- },
605
- {
606
- label: t('delete'),
607
- onClick: () => setDeleteId(Number(record.id)),
608
- variant: 'destructive',
609
- icon: <Trash2 className="size-4" />,
610
- },
611
- ]}
612
- />
613
- ))}
614
- </div>
615
-
616
- <PaginationFooter
617
- currentPage={page}
618
- pageSize={pageSize}
619
- totalItems={resourceData.total}
620
- onPageChange={setPage}
621
- onPageSizeChange={(nextPageSize) => {
622
- setPageSize(nextPageSize);
623
- setPage(1);
624
- }}
625
- />
539
+
540
+ <PaginationFooter
541
+ currentPage={page}
542
+ pageSize={pageSize}
543
+ totalItems={resourceData.total}
544
+ onPageChange={setPage}
545
+ onPageSizeChange={(nextPageSize) => {
546
+ setPageSize(nextPageSize);
547
+ setPage(1);
548
+ }}
549
+ />
550
+ </div>
551
+ ) : (
552
+ <div className="min-w-0 space-y-4">
553
+ <div className="grid min-w-0 gap-4 md:grid-cols-2 2xl:grid-cols-3">
554
+ {resourceData.data.map((record) => (
555
+ <EntityCard
556
+ key={String(record.id)}
557
+ title={String(
558
+ getFirstValue(record, config.titleFields) ??
559
+ getCatalogRecordLabel(record)
560
+ )}
561
+ description={getCardDescription(record)}
562
+ badges={getBadges(record)}
563
+ metadata={getCardMetadata(record)}
564
+ actions={[
565
+ {
566
+ label: t('edit'),
567
+ onClick: () => openEdit(record),
568
+ variant: 'outline',
569
+ icon: <Pencil className="size-4" />,
570
+ },
571
+ {
572
+ label: t('delete'),
573
+ onClick: () => setDeleteId(Number(record.id)),
574
+ variant: 'destructive',
575
+ icon: <Trash2 className="size-4" />,
576
+ },
577
+ ]}
578
+ />
579
+ ))}
626
580
  </div>
627
- )}
628
- </CardContent>
629
- </Card>
630
581
 
631
- <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
632
- <DialogContent className="max-h-[90vh] max-w-3xl overflow-hidden">
633
- <DialogHeader>
634
- <DialogTitle>
635
- {editingRecord ? t('editRecord') : t('newRecord')}
636
- </DialogTitle>
637
- <DialogDescription>{resourceDescription}</DialogDescription>
638
- </DialogHeader>
639
- <div className="min-w-0 space-y-2 overflow-hidden">
640
- <Label htmlFor="payload">{t('payload')}</Label>
641
- <Textarea
642
- id="payload"
643
- value={payload}
644
- onChange={(event) => setPayload(event.target.value)}
645
- className="min-h-[360px] font-mono text-xs"
582
+ <PaginationFooter
583
+ currentPage={page}
584
+ pageSize={pageSize}
585
+ totalItems={resourceData.total}
586
+ onPageChange={setPage}
587
+ onPageSizeChange={(nextPageSize) => {
588
+ setPageSize(nextPageSize);
589
+ setPage(1);
590
+ }}
646
591
  />
647
- <p className="text-sm text-muted-foreground">
648
- {t('resource.payloadHintDescription')}
649
- </p>
650
592
  </div>
651
- <DialogFooter>
652
- <Button variant="outline" onClick={() => setDialogOpen(false)}>
653
- {t('cancel')}
654
- </Button>
655
- <Button onClick={submit} disabled={isSubmitting}>
656
- {t('save')}
657
- </Button>
658
- </DialogFooter>
659
- </DialogContent>
660
- </Dialog>
593
+ )}
594
+ </div>
595
+ <CatalogResourceFormSheet
596
+ open={sheetOpen}
597
+ onOpenChange={(open) => {
598
+ setSheetOpen(open);
599
+ if (!open) {
600
+ setEditingRecordId(null);
601
+ }
602
+ }}
603
+ resource={resource}
604
+ resourceConfig={config}
605
+ resourceTitle={resourceTitle}
606
+ resourceDescription={resourceDescription}
607
+ recordId={editingRecordId}
608
+ onSuccess={async () => {
609
+ setEditingRecordId(null);
610
+ await refetch();
611
+ }}
612
+ />
661
613
 
662
614
  <AlertDialog
663
615
  open={deleteId !== null}