diff --git a/drizzle/0005_tan_power_man.sql b/drizzle/0005_tan_power_man.sql new file mode 100644 index 0000000..4e88911 --- /dev/null +++ b/drizzle/0005_tan_power_man.sql @@ -0,0 +1,7 @@ +CREATE TABLE "contact_messages" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "message" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..f82de8e --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,749 @@ +{ + "id": "83dc14cf-82fb-43fc-8b7d-fd377bf48689", + "prevId": "cd474707-7446-42df-afd9-44d66a9a03a2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.brands": { + "name": "brands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_messages": { + "name": "contact_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.machine_tags": { + "name": "machine_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "machine_tags_name_idx": { + "name": "machine_tags_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.machines": { + "name": "machines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "machine_tag_id": { + "name": "machine_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "machines_machine_tag_id_machine_tags_id_fk": { + "name": "machines_machine_tag_id_machine_tags_id_fk", + "tableFrom": "machines", + "tableTo": "machine_tags", + "columnsFrom": [ + "machine_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.menu_item_options": { + "name": "menu_item_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "menu_item_id": { + "name": "menu_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price_delta_cents": { + "name": "price_delta_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "menu_item_options_menu_item_id_menu_items_id_fk": { + "name": "menu_item_options_menu_item_id_menu_items_id_fk", + "tableFrom": "menu_item_options", + "tableTo": "menu_items", + "columnsFrom": [ + "menu_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.menu_item_steps": { + "name": "menu_item_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "menu_item_id": { + "name": "menu_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "machine_tag_id": { + "name": "machine_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "estimated_duration_seconds": { + "name": "estimated_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "menu_item_steps_menu_item_id_menu_items_id_fk": { + "name": "menu_item_steps_menu_item_id_menu_items_id_fk", + "tableFrom": "menu_item_steps", + "tableTo": "menu_items", + "columnsFrom": [ + "menu_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "menu_item_steps_machine_tag_id_machine_tags_id_fk": { + "name": "menu_item_steps_machine_tag_id_machine_tags_id_fk", + "tableFrom": "menu_item_steps", + "tableTo": "machine_tags", + "columnsFrom": [ + "machine_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.menu_items": { + "name": "menu_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "brand_id": { + "name": "brand_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "menu_items_brand_id_brands_id_fk": { + "name": "menu_items_brand_id_brands_id_fk", + "tableFrom": "menu_items", + "tableTo": "brands", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.order_item_options": { + "name": "order_item_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order_item_id": { + "name": "order_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price_delta_cents": { + "name": "price_delta_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "order_item_options_order_item_id_order_items_id_fk": { + "name": "order_item_options_order_item_id_order_items_id_fk", + "tableFrom": "order_item_options", + "tableTo": "order_items", + "columnsFrom": [ + "order_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.order_item_steps": { + "name": "order_item_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order_item_id": { + "name": "order_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "machine_tag_id": { + "name": "machine_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "estimated_duration_seconds": { + "name": "estimated_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "order_item_steps_order_item_id_order_items_id_fk": { + "name": "order_item_steps_order_item_id_order_items_id_fk", + "tableFrom": "order_item_steps", + "tableTo": "order_items", + "columnsFrom": [ + "order_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "order_item_steps_machine_tag_id_machine_tags_id_fk": { + "name": "order_item_steps_machine_tag_id_machine_tags_id_fk", + "tableFrom": "order_item_steps", + "tableTo": "machine_tags", + "columnsFrom": [ + "machine_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.order_items": { + "name": "order_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "menu_item_id": { + "name": "menu_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'placed'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "order_items_menu_item_id_menu_items_id_fk": { + "name": "order_items_menu_item_id_menu_items_id_fk", + "tableFrom": "order_items", + "tableTo": "menu_items", + "columnsFrom": [ + "menu_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "guest_name": { + "name": "guest_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "guest_contact": { + "name": "guest_contact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "guest_access_token": { + "name": "guest_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'placed'" + }, + "payment_charge_id": { + "name": "payment_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preparation_step_instances": { + "name": "preparation_step_instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order_item_id": { + "name": "order_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "machine_tag_id": { + "name": "machine_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "estimated_duration_seconds": { + "name": "estimated_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "machine_id": { + "name": "machine_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "done_at": { + "name": "done_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "preparation_step_instances_order_item_id_order_items_id_fk": { + "name": "preparation_step_instances_order_item_id_order_items_id_fk", + "tableFrom": "preparation_step_instances", + "tableTo": "order_items", + "columnsFrom": [ + "order_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "preparation_step_instances_machine_tag_id_machine_tags_id_fk": { + "name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk", + "tableFrom": "preparation_step_instances", + "tableTo": "machine_tags", + "columnsFrom": [ + "machine_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "preparation_step_instances_machine_id_machines_id_fk": { + "name": "preparation_step_instances_machine_id_machines_id_fk", + "tableFrom": "preparation_step_instances", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 396ba37..994218d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1789036823859, "tag": "0004_misty_hedge_knight", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1789636006749, + "tag": "0005_tan_power_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/components/orders/OrderStatusView.tsx b/src/components/orders/OrderStatusView.tsx index f93c604..2addf9d 100644 --- a/src/components/orders/OrderStatusView.tsx +++ b/src/components/orders/OrderStatusView.tsx @@ -8,14 +8,6 @@ import { cancelOrderItem, markOrderItemPickedUp } from '#/server/orders.ts' import styles from './OrderStatusView.module.scss' -const SUPPORT_EMAIL = 'help@ghostkitchen.example' - -const helpMailto = (orderId: number, item: OrderItemStatusView) => { - const subject = `Help with Order #${orderId} — ${item.menuItemName}` - const body = `Order #${orderId}\nItem: ${item.menuItemName}\nStatus: ${item.status}\n\nWhat's going on?\n` - return `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}` -} - const cancelBlockedReason = (item: OrderItemStatusView) => { if (item.cancellable || item.status === 'cancelled' || item.status === 'picked_up') { return null @@ -186,7 +178,16 @@ export const OrderStatusView = ({ I picked this up )} - Get help + + Get help + {cancelHint && ( <> rootRouteImport, } as any) +const HelpRoute = HelpRouteImport.update({ + id: '/help', + path: '/help', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -111,6 +117,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/admin': typeof AdminRouteWithChildren '/basket': typeof BasketRoute + '/help': typeof HelpRoute '/login': typeof LoginRoute '/signup': typeof SignupRoute '/admin/kitchen': typeof AdminKitchenRoute @@ -128,6 +135,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/basket': typeof BasketRoute + '/help': typeof HelpRoute '/login': typeof LoginRoute '/signup': typeof SignupRoute '/admin/kitchen': typeof AdminKitchenRoute @@ -147,6 +155,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/admin': typeof AdminRouteWithChildren '/basket': typeof BasketRoute + '/help': typeof HelpRoute '/login': typeof LoginRoute '/signup': typeof SignupRoute '/admin/kitchen': typeof AdminKitchenRoute @@ -167,6 +176,7 @@ export interface FileRouteTypes { | '/' | '/admin' | '/basket' + | '/help' | '/login' | '/signup' | '/admin/kitchen' @@ -184,6 +194,7 @@ export interface FileRouteTypes { to: | '/' | '/basket' + | '/help' | '/login' | '/signup' | '/admin/kitchen' @@ -202,6 +213,7 @@ export interface FileRouteTypes { | '/' | '/admin' | '/basket' + | '/help' | '/login' | '/signup' | '/admin/kitchen' @@ -221,6 +233,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AdminRoute: typeof AdminRouteWithChildren BasketRoute: typeof BasketRoute + HelpRoute: typeof HelpRoute LoginRoute: typeof LoginRoute SignupRoute: typeof SignupRoute ItemsMenuItemIdRoute: typeof ItemsMenuItemIdRoute @@ -252,6 +265,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BasketRouteImport parentRoute: typeof rootRouteImport } + '/help': { + id: '/help' + path: '/help' + fullPath: '/help' + preLoaderRoute: typeof HelpRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -372,6 +392,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AdminRoute: AdminRouteWithChildren, BasketRoute: BasketRoute, + HelpRoute: HelpRoute, LoginRoute: LoginRoute, SignupRoute: SignupRoute, ItemsMenuItemIdRoute: ItemsMenuItemIdRoute, diff --git a/src/routes/__root.module.scss b/src/routes/__root.module.scss index 1af8d88..9b68dc4 100644 --- a/src/routes/__root.module.scss +++ b/src/routes/__root.module.scss @@ -32,3 +32,13 @@ align-items: center; gap: $space-2; } + +.helpLink { + color: $color-text-muted; + text-decoration: none; + font-size: 0.9rem; + + &:hover { + color: $color-text; + } +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index abf04eb..22efd4b 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -33,6 +33,9 @@ const RootDocument = ({ children }: { children: React.ReactNode }) => { + + Help + diff --git a/src/routes/help.module.scss b/src/routes/help.module.scss new file mode 100644 index 0000000..49daceb --- /dev/null +++ b/src/routes/help.module.scss @@ -0,0 +1,152 @@ +@use '../styles/variables' as *; + +.page { + max-width: 640px; + margin: 0 auto; + padding: $space-5 $space-3; + display: flex; + flex-direction: column; + gap: $space-5; +} + +.heading { + margin: 0 0 $space-4; +} + +.sectionHeading { + margin: 0 0 $space-3; + font-size: 1.1rem; +} + +.orderContext { + margin-bottom: $space-4; + padding: $space-3; + border: 1px solid $color-primary; + border-radius: $radius; + background: color-mix(in srgb, $color-primary 8%, transparent); + + .sectionHeading { + margin-bottom: $space-1; + } +} + +.orderContextText { + margin: 0; + color: $color-text-muted; +} + +.faq { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.faqItem { + border: 1px solid $color-border; + border-radius: $radius; + background: $color-surface; + overflow: hidden; +} + +.faqQuestion { + display: flex; + align-items: center; + width: 100%; + padding: $space-3; + border: none; + background: none; + color: $color-text; + font: inherit; + font-weight: 600; + text-align: left; + cursor: pointer; +} + +.faqMarker { + position: relative; + flex-shrink: 0; + width: 1.2em; + height: 1.2em; +} + +.faqMarkerGlyph { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: $color-primary; +} + +.faqAnswerWrapper { + overflow: hidden; +} + +.faqAnswer { + margin: 0; + padding: 0 $space-3 $space-3 calc($space-3 + 1.2em); + color: $color-text-muted; +} + +.form { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.field { + display: flex; + flex-direction: column; + gap: $space-1; + font-size: 0.85rem; + color: $color-text-muted; + + input, + textarea { + padding: $space-2; + border: 1px solid $color-border; + border-radius: $radius; + font-size: 1rem; + font-family: inherit; + color: $color-text; + background: $color-background; + } + + textarea { + resize: vertical; + min-height: 6rem; + } +} + +.submitButton { + align-self: flex-start; + 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; + } +} + +.error { + color: $color-danger; + font-size: 0.85rem; + margin: 0; +} + +.success { + color: $color-success; + font-size: 0.85rem; + margin: 0; +} diff --git a/src/routes/help.tsx b/src/routes/help.tsx new file mode 100644 index 0000000..0758d86 --- /dev/null +++ b/src/routes/help.tsx @@ -0,0 +1,215 @@ +import { createFileRoute } from '@tanstack/react-router' +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' +import { useState } from 'react' +import type { FormEvent } from 'react' +import { z } from 'zod' + +import { orderItemStatusValues } from '#/db/schema.ts' +import type { OrderItemStatus } from '#/db/schema.ts' +import { submitContactMessage } from '#/server/contact.ts' + +import styles from './help.module.scss' + +const orderStatusHelp: Record = { + placed: 'Your order has just been placed. If you need to change or cancel it, do it soon — once the kitchen starts cooking, we can’t make changes.', + paid: 'Your order is already being prepared, so we can’t change or cancel it now. Tell us what’s wrong below and we’ll help.', + ready: 'Your order is ready for pickup. If something’s off with it, let us know below.', + picked_up: 'This order has already been picked up. If something was wrong with it, let us know below.', + cancelled: 'This order was cancelled. If that wasn’t expected, let us know below.', +} + +const faqs = [ + { + question: 'How do I track my order?', + answer: + 'Once you place an order you’ll land on its status page, which updates live as each step of your order is prepared. You can get back to it any time from the order confirmation link.', + }, + { + question: 'Can I change or cancel an order after placing it?', + answer: + 'Orders start preparing right away, so we can’t guarantee changes or cancellations once it’s placed. Reach out through the form below as soon as possible and we’ll do what we can.', + }, + { + question: 'Do I need an account to order?', + answer: + 'No — you can check out as a guest with just a name and a way to reach you. Creating an account just lets you see your order history in one place.', + }, + { + question: 'What payment methods are accepted?', + answer: 'We accept all major debit and credit cards at checkout.', + }, + { + question: 'How do I get a refund?', + answer: + 'Send us a message with your order details using the contact form below and we’ll sort it out with you.', + }, +] + +type FaqItemProps = { + question: string + answer: string +} + +const FaqItem = ({ question, answer }: FaqItemProps) => { + const reduceMotion = useReducedMotion() + const [open, setOpen] = useState(false) + const transition = { duration: reduceMotion ? 0 : 0.2, ease: 'easeOut' as const } + + return ( + + setOpen((isOpen) => !isOpen)} + > + + + + {open ? '−' : '+'} + + + + {question} + + + {open && ( + + {answer} + + )} + + + ) +} + +const HelpPage = () => { + const { orderId, item, status } = Route.useSearch() + + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [message, setMessage] = useState(() => + orderId + ? `Order #${orderId}${item ? ` — ${item}` : ''}\n\n` + : '', + ) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const [sent, setSent] = useState(false) + + const submit = async (event: FormEvent) => { + event.preventDefault() + setSubmitting(true) + setError(null) + + try { + await submitContactMessage({ data: { name, email, message } }) + setSent(true) + setName('') + setEmail('') + setMessage('') + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Could not send your message. Please try again.', + ) + } finally { + setSubmitting(false) + } + } + + return ( + + + Help + {status && ( + + + About order #{orderId} + {item ? ` — ${item}` : ''} + + {orderStatusHelp[status]} + + )} + Frequently asked questions + + {faqs.map(({ question, answer }) => ( + + ))} + + + + + Still need help? + {sent ? ( + + Thanks for reaching out — we’ll get back to you soon. + + ) : ( + + + Name + setName(event.target.value)} + required + /> + + + Email + setEmail(event.target.value)} + required + /> + + + Message + setMessage(event.target.value)} + required + /> + + + {submitting ? 'Sending…' : 'Send message'} + + {error && {error}} + + )} + + + ) +} + +const searchSchema = z.object({ + orderId: z.number().optional(), + item: z.string().optional(), + status: z.enum(orderItemStatusValues).optional(), +}) + +export const Route = createFileRoute('/help')({ + validateSearch: searchSchema, + component: HelpPage, +}) diff --git a/src/server/contact.ts b/src/server/contact.ts new file mode 100644 index 0000000..5b64b2c --- /dev/null +++ b/src/server/contact.ts @@ -0,0 +1,17 @@ +import { createServerFn } from '@tanstack/react-start' +import { z } from 'zod' + +import { db } from '#/db/index.ts' +import { contactMessages } from '#/db/schema.ts' + +export const submitContactMessage = createServerFn({ method: 'POST' }) + .validator( + z.object({ + name: z.string().min(1), + email: z.string().email(), + message: z.string().min(1), + }), + ) + .handler(async ({ data }) => { + await db.insert(contactMessages).values(data) + })
{answer}
{orderStatusHelp[status]}
+ Thanks for reaching out — we’ll get back to you soon. +
{error}