diff --git a/.env.example b/.env.example index 7c4bd57..de831bd 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,9 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= APPLE_CLIENT_ID= APPLE_CLIENT_SECRET= + +# Used by `npm run db:seed-admin` to create/promote one admin account +# (can view every order at /admin/orders). Not read by the app itself. +ADMIN_EMAIL= +ADMIN_PASSWORD= +ADMIN_NAME= diff --git a/package.json b/package.json index c00a514..c3cf975 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "db:pull": "drizzle-kit pull", "db:studio": "drizzle-kit studio", "db:seed": "tsx src/db/seed.ts", + "db:seed-admin": "tsx src/db/seed-admin.ts", "auth:migrate": "better-auth migrate", "lint": "eslint", "format": "prettier --write . && eslint --fix", diff --git a/src/components/admin/AdminMenuListView.module.scss b/src/components/admin/AdminMenuListView.module.scss new file mode 100644 index 0000000..cd6048e --- /dev/null +++ b/src/components/admin/AdminMenuListView.module.scss @@ -0,0 +1,129 @@ +@use '../../styles/variables' as *; + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-3; + margin-bottom: $space-4; +} + +.heading { + margin: 0; +} + +.addLink { + padding: $space-2 $space-4; + border-radius: $radius; + background: $color-primary; + color: $color-primary-contrast; + font-weight: 600; + text-decoration: none; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.9; + } +} + +.empty { + color: $color-text-muted; +} + +.items { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: $space-3; +} + +.item { + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-3; + padding: $space-3; + border: 1px solid $color-border; + border-radius: $radius; + background: $color-surface; +} + +.itemInfo { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.itemHeader { + display: flex; + align-items: baseline; + gap: $space-2; +} + +.itemName { + font-weight: 600; +} + +.itemPrice { + color: $color-text-muted; + font-size: 0.9rem; +} + +.itemMeta { + margin: 0; + font-size: 0.85rem; + color: $color-text-muted; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemActions { + display: flex; + align-items: center; + gap: $space-2; + flex: 0 0 auto; +} + +.editLink { + padding: $space-1 $space-3; + border: 1px solid $color-border; + border-radius: $radius; + color: $color-text; + text-decoration: none; + + &:hover { + border-color: $color-primary; + } +} + +.deleteButton { + padding: $space-1 $space-3; + border: 1px solid $color-border; + border-radius: $radius; + background: $color-background; + color: $color-danger; + cursor: pointer; + + &:hover { + border-color: $color-danger; + } +} + +.confirmButton { + padding: $space-1 $space-3; + border: none; + border-radius: $radius; + background: $color-danger; + color: $color-primary-contrast; + cursor: pointer; +} + +.error { + color: $color-danger; + font-size: 0.85rem; + margin: $space-2 0 0; +} diff --git a/src/components/admin/AdminMenuListView.tsx b/src/components/admin/AdminMenuListView.tsx new file mode 100644 index 0000000..fd4bf4e --- /dev/null +++ b/src/components/admin/AdminMenuListView.tsx @@ -0,0 +1,108 @@ +import { Link } from '@tanstack/react-router' +import { useState } from 'react' + +import type { AdminMenuItem } from '#/domain/menu.ts' +import { formatPrice } from '#/lib/format.ts' + +import styles from './AdminMenuListView.module.scss' + +type AdminMenuListViewProps = { + items: Array + onDelete: (menuItemId: number) => Promise +} + +export const AdminMenuListView = ({ + items, + onDelete, +}: AdminMenuListViewProps) => { + const [confirmingId, setConfirmingId] = useState(null) + const [deletingId, setDeletingId] = useState(null) + const [error, setError] = useState(null) + + const confirmDelete = async (menuItemId: number) => { + setDeletingId(menuItemId) + setError(null) + try { + await onDelete(menuItemId) + } catch (deleteError) { + setError( + deleteError instanceof Error + ? deleteError.message + : 'Something went wrong.', + ) + } finally { + setDeletingId(null) + setConfirmingId(null) + } + } + + return ( +
+
+

Menu

+ + Add item + +
+ + {items.length === 0 ? ( +

No menu items yet.

+ ) : ( +
    + {items.map((item) => ( +
  • +
    +
    + {item.name} + + {formatPrice(item.priceCents)} + +
    +

    + {item.steps.length} step + {item.steps.length === 1 ? '' : 's'} ·{' '} + {item.options.length} option + {item.options.length === 1 ? '' : 's'} + {item.description ? ` · ${item.description}` : ''} +

    +
    +
    + + Edit + + {confirmingId === item.id ? ( + + ) : ( + + )} +
    +
  • + ))} +
+ )} + {error &&

{error}

} +
+ ) +} diff --git a/src/components/admin/MenuItemForm.module.scss b/src/components/admin/MenuItemForm.module.scss new file mode 100644 index 0000000..6183be6 --- /dev/null +++ b/src/components/admin/MenuItemForm.module.scss @@ -0,0 +1,145 @@ +@use '../../styles/variables' as *; + +.form { + display: flex; + flex-direction: column; + gap: $space-4; + max-width: 640px; +} + +.field { + display: flex; + flex-direction: column; + gap: $space-1; + font-size: 0.85rem; + color: $color-text-muted; + + input, + textarea, + select { + padding: $space-2; + border: 1px solid $color-border; + border-radius: $radius; + font-size: 1rem; + font-family: inherit; + color: $color-text; + } + + textarea { + resize: vertical; + min-height: 4.5rem; + } +} + +.row { + display: flex; + gap: $space-2; +} + +.section { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; +} + +.sectionTitle { + margin: 0; + font-size: 0.95rem; + font-weight: 600; +} + +.repeatingRow { + display: flex; + align-items: flex-start; + gap: $space-2; + + .field { + flex: 1; + } +} + +.stepName { + flex: 2; +} + +.narrowField { + flex: 1; +} + +.removeButton { + flex: 0 0 auto; + width: 2.25rem; + height: 2.25rem; + margin-top: 1.35rem; + border: 1px solid $color-border; + border-radius: $radius; + background: $color-background; + color: $color-danger; + cursor: pointer; + + &:hover { + border-color: $color-danger; + } +} + +.addButton { + align-self: flex-start; + padding: $space-1 $space-3; + border: 1px dashed $color-border; + border-radius: $radius; + background: $color-background; + cursor: pointer; + font-size: 0.85rem; + + &:hover { + border-color: $color-primary; + color: $color-primary; + } +} + +.actions { + display: flex; + align-items: center; + gap: $space-3; +} + +.submitButton { + padding: $space-2 $space-4; + border: none; + border-radius: $radius; + background: $color-primary; + color: $color-primary-contrast; + cursor: pointer; + font-weight: 600; + transition: opacity 0.15s ease; + + &:hover:not(:disabled) { + opacity: 0.9; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.cancelLink { + color: $color-text-muted; + text-decoration: none; + + &:hover { + color: $color-text; + } +} + +.error { + color: $color-danger; + font-size: 0.85rem; + margin: 0; +} diff --git a/src/components/admin/MenuItemForm.tsx b/src/components/admin/MenuItemForm.tsx new file mode 100644 index 0000000..1586192 --- /dev/null +++ b/src/components/admin/MenuItemForm.tsx @@ -0,0 +1,268 @@ +import { Link } from '@tanstack/react-router' +import { useState } from 'react' +import type { FormEvent } from 'react' + +import type { AdminMenuItem, MenuItemInput } from '#/domain/menu.ts' +import type { MachineTagOption } from '#/domain/kitchen.ts' + +import styles from './MenuItemForm.module.scss' + +type OptionRow = { name: string; priceDelta: string } +type StepRow = { name: string; machineTagId: string; durationSeconds: string } + +const toOptionRows = (item?: AdminMenuItem): Array => + item?.options.map((option) => ({ + name: option.name, + priceDelta: (option.priceDeltaCents / 100).toString(), + })) ?? [] + +const toStepRows = (item?: AdminMenuItem): Array => + item?.steps.map((step) => ({ + name: step.name, + machineTagId: String(step.machineTagId), + durationSeconds: String(step.estimatedDurationSeconds), + })) ?? [] + +type MenuItemFormProps = { + machineTags: Array + initialItem?: AdminMenuItem + submitLabel: string + onSubmit: (input: MenuItemInput) => Promise +} + +export const MenuItemForm = ({ + machineTags, + initialItem, + submitLabel, + onSubmit, +}: MenuItemFormProps) => { + const [name, setName] = useState(initialItem?.name ?? '') + const [description, setDescription] = useState( + initialItem?.description ?? '', + ) + const [price, setPrice] = useState( + initialItem ? (initialItem.priceCents / 100).toString() : '', + ) + const [options, setOptions] = useState>( + toOptionRows(initialItem), + ) + const [steps, setSteps] = useState>(toStepRows(initialItem)) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const updateOption = (index: number, patch: Partial) => + setOptions((rows) => + rows.map((row, i) => (i === index ? { ...row, ...patch } : row)), + ) + const updateStep = (index: number, patch: Partial) => + setSteps((rows) => + rows.map((row, i) => (i === index ? { ...row, ...patch } : row)), + ) + + const submit = async (event: FormEvent) => { + event.preventDefault() + setError(null) + + if (steps.length === 0) { + setError('Add at least one Preparation Step — otherwise this item can never be cooked.') + return + } + + const input: MenuItemInput = { + name, + description: description || undefined, + priceCents: Math.round(Number(price) * 100), + options: options + .filter((option) => option.name.trim() !== '') + .map((option) => ({ + name: option.name, + priceDeltaCents: Math.round(Number(option.priceDelta || '0') * 100), + })), + steps: steps.map((step) => ({ + name: step.name, + machineTagId: Number(step.machineTagId), + estimatedDurationSeconds: Math.round(Number(step.durationSeconds)), + })), + } + + setSubmitting(true) + try { + await onSubmit(input) + } catch (submitError) { + setError( + submitError instanceof Error + ? submitError.message + : 'Something went wrong.', + ) + } finally { + setSubmitting(false) + } + } + + return ( +
+ + +