diff --git a/drizzle/0006_same_nick_fury.sql b/drizzle/0006_same_nick_fury.sql new file mode 100644 index 0000000..c9066db --- /dev/null +++ b/drizzle/0006_same_nick_fury.sql @@ -0,0 +1,14 @@ +CREATE TABLE "order-settings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "adminNotificationEmails" text[] DEFAULT '{}'::text[] NOT NULL, + "created" timestamp DEFAULT (now()) NOT NULL, + "updated" timestamp DEFAULT (now()) NOT NULL +); +--> statement-breakpoint +ALTER TABLE "order" ALTER COLUMN "status" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "order" ALTER COLUMN "status" SET DEFAULT 'new'::text;--> statement-breakpoint +DROP TYPE "public"."order_status";--> statement-breakpoint +CREATE TYPE "public"."order_status" AS ENUM('new', 'confirmed', 'printed', 'packed', 'shipped', 'received', 'archived');--> statement-breakpoint +ALTER TABLE "order" ALTER COLUMN "status" SET DEFAULT 'new'::"public"."order_status";--> statement-breakpoint +ALTER TABLE "order" ALTER COLUMN "status" SET DATA TYPE "public"."order_status" USING "status"::"public"."order_status";--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "orderNotificationEmails" text; \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..b2941e8 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,789 @@ +{ + "id": "eac6bfac-3d9b-4634-98b7-f78cca304eb6", + "prevId": "04c536c5-124e-4d14-afdb-c761ef611297", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + }, + "updated": { + "name": "updated", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authenticator": { + "name": "authenticator", + "schema": "", + "columns": { + "credentialID": { + "name": "credentialID", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "credentialPublicKey": { + "name": "credentialPublicKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credentialDeviceType": { + "name": "credentialDeviceType", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "credentialBackedUp": { + "name": "credentialBackedUp", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + }, + "updated": { + "name": "updated", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "authenticator_userId_user_id_fk": { + "name": "authenticator_userId_user_id_fk", + "tableFrom": "authenticator", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "authenticator_userId_credentialID_pk": { + "name": "authenticator_userId_credentialID_pk", + "columns": [ + "userId", + "credentialID" + ] + } + }, + "uniqueConstraints": { + "authenticator_credentialID_unique": { + "name": "authenticator_credentialID_unique", + "nullsNotDistinct": false, + "columns": [ + "credentialID" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.guest-cart": { + "name": "guest-cart", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + }, + "updated": { + "name": "updated", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.order-settings": { + "name": "order-settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "adminNotificationEmails": { + "name": "adminNotificationEmails", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "created": { + "name": "created", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + }, + "updated": { + "name": "updated", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.order": { + "name": "order", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "order_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "paid": { + "name": "paid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "items": { + "name": "items", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "itemsSummary": { + "name": "itemsSummary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "deliveryAddress": { + "name": "deliveryAddress", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "invoiceAddress": { + "name": "invoiceAddress", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "delivery": { + "name": "delivery", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sum": { + "name": "sum", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created": { + "name": "created", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + }, + "updated": { + "name": "updated", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "order_userId_user_id_fk": { + "name": "order_userId_user_id_fk", + "tableFrom": "order", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product": { + "name": "product", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"en\":\"\",\"sk\":\"\"}'::jsonb" + }, + "name": { + "name": "name", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"en\":\"\",\"sk\":\"\"}'::jsonb" + }, + "description": { + "name": "description", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"en\":\"\",\"sk\":\"\"}'::jsonb" + }, + "status": { + "name": "status", + "type": "product_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "image": { + "name": "image", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "size": { + "name": "size", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "pricing": { + "name": "pricing", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created": { + "name": "created", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + }, + "updated": { + "name": "updated", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + }, + "updated": { + "name": "updated", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'customer'" + }, + "company": { + "name": "company", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "companyId": { + "name": "companyId", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "taxId": { + "name": "taxId", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "vatId": { + "name": "vatId", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "orderNotificationEmails": { + "name": "orderNotificationEmails", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cart": { + "name": "cart", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + }, + "updated": { + "name": "updated", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.order_status": { + "name": "order_status", + "schema": "public", + "values": [ + "new", + "confirmed", + "printed", + "packed", + "shipped", + "received", + "archived" + ] + }, + "public.product_status": { + "name": "product_status", + "schema": "public", + "values": [ + "active", + "draft" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "customer", + "admin" + ] + } + }, + "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 63ad7ac..154b69a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1775324215880, "tag": "0005_chunky_boomer", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1775405731036, + "tag": "0006_same_nick_fury", + "breakpoints": true } ] } \ No newline at end of file diff --git a/emails/order-new-admin.tsx b/emails/order-new-admin.tsx new file mode 100644 index 0000000..4d1354a --- /dev/null +++ b/emails/order-new-admin.tsx @@ -0,0 +1,119 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Link, + Preview, + Section, + Text, + Tailwind, +} from "@react-email/components"; +import { createTranslator } from "next-intl"; +import * as React from "react"; + +import messages from "../messages"; +import _tailwindConfig from "./_tailwind.config"; +import { Locales } from "@/i18n/locales"; + +type OrderNewAdminEmailProps = { + locale: Locales; + orderId: string; + customerEmail: string; + itemCount: number; + subtotal: number; + delivery: number; + vat: number; + total: number; + preview: string; + orderUrl: string; +}; + +const OrderNewAdminEmail = ({ + locale, + orderId, + customerEmail, + itemCount, + subtotal, + delivery, + vat, + total, + preview, + orderUrl, +}: OrderNewAdminEmailProps) => { + const intl = createTranslator({ + messages: messages[locale].OrderNewAdminEmail, + locale, + }); + + return ( + + + + {preview} + + +
+ + {intl("title")} + + + + {intl("description", { + orderId: orderId.slice(0, 8).toUpperCase(), + })} + + + + {intl("customerEmail")}: {customerEmail} + + + {intl("itemCount")}: {itemCount} + + + {intl("subtotal")}: {subtotal.toFixed(2)} EUR + + + {intl("delivery")}: {delivery.toFixed(2)} EUR + + + {intl("vat")}: {vat.toFixed(2)} EUR + + + {intl("total")}: {total.toFixed(2)} EUR + + + + {intl("openOrder")} + +
+
+ + +
+ ); +}; + +OrderNewAdminEmail.PreviewProps = { + locale: "en", + orderId: "e6d4a9f2-2f8d-4b97-b3b6-2eaf9e7142e9", + customerEmail: "customer@example.com", + itemCount: 4, + subtotal: 19.2, + delivery: 3.9, + vat: 4.62, + total: 27.72, + preview: "New order #E6D4A9F2 submitted.", + orderUrl: "https://pixea.sk/en/orders/e6d4a9f2-2f8d-4b97-b3b6-2eaf9e7142e9", +} as OrderNewAdminEmailProps; + +export default OrderNewAdminEmail; + +const main = { + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", +}; diff --git a/emails/order-status-customer.tsx b/emails/order-status-customer.tsx new file mode 100644 index 0000000..d1e64a2 --- /dev/null +++ b/emails/order-status-customer.tsx @@ -0,0 +1,95 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Link, + Preview, + Section, + Text, + Tailwind, +} from "@react-email/components"; +import { createTranslator } from "next-intl"; +import * as React from "react"; + +import messages from "../messages"; +import _tailwindConfig from "./_tailwind.config"; +import { Locales } from "@/i18n/locales"; + +interface OrderStatusCustomerEmailProps { + locale: Locales; + orderId: string; + statusLabel: string; + orderUrl: string; +} + +export const OrderStatusCustomerEmail = ({ + locale, + orderId, + statusLabel, + orderUrl, +}: OrderStatusCustomerEmailProps) => { + const intl = createTranslator({ + messages: messages[locale].OrderStatusCustomerEmail, + locale, + }); + + return ( + + + + {intl("preview", { orderId, status: statusLabel })} + + + + {intl("title")} + + + + {intl("description", { orderId })} + + +
+ + {intl("statusLabel")} + + + {statusLabel} + +
+ + + + {intl("openOrders")} + + + + + © 2026 Pixea + +
+ + +
+ ); +}; + +OrderStatusCustomerEmail.PreviewProps = { + locale: "en", + orderId: "ORD-12345", + statusLabel: "Shipped", + orderUrl: "https://pixea.sk/en/orders", +} as OrderStatusCustomerEmailProps; + +export default OrderStatusCustomerEmail; + +const main = { + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", +}; diff --git a/messages/en.json b/messages/en.json index bff3960..701c29f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -54,7 +54,25 @@ "remove": "Remove", "fileTooLarge": "This file is too large to be displayed.", "download": "Download", - "close": "Close" + "close": "Close", + "checkout": "Checkout", + "loginRequired": "Please log in to continue with your order submission.", + "loginToContinue": "Log in to continue", + "checkoutEmpty": "Add at least one configured item before submitting the order.", + "deliveryMethod": "Delivery method", + "delivery": { + "courier": "Courier delivery", + "pickup": "Personal pickup" + }, + "deliveryDetails": "Delivery details", + "invoiceDetails": "Invoicing details", + "invoiceSameAsDelivery": "Use delivery address for invoice", + "summary": "Order summary", + "summaryItems": "{count, plural, =0 {Items} =1 {1 item} other {# items}}", + "summaryDelivery": "Delivery", + "summaryVat": "VAT (20%)", + "summaryTotal": "Total", + "submitOrder": "Submit order" }, "OrderItemSelection": { "title": "Product selection", @@ -116,6 +134,80 @@ "back": "Back", "save": "Save" }, + "Orders": { + "title": "Order administration", + "myOrdersTitle": "My orders", + "id": "Order", + "email": "E-mail", + "status": "Status", + "total": "Total", + "created": "Created", + "updated": "Updated", + "actions": "Actions", + "updateStatus": "Update status", + "edit": "Edit", + "delete": "Delete", + "back": "Back", + "statusValues": { + "new": "New", + "confirmed": "Confirmed", + "printed": "Printed", + "packed": "Packed", + "shipped": "Shipped", + "received": "Received", + "archived": "Archived" + }, + "notifications": { + "title": "New order notifications", + "description": "Set administrator e-mails that should receive notifications when a new order is submitted.", + "placeholder": "owner@pixea.sk\nops@pixea.sk", + "save": "Save recipients" + }, + "empty": "No orders found yet." + }, + "OrderNotification": { + "statuses": { + "new": "New", + "confirmed": "Confirmed", + "printed": "Printed", + "packed": "Packed", + "shipped": "Shipped", + "received": "Received", + "archived": "Archived" + }, + "adminNewOrder": { + "subject": "New order #{orderId} submitted", + "preview": "A new order #{orderId} was submitted. Total: {total} EUR.", + "title": "New order submitted", + "description": "A customer submitted order #{orderId}.", + "customerEmail": "Customer e-mail", + "itemCount": "Items", + "totals": "Total", + "openOrder": "Open order detail" + }, + "customerStatus": { + "subject": "Order #{orderId} status update", + "preview": "Your order #{orderId} status changed to {status}.", + "title": "Your order status changed", + "description": "Order #{orderId} is now in status: {status}.", + "openOrders": "Open my orders" + } + }, + "OrderNewAdminEmail": { + "preview": "New order #{orderId} submitted.", + "title": "New order submitted", + "description": "A customer submitted order #{orderId}.", + "customerEmail": "Customer e-mail", + "createdAt": "Created", + "openOrder": "Open order detail" + }, + "OrderStatusCustomerEmail": { + "preview": "Order #{orderId} status changed to {status}.", + "title": "Order status update", + "description": "Your order #{orderId} is now {status}.", + "statusLabel": "Current status", + "openOrders": "Open my orders" + }, "Auth": { "title": "Login", "description": "Sign in for order tracking and management, data prefilling and a better overall experience.", @@ -186,7 +278,8 @@ "desc": "This is the tab a casual customer sees. Therefore, a user with the \"admin\" role can use it for e.g. testing out the order process.", "link": "Open management of all received orders" }, - "empty": "Create your first order to see it here." + "empty": "Create your first order to see it here.", + "openAll": "Open all orders" }, "logout": "Log out", "save": "Save", diff --git a/messages/sk.json b/messages/sk.json index 73fc74d..907aa25 100644 --- a/messages/sk.json +++ b/messages/sk.json @@ -49,6 +49,24 @@ "quantityUnit": "ks", "price": "Cena", "removeItem": "Odstrániť položku", + "checkout": "Dokončenie objednávky", + "loginRequired": "Pre odoslanie objednávky sa prosím prihláste.", + "loginToContinue": "Prihlásiť sa", + "checkoutEmpty": "Do objednávky ešte nemáte pridanú žiadnu položku.", + "deliveryMethod": "Spôsob doručenia", + "deliveryDetails": "Doručovacie údaje", + "invoiceDetails": "Fakturačné údaje", + "invoiceSameAsDelivery": "Rovnaké ako doručovacia adresa", + "summary": "Súhrn", + "summaryItems": "Položky ({count})", + "summaryDelivery": "Doručenie", + "summaryVat": "DPH (20 %)", + "summaryTotal": "Spolu", + "submitOrder": "Odoslať objednávku", + "delivery": { + "courier": "Kuriér", + "pickup": "Osobný odber" + }, "uploading": "Nahrávam...", "open": "Otvoriť", "remove": "Odstrániť", @@ -100,6 +118,38 @@ "generateDraft": "Vygenerovať návrh", "applyAiUpdate": "Použiť AI úpravu" }, + "Orders": { + "title": "Správa objednávok", + "myOrdersTitle": "Moje objednávky", + "id": "Objednávka", + "email": "E-mail", + "status": "Stav", + "total": "Celkom", + "created": "Vytvorená", + "updated": "Upravená", + "actions": "Akcie", + "edit": "Detail", + "delete": "Vymazať", + "save": "Uložiť", + "back": "Späť", + "updateStatus": "Aktualizovať", + "statusValues": { + "new": "Nová", + "confirmed": "Potvrdená", + "printed": "Vytlačená", + "packed": "Zabalená", + "shipped": "Odoslaná", + "received": "Doručená", + "archived": "Archivovaná" + }, + "notifications": { + "title": "Notifikácie pre administrátorov", + "description": "E-mailové adresy, ktoré dostanú upozornenie na novú objednávku. Oddeľte ich novým riadkom, čiarkou alebo bodkočiarkou.", + "placeholder": "admin@pixea.sk\norders@pixea.sk", + "save": "Uložiť notifikácie" + }, + "empty": "Zatiaľ nebola nájdená žiadna objednávka." + }, "Users": { "title": "Administrácia používateľov", "name": "Meno", @@ -186,7 +236,8 @@ "desc": "Ide o tab, ktorý vidí aj bežný zákazník. Tým pádom používateľ s rolou \"admin\" cez neho dokáže napríklad testovať objednávkový proces.", "link": "Otvoriť správu všetkých prijatých objednávok" }, - "empty": "Vytvorte svoju prvú objednávku, aby ste ju tu videli." + "empty": "Vytvorte svoju prvú objednávku, aby ste ju tu videli.", + "openAll": "Otvoriť všetky objednávky" }, "logout": "Odhlásiť sa", "save": "Uložiť", @@ -209,6 +260,45 @@ "continue": "Pokračovať", "back": "Späť" }, + "OrderNotification": { + "adminNewOrder": { + "subject": "Pixea: Nová objednávka #{orderId}", + "preview": "Prišla nová objednávka #{orderId} v hodnote {total} EUR." + }, + "customerStatus": { + "subject": "Pixea: Aktualizácia objednávky #{orderId}", + "preview": "Objednávka #{orderId} má nový stav: {status}." + }, + "statuses": { + "new": "Nová", + "confirmed": "Potvrdená", + "printed": "Vytlačená", + "packed": "Zabalená", + "shipped": "Odoslaná", + "received": "Doručená", + "archived": "Archivovaná" + } + }, + "OrderNewAdminEmail": { + "title": "Nová objednávka", + "description": "Práve prišla nová objednávka #{orderId}.", + "orderId": "Objednávka", + "customerEmail": "Zákazník", + "itemCount": "Počet položiek", + "subtotal": "Medzisúčet", + "delivery": "Doručenie", + "vat": "DPH", + "total": "Celkom", + "openOrder": "Otvoriť objednávku", + "preview": "Nová objednávka #{orderId} v hodnote {total} EUR." + }, + "OrderStatusCustomerEmail": { + "title": "Stav objednávky bol aktualizovaný", + "description": "Objednávka #{orderId} má nový stav.", + "statusLabel": "Nový stav", + "openOrders": "Zobraziť moje objednávky", + "preview": "Objednávka #{orderId} má nový stav: {status}." + }, "VerificationEmail": { "subject": "Prihlásenie do systému Pixea", "preview": "Použite kód {code} na overenie svojej e-mailovej adresy a prihlásenie.", diff --git a/src/app/[locale]/auth/account.tsx b/src/app/[locale]/auth/account.tsx index d717241..435f01e 100644 --- a/src/app/[locale]/auth/account.tsx +++ b/src/app/[locale]/auth/account.tsx @@ -30,6 +30,7 @@ import { logoutAction } from "./actions"; import { useRouter, useSearchParams } from "next/navigation"; import { ActionState } from "@/lib/utils"; import { ActionToasts } from "@/components/actionToasts"; +import OrderStatusBadge from "@/components/order-status-badge"; const countries = [ "at", @@ -100,6 +101,7 @@ const Account = ({ session }: { session: Session }) => { const address = session.user?.address; const role = session.user?.role; + const userOrders = session.user?.orders || []; // "Fill in company info" switch handling const hasAnyCompanyDetails = Boolean(company || companyId || taxId || vatId); @@ -157,13 +159,41 @@ const Account = ({ session }: { session: Session }) => { ) : undefined} - - {t("orders.empty")} - + {userOrders.length ? ( + + {userOrders.map((order) => ( + + + + #{order.id.slice(0, 8)} + + + {new Date(order.created).toLocaleString()} + + + + + ))} + + + + + ) : ( + + {t("orders.empty")} + + )} {/* ACCOUNT */} diff --git a/src/app/[locale]/order/actions.ts b/src/app/[locale]/order/actions.ts new file mode 100644 index 0000000..2a820f5 --- /dev/null +++ b/src/app/[locale]/order/actions.ts @@ -0,0 +1,265 @@ +"use server"; + +import { auth } from "@/auth"; +import db from "@/db"; +import { orders, products, users } from "@/db/schema"; +import { OrderItemPayload } from "@/db/validation"; +import { calculateItemPrice } from "@/utils/pricing"; +import { eq, inArray } from "drizzle-orm"; +import { DateTime } from "luxon"; +import { getLocale } from "next-intl/server"; +import { revalidatePath } from "next/cache"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { randomUUID } from "node:crypto"; + +import { error, noChanges, success } from "@/lib/utils"; +import { notifyNewOrderToAdmins } from "@/emails/orders"; + +const toOptionalString = (value: FormDataEntryValue | null) => { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length ? trimmed : undefined; +}; + +const toRequiredString = ( + value: FormDataEntryValue | null, + fieldName: string, +) => { + const normalized = toOptionalString(value); + if (!normalized) { + throw new Error(`Missing required field: ${fieldName}`); + } + return normalized; +}; + +const toCountryCode = (value: FormDataEntryValue | null) => { + const normalized = toOptionalString(value); + if (!normalized || normalized === "none") { + return undefined; + } + return normalized; +}; + +const roundMoney = (value: number) => + Number((Number.isFinite(value) ? value : 0).toFixed(2)); + +const COUNTRY_CODES = new Set([ + "at", + "be", + "bg", + "cy", + "cz", + "de", + "dk", + "ee", + "es", + "fi", + "fr", + "gr", + "hr", + "hu", + "ie", + "im", + "it", + "lt", + "lu", + "lv", + "mc", + "mt", + "nl", + "pl", + "pt", + "ro", + "se", + "si", + "sk", +]); + +export const submitOrderAction = async ( + _prevState: unknown, + formData: FormData, +) => { + const locale = await getLocale(); + const session = await auth(); + + if (!session?.user?.id || !session.user.email) { + redirect(`/auth?redirect=${encodeURIComponent(`/${locale}/order`)}`); + } + + try { + const [user] = await db + .select({ + id: users.id, + email: users.email, + phone: users.phone, + cart: users.cart, + company: users.company, + companyId: users.companyId, + taxId: users.taxId, + vatId: users.vatId, + }) + .from(users) + .where(eq(users.id, session.user.id)) + .limit(1); + + if (!user || !user.cart) { + return noChanges(); + } + + const cartItems = (user.cart.items || []).filter( + (item): item is OrderItemPayload => + !!item?.id && + !!item.productId && + Array.isArray(item.files?.items) && + item.files.items.length > 0 && + !!item.size?.dimensions?.length, + ); + if (!cartItems.length) { + return noChanges(); + } + + const uniqueProductIds = Array.from( + new Set(cartItems.map((item) => item.productId)), + ); + const cartProducts = + uniqueProductIds.length > 0 + ? await db.query.products.findMany({ + where: inArray(products.id, uniqueProductIds), + }) + : []; + + const productById = new Map( + cartProducts.map((product) => [product.id, product]), + ); + const pricedItems = cartItems.map((item) => { + const product = productById.get(item.productId); + const computedPrice = product ? calculateItemPrice(product, item) : 0; + return { + ...item, + price: roundMoney(item.price ?? computedPrice), + }; + }); + + const subtotal = roundMoney( + pricedItems.reduce((sum, item) => sum + (item.price || 0), 0), + ); + + const deliveryTypeRaw = toRequiredString( + formData.get("deliveryType"), + "deliveryType", + ); + const deliveryType: "courier" | "pickup" = + deliveryTypeRaw === "pickup" ? "pickup" : "courier"; + const deliveryCost = deliveryType === "pickup" ? 0 : 3.9; + const vat = roundMoney((subtotal + deliveryCost) * 0.2); + + const deliveryCountry = toCountryCode(formData.get("country")); + if (!deliveryCountry || !COUNTRY_CODES.has(deliveryCountry)) { + throw new Error("Missing required field: country"); + } + + const deliveryAddress = { + street: toRequiredString(formData.get("street"), "street"), + additional: toOptionalString(formData.get("additional")), + zip: toRequiredString(formData.get("zip"), "zip"), + city: toRequiredString(formData.get("city"), "city"), + country: deliveryCountry, + }; + + const invoiceSameAsDelivery = formData.get("invoiceSameAsDelivery") === "on"; + + const invoiceAddress = invoiceSameAsDelivery + ? { + company: toOptionalString(formData.get("company")) || user.company || undefined, + companyId: + toOptionalString(formData.get("companyId")) || user.companyId || undefined, + taxId: toOptionalString(formData.get("taxId")) || user.taxId || undefined, + vatId: toOptionalString(formData.get("vatId")) || user.vatId || undefined, + ...deliveryAddress, + } + : { + company: + toOptionalString(formData.get("invoiceCompany")) || user.company || undefined, + companyId: + toOptionalString(formData.get("invoiceCompanyId")) || + user.companyId || + undefined, + taxId: toOptionalString(formData.get("invoiceTaxId")) || user.taxId || undefined, + vatId: toOptionalString(formData.get("invoiceVatId")) || user.vatId || undefined, + street: toRequiredString(formData.get("invoiceStreet"), "invoiceStreet"), + additional: toOptionalString(formData.get("invoiceAdditional")), + zip: toRequiredString(formData.get("invoiceZip"), "invoiceZip"), + city: toRequiredString(formData.get("invoiceCity"), "invoiceCity"), + country: (() => { + const country = toCountryCode(formData.get("invoiceCountry")); + if (!country || !COUNTRY_CODES.has(country)) { + throw new Error("Missing required field: invoiceCountry"); + } + return country; + })(), + }; + + const order: typeof orders.$inferInsert = { + status: "new" as const, + paid: false, + items: pricedItems, + itemsSummary: `${pricedItems.length} item(s)`, + userId: user.id, + email: user.email || session.user.email, + phone: toOptionalString(formData.get("phone")) || user.phone, + deliveryAddress, + invoiceAddress, + delivery: { + type: deliveryType, + tracking: null, + }, + sum: { + cost: subtotal, + originalMargin: undefined, + margin: subtotal, + delivery: roundMoney(deliveryCost), + vat, + }, + }; + + const created = await db.transaction(async (tx) => { + const [createdOrder] = await tx + .insert(orders) + .values(order) + .returning({ id: orders.id, email: orders.email, sum: orders.sum }); + + const nextCart = { + id: randomUUID(), + saved: DateTime.utc().toISO(), + }; + + await tx + .update(users) + .set({ cart: nextCart }) + .where(eq(users.id, user.id)); + return { nextCart, order: createdOrder }; + }); + + await notifyNewOrderToAdmins({ + orderId: created.order.id, + locale: locale as "en" | "sk", + }); + + const cookieStore = await cookies(); + cookieStore.set({ + name: "cartId", + value: created.nextCart.id, + maxAge: 60 * 60 * 24 * 31, + httpOnly: true, + path: "/", + sameSite: "lax", + }); + revalidatePath(`/${locale}/order`); + revalidatePath(`/${locale}/orders`); + } catch (e) { + return error("error", e); + } + + return success(); +}; diff --git a/src/app/[locale]/order/checkout.tsx b/src/app/[locale]/order/checkout.tsx index b9b6bbb..0912519 100644 --- a/src/app/[locale]/order/checkout.tsx +++ b/src/app/[locale]/order/checkout.tsx @@ -1,13 +1,587 @@ -import { Flex, Text } from "@radix-ui/themes"; -// import { useTranslations } from "next-intl"; +"use client"; -const Checkout = () => { - // const t = useTranslations("Order"); +import { useActionState, useEffect, useMemo, useState } from "react"; +import { + Box, + Button, + Card, + Flex, + Grid, + RadioCards, + Select, + Separator, + Switch, + Text, + TextField, +} from "@radix-ui/themes"; +import { Session } from "next-auth"; +import { useFormatter, useTranslations } from "next-intl"; + +import { Product } from "@/db/schema"; +import { ShoppingCart } from "@/db/validation"; +import { calculateItemPrice } from "@/utils/pricing"; +import { ActionState } from "@/lib/utils"; +import { ActionToasts } from "@/components/actionToasts"; +import BottomBar from "@/components/bottomBar"; +import useAuthUrl from "@/hooks/useAuthUrl"; +import { Link } from "@/i18n/routing"; + +import { submitOrderAction } from "./actions"; + +const countries = [ + "at", + "be", + "bg", + "hr", + "cy", + "dk", + "ee", + "fi", + "fr", + "de", + "gr", + "hu", + "ie", + "im", + "it", + "lv", + "lt", + "lu", + "mt", + "mc", + "nl", + "pl", + "pt", + "ro", + "si", + "es", + "se", + "sk", + "cz", +]; + +const roundMoney = (value: number) => Number(value.toFixed(2)); + +type Props = { + cart: ShoppingCart; + products: Product[]; + session?: Session | null; + isPending: boolean; + resetCart: () => void; +}; + +const Checkout = ({ cart, products, session, isPending, resetCart }: Props) => { + const t = useTranslations("Order"); + const tAuth = useTranslations("Auth"); + const format = useFormatter(); + const authUrl = useAuthUrl(); + + const [actionState, submitAction, submitPending] = useActionState( + submitOrderAction, + { message: "" } as ActionState, + ); + + useEffect(() => { + if (actionState.result === "success") { + resetCart(); + } + }, [actionState.result, resetCart]); + + const user = session?.user; + const userAddress = user?.address; + const hasAnyCompanyDetails = Boolean( + user?.company || user?.companyId || user?.taxId || user?.vatId, + ); + + const [deliveryType, setDeliveryType] = useState<"courier" | "pickup">( + "courier", + ); + const [invoiceSameAsDelivery, setInvoiceSameAsDelivery] = useState(true); + const [companyChecked, setCompanyChecked] = useState(hasAnyCompanyDetails); + + const countryOptions = countries + .map((code) => ({ code, label: tAuth(`address.country.${code}`) })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const pricedItems = useMemo(() => { + return (cart.items || []).map((item) => { + const product = products.find((candidate) => candidate.id === item.productId); + const computedPrice = product ? calculateItemPrice(product, item) : 0; + return { + id: item.id, + price: roundMoney(item.price ?? computedPrice), + }; + }); + }, [cart.items, products]); + + const subtotal = roundMoney( + pricedItems.reduce((sum, item) => sum + item.price, 0), + ); + const deliveryCost = deliveryType === "pickup" ? 0 : 3.9; + const vat = roundMoney((subtotal + deliveryCost) * 0.2); + const total = roundMoney(subtotal + deliveryCost + vat); + + const canSubmit = !!session?.user && !!cart.items?.length && !isPending; return ( - - Checkout - +
+ + + + + + {t("checkout")} + + + {!session?.user ? ( + + {t("loginRequired")} + + + ) : null} + + {!cart.items?.length ? ( + {t("checkoutEmpty")} + ) : null} + + {session?.user && cart.items?.length ? ( + <> + + + + + {t("deliveryMethod")} + + + setDeliveryType(value === "pickup" ? "pickup" : "courier") + } + > + + + {t("delivery.courier")} + + {format.number(3.9, { style: "currency", currency: "EUR" })} + + + + + + {t("delivery.pickup")} + + {format.number(0, { style: "currency", currency: "EUR" })} + + + + + + + + + + + {t("deliveryDetails")} + + + + + + {tAuth("phone")} + + + + + + + + + {tAuth("address.street")} + + + + + + {tAuth("address.additional")} ({tAuth("optional")}) + + + + + + + + + {tAuth("address.zip")} + + + + + + {tAuth("address.city")} + + + + + + + + + {tAuth("address.country.title")} + + + + + + {countryOptions.map(({ code, label }) => ( + + {label} + + ))} + + + + + + + + + + + + + {t("invoiceDetails")} + + + + + {t("invoiceSameAsDelivery")} + + + + + + + + {tAuth("company.switch")} + + + + {companyChecked ? ( + + + + {tAuth("company.name")} + + + + + + {tAuth("company.id")} + + + + + + {tAuth("company.taxId")} + + + + + + {tAuth("company.vatId")} + + + + + ) : null} + + {!invoiceSameAsDelivery ? ( + <> + + + + {tAuth("address.street")} + + + + + + {tAuth("address.additional")} ({tAuth("optional")}) + + + + + + + + {tAuth("address.zip")} + + + + + + {tAuth("address.city")} + + + + + + + + {tAuth("address.country.title")} + + + + + + {countryOptions.map(({ code, label }) => ( + + {label} + + ))} + + + + + + + ) : null} + + + + + ) : null} + + + + {t("summary")} + + + {t("summaryItems", { count: pricedItems.length })} + {format.number(subtotal, { style: "currency", currency: "EUR" })} + + + {t("summaryDelivery")} + + {format.number(deliveryCost, { style: "currency", currency: "EUR" })} + + + + {t("summaryVat")} + {format.number(vat, { style: "currency", currency: "EUR" })} + + + + {t("summaryTotal")} + + {format.number(total, { style: "currency", currency: "EUR" })} + + + + + + + + + + {t("summaryTotal")} + + + {format.number(total, { style: "currency", currency: "EUR" })} + + + + + + + + ); }; diff --git a/src/app/[locale]/order/content.tsx b/src/app/[locale]/order/content.tsx new file mode 100644 index 0000000..4a8ba18 --- /dev/null +++ b/src/app/[locale]/order/content.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Product } from "@/db/schema"; +import { ShoppingCart } from "@/db/validation"; +import useCart from "@/hooks/useCart/useCart"; +import { Session } from "next-auth"; + +import Checkout from "./checkout"; +import OrderItems from "./items"; + +type Props = { + initialCart: ShoppingCart; + products: Product[]; + session?: Session | null; +}; + +const OrderContent = ({ initialCart, products, session }: Props) => { + const { cart, removeCartItem, resetCart, isPending } = useCart(initialCart); + + return ( + <> + + + + ); +}; + +export default OrderContent; diff --git a/src/app/[locale]/order/items.tsx b/src/app/[locale]/order/items.tsx index f1d8cbd..b567f8a 100644 --- a/src/app/[locale]/order/items.tsx +++ b/src/app/[locale]/order/items.tsx @@ -2,7 +2,6 @@ import { Product } from "@/db/schema"; import { ShoppingCart } from "@/db/validation"; -import useCart from "@/hooks/useCart/useCart"; import { Locales } from "@/i18n/locales"; import { Link } from "@/i18n/routing"; import { XMarkIcon } from "@heroicons/react/24/solid"; @@ -21,13 +20,13 @@ import { calculateItemPrice } from "@/utils/pricing"; type Props = { cart: ShoppingCart; products: Product[]; + removeCartItem: (cartItemId: string) => Promise; }; -const OrderItems = ({ cart: initialCart, products }: Props) => { +const OrderItems = ({ cart, products, removeCartItem }: Props) => { const t = useTranslations("Order"); const locale = useLocale() as Locales; const format = useFormatter(); - const { cart, removeCartItem } = useCart(initialCart); return ( diff --git a/src/app/[locale]/order/page.tsx b/src/app/[locale]/order/page.tsx index 5bf81bb..6ef055e 100644 --- a/src/app/[locale]/order/page.tsx +++ b/src/app/[locale]/order/page.tsx @@ -1,17 +1,18 @@ import { Link } from "@/i18n/routing"; import { Button, Flex, Heading, Text, Container } from "@radix-ui/themes"; -import OrderItems from "./items"; import { PlusIcon } from "@heroicons/react/24/solid"; import { getTranslations } from "next-intl/server"; import { getCurrentCartContentAction } from "@/hooks/useCart/actions"; -import Checkout from "./checkout"; import { inArray } from "drizzle-orm"; import db from "@/db"; import { products as productsSchema } from "@/db/schema"; +import { auth } from "@/auth"; +import OrderContent from "./content"; export default async function OrderPage() { const t = await getTranslations("Order"); const cart = await getCurrentCartContentAction(); + const session = await auth(); const relevantProductIds = cart.content?.items ?.map((item) => item.productId) @@ -44,9 +45,11 @@ export default async function OrderPage() { - - - + ); diff --git a/src/app/[locale]/orders/[id]/form.tsx b/src/app/[locale]/orders/[id]/form.tsx index d713558..a49bb7f 100644 --- a/src/app/[locale]/orders/[id]/form.tsx +++ b/src/app/[locale]/orders/[id]/form.tsx @@ -1,57 +1,176 @@ "use client"; -import { useActionState } from "react"; -import { Button, TextField } from "@radix-ui/themes"; +import { Card, Flex, Grid, Table, Text } from "@radix-ui/themes"; import { useTranslations } from "next-intl"; -import { ZodStandardJSONSchemaPayload } from "zod/v4/core"; - -import BottomBar from "@/components/bottomBar"; -import { ActionToasts } from "@/components/actionToasts"; -import MonacoInput from "@/components/monaco"; -import { orderSchema } from "@/db/validation"; - -import { saveOrderAction } from "../actions"; - -const OrderForm = ({ - id, - schema, - value, -}: { - id?: string; - schema: ZodStandardJSONSchemaPayload; - value: string; -}) => { - const t = useTranslations("Orders"); +import { DateTime } from "luxon"; + +import { Order } from "@/db/schema"; +import { OrderStatusValue } from "@/lib/order-status"; +import OrderStatusBadge from "@/components/order-status-badge"; - const [actionState, action, actionPending] = useActionState(saveOrderAction, { - message: "", - }); +const formatMoney = (value: number, locale: string) => + new Intl.NumberFormat(locale, { + style: "currency", + currency: "EUR", + }).format(value || 0); + +const OrderForm = ({ order, locale }: { order: Order; locale: string }) => { + const t = useTranslations("Orders"); return ( -
- - - - - - - - - - + + + + + + {t("status")} + + + + + + {t("created")} + + + {DateTime.fromSQL(order.created, { zone: "UTC" }).toLocaleString( + DateTime.DATETIME_MED, + )} + + + + + {t("updated")} + + + {DateTime.fromSQL(order.updated, { zone: "UTC" }).toLocaleString( + DateTime.DATETIME_MED, + )} + + + + + {t("total")} + + + {formatMoney( + (order.sum?.cost || 0) + + (order.sum?.delivery || 0) + + (order.sum?.vat || 0), + locale, + )} + + + + + + + + + {t("customerInfo")} + + + + + {t("email")} + + {order.email} + + + + {t("phone")} + + {order.phone || "-"} + + + + + + + + + + {t("deliveryAddress")} + + + {order.deliveryAddress.street} + {order.deliveryAddress.additional + ? `, ${order.deliveryAddress.additional}` + : ""} + + + {order.deliveryAddress.zip} {order.deliveryAddress.city} + + {order.deliveryAddress.country.toUpperCase()} + + + + + + + {t("invoiceAddress")} + + {order.invoiceAddress.company ? ( + {order.invoiceAddress.company} + ) : null} + + {order.invoiceAddress.street} + {order.invoiceAddress.additional + ? `, ${order.invoiceAddress.additional}` + : ""} + + + {order.invoiceAddress.zip} {order.invoiceAddress.city} + + {order.invoiceAddress.country.toUpperCase()} + {order.invoiceAddress.companyId ? ( + + {t("companyId")}: {order.invoiceAddress.companyId} + + ) : null} + {order.invoiceAddress.taxId ? ( + + {t("taxId")}: {order.invoiceAddress.taxId} + + ) : null} + {order.invoiceAddress.vatId ? ( + + {t("vatId")}: {order.invoiceAddress.vatId} + + ) : null} + + + + + + + + {t("items")} + + + + + # + {t("itemProduct")} + {t("itemFiles")} + {t("itemSize")} + {t("itemPrice")} + + + + {order.items.map((item, index) => ( + + {index + 1} + {item.productId} + {item.files?.items?.length || 0} + {item.size?.dimensions?.join(" × ") || "-"} + {formatMoney(item.price || 0, locale)} + + ))} + + + + + ); }; diff --git a/src/app/[locale]/orders/[id]/page.tsx b/src/app/[locale]/orders/[id]/page.tsx index 979c1fb..0147d29 100644 --- a/src/app/[locale]/orders/[id]/page.tsx +++ b/src/app/[locale]/orders/[id]/page.tsx @@ -8,7 +8,6 @@ import { auth } from "@/auth"; import { Link } from "@/i18n/routing"; import db from "@/db"; import { orders } from "@/db/schema"; -import { orderSchema } from "@/db/validation"; import OrderForm from "./form"; const OrderEditPage = async ({ @@ -29,19 +28,9 @@ const OrderEditPage = async ({ id === "new" ? null : await db.query.orders.findFirst({ where: eq(orders.id, id) }); - - const value = JSON.stringify( - order - ? { - ...order, - id: undefined, - created: undefined, - updated: undefined, - } - : {}, - null, - 2 - ); + if (!order) { + redirect(`/${locale}/orders`); + } return ( @@ -63,7 +52,7 @@ const OrderEditPage = async ({ - + ); diff --git a/src/app/[locale]/orders/actions.ts b/src/app/[locale]/orders/actions.ts index 0944980..4c8ea48 100644 --- a/src/app/[locale]/orders/actions.ts +++ b/src/app/[locale]/orders/actions.ts @@ -2,14 +2,25 @@ import { auth } from "@/auth"; import db from "@/db"; -import { orders } from "@/db/schema"; -// import { OrderPayload } from "@/db/validation"; +import { orderSettings, orders } from "@/db/schema"; +import { OrderStatusValue, orderStatusValues } from "@/lib/order-status"; import { error, noChanges, success } from "@/lib/utils"; import { eq } from "drizzle-orm"; import { getLocale } from "next-intl/server"; import { revalidatePath } from "next/cache"; -import { isRedirectError } from "next/dist/client/components/redirect-error"; import { redirect } from "next/navigation"; +import { notifyOrderStatusChanged } from "@/emails/orders"; +import { ZodError, z } from "zod"; +import { Locales } from "@/i18n/locales"; + +const statusValueSet = new Set(orderStatusValues); +const parseAdminEmails = (value: string) => + value + .split(/[\n,;]+/g) + .map((item) => item.trim().toLowerCase()) + .filter(Boolean) + .filter((value, index, array) => array.indexOf(value) === index); +const emailSchema = z.string().email(); export const saveOrderAction = async ( _prevState: unknown, @@ -22,37 +33,87 @@ export const saveOrderAction = async ( redirect(`/auth?redirect=${encodeURIComponent(`/${locale}/orders`)}`); } - // const id = formData.get("id") as string; - const valuesString = formData.get("values") as string; + const id = formData.get("id") as string; + const status = formData.get("status") as string; - if (!valuesString) { + if (!id || !status) { return noChanges(); } - // const values = { - // ...(JSON.parse(valuesString) as OrderPayload), - // // Would wreak havoc - // emailVerified: undefined, - // }; + if (!statusValueSet.has(status as OrderStatusValue)) { + return error("error", new Error("Invalid status value")); + } + const nextStatus = status as OrderStatusValue; try { - // if (id) { - // await db.update(orders).set(values).where(eq(orders.id, id)); - // } else { - // const order = await db - // .insert(orders) - // .values(values) - // .returning({ id: orders.id }); - // redirect(`/${locale}/orders/${order[0].id}`); - // } - } catch (e) { - if (isRedirectError(e)) { - throw e; + const existingOrder = await db.query.orders.findFirst({ + where: eq(orders.id, id), + }); + if (!existingOrder) { + return error("error", new Error("Order not found")); } + if (existingOrder.status === nextStatus) { + return noChanges(); + } + + await db.update(orders).set({ status: nextStatus }).where(eq(orders.id, id)); + + await notifyOrderStatusChanged({ + orderId: existingOrder.id, + locale: locale as Locales, + nextStatus, + }); + } catch (e) { + return error("error", e); + } + + revalidatePath(`/${locale}/orders`); + revalidatePath(`/${locale}/orders/${id}`); + revalidatePath(`/${locale}/auth`); + + return success(); +}; + +export const saveOrderSettingsAction = async ( + _prevState: unknown, + formData: FormData, +) => { + const locale = await getLocale(); + const session = await auth(); + if (session?.user.role !== "admin") { + redirect(`/auth?redirect=${encodeURIComponent(`/${locale}/orders`)}`); + } + + const emails = parseAdminEmails((formData.get("adminNotificationEmails") as string) || ""); + + try { + emails.forEach((email) => { + emailSchema.parse(email); + }); + } catch (e) { + return error("error", e instanceof ZodError ? e.issues : e); + } + + try { + const [current] = await db.select().from(orderSettings).limit(1); + + if (!current) { + await db.insert(orderSettings).values({ + adminNotificationEmails: emails, + }); + } else { + await db + .update(orderSettings) + .set({ adminNotificationEmails: emails }) + .where(eq(orderSettings.id, current.id)); + } + } catch (e) { return error("error", e); } + revalidatePath(`/${locale}/orders`); + return success(); }; @@ -75,6 +136,9 @@ export const deleteOrderAction = async ( } revalidatePath(`/${locale}/orders/${id}`); + revalidatePath(`/${locale}/orders`); + revalidatePath(`/${locale}/order`); + revalidatePath(`/${locale}/auth`); return success(); }; diff --git a/src/app/[locale]/orders/page.tsx b/src/app/[locale]/orders/page.tsx index 5f6d9aa..9d2c5f9 100644 --- a/src/app/[locale]/orders/page.tsx +++ b/src/app/[locale]/orders/page.tsx @@ -5,28 +5,52 @@ import db from "@/db"; import { orders as ordersSchema } from "@/db/schema"; import { auth } from "@/auth"; import { redirect } from "next/navigation"; -import { desc } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; const OrdersPage = async () => { const locale = await getLocale(); const session = await auth(); - if (session?.user.role !== "admin") { - redirect(`/auth?redirect=${encodeURIComponent(`/${locale}/orders`)}`); - } const t = await getTranslations("Orders"); - const orders = await db + if (session?.user.role === "admin") { + const [orders, settings] = await Promise.all([ + db.select().from(ordersSchema).orderBy(desc(ordersSchema.created)), + db.query.orderSettings.findFirst(), + ]); + + return ( + + + {t("title")} + + + + + ); + } + + if (!session?.user?.id) { + redirect(`/auth?redirect=${encodeURIComponent(`/${locale}/orders`)}`); + } + + const customerOrders = await db .select() .from(ordersSchema) + .where(eq(ordersSchema.userId, session.user.id)) .orderBy(desc(ordersSchema.created)); return ( - {t("title")} + {t("myOrdersTitle")} - + ); diff --git a/src/app/[locale]/orders/table.tsx b/src/app/[locale]/orders/table.tsx index d4f3621..2e09bcf 100644 --- a/src/app/[locale]/orders/table.tsx +++ b/src/app/[locale]/orders/table.tsx @@ -2,7 +2,7 @@ import { useActionState } from "react"; import { PencilIcon, TrashIcon } from "@heroicons/react/24/solid"; -import { Button, Flex, Table } from "@radix-ui/themes"; +import { Button, Card, Flex, Select, Table, Text, TextArea } from "@radix-ui/themes"; import { useLocale, useTranslations } from "next-intl"; import Link from "next/link"; import { DateTime } from "luxon"; @@ -10,47 +10,106 @@ import { DateTime } from "luxon"; import { Order } from "@/db/schema"; import { Locales } from "@/i18n/locales"; import { ActionToasts } from "@/components/actionToasts"; +import { OrderStatusValue, orderStatusValues } from "@/lib/order-status"; +import OrderStatusBadge from "@/components/order-status-badge"; -import { deleteOrderAction } from "./actions"; +import { deleteOrderAction, saveOrderAction, saveOrderSettingsAction } from "./actions"; import { ActionState } from "@/lib/utils"; -const OrderTable = ({ orders }: { orders: Order[] }) => { +type Props = { + orders: Order[]; + mode: "admin" | "customer"; + adminNotificationEmails?: string[]; + notificationEmailCount?: number; +}; + +const OrderTable = ({ + orders, + mode, + adminNotificationEmails = [], + notificationEmailCount = 0, +}: Props) => { const t = useTranslations("Orders"); const locale = useLocale() as Locales; const [deleteState, deleteAction, deletePending] = useActionState( deleteOrderAction, - {} as ActionState + {} as ActionState, + ); + const [saveState, saveAction, savePending] = useActionState( + saveOrderAction, + {} as ActionState, + ); + const [settingsState, settingsAction, settingsPending] = useActionState( + saveOrderSettingsAction, + {} as ActionState, ); + const notificationEmailText = adminNotificationEmails.join("\n"); + return ( <> + {mode === "admin" ? ( + +
+ + + {t("notifications.title")} + + + {t("notifications.description")} + +