Adds the Offerings module and retargets the CI/CD pipeline to Api.SlpSoftware
Continuous Integration / config (pull_request) Successful in 12s
Continuous Integration / changes (pull_request) Successful in 22s
Continuous Integration / backend-build (pull_request) Successful in 5m53s
Continuous Integration / vulnerability-scan (pull_request) Successful in 5m46s
Continuous Integration / frontend-prepare (pull_request) Successful in 1m54s
Continuous Integration / backend-test (pull_request) Successful in 7m37s
Continuous Integration / frontend-build (pull_request) Successful in 2m14s
Continuous Integration / frontend-test (pull_request) Successful in 4m59s
Continuous Integration / frontend-lint (pull_request) Successful in 2m2s
Continuous Integration / publish-production (pull_request) Skipped
Continuous Integration / deploy-production (pull_request) Skipped
Continuous Integration / publish-test (pull_request) Successful in 7m34s
Continuous Integration / deploy-test (pull_request) Skipped
Continuous Integration / config (pull_request) Successful in 12s
Continuous Integration / changes (pull_request) Successful in 22s
Continuous Integration / backend-build (pull_request) Successful in 5m53s
Continuous Integration / vulnerability-scan (pull_request) Successful in 5m46s
Continuous Integration / frontend-prepare (pull_request) Successful in 1m54s
Continuous Integration / backend-test (pull_request) Successful in 7m37s
Continuous Integration / frontend-build (pull_request) Successful in 2m14s
Continuous Integration / frontend-test (pull_request) Successful in 4m59s
Continuous Integration / frontend-lint (pull_request) Successful in 2m2s
Continuous Integration / publish-production (pull_request) Skipped
Continuous Integration / deploy-production (pull_request) Skipped
Continuous Integration / publish-test (pull_request) Successful in 7m34s
Continuous Integration / deploy-test (pull_request) Skipped
Implements Unit 2 "Offerings" (backend module, admin CRUD UI with drag-and-drop reordering, public GET /api/v1/offerings endpoint) and executes the feature's D-15 CI/CD cutover, switching the deploy pipeline's build/publish target from SlpModularCms.Api to SlpModularCms.Api.SlpSoftware. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FWyStNL2ZsjrS7FLd7xvvN
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LayoutDashboard, Users, FileText, Settings, X } from 'lucide-react';
|
||||
import { LayoutDashboard, Users, FileText, Package, Settings, X } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/features/auth/context/auth-context';
|
||||
@@ -27,6 +27,14 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
|
||||
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] },
|
||||
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'], requiredModule: 'Master' },
|
||||
{
|
||||
to: '/offerings',
|
||||
labelKey: 'nav.offerings',
|
||||
icon: Package,
|
||||
testId: 'nav-offerings',
|
||||
roles: ['Owner', 'Administrator'],
|
||||
requiredModule: 'Offerings',
|
||||
},
|
||||
];
|
||||
|
||||
const SETTINGS_ITEM: NavItem = {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import type { OfferingAdminDto } from '../services/types';
|
||||
|
||||
interface DeleteOfferingDialogProps {
|
||||
offering: OfferingAdminDto | null;
|
||||
isDeleting: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DeleteOfferingDialog({ offering, isDeleting, onConfirm, onCancel }: DeleteOfferingDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={offering !== null} onOpenChange={(open) => { if (!open) onCancel(); }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('offerings.deleteDialog.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground" data-testid="delete-offering-message">
|
||||
{t('offerings.deleteDialog.message', { title: offering?.title ?? '' })}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onCancel} data-testid="delete-offering-cancel">
|
||||
{t('offerings.deleteDialog.cancelButton')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
data-testid="delete-offering-confirm"
|
||||
>
|
||||
{isDeleting ? '…' : t('offerings.deleteDialog.confirmButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { FieldError } from '@/components/ui/FieldError';
|
||||
import { offeringFormSchema, type OfferingFormData } from '../schemas/offering';
|
||||
import type { OfferingAdminDto } from '../services/types';
|
||||
|
||||
interface OfferingFormProps {
|
||||
initialValues?: OfferingAdminDto;
|
||||
onSubmit: (values: OfferingFormData) => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export function OfferingForm({ initialValues, onSubmit, isSubmitting }: OfferingFormProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<OfferingFormData>({
|
||||
resolver: zodResolver(offeringFormSchema),
|
||||
mode: 'onTouched',
|
||||
defaultValues: initialValues ?? {
|
||||
title: '',
|
||||
description: '',
|
||||
price: '',
|
||||
priceNote: '',
|
||||
features: [''],
|
||||
ctaLabel: '',
|
||||
featured: false,
|
||||
},
|
||||
});
|
||||
|
||||
// useFieldArray requires object-shaped array items — features is a plain string[],
|
||||
// so add/remove are handled directly via setValue instead.
|
||||
const features = useWatch({ control, name: 'features' });
|
||||
|
||||
function addFeature() {
|
||||
setValue('features', [...features, ''], { shouldValidate: true });
|
||||
}
|
||||
|
||||
function removeFeature(index: number) {
|
||||
setValue('features', features.filter((_, i) => i !== index), { shouldValidate: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offering-title">{t('offerings.form.titleLabel')}</Label>
|
||||
<Input id="offering-title" data-testid="offering-title" aria-invalid={errors.title !== undefined} {...register('title')} />
|
||||
{errors.title && <FieldError message={errors.title.message} testId="offering-title-error" />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offering-description">{t('offerings.form.descriptionLabel')}</Label>
|
||||
<textarea
|
||||
id="offering-description"
|
||||
data-testid="offering-description"
|
||||
aria-invalid={errors.description !== undefined}
|
||||
className="flex min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
{...register('description')}
|
||||
/>
|
||||
{errors.description && <FieldError message={errors.description.message} testId="offering-description-error" />}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offering-price">{t('offerings.form.priceLabel')}</Label>
|
||||
<Input id="offering-price" data-testid="offering-price" aria-invalid={errors.price !== undefined} {...register('price')} />
|
||||
{errors.price && <FieldError message={errors.price.message} testId="offering-price-error" />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offering-price-note">{t('offerings.form.priceNoteLabel')}</Label>
|
||||
<Input
|
||||
id="offering-price-note"
|
||||
data-testid="offering-price-note"
|
||||
aria-invalid={errors.priceNote !== undefined}
|
||||
{...register('priceNote')}
|
||||
/>
|
||||
{errors.priceNote && <FieldError message={errors.priceNote.message} testId="offering-price-note-error" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('offerings.form.featuresLabel')}</Label>
|
||||
{features.map((_, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
data-testid={`offering-feature-${index}`}
|
||||
aria-invalid={errors.features?.[index] !== undefined}
|
||||
{...register(`features.${index}` as const)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-testid={`offering-feature-${index}-remove`}
|
||||
aria-label={t('offerings.form.removeFeatureButton')}
|
||||
disabled={features.length <= 1}
|
||||
onClick={() => removeFeature(index)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{errors.features?.message && <FieldError message={errors.features.message} testId="offering-features-error" />}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="offering-add-feature"
|
||||
disabled={features.length >= 10}
|
||||
onClick={addFeature}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t('offerings.form.addFeatureButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offering-cta-label">{t('offerings.form.ctaLabelLabel')}</Label>
|
||||
<Input
|
||||
id="offering-cta-label"
|
||||
data-testid="offering-cta-label"
|
||||
aria-invalid={errors.ctaLabel !== undefined}
|
||||
{...register('ctaLabel')}
|
||||
/>
|
||||
{errors.ctaLabel && <FieldError message={errors.ctaLabel.message} testId="offering-cta-label-error" />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="offering-featured"
|
||||
type="checkbox"
|
||||
data-testid="offering-featured"
|
||||
className="size-4 rounded border-input"
|
||||
{...register('featured')}
|
||||
/>
|
||||
<Label htmlFor="offering-featured">{t('offerings.form.featuredLabel')}</Label>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting} data-testid="offering-form-submit">
|
||||
{isSubmitting ? '…' : t('offerings.form.submitButton')}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GripVertical, Star, ChevronUp, ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { useUpdateOffering } from '../services/useUpdateOffering';
|
||||
import { useMoveOffering } from '../services/useMoveOffering';
|
||||
import type { OfferingAdminDto } from '../services/types';
|
||||
|
||||
interface OfferingRowProps {
|
||||
offering: OfferingAdminDto;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onDeleteRequested: () => void;
|
||||
}
|
||||
|
||||
export function OfferingRow({ offering, isFirst, isLast, onDeleteRequested }: OfferingRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: offering.id });
|
||||
const updateOffering = useUpdateOffering();
|
||||
const moveUp = useMoveOffering('up');
|
||||
const moveDown = useMoveOffering('down');
|
||||
|
||||
// The moving visual is rendered by <DragOverlay> instead (see OfferingsList) — a portal
|
||||
// outside the document flow, so it can't grow the page's scrollable area the way
|
||||
// transforming this row in place would. This row just hides itself while dragging.
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} data-testid={`offering-row-${offering.id}`}>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-grab text-muted-foreground touch-none"
|
||||
aria-label={t('offerings.actions.drag')}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>{offering.title}</TableCell>
|
||||
<TableCell>{offering.price}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-testid={`offering-row-${offering.id}-featured-toggle`}
|
||||
aria-label={offering.featured ? t('offerings.actions.unmarkFeatured') : t('offerings.actions.markFeatured')}
|
||||
onClick={() =>
|
||||
updateOffering.mutate({
|
||||
id: offering.id,
|
||||
title: offering.title,
|
||||
description: offering.description,
|
||||
price: offering.price,
|
||||
priceNote: offering.priceNote,
|
||||
features: offering.features,
|
||||
ctaLabel: offering.ctaLabel,
|
||||
featured: !offering.featured,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Star className={`size-4 ${offering.featured ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-testid={`offering-row-${offering.id}-move-up-button`}
|
||||
aria-label={t('offerings.actions.moveUp')}
|
||||
disabled={isFirst}
|
||||
onClick={() => moveUp.mutate(offering.id)}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-testid={`offering-row-${offering.id}-move-down-button`}
|
||||
aria-label={t('offerings.actions.moveDown')}
|
||||
disabled={isLast}
|
||||
onClick={() => moveDown.mutate(offering.id)}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" asChild data-testid={`offering-row-${offering.id}-edit-link`}>
|
||||
<Link to="/offerings/$id/edit" params={{ id: offering.id }} aria-label={t('offerings.actions.edit')}>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-testid={`offering-row-${offering.id}-delete-button`}
|
||||
aria-label={t('offerings.actions.delete')}
|
||||
onClick={onDeleteRequested}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { DndContext, DragOverlay, closestCenter } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { OfferingRow } from './OfferingRow';
|
||||
import { useOfferingsDnd } from '../hooks/useOfferingsDnd';
|
||||
import type { OfferingAdminDto } from '../services/types';
|
||||
|
||||
interface OfferingsListProps {
|
||||
offerings: OfferingAdminDto[];
|
||||
onDeleteRequested: (offering: OfferingAdminDto) => void;
|
||||
}
|
||||
|
||||
export function OfferingsList({ offerings, onDeleteRequested }: OfferingsListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { items, sensors, activeItem, handleDragStart, handleDragEnd } = useOfferingsDnd(offerings);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead />
|
||||
<TableHead>{t('offerings.table.title')}</TableHead>
|
||||
<TableHead>{t('offerings.table.price')}</TableHead>
|
||||
<TableHead>{t('offerings.table.featured')}</TableHead>
|
||||
<TableHead>{t('offerings.table.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<SortableContext items={items.map((o) => o.id)} strategy={verticalListSortingStrategy}>
|
||||
{items.map((offering, index) => (
|
||||
<OfferingRow
|
||||
key={offering.id}
|
||||
offering={offering}
|
||||
isFirst={index === 0}
|
||||
isLast={index === items.length - 1}
|
||||
onDeleteRequested={() => onDeleteRequested(offering)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Rendered in a portal outside document flow, so the dragged item's position
|
||||
never grows the page's scrollable area the way transforming a table row in
|
||||
place would (a real browser behavior with in-place transforms, not a bug in
|
||||
dnd-kit itself). */}
|
||||
<DragOverlay>
|
||||
{activeItem && (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3 shadow-lg">
|
||||
<GripVertical className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">{activeItem.title}</span>
|
||||
<span className="text-muted-foreground">{activeItem.price}</span>
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
PointerSensor,
|
||||
KeyboardSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
import { useReorderOfferings } from '../services/useReorderOfferings';
|
||||
import type { OfferingAdminDto } from '../services/types';
|
||||
|
||||
export function useOfferingsDnd(offerings: OfferingAdminDto[]) {
|
||||
const [items, setItems] = useState(offerings);
|
||||
const [syncedOfferings, setSyncedOfferings] = useState(offerings);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const reorderMutation = useReorderOfferings();
|
||||
|
||||
// Adjusting state during render (not in an effect) avoids the extra render pass
|
||||
// an effect-based sync would cause — see https://react.dev/learn/you-might-not-need-an-effect.
|
||||
if (offerings !== syncedOfferings) {
|
||||
setSyncedOfferings(offerings);
|
||||
setItems(offerings);
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
setActiveId(event.active.id as string);
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (over === null || active.id === over.id) return;
|
||||
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const reordered = arrayMove(items, oldIndex, newIndex);
|
||||
setItems(reordered);
|
||||
reorderMutation.mutate(reordered.map((item) => item.id));
|
||||
}
|
||||
|
||||
const activeItem = activeId === null ? null : (items.find((item) => item.id === activeId) ?? null);
|
||||
|
||||
return { items, sensors, activeItem, handleDragStart, handleDragEnd };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import type { OfferingAdminDto, CreateOfferingRequest, UpdateOfferingRequest } from '@/features/offerings/services/types';
|
||||
import { API_BASE } from '@/features/auth/mocks/fixtures';
|
||||
|
||||
const seed: OfferingAdminDto[] = [
|
||||
{
|
||||
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
title: 'Starter',
|
||||
description: 'A simple website',
|
||||
price: '€ 300',
|
||||
priceNote: 'one-time',
|
||||
features: ['1 page', 'Contact form'],
|
||||
ctaLabel: 'Get started',
|
||||
featured: false,
|
||||
displayOrder: 0,
|
||||
},
|
||||
{
|
||||
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
title: 'Pro',
|
||||
description: 'A full website',
|
||||
price: '€ 800',
|
||||
priceNote: 'one-time',
|
||||
features: ['5 pages', 'Contact form', 'SEO'],
|
||||
ctaLabel: 'Get started',
|
||||
featured: true,
|
||||
displayOrder: 1,
|
||||
},
|
||||
];
|
||||
|
||||
let mockOfferings: OfferingAdminDto[] = [...seed];
|
||||
|
||||
export const resetMockOfferings = () => {
|
||||
mockOfferings = [...seed];
|
||||
};
|
||||
|
||||
export const getMockOfferings = () => mockOfferings;
|
||||
|
||||
export const offeringsHandlers = [
|
||||
http.get(`${API_BASE}/api/v1/offerings`, () =>
|
||||
HttpResponse.json(mockOfferings.map(({ displayOrder: _displayOrder, ...dto }) => dto)),
|
||||
),
|
||||
|
||||
http.get(`${API_BASE}/api/v1/offerings/admin`, () => HttpResponse.json(mockOfferings)),
|
||||
|
||||
http.post(`${API_BASE}/api/v1/offerings/admin`, async ({ request }) => {
|
||||
const body = (await request.json()) as CreateOfferingRequest;
|
||||
const newOffering: OfferingAdminDto = {
|
||||
id: crypto.randomUUID(),
|
||||
...body,
|
||||
displayOrder: mockOfferings.length,
|
||||
};
|
||||
mockOfferings = [...mockOfferings, newOffering];
|
||||
return HttpResponse.json(newOffering, { status: 201 });
|
||||
}),
|
||||
|
||||
http.put(`${API_BASE}/api/v1/offerings/admin/:id`, async ({ params, request }) => {
|
||||
const { id } = params as { id: string };
|
||||
const body = (await request.json()) as UpdateOfferingRequest;
|
||||
const existing = mockOfferings.find((o) => o.id === id);
|
||||
if (!existing) return new HttpResponse(null, { status: 404 });
|
||||
|
||||
const updated: OfferingAdminDto = { ...existing, ...body };
|
||||
mockOfferings = mockOfferings.map((o) => (o.id === id ? updated : o));
|
||||
return HttpResponse.json(updated);
|
||||
}),
|
||||
|
||||
http.delete(`${API_BASE}/api/v1/offerings/admin/:id`, ({ params }) => {
|
||||
const { id } = params as { id: string };
|
||||
mockOfferings = mockOfferings.filter((o) => o.id !== id);
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
|
||||
http.put(`${API_BASE}/api/v1/offerings/admin/reorder`, async ({ request }) => {
|
||||
const body = (await request.json()) as { orderedIds: string[] };
|
||||
mockOfferings = body.orderedIds
|
||||
.map((id, index) => {
|
||||
const offering = mockOfferings.find((o) => o.id === id);
|
||||
return offering ? { ...offering, displayOrder: index } : null;
|
||||
})
|
||||
.filter((o): o is OfferingAdminDto => o !== null);
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
|
||||
http.post(`${API_BASE}/api/v1/offerings/admin/:id/move-up`, ({ params }) => {
|
||||
const { id } = params as { id: string };
|
||||
moveAdjacent(id, -1);
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
|
||||
http.post(`${API_BASE}/api/v1/offerings/admin/:id/move-down`, ({ params }) => {
|
||||
const { id } = params as { id: string };
|
||||
moveAdjacent(id, 1);
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
];
|
||||
|
||||
function moveAdjacent(id: string, delta: number) {
|
||||
const sorted = [...mockOfferings].sort((a, b) => a.displayOrder - b.displayOrder);
|
||||
const index = sorted.findIndex((o) => o.id === id);
|
||||
const swapIndex = index + delta;
|
||||
if (index === -1 || swapIndex < 0 || swapIndex >= sorted.length) return;
|
||||
|
||||
const a = sorted[index];
|
||||
const b = sorted[swapIndex];
|
||||
[a.displayOrder, b.displayOrder] = [b.displayOrder, a.displayOrder];
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderApp, mockAuthenticated } from '@/test/utils';
|
||||
import { resetMockOfferings } from '@/features/offerings/mocks/handlers';
|
||||
import { _resetSetupStatusCache } from '@/router';
|
||||
|
||||
beforeEach(() => {
|
||||
_resetSetupStatusCache();
|
||||
resetMockOfferings();
|
||||
});
|
||||
|
||||
describe('OfferingFormPage', () => {
|
||||
it('creates a new offering and returns to the list', async () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/offerings/new');
|
||||
|
||||
await screen.findByTestId('offering-form-submit', {}, { timeout: 10000 });
|
||||
|
||||
await userEvent.type(screen.getByTestId('offering-title'), 'Enterprise');
|
||||
await userEvent.type(screen.getByTestId('offering-description'), 'A large website');
|
||||
await userEvent.type(screen.getByTestId('offering-price'), '€ 2000');
|
||||
await userEvent.type(screen.getByTestId('offering-price-note'), 'one-time');
|
||||
await userEvent.type(screen.getByTestId('offering-feature-0'), 'Unlimited pages');
|
||||
await userEvent.type(screen.getByTestId('offering-cta-label'), 'Contact us');
|
||||
|
||||
await userEvent.click(screen.getByTestId('offering-form-submit'));
|
||||
|
||||
expect(await screen.findByTestId('offerings-title', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
expect(screen.getByText('Enterprise')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a validation error when required fields are missing', async () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/offerings/new');
|
||||
|
||||
await screen.findByTestId('offering-form-submit', {}, { timeout: 10000 });
|
||||
await userEvent.click(screen.getByTestId('offering-form-submit'));
|
||||
|
||||
expect(await screen.findByTestId('offering-title-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills the form when editing an existing offering', async () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/offerings/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/edit');
|
||||
|
||||
const titleInput = await screen.findByTestId('offering-title', {}, { timeout: 10000 });
|
||||
expect(titleInput).toHaveValue('Starter');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
|
||||
import { OfferingForm } from '../components/OfferingForm';
|
||||
import { useOffering } from '../services/useOffering';
|
||||
import { useCreateOffering } from '../services/useCreateOffering';
|
||||
import { useUpdateOffering } from '../services/useUpdateOffering';
|
||||
import { NetworkError } from '@/lib/api-client';
|
||||
import type { OfferingFormData } from '../schemas/offering';
|
||||
|
||||
export function OfferingFormPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams({ strict: false }) as { id?: string };
|
||||
const isEdit = id !== undefined;
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const { data: existingOffering, isPending: isLoadingExisting } = useOffering(id);
|
||||
const createOffering = useCreateOffering();
|
||||
const updateOffering = useUpdateOffering();
|
||||
|
||||
const isSubmitting = createOffering.isPending || updateOffering.isPending;
|
||||
|
||||
const onSubmit = async (values: OfferingFormData) => {
|
||||
setServerError(null);
|
||||
try {
|
||||
if (isEdit && id !== undefined) {
|
||||
await updateOffering.mutateAsync({ id, ...values });
|
||||
} else {
|
||||
await createOffering.mutateAsync(values);
|
||||
}
|
||||
toast.success(t('offerings.form.successToast'));
|
||||
navigate({ to: '/offerings' });
|
||||
} catch (err) {
|
||||
setServerError(err instanceof NetworkError ? t('errors.network') : t('errors.generic'));
|
||||
}
|
||||
};
|
||||
|
||||
if (isEdit && isLoadingExisting) {
|
||||
return <p className="p-4 text-sm text-muted-foreground">{t('common.loading')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4 p-4">
|
||||
<h1 className="text-2xl font-semibold" data-testid="offering-form-title">
|
||||
{isEdit ? t('offerings.form.editTitle') : t('offerings.form.createTitle')}
|
||||
</h1>
|
||||
|
||||
<FormErrorBanner
|
||||
error={serverError !== null ? { message: serverError } : null}
|
||||
onDismiss={() => setServerError(null)}
|
||||
/>
|
||||
|
||||
<OfferingForm initialValues={existingOffering} onSubmit={onSubmit} isSubmitting={isSubmitting} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
|
||||
import { server } from '@/mocks/server';
|
||||
import { API_BASE } from '@/features/auth/mocks/fixtures';
|
||||
import { resetMockOfferings } from '@/features/offerings/mocks/handlers';
|
||||
import { _resetSetupStatusCache } from '@/router';
|
||||
|
||||
beforeEach(() => {
|
||||
_resetSetupStatusCache();
|
||||
resetMockOfferings();
|
||||
});
|
||||
|
||||
const OFFERINGS_URL = `${API_BASE}/api/v1/offerings/admin`;
|
||||
|
||||
describe('OfferingsListPage', () => {
|
||||
it('renders the page title and Add button', async () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/offerings');
|
||||
|
||||
expect(await screen.findByTestId('offerings-title', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('offerings-add-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the seeded offerings', async () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/offerings');
|
||||
|
||||
expect(await screen.findByText('Starter', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no offerings exist', async () => {
|
||||
mockAuthenticated();
|
||||
server.use(http.get(OFFERINGS_URL, () => HttpResponse.json([])));
|
||||
renderApp('/offerings');
|
||||
|
||||
expect(await screen.findByTestId('offerings-empty-state', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes an offering after confirmation', async () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/offerings');
|
||||
|
||||
const deleteButton = await screen.findByTestId(
|
||||
'offering-row-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-delete-button',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
await userEvent.click(await screen.findByTestId('delete-offering-confirm'));
|
||||
|
||||
expect(screen.queryByText('Starter')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels delete without removing the offering', async () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/offerings');
|
||||
|
||||
const deleteButton = await screen.findByTestId(
|
||||
'offering-row-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-delete-button',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await userEvent.click(deleteButton);
|
||||
await userEvent.click(await screen.findByTestId('delete-offering-cancel'));
|
||||
|
||||
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the move-up button on the first row and move-down on the last', async () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/offerings');
|
||||
|
||||
await screen.findByText('Starter', {}, { timeout: 10000 });
|
||||
|
||||
expect(
|
||||
screen.getByTestId('offering-row-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-move-up-button'),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByTestId('offering-row-bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb-move-down-button'),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('redirects unauthenticated users to login', async () => {
|
||||
mockGuest();
|
||||
renderApp('/offerings');
|
||||
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Package } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OfferingsList } from '../components/OfferingsList';
|
||||
import { DeleteOfferingDialog } from '../components/DeleteOfferingDialog';
|
||||
import { useOfferings } from '../services/useOfferings';
|
||||
import { useDeleteOffering } from '../services/useDeleteOffering';
|
||||
import type { OfferingAdminDto } from '../services/types';
|
||||
|
||||
export function OfferingsListPage() {
|
||||
const { t } = useTranslation();
|
||||
const [offeringPendingDelete, setOfferingPendingDelete] = useState<OfferingAdminDto | null>(null);
|
||||
const { data: offerings, isPending, isError } = useOfferings();
|
||||
const deleteOffering = useDeleteOffering({
|
||||
onSuccess: () => setOfferingPendingDelete(null),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold" data-testid="offerings-title">
|
||||
{t('offerings.title')}
|
||||
</h1>
|
||||
<Button asChild data-testid="offerings-add-button">
|
||||
<Link to="/offerings/new">{t('offerings.addButton')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPending && <p className="text-sm text-muted-foreground">{t('common.loading')}</p>}
|
||||
|
||||
{isError && <p className="text-sm text-destructive">{t('errors.generic')}</p>}
|
||||
|
||||
{!isPending && !isError && offerings?.length === 0 && (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-16 text-center space-y-4"
|
||||
data-testid="offerings-empty-state"
|
||||
>
|
||||
<Package className="size-12 text-muted-foreground" />
|
||||
<h2 className="text-xl font-semibold">{t('offerings.emptyState.heading')}</h2>
|
||||
<p className="text-muted-foreground max-w-sm">{t('offerings.emptyState.description')}</p>
|
||||
<Button asChild data-testid="offerings-empty-add-button">
|
||||
<Link to="/offerings/new">{t('offerings.addButton')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPending && !isError && offerings && offerings.length > 0 && (
|
||||
<OfferingsList offerings={offerings} onDeleteRequested={setOfferingPendingDelete} />
|
||||
)}
|
||||
|
||||
<DeleteOfferingDialog
|
||||
offering={offeringPendingDelete}
|
||||
isDeleting={deleteOffering.isPending}
|
||||
onConfirm={() => {
|
||||
if (offeringPendingDelete) deleteOffering.mutate(offeringPendingDelete.id);
|
||||
}}
|
||||
onCancel={() => setOfferingPendingDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { offeringFormSchema } from './offering';
|
||||
|
||||
function validOffering() {
|
||||
return {
|
||||
title: 'Starter',
|
||||
description: 'A simple website',
|
||||
price: '€ 300',
|
||||
priceNote: 'one-time',
|
||||
features: ['1 page'],
|
||||
ctaLabel: 'Get started',
|
||||
featured: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('offeringFormSchema', () => {
|
||||
it('accepts valid data', () => {
|
||||
const result = offeringFormSchema.safeParse(validOffering());
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects title over 100 characters', () => {
|
||||
const result = offeringFormSchema.safeParse({ ...validOffering(), title: 'a'.repeat(101) });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects description over 500 characters', () => {
|
||||
const result = offeringFormSchema.safeParse({ ...validOffering(), description: 'a'.repeat(501) });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty features list', () => {
|
||||
const result = offeringFormSchema.safeParse({ ...validOffering(), features: [] });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects more than 10 features', () => {
|
||||
const result = offeringFormSchema.safeParse({ ...validOffering(), features: Array(11).fill('Feature') });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a feature over 200 characters', () => {
|
||||
const result = offeringFormSchema.safeParse({ ...validOffering(), features: ['a'.repeat(201)] });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing required fields', () => {
|
||||
const result = offeringFormSchema.safeParse({ ...validOffering(), title: '' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const offeringFormSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(100, 'Title must be 100 characters or fewer'),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, 'Description is required')
|
||||
.max(500, 'Description must be 500 characters or fewer'),
|
||||
price: z.string().min(1, 'Price is required').max(50, 'Price must be 50 characters or fewer'),
|
||||
priceNote: z
|
||||
.string()
|
||||
.min(1, 'Price note is required')
|
||||
.max(100, 'Price note must be 100 characters or fewer'),
|
||||
features: z
|
||||
.array(z.string().min(1, 'Feature cannot be empty').max(200, 'Feature must be 200 characters or fewer'))
|
||||
.min(1, 'At least 1 feature is required')
|
||||
.max(10, 'At most 10 features are allowed'),
|
||||
ctaLabel: z.string().min(1, 'CTA label is required').max(50, 'CTA label must be 50 characters or fewer'),
|
||||
featured: z.boolean(),
|
||||
});
|
||||
|
||||
export type OfferingFormData = z.infer<typeof offeringFormSchema>;
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface OfferingAdminDto {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
price: string;
|
||||
priceNote: string;
|
||||
features: string[];
|
||||
ctaLabel: string;
|
||||
featured: boolean;
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
export interface CreateOfferingRequest {
|
||||
title: string;
|
||||
description: string;
|
||||
price: string;
|
||||
priceNote: string;
|
||||
features: string[];
|
||||
ctaLabel: string;
|
||||
featured: boolean;
|
||||
}
|
||||
|
||||
export type UpdateOfferingRequest = CreateOfferingRequest;
|
||||
|
||||
export type MoveDirection = 'up' | 'down';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { CreateOfferingRequest, OfferingAdminDto } from './types';
|
||||
|
||||
export function useCreateOffering(
|
||||
options?: Pick<UseMutationOptions<OfferingAdminDto, Error, CreateOfferingRequest>, 'onSuccess' | 'onError'>,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<OfferingAdminDto, Error, CreateOfferingRequest>({
|
||||
mutationFn: (data) => api.post<OfferingAdminDto>('/api/v1/offerings/admin', data),
|
||||
onSuccess: (data, variables, ...rest) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
|
||||
options?.onSuccess?.(data, variables, ...rest);
|
||||
},
|
||||
onError: options?.onError,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
|
||||
export function useDeleteOffering(
|
||||
options?: Pick<UseMutationOptions<void, Error, string>, 'onSuccess' | 'onError'>,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (id) => api.delete<void>(`/api/v1/offerings/admin/${id}`),
|
||||
onSuccess: (data, variables, ...rest) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
|
||||
options?.onSuccess?.(data, variables, ...rest);
|
||||
},
|
||||
onError: options?.onError,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { MoveDirection } from './types';
|
||||
|
||||
export function useMoveOffering(
|
||||
direction: MoveDirection,
|
||||
options?: Pick<UseMutationOptions<void, Error, string>, 'onSuccess' | 'onError'>,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (id) => api.post<void>(`/api/v1/offerings/admin/${id}/move-${direction}`),
|
||||
onSuccess: (data, variables, ...rest) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
|
||||
options?.onSuccess?.(data, variables, ...rest);
|
||||
},
|
||||
onError: options?.onError,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useOfferings } from './useOfferings';
|
||||
|
||||
/** Derived from the `useOfferings()` admin-list cache — no dedicated fetch needed. */
|
||||
export function useOffering(id: string | undefined) {
|
||||
const query = useOfferings();
|
||||
const offering = id === undefined ? undefined : query.data?.find((o) => o.id === id);
|
||||
return { ...query, data: offering };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { OfferingAdminDto } from './types';
|
||||
|
||||
export function useOfferings() {
|
||||
return useQuery<OfferingAdminDto[], Error>({
|
||||
queryKey: ['offerings', 'admin'],
|
||||
queryFn: () => api.get<OfferingAdminDto[]>('/api/v1/offerings/admin'),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
|
||||
export function useReorderOfferings(
|
||||
options?: Pick<UseMutationOptions<void, Error, string[]>, 'onSuccess' | 'onError'>,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, string[]>({
|
||||
mutationFn: (orderedIds) => api.put<void>('/api/v1/offerings/admin/reorder', { orderedIds }),
|
||||
onSuccess: (data, variables, ...rest) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
|
||||
options?.onSuccess?.(data, variables, ...rest);
|
||||
},
|
||||
onError: options?.onError,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { OfferingAdminDto, UpdateOfferingRequest } from './types';
|
||||
|
||||
type UpdateVariables = { id: string } & UpdateOfferingRequest;
|
||||
|
||||
export function useUpdateOffering(
|
||||
options?: Pick<UseMutationOptions<OfferingAdminDto, Error, UpdateVariables>, 'onSuccess' | 'onError'>,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<OfferingAdminDto, Error, UpdateVariables>({
|
||||
mutationFn: ({ id, ...body }) => api.put<OfferingAdminDto>(`/api/v1/offerings/admin/${id}`, body),
|
||||
onSuccess: (data, variables, ...rest) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
|
||||
options?.onSuccess?.(data, variables, ...rest);
|
||||
},
|
||||
onError: options?.onError,
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { http, HttpResponse } from 'msw';
|
||||
export const systemHandlers = [
|
||||
http.get('*/System/capabilities', () =>
|
||||
HttpResponse.json({
|
||||
modules: ['Availability', 'Identity', 'Master'],
|
||||
modules: ['Availability', 'Identity', 'Master', 'Offerings'],
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"dashboard": "Dashboard",
|
||||
"users": "Users",
|
||||
"cms": "CMS",
|
||||
"offerings": "Offerings",
|
||||
"profile": "Profile",
|
||||
"settings": "Settings",
|
||||
"openMenu": "Open navigation",
|
||||
@@ -230,6 +231,50 @@
|
||||
"successUnreachableToast": "Status saved — cliënt unreachable"
|
||||
}
|
||||
},
|
||||
"offerings": {
|
||||
"title": "Offerings",
|
||||
"addButton": "Add Offering",
|
||||
"emptyState": {
|
||||
"heading": "No offerings yet",
|
||||
"description": "Add your first offering to get started."
|
||||
},
|
||||
"table": {
|
||||
"title": "Title",
|
||||
"price": "Price",
|
||||
"featured": "Featured",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"actions": {
|
||||
"drag": "Drag to reorder",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"markFeatured": "Mark as featured",
|
||||
"unmarkFeatured": "Remove featured"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete offering",
|
||||
"message": "Are you sure you want to delete '{{title}}'?",
|
||||
"confirmButton": "Delete",
|
||||
"cancelButton": "Cancel"
|
||||
},
|
||||
"form": {
|
||||
"createTitle": "New Offering",
|
||||
"editTitle": "Edit Offering",
|
||||
"titleLabel": "Title",
|
||||
"descriptionLabel": "Description",
|
||||
"priceLabel": "Price",
|
||||
"priceNoteLabel": "Price note",
|
||||
"featuresLabel": "Features",
|
||||
"addFeatureButton": "Add feature",
|
||||
"removeFeatureButton": "Remove",
|
||||
"ctaLabelLabel": "Button text",
|
||||
"featuredLabel": "Featured",
|
||||
"submitButton": "Save",
|
||||
"successToast": "Offering saved successfully"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"403": {
|
||||
"title": "Access Denied",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"dashboard": "Dashboard",
|
||||
"users": "Gebruikers",
|
||||
"cms": "CMS",
|
||||
"offerings": "Aanbiedingen",
|
||||
"profile": "Profiel",
|
||||
"settings": "Instellingen",
|
||||
"openMenu": "Navigatie openen",
|
||||
@@ -230,6 +231,50 @@
|
||||
"successUnreachableToast": "Status opgeslagen — cliënt niet bereikbaar"
|
||||
}
|
||||
},
|
||||
"offerings": {
|
||||
"title": "Aanbiedingen",
|
||||
"addButton": "Aanbieding toevoegen",
|
||||
"emptyState": {
|
||||
"heading": "Nog geen aanbiedingen",
|
||||
"description": "Voeg je eerste aanbieding toe om te beginnen."
|
||||
},
|
||||
"table": {
|
||||
"title": "Titel",
|
||||
"price": "Prijs",
|
||||
"featured": "Uitgelicht",
|
||||
"actions": "Acties"
|
||||
},
|
||||
"actions": {
|
||||
"drag": "Sleep om te herordenen",
|
||||
"edit": "Bewerken",
|
||||
"delete": "Verwijderen",
|
||||
"moveUp": "Omhoog verplaatsen",
|
||||
"moveDown": "Omlaag verplaatsen",
|
||||
"markFeatured": "Markeren als uitgelicht",
|
||||
"unmarkFeatured": "Uitgelicht verwijderen"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Aanbieding verwijderen",
|
||||
"message": "Weet je zeker dat je '{{title}}' wilt verwijderen?",
|
||||
"confirmButton": "Verwijderen",
|
||||
"cancelButton": "Annuleren"
|
||||
},
|
||||
"form": {
|
||||
"createTitle": "Nieuwe aanbieding",
|
||||
"editTitle": "Aanbieding bewerken",
|
||||
"titleLabel": "Titel",
|
||||
"descriptionLabel": "Beschrijving",
|
||||
"priceLabel": "Prijs",
|
||||
"priceNoteLabel": "Prijsnotitie",
|
||||
"featuresLabel": "Kenmerken",
|
||||
"addFeatureButton": "Kenmerk toevoegen",
|
||||
"removeFeatureButton": "Verwijderen",
|
||||
"ctaLabelLabel": "Knoptekst",
|
||||
"featuredLabel": "Uitgelicht",
|
||||
"submitButton": "Opslaan",
|
||||
"successToast": "Aanbieding succesvol opgeslagen"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"403": {
|
||||
"title": "Toegang geweigerd",
|
||||
|
||||
@@ -5,9 +5,10 @@ import { invitationHandlers } from '@/features/invitation/mocks/handlers';
|
||||
import { availabilityHandlers } from '@/features/availability/mocks/handlers';
|
||||
import { cmsHandlers } from '@/features/cms/mocks/handlers';
|
||||
import { systemHandlers } from '@/features/system/mocks/handlers';
|
||||
import { offeringsHandlers } from '@/features/offerings/mocks/handlers';
|
||||
|
||||
/** All default MSW handlers, composed from feature folders (Q3-B). */
|
||||
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers, ...availabilityHandlers, ...cmsHandlers, ...systemHandlers];
|
||||
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers, ...availabilityHandlers, ...cmsHandlers, ...systemHandlers, ...offeringsHandlers];
|
||||
|
||||
export { authHandlers } from '@/features/auth/mocks/handlers';
|
||||
export { userHandlers } from '@/features/users/mocks/handlers';
|
||||
@@ -16,4 +17,5 @@ export { invitationHandlers } from '@/features/invitation/mocks/handlers';
|
||||
export { availabilityHandlers } from '@/features/availability/mocks/handlers';
|
||||
export { cmsHandlers, resetMockCmsInstances, getMockCmsInstances } from '@/features/cms/mocks/handlers';
|
||||
export { systemHandlers } from '@/features/system/mocks/handlers';
|
||||
export { offeringsHandlers, resetMockOfferings, getMockOfferings } from '@/features/offerings/mocks/handlers';
|
||||
export * from '@/features/auth/mocks/fixtures';
|
||||
|
||||
+46
-1
@@ -197,6 +197,42 @@ const cmsRoute = createRoute({
|
||||
),
|
||||
});
|
||||
|
||||
const offeringsRoute = createRoute({
|
||||
getParentRoute: () => authenticatedRoute,
|
||||
path: '/offerings',
|
||||
component: () => (
|
||||
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
|
||||
<ModuleGuard requiredModule="Offerings">
|
||||
{lazyPage(() => import('@/features/offerings/pages/OfferingsListPage'), 'OfferingsListPage')()}
|
||||
</ModuleGuard>
|
||||
</RoleGuard>
|
||||
),
|
||||
});
|
||||
|
||||
const offeringsNewRoute = createRoute({
|
||||
getParentRoute: () => authenticatedRoute,
|
||||
path: '/offerings/new',
|
||||
component: () => (
|
||||
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
|
||||
<ModuleGuard requiredModule="Offerings">
|
||||
{lazyPage(() => import('@/features/offerings/pages/OfferingFormPage'), 'OfferingFormPage')()}
|
||||
</ModuleGuard>
|
||||
</RoleGuard>
|
||||
),
|
||||
});
|
||||
|
||||
const offeringsEditRoute = createRoute({
|
||||
getParentRoute: () => authenticatedRoute,
|
||||
path: '/offerings/$id/edit',
|
||||
component: () => (
|
||||
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
|
||||
<ModuleGuard requiredModule="Offerings">
|
||||
{lazyPage(() => import('@/features/offerings/pages/OfferingFormPage'), 'OfferingFormPage')()}
|
||||
</ModuleGuard>
|
||||
</RoleGuard>
|
||||
),
|
||||
});
|
||||
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => authenticatedRoute,
|
||||
path: '/settings',
|
||||
@@ -225,7 +261,16 @@ export const routeTree = rootRoute.addChildren([
|
||||
setupRoute,
|
||||
inviteCompleteRoute,
|
||||
accessDeniedRoute,
|
||||
authenticatedRoute.addChildren([dashboardRoute, usersRoute, cmsRoute, settingsRoute, profileRoute]),
|
||||
authenticatedRoute.addChildren([
|
||||
dashboardRoute,
|
||||
usersRoute,
|
||||
cmsRoute,
|
||||
offeringsRoute,
|
||||
offeringsNewRoute,
|
||||
offeringsEditRoute,
|
||||
settingsRoute,
|
||||
profileRoute,
|
||||
]),
|
||||
]);
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
Reference in New Issue
Block a user