diff --git a/package-lock.json b/package-lock.json index 7b7125c..a9820b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@heroicons/react": "^2.2.0", + "@hookform/resolvers": "^5.6.0", "@stellar/freighter-api": "^6.0.1", "autosuggest-highlight": "^3.3.4", "axios": "^1.18.1", @@ -634,6 +635,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", @@ -698,6 +723,116 @@ "react": ">= 16 || ^19.0.0-rc" } }, + "node_modules/@hookform/resolvers": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.6.0.tgz", + "integrity": "sha512-qtgE4NUK/WQFPq8aDe+GOhr0/UiUSKT0m9ta9SMnDS5ZE63yC850ShAGz1lxtwO+dBjhUKvUxmHwkp4eN7/kBQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "@sinclair/typebox": ">=0.25.24", + "@standard-schema/spec": "^1.0.0", + "@typeschema/main": ">=0.13.7", + "@vinejs/vine": "^2.0.0 || ^3.0.0", + "ajv": "^8.12.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "arktype": "^2.0.0", + "ata-validator": "^0.7.0", + "class-transformer": ">=0.4.0", + "class-validator": ">=0.12.0", + "computed-types": "^1.0.0", + "effect": "^3.10.3", + "fluentvalidation-ts": "^3.0.0", + "fp-ts": "^2.7.0", + "io-ts": "^2.0.0", + "joi": "^17.0.0", + "nope-validator": ">=0.12.0", + "react-hook-form": "^7.55.0", + "superstruct": ">=0.12.0", + "typanion": "^3.3.2", + "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", + "vest": ">=3.0.0", + "yup": "^1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + }, + "@standard-schema/spec": { + "optional": true + }, + "@typeschema/main": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "ajv": { + "optional": true + }, + "ajv-errors": { + "optional": true + }, + "ajv-formats": { + "optional": true + }, + "arktype": { + "optional": true + }, + "ata-validator": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "computed-types": { + "optional": true + }, + "effect": { + "optional": true + }, + "fluentvalidation-ts": { + "optional": true + }, + "fp-ts": { + "optional": true + }, + "io-ts": { + "optional": true + }, + "joi": { + "optional": true + }, + "nope-validator": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "typanion": { + "optional": true + }, + "valibot": { + "optional": true + }, + "vest": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1821,7 +1956,13 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "devOptional": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@stellar/freighter-api": { @@ -3001,23 +3142,6 @@ "node": ">= 6.0.0" } }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4487,6 +4611,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -5794,13 +5942,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -8489,7 +8630,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 2e95421..a7f79a9 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@heroicons/react": "^2.2.0", + "@hookform/resolvers": "^5.6.0", "@stellar/freighter-api": "^6.0.1", "autosuggest-highlight": "^3.3.4", "axios": "^1.18.1", diff --git a/src/app/dispute/page.tsx b/src/app/dispute/page.tsx new file mode 100644 index 0000000..4a32692 --- /dev/null +++ b/src/app/dispute/page.tsx @@ -0,0 +1,18 @@ +import DisputeForm from "@/components/DisputeForm"; + +export default function DisputePage() { + return ( +
+ + Support + +

File a dispute

+

+ If something went wrong with a booking, let us know. We'll review + the case and help both parties reach a fair resolution. +

+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/DisputeForm.tsx b/src/components/DisputeForm.tsx new file mode 100644 index 0000000..ca6faa3 --- /dev/null +++ b/src/components/DisputeForm.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; +import { HiExclamationCircle, HiCheckCircle } from "react-icons/hi"; +import Button from "@/components/ui/Button"; +import Card from "@/components/ui/Card"; +import Badge from "@/components/ui/Badge"; +import Input from "@/components/ui/Input"; +import Select from "@/components/ui/Select"; +import EvidenceDropzone from "@/components/EvidenceDropzone"; + +/* ─── Schema ─────────────────────────────────────── */ + +const DISPUTE_REASONS = [ + { value: "service_not_completed", label: "Service not completed" }, + { value: "poor_quality", label: "Poor quality of work" }, + { value: "no_show", label: "Worker did not show up" }, + { value: "incorrect_charges", label: "Incorrect charges / overpayment" }, + { value: "damage_to_property", label: "Damage to property" }, + { value: "misrepresentation", label: "Misrepresentation of service" }, + { value: "other", label: "Other" }, +] as const; + +const schema = yup.object({ + disputeReason: yup.string().required("Please select a reason for the dispute"), + description: yup + .string() + .min(20, "Please provide at least 20 characters of detail") + .max(5000, "Description may not exceed 5,000 characters") + .required("Please describe the issue in detail"), + desiredResolution: yup + .string() + .min(10, "Please describe your desired resolution") + .max(2000, "Resolution may not exceed 2,000 characters") + .required("Please state your desired resolution"), + appointmentId: yup.string().optional(), + contactEmail: yup + .string() + .email("Please enter a valid email address") + .required("A contact email is required so we can reach you"), +}); + +type DisputeFormValues = yup.InferType; + +/* ─── Evidence file type ──────────────────────────── */ + +interface EvidenceFile { + file: File; + id: string; +} + +let nextId = 1; +function newEvidenceId(): string { + return `ev-${nextId++}-${Date.now()}`; +} + +/* ─── Submission helper ───────────────────────────── */ + +interface SubmitResult { + status: "idle" | "submitting" | "success" | "error"; + message?: string; + referenceId?: string; +} + +async function submitDispute( + _values: DisputeFormValues, + _evidence: EvidenceFile[], +): Promise<{ referenceId: string }> { + // Simulate API call with a realistic delay. + // In production this would POST to /api/disputes with FormData. + await new Promise((resolve) => setTimeout(resolve, 1500)); + return { referenceId: `DSP-${Date.now().toString(36).toUpperCase()}` }; +} + +/* ─── Component ───────────────────────────────────── */ + +export default function DisputeForm() { + const { + register, + handleSubmit, + formState: { errors, isValid }, + watch, + } = useForm({ + mode: "onChange", + resolver: yupResolver(schema), + defaultValues: { + disputeReason: "", + description: "", + desiredResolution: "", + appointmentId: "", + contactEmail: "", + }, + }); + + const [evidence, setEvidence] = useState([]); + const [submitResult, setSubmitResult] = useState({ status: "idle" }); + + const descriptionLength = watch("description")?.length ?? 0; + + function handleAddEvidence(file: File) { + setEvidence((prev) => [...prev, { file, id: newEvidenceId() }]); + } + + function handleRemoveEvidence(id: string) { + setEvidence((prev) => prev.filter((ef) => ef.id !== id)); + } + + async function onSubmit(values: DisputeFormValues) { + setSubmitResult({ status: "submitting" }); + try { + const result = await submitDispute(values, evidence); + setSubmitResult({ + status: "success", + referenceId: result.referenceId, + }); + } catch (error) { + setSubmitResult({ + status: "error", + message: + error instanceof Error + ? error.message + : "Something went wrong. Please try again.", + }); + } + } + + /* ─── Success state ──────────────────────────────── */ + if (submitResult.status === "success") { + return ( + + + Dispute filed + + +

Dispute submitted

+

+ Your dispute has been received. Reference{" "} + {submitResult.referenceId}. + We'll review it and reach out to both parties within 1–2 business + days. +

+ +
+ ); + } + + /* ─── Form ───────────────────────────────────────── */ + return ( + +
+ {/* Appointment ID (optional) */} + + + {/* Dispute reason */} + + + {/* Description */} +