Add a Help page with FAQ and contact form
Adds a /help route with an animated FAQ accordion and a contact form (persisted via a new contact_messages table). The per-order-item "Get help" link now points here and passes the order/item/status as search params, which the page uses to show a short, state-specific note and prefill the message field. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
);
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,13 @@
|
|||||||
"when": 1789036823859,
|
"when": 1789036823859,
|
||||||
"tag": "0004_misty_hedge_knight",
|
"tag": "0004_misty_hedge_knight",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789636006749,
|
||||||
|
"tag": "0005_tan_power_man",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -8,14 +8,6 @@ import { cancelOrderItem, markOrderItemPickedUp } from '#/server/orders.ts'
|
|||||||
|
|
||||||
import styles from './OrderStatusView.module.scss'
|
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) => {
|
const cancelBlockedReason = (item: OrderItemStatusView) => {
|
||||||
if (item.cancellable || item.status === 'cancelled' || item.status === 'picked_up') {
|
if (item.cancellable || item.status === 'cancelled' || item.status === 'picked_up') {
|
||||||
return null
|
return null
|
||||||
@@ -186,7 +178,16 @@ export const OrderStatusView = ({
|
|||||||
I picked this up
|
I picked this up
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<a href={helpMailto(status.id, item)}>Get help</a>
|
<Link
|
||||||
|
to="/help"
|
||||||
|
search={{
|
||||||
|
orderId: status.id,
|
||||||
|
item: item.menuItemName,
|
||||||
|
status: item.status,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Get help
|
||||||
|
</Link>
|
||||||
{cancelHint && (
|
{cancelHint && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -168,3 +168,12 @@ export const preparationStepInstances = pgTable('preparation_step_instances', {
|
|||||||
startedAt: timestamp('started_at'),
|
startedAt: timestamp('started_at'),
|
||||||
doneAt: timestamp('done_at'),
|
doneAt: timestamp('done_at'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A message submitted through the Help page's contact form.
|
||||||
|
export const contactMessages = pgTable('contact_messages', {
|
||||||
|
id: serial().primaryKey(),
|
||||||
|
name: text().notNull(),
|
||||||
|
email: text().notNull(),
|
||||||
|
message: text().notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as AdminRouteImport } from './routes/admin'
|
import { Route as AdminRouteImport } from './routes/admin'
|
||||||
import { Route as BasketRouteImport } from './routes/basket'
|
import { Route as BasketRouteImport } from './routes/basket'
|
||||||
|
import { Route as HelpRouteImport } from './routes/help'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as SignupRouteImport } from './routes/signup'
|
import { Route as SignupRouteImport } from './routes/signup'
|
||||||
import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
||||||
@@ -41,6 +42,11 @@ const BasketRoute = BasketRouteImport.update({
|
|||||||
path: '/basket',
|
path: '/basket',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const HelpRoute = HelpRouteImport.update({
|
||||||
|
id: '/help',
|
||||||
|
path: '/help',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
id: '/login',
|
id: '/login',
|
||||||
path: '/login',
|
path: '/login',
|
||||||
@@ -111,6 +117,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/admin': typeof AdminRouteWithChildren
|
'/admin': typeof AdminRouteWithChildren
|
||||||
'/basket': typeof BasketRoute
|
'/basket': typeof BasketRoute
|
||||||
|
'/help': typeof HelpRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/admin/kitchen': typeof AdminKitchenRoute
|
'/admin/kitchen': typeof AdminKitchenRoute
|
||||||
@@ -128,6 +135,7 @@ export interface FileRoutesByFullPath {
|
|||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/basket': typeof BasketRoute
|
'/basket': typeof BasketRoute
|
||||||
|
'/help': typeof HelpRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/admin/kitchen': typeof AdminKitchenRoute
|
'/admin/kitchen': typeof AdminKitchenRoute
|
||||||
@@ -147,6 +155,7 @@ export interface FileRoutesById {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/admin': typeof AdminRouteWithChildren
|
'/admin': typeof AdminRouteWithChildren
|
||||||
'/basket': typeof BasketRoute
|
'/basket': typeof BasketRoute
|
||||||
|
'/help': typeof HelpRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/admin/kitchen': typeof AdminKitchenRoute
|
'/admin/kitchen': typeof AdminKitchenRoute
|
||||||
@@ -167,6 +176,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/basket'
|
| '/basket'
|
||||||
|
| '/help'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/admin/kitchen'
|
| '/admin/kitchen'
|
||||||
@@ -184,6 +194,7 @@ export interface FileRouteTypes {
|
|||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/basket'
|
| '/basket'
|
||||||
|
| '/help'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/admin/kitchen'
|
| '/admin/kitchen'
|
||||||
@@ -202,6 +213,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/basket'
|
| '/basket'
|
||||||
|
| '/help'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/admin/kitchen'
|
| '/admin/kitchen'
|
||||||
@@ -221,6 +233,7 @@ export interface RootRouteChildren {
|
|||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AdminRoute: typeof AdminRouteWithChildren
|
AdminRoute: typeof AdminRouteWithChildren
|
||||||
BasketRoute: typeof BasketRoute
|
BasketRoute: typeof BasketRoute
|
||||||
|
HelpRoute: typeof HelpRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
SignupRoute: typeof SignupRoute
|
SignupRoute: typeof SignupRoute
|
||||||
ItemsMenuItemIdRoute: typeof ItemsMenuItemIdRoute
|
ItemsMenuItemIdRoute: typeof ItemsMenuItemIdRoute
|
||||||
@@ -252,6 +265,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof BasketRouteImport
|
preLoaderRoute: typeof BasketRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/help': {
|
||||||
|
id: '/help'
|
||||||
|
path: '/help'
|
||||||
|
fullPath: '/help'
|
||||||
|
preLoaderRoute: typeof HelpRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/login': {
|
'/login': {
|
||||||
id: '/login'
|
id: '/login'
|
||||||
path: '/login'
|
path: '/login'
|
||||||
@@ -372,6 +392,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AdminRoute: AdminRouteWithChildren,
|
AdminRoute: AdminRouteWithChildren,
|
||||||
BasketRoute: BasketRoute,
|
BasketRoute: BasketRoute,
|
||||||
|
HelpRoute: HelpRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
SignupRoute: SignupRoute,
|
SignupRoute: SignupRoute,
|
||||||
ItemsMenuItemIdRoute: ItemsMenuItemIdRoute,
|
ItemsMenuItemIdRoute: ItemsMenuItemIdRoute,
|
||||||
|
|||||||
@@ -32,3 +32,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $space-2;
|
gap: $space-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.helpLink {
|
||||||
|
color: $color-text-muted;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const RootDocument = ({ children }: { children: React.ReactNode }) => {
|
|||||||
<Logo name={brand.name} />
|
<Logo name={brand.name} />
|
||||||
</Link>
|
</Link>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
|
<Link to="/help" className={styles.helpLink}>
|
||||||
|
Help
|
||||||
|
</Link>
|
||||||
<BasketLink />
|
<BasketLink />
|
||||||
<BetterAuthHeader />
|
<BetterAuthHeader />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<OrderItemStatus, string> = {
|
||||||
|
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 (
|
||||||
|
<div className={styles.faqItem}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.faqQuestion}
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((isOpen) => !isOpen)}
|
||||||
|
>
|
||||||
|
<span className={styles.faqMarker} aria-hidden="true">
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.span
|
||||||
|
key={open ? 'minus' : 'plus'}
|
||||||
|
className={styles.faqMarkerGlyph}
|
||||||
|
initial={{ opacity: 0, rotate: -90 }}
|
||||||
|
animate={{ opacity: 1, rotate: 0 }}
|
||||||
|
exit={{ opacity: 0, rotate: 90 }}
|
||||||
|
transition={transition}
|
||||||
|
>
|
||||||
|
{open ? '−' : '+'}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</span>
|
||||||
|
{question}
|
||||||
|
</button>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
key="content"
|
||||||
|
className={styles.faqAnswerWrapper}
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={transition}
|
||||||
|
>
|
||||||
|
<p className={styles.faqAnswer}>{answer}</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(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 (
|
||||||
|
<main className={styles.page}>
|
||||||
|
<div>
|
||||||
|
<h1 className={styles.heading}>Help</h1>
|
||||||
|
{status && (
|
||||||
|
<div className={styles.orderContext}>
|
||||||
|
<h2 className={styles.sectionHeading}>
|
||||||
|
About order #{orderId}
|
||||||
|
{item ? ` — ${item}` : ''}
|
||||||
|
</h2>
|
||||||
|
<p className={styles.orderContextText}>{orderStatusHelp[status]}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 className={styles.sectionHeading}>Frequently asked questions</h2>
|
||||||
|
<div className={styles.faq}>
|
||||||
|
{faqs.map(({ question, answer }) => (
|
||||||
|
<FaqItem key={question} question={question} answer={answer} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className={styles.sectionHeading}>Still need help?</h2>
|
||||||
|
{sent ? (
|
||||||
|
<p className={styles.success}>
|
||||||
|
Thanks for reaching out — we’ll get back to you soon.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form className={styles.form} onSubmit={submit}>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.field}>
|
||||||
|
Message
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(event) => setMessage(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.submitButton}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? 'Sending…' : 'Send message'}
|
||||||
|
</button>
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user