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
-
+
);
};
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" ? (
+
+
+
+ ) : null}
+
- {t("name")}
- {t("company")}
+ {t("id")}
{t("email")}
- {t("role")}
+ {t("status")}
+ {t("total")}
{t("created")}
{t("updated")}
- {t("actions")}
+ {mode === "admin" ? (
+ {t("actions")}
+ ) : null}
{orders.map((order) => (
- {/* {order.name}
- {order.company} */}
+ #{order.id.slice(0, 8)}
{order.email}
- {/* {order.role === "admin" && (
- {t("admin")}
+
+
+
+ {new Intl.NumberFormat(locale, {
+ style: "currency",
+ currency: "EUR",
+ }).format(
+ (order.sum?.cost || 0) + (order.sum?.delivery || 0) + (order.sum?.vat || 0),
)}
- {order.role === "customer" && (
- {t("customer")}
- )} */}
{DateTime.fromSQL(order.created, {
@@ -62,29 +121,64 @@ const OrderTable = ({ orders }: { orders: Order[] }) => {
zone: "UTC",
}).toRelative()}
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ ) : null}
))}
+
+
>
);
};
diff --git a/src/auth.ts b/src/auth.ts
index ac0b918..8f4676e 100644
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -6,12 +6,15 @@ import { resendConfig } from "./emails/magic";
import {
accounts,
authenticators,
+ orders,
sessions,
users,
verificationTokens,
} from "./db/schema";
import { Address } from "./db/address";
import { ShoppingCart } from "./db/validation";
+import { OrderStatusValue } from "./lib/order-status";
+import { eq } from "drizzle-orm";
declare module "next-auth" {
interface Session {
@@ -24,6 +27,12 @@ declare module "next-auth" {
phone?: string;
address?: Partial;
cart?: ShoppingCart;
+ orders?: Array<{
+ id: string;
+ status: OrderStatusValue;
+ created: string;
+ updated: string;
+ }>;
} & DefaultSession["user"];
}
@@ -55,7 +64,7 @@ export const { handlers, signIn, signOut, auth, unstable_update } = NextAuth({
verifyRequest: "/auth/verify",
},
callbacks: {
- session({ session, user }) {
+ async session({ session, user }) {
session.user.role = user.role;
session.user.company = user.company;
session.user.companyId = user.companyId;
@@ -64,6 +73,26 @@ export const { handlers, signIn, signOut, auth, unstable_update } = NextAuth({
session.user.phone = user.phone;
session.user.address = user.address;
session.user.cart = user.cart;
+
+ if (!user.id) {
+ return session;
+ }
+
+ const recentOrders = await db
+ .select({
+ id: orders.id,
+ status: orders.status,
+ created: orders.created,
+ updated: orders.updated,
+ })
+ .from(orders)
+ .where(eq(orders.userId, user.id));
+
+ session.user.orders = recentOrders.map((order) => ({
+ ...order,
+ status: order.status as OrderStatusValue,
+ }));
+
return session;
},
},
diff --git a/src/components/order-status-badge.tsx b/src/components/order-status-badge.tsx
new file mode 100644
index 0000000..7f13181
--- /dev/null
+++ b/src/components/order-status-badge.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { Badge, BadgeProps } from "@radix-ui/themes";
+import { useTranslations } from "next-intl";
+
+import { OrderStatusValue } from "@/lib/order-status";
+
+const statusColor = (status: OrderStatusValue): BadgeProps["color"] => {
+ if (status === "new") return "blue";
+ if (status === "confirmed") return "cyan";
+ if (status === "printed") return "orange";
+ if (status === "packed") return "yellow";
+ if (status === "shipped") return "indigo";
+ if (status === "received") return "green";
+ return "gray";
+};
+
+const OrderStatusBadge = ({ status }: { status: OrderStatusValue }) => {
+ const t = useTranslations("Orders");
+
+ return {t(`statusValues.${status}`)};
+};
+
+export default OrderStatusBadge;
diff --git a/src/db/schema.ts b/src/db/schema.ts
index f4a783b..440ba0f 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -20,6 +20,7 @@ import {
ProductPayload,
ShoppingCart,
} from "./validation";
+import { orderStatusValues } from "@/lib/order-status";
const timestamps = () => ({
created: timestamp({ mode: "string" })
@@ -41,13 +42,7 @@ export const roleEnum = pgEnum("role", ["customer", "admin"]);
export const productStatus = pgEnum("product_status", ["active", "draft"]);
-export const orderStatus = pgEnum("order_status", [
- "new",
- "processed",
- "prepared",
- "delivery",
- "delivered",
-]);
+export const orderStatus = pgEnum("order_status", [...orderStatusValues]);
export const users = pgTable("user", {
id: uuid().primaryKey().defaultRandom(),
@@ -192,6 +187,13 @@ export const orders = pgTable("order", {
...timestamps(),
});
+export const orderSettings = pgTable("order-settings", {
+ id: uuid().primaryKey().defaultRandom(),
+ adminNotificationEmails: text().array().notNull().default(sql`'{}'::text[]`),
+ ...timestamps(),
+});
+
export type User = InferSelectModel;
export type Product = InferSelectModel;
export type Order = InferSelectModel;
+export type OrderSettings = InferSelectModel;
diff --git a/src/db/validation.ts b/src/db/validation.ts
index 4aec9af..5907d91 100644
--- a/src/db/validation.ts
+++ b/src/db/validation.ts
@@ -1,4 +1,5 @@
import z from "zod";
+import { orderStatusValues } from "@/lib/order-status";
export const translatedPropertySchema = z.object({
sk: z.string().describe("Localized value in Slovak."),
@@ -192,9 +193,7 @@ export const orderItemSchema = z.object({
});
export const orderSchema = z.object({
- status: z
- .enum(["new", "processed", "prepared", "delivery", "delivered"])
- .default("new"),
+ status: z.enum(orderStatusValues).default("new"),
paid: z.boolean().default(false),
items: z.array(orderItemSchema),
diff --git a/src/emails/index.ts b/src/emails/index.ts
index 6baebb5..3ee5af8 100644
--- a/src/emails/index.ts
+++ b/src/emails/index.ts
@@ -1,3 +1,5 @@
import { Resend } from "resend";
export const resend = new Resend(process.env.AUTH_RESEND_KEY!);
+export const emailFrom = "Pixea ";
+export const emailReplyTo = "Pixea ";
diff --git a/src/emails/magic.ts b/src/emails/magic.ts
index fcc44a0..22115ea 100644
--- a/src/emails/magic.ts
+++ b/src/emails/magic.ts
@@ -7,14 +7,11 @@ import { render } from "@react-email/render";
import { Locales } from "@/i18n/locales";
import VerificationEmail from "../../emails/verification";
-import { resend } from ".";
+import { emailFrom, emailReplyTo, resend } from ".";
import messages from "../../messages";
-const from = "Pixea ";
-const replyTo = "Pixea ";
-
export const resendConfig: EmailUserConfig = {
- from,
+ from: emailFrom,
generateVerificationToken: () => CrockfordBase32.encode(randomBytes(5)),
maxAge: 60 * 60 * 1,
sendVerificationRequest: async (params) => {
@@ -27,9 +24,9 @@ export const resendConfig: EmailUserConfig = {
const emailJSX = VerificationEmail({ code: token, locale });
const res = await resend.emails.send({
- from,
+ from: emailFrom,
to,
- replyTo,
+ replyTo: emailReplyTo,
subject,
html: await render(emailJSX, { pretty: true }),
text: await render(emailJSX, { plainText: true }),
diff --git a/src/emails/orders.ts b/src/emails/orders.ts
new file mode 100644
index 0000000..bf4c247
--- /dev/null
+++ b/src/emails/orders.ts
@@ -0,0 +1,192 @@
+import { render } from "@react-email/render";
+import { createTranslator } from "next-intl";
+
+import messages from "../../messages";
+import OrderNewAdminEmail from "../../emails/order-new-admin";
+import OrderStatusCustomerEmail from "../../emails/order-status-customer";
+import { Locales } from "@/i18n/locales";
+import { Order, OrderSettings } from "@/db/schema";
+import { OrderStatusValue } from "@/lib/order-status";
+
+import { emailFrom, emailReplyTo, resend } from ".";
+
+const normalizeRecipients = (emails: string[]) =>
+ Array.from(
+ new Set(
+ emails
+ .map((email) => email.trim().toLowerCase())
+ .filter((email) => email.length > 3),
+ ),
+ );
+
+const toOrderSummaryText = (order: Order) => {
+ const subtotal = order.sum?.cost ?? 0;
+ const delivery = order.sum?.delivery ?? 0;
+ const vat = order.sum?.vat ?? 0;
+ const total = subtotal + delivery + vat;
+
+ return [
+ `Order #${order.id}`,
+ `Status: ${order.status}`,
+ `Items: ${order.items?.length ?? 0}`,
+ `Subtotal: ${subtotal.toFixed(2)} EUR`,
+ `Delivery: ${delivery.toFixed(2)} EUR`,
+ `VAT: ${vat.toFixed(2)} EUR`,
+ `Total: ${total.toFixed(2)} EUR`,
+ ].join("\n");
+};
+
+export const sendAdminNewOrderNotification = async ({
+ locale,
+ order,
+ settings,
+}: {
+ locale: Locales;
+ order: Order;
+ settings?: OrderSettings | null;
+}) => {
+ const recipients = normalizeRecipients(settings?.adminNotificationEmails || []);
+ if (!recipients.length) {
+ return;
+ }
+
+ const intl = createTranslator({
+ locale,
+ messages: messages[locale].OrderNotification,
+ });
+
+ const subject = intl("adminNewOrder.subject", {
+ orderId: order.id.slice(0, 8).toUpperCase(),
+ });
+ const preview = intl("adminNewOrder.preview", {
+ orderId: order.id.slice(0, 8).toUpperCase(),
+ total: (order.sum.cost + order.sum.delivery + order.sum.vat).toFixed(2),
+ });
+ const orderUrl = `https://pixea.sk/${locale}/orders/${order.id}`;
+
+ const emailJSX = OrderNewAdminEmail({
+ locale,
+ orderId: order.id,
+ customerEmail: order.email,
+ itemCount: order.items.length,
+ subtotal: order.sum.cost,
+ delivery: order.sum.delivery,
+ vat: order.sum.vat,
+ total: order.sum.cost + order.sum.delivery + order.sum.vat,
+ preview,
+ orderUrl,
+ });
+
+ const result = await resend.emails.send({
+ from: emailFrom,
+ to: recipients,
+ replyTo: emailReplyTo,
+ subject,
+ html: await render(emailJSX, { pretty: true }),
+ text: toOrderSummaryText(order),
+ });
+
+ if (result.error) {
+ throw new Error("Resend error: " + JSON.stringify(result.error));
+ }
+};
+
+export const sendCustomerStatusNotification = async ({
+ locale,
+ order,
+ status,
+}: {
+ locale: Locales;
+ order: Order;
+ status: OrderStatusValue;
+}) => {
+ if (!order.email) return;
+
+ const intl = createTranslator({
+ locale,
+ messages: messages[locale].OrderNotification,
+ });
+
+ const subject = intl("customerStatus.subject", {
+ orderId: order.id.slice(0, 8).toUpperCase(),
+ });
+ const orderUrl = `https://pixea.sk/${locale}/orders`;
+
+ const emailJSX = OrderStatusCustomerEmail({
+ locale,
+ orderId: order.id,
+ statusLabel: intl(`statuses.${status}`),
+ orderUrl,
+ });
+
+ const result = await resend.emails.send({
+ from: emailFrom,
+ to: order.email,
+ replyTo: emailReplyTo,
+ subject,
+ html: await render(emailJSX, { pretty: true }),
+ text: [
+ `Order #${order.id}`,
+ `Status changed to: ${status}`,
+ "",
+ toOrderSummaryText(order),
+ ].join("\n"),
+ });
+
+ if (result.error) {
+ throw new Error("Resend error: " + JSON.stringify(result.error));
+ }
+};
+
+export const notifyNewOrderToAdmins = async ({
+ orderId,
+ locale,
+}: {
+ orderId: string;
+ locale: Locales;
+}) => {
+ try {
+ const { default: db } = await import("@/db");
+ const { orders } = await import("@/db/schema");
+ const { eq } = await import("drizzle-orm");
+
+ const [order, settings] = await Promise.all([
+ db.query.orders.findFirst({ where: eq(orders.id, orderId) }),
+ db.query.orderSettings.findFirst(),
+ ]);
+ if (!order) return;
+
+ await sendAdminNewOrderNotification({ locale, order, settings });
+ } catch (e) {
+ console.error("Failed to send admin new-order notification:", e);
+ }
+};
+
+export const notifyOrderStatusChanged = async ({
+ orderId,
+ locale,
+ nextStatus,
+}: {
+ orderId: string;
+ locale: Locales;
+ nextStatus: OrderStatusValue;
+}) => {
+ try {
+ const { default: db } = await import("@/db");
+ const { orders } = await import("@/db/schema");
+ const { eq } = await import("drizzle-orm");
+
+ const order = await db.query.orders.findFirst({
+ where: eq(orders.id, orderId),
+ });
+ if (!order) return;
+
+ await sendCustomerStatusNotification({
+ locale,
+ order,
+ status: nextStatus,
+ });
+ } catch (e) {
+ console.error("Failed to send customer status notification:", e);
+ }
+};
diff --git a/src/hooks/useCart/useCart.ts b/src/hooks/useCart/useCart.ts
index 4007693..001fc12 100644
--- a/src/hooks/useCart/useCart.ts
+++ b/src/hooks/useCart/useCart.ts
@@ -164,6 +164,14 @@ const useCart = (initialCartState: ShoppingCart, options?: CartOptions) => {
}
}, []);
+ const resetCart = useCallback(() => {
+ setCart((currentCart) => ({
+ id: currentCart.id,
+ saved: new Date().toISOString(),
+ items: [],
+ }));
+ }, []);
+
return {
cart,
addFileToCartItem,
@@ -172,6 +180,7 @@ const useCart = (initialCartState: ShoppingCart, options?: CartOptions) => {
saveCartItemSize,
savePieces,
removeCartItem,
+ resetCart,
isPending: pendingActions > 0,
};
};
diff --git a/src/lib/order-status.ts b/src/lib/order-status.ts
new file mode 100644
index 0000000..b841c31
--- /dev/null
+++ b/src/lib/order-status.ts
@@ -0,0 +1,11 @@
+export const orderStatusValues = [
+ "new",
+ "confirmed",
+ "printed",
+ "packed",
+ "shipped",
+ "received",
+ "archived",
+] as const;
+
+export type OrderStatusValue = (typeof orderStatusValues)[number];