diff --git a/apps/web/app/[language]/api/search/route.tsx b/apps/web/app/[language]/api/search/route.tsx
index 176a07b02..5231e7ead 100644
--- a/apps/web/app/[language]/api/search/route.tsx
+++ b/apps/web/app/[language]/api/search/route.tsx
@@ -52,7 +52,7 @@ type LocalizedNameInput = {
name_fr?: Prisma.StringFilter | string;
}
-function nameQuery(terms: string[]): LocalizedNameInput[] {
+export function nameQuery(terms: string[]): LocalizedNameInput[] {
const nameQueries: LocalizedNameInput[] = ['de', 'en', 'es', 'fr'].map((lang) => ({
AND: terms.map((term) => ({ [`name_${lang}`]: { contains: term, mode: 'insensitive' }}))
}));
diff --git a/apps/web/app/[language]/item/[id]/_edit-vendor/EditVendor.tsx b/apps/web/app/[language]/item/[id]/_edit-vendor/EditVendor.tsx
new file mode 100644
index 000000000..32aea8ce5
--- /dev/null
+++ b/apps/web/app/[language]/item/[id]/_edit-vendor/EditVendor.tsx
@@ -0,0 +1,135 @@
+'use client';
+import { AchievementLink } from '@/components/Achievement/AchievementLink';
+import { SearchAchievementDialog } from '@/components/Achievement/SearchAchievementDialog';
+import { Dialog } from '@/components/Dialog/Dialog';
+import { LocalizedTextInput } from '@/components/Form/LocalizedTextInput';
+import { ItemLink } from '@/components/Item/ItemLink';
+import { SearchItemDialog } from '@/components/Item/SearchItemDialog';
+import { FlexRow } from '@/components/Layout/FlexRow';
+import { Tab, TabList, TabProps } from '@/components/TabList/TabList';
+import { Tip } from '@/components/Tip/Tip';
+import { LocalizedEntity, localizedName } from '@/lib/localizedName';
+import { WithIcon } from '@/lib/with';
+import { Achievement, Item, VendorTab } from '@gw2treasures/database';
+import { Icon } from '@gw2treasures/ui';
+import { Button } from '@gw2treasures/ui/components/Form/Button';
+import { Checkbox } from '@gw2treasures/ui/components/Form/Checkbox';
+import { Label } from '@gw2treasures/ui/components/Form/Label';
+import { NumberInput } from '@gw2treasures/ui/components/Form/NumberInput';
+import { TextInput } from '@gw2treasures/ui/components/Form/TextInput';
+import { Headline } from '@gw2treasures/ui/components/Headline/Headline';
+import { Table } from '@gw2treasures/ui/components/Table/Table';
+import { TableRowButton } from '@gw2treasures/ui/components/Table/TableRowButton';
+import { FC, ReactElement, useState } from 'react';
+
+export interface EditVendorProps {
+ // TODO: add props
+}
+
+export const EditVendor: FC = ({ }) => {
+ const [dialogOpen, setDialogOpen] = useState(false);
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export interface EditVendorDialogProps {
+ // TODO: add props
+}
+
+type EditVendorTab = Omit & {
+ requiresItem: WithIcon> | null;
+ requiresAchievement: WithIcon> | null;
+}
+
+const EmptyVendorTab: Omit = {
+ dailyPurchaseLimit: null,
+ name_de: '',
+ name_en: '',
+ name_es: '',
+ name_fr: '',
+ requiresItem: null,
+ requiresAchievement: null,
+ rotation: false,
+ unlock_de: '',
+ unlock_en: '',
+ unlock_es: '',
+ unlock_fr: '',
+ vendorId: 'TODO',
+};
+
+export const EditVendorDialog: FC = ({ }) => {
+ const [tabs, setTabs] = useState([{ ...EmptyVendorTab, id: crypto.randomUUID() }]);
+
+ const editTabWithId = (id: string) => (update: Partial) => {
+ setTabs((tabs) => tabs.map(
+ (t) => t.id === id ? { ...t, ...update } : t
+ ));
+ };
+
+ const [addItemRequirement, setAddItemRequirement] = useState();
+ const [addAchievementRequirement, setAddAchievementRequirement] = useState();
+
+ return (
+ <>
+ setTabs([...tabs, { ...EmptyVendorTab, id: crypto.randomUUID() }])}>Add tab}>
+ {tabs.map>((tab) => {
+ const edit = editTabWithId(tab.id);
+
+ return (
+
+
+
+
}>
+
+
+
+
+
+
+ Items
+
+
+
+ Item
+ Cost
+ Limits
+ Requirements
+
+
+
+ {}}> Add Item
+
+
+
+ );
+ })}
+
+ { addItemRequirement && editTabWithId(addItemRequirement)({ requiresItem }); setAddItemRequirement(undefined); }}/>
+ { addAchievementRequirement && editTabWithId(addAchievementRequirement)({ requiresAchievement }); setAddAchievementRequirement(undefined); }}/>
+ >
+ );
+};
diff --git a/apps/web/app/[language]/item/[id]/component.tsx b/apps/web/app/[language]/item/[id]/component.tsx
index f5fb75262..a779be870 100644
--- a/apps/web/app/[language]/item/[id]/component.tsx
+++ b/apps/web/app/[language]/item/[id]/component.tsx
@@ -32,6 +32,7 @@ import { EditContents } from './_edit-content/EditContents';
import { CurrencyLink } from '@/components/Currency/CurrencyLink';
import { CurrencyValue } from '@/components/Currency/CurrencyValue';
import { compareLocalizedName } from '@/lib/localizedName';
+import { EditVendor } from './_edit-vendor/EditVendor';
import { ItemTable } from '@/components/ItemTable/ItemTable';
import { ItemTableContext } from '@/components/ItemTable/ItemTableContext';
import { ItemTableColumnsButton } from '@/components/ItemTable/ItemTableColumnsButton';
@@ -150,6 +151,15 @@ export const ItemPageComponent: AsyncComponent = async (
>
)}
+ {(data.details?.vendor_ids?.length ?? 0) > 0 && (
+ <>
+ Vendor
+ Using this item opens a vendor.
+
+
+ >
+ )}
+
{item.recipeOutput && item.recipeOutput.length > 0 && (
<>
Crafted From
diff --git a/apps/web/components/Achievement/SearchAchievementDialog.actions.ts b/apps/web/components/Achievement/SearchAchievementDialog.actions.ts
new file mode 100644
index 000000000..bb8082f6c
--- /dev/null
+++ b/apps/web/components/Achievement/SearchAchievementDialog.actions.ts
@@ -0,0 +1,25 @@
+'use server';
+
+import { Achievement } from '@gw2treasures/database';
+import { WithIcon } from '@/lib/with';
+import { nameQuery, splitSearchTerms } from 'app/[language]/api/search/route';
+import { db } from '@/lib/prisma';
+import { isTruthy } from '@gw2treasures/ui';
+
+// eslint-disable-next-line require-await
+export async function searchAchievement(query: string): Promise[]> {
+
+ const searchTerms = splitSearchTerms(query);
+ const nameQueries = nameQuery(searchTerms);
+
+ return db.achievement.findMany({
+ where: {
+ OR: [
+ { id: { in: searchTerms.map((id) => parseInt(id)).filter(isTruthy) }},
+ ...nameQueries,
+ ]
+ },
+ include: { icon: true },
+ take: 5
+ });
+}
diff --git a/apps/web/components/Achievement/SearchAchievementDialog.tsx b/apps/web/components/Achievement/SearchAchievementDialog.tsx
new file mode 100644
index 000000000..e2d887d45
--- /dev/null
+++ b/apps/web/components/Achievement/SearchAchievementDialog.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import { LocalizedEntity } from '@/lib/localizedName';
+import { WithIcon } from '@/lib/with';
+import { Achievement } from '@gw2treasures/database';
+import { FC, useEffect, useState } from 'react';
+import { Dialog } from '../Dialog/Dialog';
+import { TextInput } from '@gw2treasures/ui/components/Form/TextInput';
+import { Table } from '@gw2treasures/ui/components/Table/Table';
+import { useDebounce } from '@/lib/useDebounce';
+import { SkeletonTable } from '../Skeleton/SkeletonTable';
+import { AchievementLink } from './AchievementLink';
+import { Button } from '@gw2treasures/ui/components/Form/Button';
+import { getLinkProperties } from '@/lib/linkProperties';
+import { searchAchievement } from './SearchAchievementDialog.actions';
+
+export type SearchAchievementDialogSubmitHandler = (achievement?: WithIcon>) => void;
+
+export interface SearchAchievementDialogProps {
+ onSubmit: SearchAchievementDialogSubmitHandler;
+ open: boolean;
+}
+
+export const SearchAchievementDialog: FC = ({ onSubmit, open }) => {
+ const [searchValue, setSearchValue] = useState('');
+ const debouncedValue = useDebounce(searchValue, 1000);
+ const [achievements, setAchievements] = useState[] | 'loading'>('loading');
+
+ useEffect(() => {
+ setAchievements('loading');
+ searchAchievement(debouncedValue).then(setAchievements);
+ }, [debouncedValue]);
+
+ return (
+
+ );
+};
diff --git a/apps/web/components/Dialog/Dialog.module.css b/apps/web/components/Dialog/Dialog.module.css
index 71154f9ca..81e032d34 100644
--- a/apps/web/components/Dialog/Dialog.module.css
+++ b/apps/web/components/Dialog/Dialog.module.css
@@ -19,7 +19,7 @@
flex-direction: column;
background: var(--color-background);
border: 1px solid var(--dialog-color-border);
- min-width: 500px;
+ min-width: var(--dialog-min-width, 500px);
box-shadow: var(--shadow);
border-radius: 2px;
overflow: hidden;
@@ -27,6 +27,12 @@
--table-sticky-top: -16px;
}
+.dialogWide {
+ composes: dialog;
+
+ --dialog-min-width: 1200px;
+}
+
@media(max-width: 640px) {
.dialog {
max-width: none;
diff --git a/apps/web/components/Dialog/Dialog.tsx b/apps/web/components/Dialog/Dialog.tsx
index b5a82101e..2d3a12646 100644
--- a/apps/web/components/Dialog/Dialog.tsx
+++ b/apps/web/components/Dialog/Dialog.tsx
@@ -9,9 +9,10 @@ export interface DialogProps {
title: ReactNode,
open?: boolean;
onClose: () => void
+ wide?: boolean;
}
-export const Dialog: FC = ({ children, title, open = true, onClose }) => {
+export const Dialog: FC = ({ children, title, open = true, onClose, wide }) => {
const { refs, context } = useFloating({
open,
onOpenChange: onClose,
@@ -33,7 +34,7 @@ export const Dialog: FC = ({ children, title, open = true, onClose
-
+
diff --git a/apps/web/components/Form/LocalizedTextInput.module.css b/apps/web/components/Form/LocalizedTextInput.module.css
new file mode 100644
index 000000000..1ef165161
--- /dev/null
+++ b/apps/web/components/Form/LocalizedTextInput.module.css
@@ -0,0 +1,61 @@
+.box {
+ border: 2px solid var(--color-border-dark);
+ border-radius: 2px;
+ flex: 1;
+ background-color: var(--color-background);
+
+ display: flex;
+ flex-direction: column;
+
+ transition: border-color .3s ease;
+}
+
+.box:focus-within {
+ border-color: var(--color-focus);
+}
+
+.row {
+ display: grid;
+ grid-template-columns: 40px 1fr;
+ position: relative;
+}
+
+.row + .row {
+ margin-top: 1px;
+}
+
+.row + .row::before {
+ content: '';
+ position: absolute;
+ top: -1px;
+ height: 1px;
+ left: 16px;
+ right: 16px;
+ background-color: var(--color-border);
+}
+
+.lang {
+ color: var(--color-text-muted);
+ padding: 8px 0 8px 16px;
+ font-size: 16px;
+ align-self: center;
+ transition: color .3s ease;
+}
+
+.row:focus-within .lang {
+ color: var(--color-focus);
+}
+
+.input {
+ padding: 8px;
+ margin: 0;
+ background: transparent;
+ border: none;
+ border-radius: 0;
+ font: inherit;
+ align-self: stretch;
+}
+
+.input:focus {
+ outline: none;
+}
diff --git a/apps/web/components/Form/LocalizedTextInput.tsx b/apps/web/components/Form/LocalizedTextInput.tsx
new file mode 100644
index 000000000..a500b9921
--- /dev/null
+++ b/apps/web/components/Form/LocalizedTextInput.tsx
@@ -0,0 +1,25 @@
+import { Language } from '@gw2treasures/database';
+import { FC, Fragment, useId } from 'react';
+import styles from './LocalizedTextInput.module.css';
+
+export interface LocalizedTextInputProps {
+ value: Record
;
+ onChange: (language: Language, value: string) => void
+}
+
+const languages = Object.keys(Language) as Language[];
+
+export const LocalizedTextInput: FC = ({ value, onChange }) => {
+ const id = useId();
+
+ return (
+
+ {languages.map((language) => (
+
+
+ onChange(language, e.target.value)}/>
+
+ ))}
+
+ );
+};
diff --git a/apps/web/components/TabList/TabList.module.css b/apps/web/components/TabList/TabList.module.css
new file mode 100644
index 000000000..089c323d2
--- /dev/null
+++ b/apps/web/components/TabList/TabList.module.css
@@ -0,0 +1,48 @@
+.tabList {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+.tabButtons {
+ display: flex;
+ gap: 8px;
+ align-items: stretch;
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+}
+
+.button {
+ position: relative;
+ transition: all .3s ease;
+}
+
+.activeButton {
+ composes: button;
+}
+
+.button::after {
+ content: '';
+ position: absolute;
+ display: block;
+ bottom: -4px;
+ left: 16px;
+ right: 16px;
+ height: 4px;
+ border-radius: 2px;
+ background-color: var(--color-rarity, var(--color-focus));
+ opacity: 0;
+ transition: opacity .1s ease-in;
+}
+
+.activeButton::after {
+ opacity: 1;
+ transition: opacity .1s ease-out;
+}
+
+.actions {
+ display: flex;
+ gap: 8px;
+ align-items: stretch;
+ margin-left: auto;
+}
diff --git a/apps/web/components/TabList/TabList.tsx b/apps/web/components/TabList/TabList.tsx
new file mode 100644
index 000000000..be3d80d8b
--- /dev/null
+++ b/apps/web/components/TabList/TabList.tsx
@@ -0,0 +1,52 @@
+import { Icon, IconName } from '@gw2treasures/ui';
+import { Children, FC, Key, ReactElement, ReactNode, useEffect, useState } from 'react';
+import styles from './TabList.module.css';
+import { Button } from '@gw2treasures/ui/components/Form/Button';
+
+export interface TabListProps {
+ children: ReactElement | Array>;
+ actions?: ReactNode;
+}
+
+const identity = (x: T): T => x;
+
+export const TabList: FC = ({ children, actions }) => {
+ const [activeTab, setActiveTab] = useState(() => Children.map(children, identity)[0]?.props.id);
+
+ useEffect(() => {
+ const childs = Children.map(children, identity);
+ if(!childs.find((child) => child.props.id === activeTab)) {
+ setActiveTab(childs[0]?.props.id);
+ }
+ }, [activeTab, children]);
+
+ return (
+
+
+ {Children.map(children, (tab) => (
+
+ ))}
+ {actions &&
{actions}
}
+
+
+
+ {Children.map(children, (tab) => (
+ tab.props.id === activeTab ? tab : null
+ ))}
+
+
+ );
+};
+
+export interface TabProps {
+ id: string;
+ title: ReactNode;
+ icon?: IconName;
+ children: ReactNode;
+}
+
+export const Tab: FC = ({ title, icon, children }) => {
+ return <>{children}>;
+};
diff --git a/packages/database/prisma/migrations/20230620103216_add_vendors/migration.sql b/packages/database/prisma/migrations/20230620103216_add_vendors/migration.sql
new file mode 100644
index 000000000..f402e087a
--- /dev/null
+++ b/packages/database/prisma/migrations/20230620103216_add_vendors/migration.sql
@@ -0,0 +1,134 @@
+-- CreateTable
+CREATE TABLE "VendorNpc" (
+ "id" TEXT NOT NULL,
+ "name_de" TEXT NOT NULL,
+ "name_en" TEXT NOT NULL,
+ "name_es" TEXT NOT NULL,
+ "name_fr" TEXT NOT NULL,
+
+ CONSTRAINT "VendorNpc_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Vendor" (
+ "id" TEXT NOT NULL,
+
+ CONSTRAINT "Vendor_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "VendorTab" (
+ "id" TEXT NOT NULL,
+ "vendorId" TEXT NOT NULL,
+ "name_de" TEXT NOT NULL,
+ "name_en" TEXT NOT NULL,
+ "name_es" TEXT NOT NULL,
+ "name_fr" TEXT NOT NULL,
+ "dailyPurchaseLimit" INTEGER,
+ "rotation" BOOLEAN NOT NULL,
+ "requiresItemId" INTEGER,
+ "requiresAchievementId" INTEGER,
+ "unlock_de" TEXT,
+ "unlock_en" TEXT,
+ "unlock_es" TEXT,
+ "unlock_fr" TEXT,
+
+ CONSTRAINT "VendorTab_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "VendorOffer" (
+ "id" TEXT NOT NULL,
+ "vendorTabId" TEXT NOT NULL,
+ "itemId" INTEGER NOT NULL,
+ "quantity" INTEGER NOT NULL,
+ "dailyPurchaseLimit" INTEGER,
+ "weeklyPurchaseLimit" INTEGER,
+ "characterPurchaseLimit" INTEGER,
+ "accountPurchaseLimit" INTEGER,
+ "requiresItemId" INTEGER,
+ "requiresAchievementId" INTEGER,
+ "unlock_de" TEXT,
+ "unlock_en" TEXT,
+ "unlock_es" TEXT,
+ "unlock_fr" TEXT,
+
+ CONSTRAINT "VendorOffer_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "VendorCost" (
+ "id" TEXT NOT NULL,
+ "offerId" TEXT NOT NULL,
+ "quantity" INTEGER NOT NULL,
+ "itemId" INTEGER,
+ "currencyId" INTEGER,
+
+ CONSTRAINT "VendorCost_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "_vendorItem" (
+ "A" INTEGER NOT NULL,
+ "B" TEXT NOT NULL
+);
+
+-- CreateTable
+CREATE TABLE "_vendorNpc" (
+ "A" TEXT NOT NULL,
+ "B" TEXT NOT NULL
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "_vendorItem_AB_unique" ON "_vendorItem"("A", "B");
+
+-- CreateIndex
+CREATE INDEX "_vendorItem_B_index" ON "_vendorItem"("B");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "_vendorNpc_AB_unique" ON "_vendorNpc"("A", "B");
+
+-- CreateIndex
+CREATE INDEX "_vendorNpc_B_index" ON "_vendorNpc"("B");
+
+-- AddForeignKey
+ALTER TABLE "VendorTab" ADD CONSTRAINT "VendorTab_requiresItemId_fkey" FOREIGN KEY ("requiresItemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorTab" ADD CONSTRAINT "VendorTab_requiresAchievementId_fkey" FOREIGN KEY ("requiresAchievementId") REFERENCES "Achievement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorTab" ADD CONSTRAINT "VendorTab_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorOffer" ADD CONSTRAINT "VendorOffer_requiresItemId_fkey" FOREIGN KEY ("requiresItemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorOffer" ADD CONSTRAINT "VendorOffer_requiresAchievementId_fkey" FOREIGN KEY ("requiresAchievementId") REFERENCES "Achievement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorOffer" ADD CONSTRAINT "VendorOffer_vendorTabId_fkey" FOREIGN KEY ("vendorTabId") REFERENCES "VendorTab"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorOffer" ADD CONSTRAINT "VendorOffer_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorCost" ADD CONSTRAINT "VendorCost_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "VendorOffer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorCost" ADD CONSTRAINT "VendorCost_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "VendorCost" ADD CONSTRAINT "VendorCost_currencyId_fkey" FOREIGN KEY ("currencyId") REFERENCES "Currency"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "_vendorItem" ADD CONSTRAINT "_vendorItem_A_fkey" FOREIGN KEY ("A") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "_vendorItem" ADD CONSTRAINT "_vendorItem_B_fkey" FOREIGN KEY ("B") REFERENCES "Vendor"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "_vendorNpc" ADD CONSTRAINT "_vendorNpc_A_fkey" FOREIGN KEY ("A") REFERENCES "Vendor"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "_vendorNpc" ADD CONSTRAINT "_vendorNpc_B_fkey" FOREIGN KEY ("B") REFERENCES "VendorNpc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma
index 27d1d9df3..1115e7476 100644
--- a/packages/database/prisma/schema.prisma
+++ b/packages/database/prisma/schema.prisma
@@ -55,6 +55,12 @@ model Item {
// review queue
reviews Review[] @relation("itemReview")
+ // vendor
+ vendorOffer VendorOffer[] @relation("vendorOffer")
+ vendorCost VendorCost[] @relation("vendorCostItem")
+ vendors Vendor[] @relation("vendorItem")
+ vendorTabRequiresItem VendorTab[] @relation("vendorTabRequiresItem")
+ vendorOfferRequiresItem VendorOffer[] @relation("vendorOfferRequiresItem")
removedFromApi Boolean @default(false)
@@ -326,6 +332,10 @@ model Achievement {
rewardsItem Item[] @relation(name: "rewards_item")
rewardsItemIds Int[]
+ // vendor
+ vendorTabRequiresAchievement VendorTab[] @relation("vendorTabRequiresAchievement")
+ vendorOfferRequiresAchievement VendorOffer[] @relation("vendorOfferRequiresAchievement")
+
unlocks Float?
removedFromApi Boolean @default(false)
@@ -573,8 +583,12 @@ model Currency {
order Int
+ // container
containedIn CurrencyContent[] @relation("containedCurrency")
+ // vendor
+ vendorCost VendorCost[] @relation("vendorCostCurrency")
+
removedFromApi Boolean @default(false)
current_de Revision @relation("current_de", fields: [currentId_de], references: [id], onDelete: Cascade)
@@ -609,6 +623,110 @@ model CurrencyHistory {
@@id([currencyId, revisionId])
}
+model VendorNpc {
+ // `VendorNpc` are npcs that open a `Vendor` when interacted with.
+ // TODO: add location
+
+ id String @id @default(uuid())
+
+ name_de String
+ name_en String
+ name_es String
+ name_fr String
+
+ vendors Vendor[] @relation("vendorNpc")
+}
+
+model Vendor {
+ // `Vendor` is the shop window that opens, not an npc. A vendor window can also be opened in other ways (for example by items)
+
+ id String @id @default(uuid())
+
+ tabs VendorTab[] @relation("vendorTabs")
+
+ npcs VendorNpc[] @relation("vendorNpc")
+ items Item[] @relation("vendorItem")
+}
+
+model VendorTab {
+ id String @id @default(uuid())
+ vendorId String
+
+ name_de String
+ name_en String
+ name_es String
+ name_fr String
+
+ dailyPurchaseLimit Int?
+ rotation Boolean
+
+ // requirements
+ requiresItemId Int?
+ requiresItem Item? @relation("vendorTabRequiresItem", fields: [requiresItemId], references: [id], onDelete: Cascade)
+
+ requiresAchievementId Int?
+ requiresAchievement Achievement? @relation("vendorTabRequiresAchievement", fields: [requiresAchievementId], references: [id], onDelete: Cascade)
+
+ // requiresMasteryId Int?
+ // requiresMastery Mastery? @relation("vendorTabRequiresMastery", fields: [requiresMasteryId], references: [id], onDelete: Cascade)
+
+ // optional unlock description
+ unlock_de String?
+ unlock_en String?
+ unlock_es String?
+ unlock_fr String?
+
+ offers VendorOffer[] @relation("offers")
+
+ vendor Vendor @relation("vendorTabs", fields: [vendorId], references: [id], onDelete: Cascade)
+}
+
+model VendorOffer {
+ id String @id @default(uuid())
+
+ vendorTabId String
+ itemId Int
+ quantity Int
+
+ dailyPurchaseLimit Int?
+ weeklyPurchaseLimit Int?
+ characterPurchaseLimit Int?
+ accountPurchaseLimit Int?
+
+ requiresItemId Int?
+ requiresItem Item? @relation("vendorOfferRequiresItem", fields: [requiresItemId], references: [id], onDelete: Cascade)
+
+ requiresAchievementId Int?
+ requiresAchievement Achievement? @relation("vendorOfferRequiresAchievement", fields: [requiresAchievementId], references: [id], onDelete: Cascade)
+
+ // requiresMasteryId Int?
+ // requiresMastery Mastery? @relation("vendorOfferRequiresMastery", fields: [requiresMasteryId], references: [id], onDelete: Cascade)
+
+ unlock_de String?
+ unlock_en String?
+ unlock_es String?
+ unlock_fr String?
+
+ cost VendorCost[] @relation("cost")
+
+ vendorTab VendorTab @relation("offers", fields: [vendorTabId], references: [id], onDelete: Cascade)
+ item Item @relation("vendorOffer", fields: [itemId], references: [id], onDelete: Cascade)
+}
+
+model VendorCost {
+ id String @id @default(uuid())
+
+ offerId String
+
+ quantity Int
+ itemId Int?
+ currencyId Int?
+
+ offer VendorOffer @relation("cost", fields: [offerId], references: [id], onDelete: Cascade)
+ item Item? @relation("vendorCostItem", fields: [itemId], references: [id], onDelete: Cascade)
+ currency Currency? @relation("vendorCostCurrency", fields: [currencyId], references: [id], onDelete: Cascade)
+}
+
model Job {
id String @id @default(uuid())
diff --git a/packages/ui/components/Form/Checkbox.module.css b/packages/ui/components/Form/Checkbox.module.css
index 7cc6f32ea..9f6c4c6e5 100644
--- a/packages/ui/components/Form/Checkbox.module.css
+++ b/packages/ui/components/Form/Checkbox.module.css
@@ -6,6 +6,7 @@
border-radius: 2px;
gap: 10px;
flex-direction: row;
+ flex: 1;
}
.wrapper:hover {
diff --git a/packages/ui/components/Form/Label.module.css b/packages/ui/components/Form/Label.module.css
index 351754b1a..b0115ec18 100644
--- a/packages/ui/components/Form/Label.module.css
+++ b/packages/ui/components/Form/Label.module.css
@@ -9,3 +9,7 @@
display: flex;
gap: 8px;
}
+
+.label:not(:first-child) {
+ margin-top: 16px;
+}
diff --git a/packages/ui/components/Form/Label.tsx b/packages/ui/components/Form/Label.tsx
index 1bd872a9b..80f301979 100644
--- a/packages/ui/components/Form/Label.tsx
+++ b/packages/ui/components/Form/Label.tsx
@@ -4,13 +4,16 @@ import styles from './Label.module.css';
export interface LabelProps {
label: ReactNode;
children: ReactNode;
+ visualOnly?: boolean;
}
-export const Label: FC = ({ label, children }) => {
+export const Label: FC = ({ label, children, visualOnly }) => {
+ const Tag = visualOnly ? 'div' : 'label';
+
return (
-
+
);
};
diff --git a/packages/ui/components/Form/NumberInput.tsx b/packages/ui/components/Form/NumberInput.tsx
index 25adb7b21..f06fc7594 100644
--- a/packages/ui/components/Form/NumberInput.tsx
+++ b/packages/ui/components/Form/NumberInput.tsx
@@ -2,21 +2,24 @@ import { ChangeEvent, FC, useCallback } from 'react';
import styles from './TextInput.module.css';
export interface NumberInputProps {
- value?: number;
+ value?: number | null;
defaultValue?: number;
onChange?: (value: number) => void;
placeholder?: string;
name?: string;
readOnly?: boolean;
+ min?: number;
+ max?: number;
+ step?: number;
};
-export const NumberInput: FC = ({ value, defaultValue, onChange, placeholder, name, readOnly }) => {
+export const NumberInput: FC = ({ value, defaultValue, onChange, placeholder, name, readOnly, min, max, step }) => {
const handleChange = useCallback((e: ChangeEvent) => {
const number = parseInt(e.target.value);
onChange?.(number);
}, [onChange]);
return (
-
+
);
};
diff --git a/packages/ui/icons/index.ts b/packages/ui/icons/index.ts
index f765fecc4..057849ddc 100644
--- a/packages/ui/icons/index.ts
+++ b/packages/ui/icons/index.ts
@@ -50,6 +50,7 @@ import DeveloperIcon from './developer.svg?svgr';
import AddIcon from './add.svg?svgr';
import ReviewQueueIcon from './review-queue.svg?svgr';
import DeleteIcon from './delete.svg?svgr';
+import VendorIcon from './vendor.svg?svgr';
import ColumnsIcon from './columns.svg?svgr';
import ChevronDownIcon from './chevron-down.svg?svgr';
import ChevronLeftIcon from './chevron-left.svg?svgr';
@@ -65,7 +66,7 @@ export type IconName = 'menu' | 'gw2treasures' | 'user' | 'revision' | 'search'
| 'armorsmith' | 'artificer' | 'chef' | 'huntsman' | 'jeweler' | 'leatherworker' | 'scribe' | 'tailor' | 'weaponsmith'
| 'filter' | 'filter-active' | 'shuffle' | 'achievementPoints' | 'info' | 'locale' | 'checkmark' | 'close'
| 'api-status' | 'discord' | 'external' | 'external-link' | 'mastery' | 'coins' | 'upgrade-slot' | 'infusion-slot' | 'enrichment-slot'
- | 'eye' | 'status' | 'unlock' | 'more' | 'developer' | 'add' | 'review-queue' | 'delete' | 'columns'
+ | 'eye' | 'status' | 'unlock' | 'more' | 'developer' | 'add' | 'review-queue' | 'delete' | 'columns' | 'vendor'
| 'chevron-down' | 'chevron-left' | 'chevron-right' | 'chevron-up' | 'sort' | 'sort-asc' | 'sort-desc' | 'loading';
type IconComponent = FunctionComponent>;
@@ -122,6 +123,7 @@ export const Icons: Record = {
'add': AddIcon,
'review-queue': ReviewQueueIcon,
'delete': DeleteIcon,
+ 'vendor': VendorIcon,
'columns': ColumnsIcon,
'chevron-down': ChevronDownIcon,
'chevron-left': ChevronLeftIcon,
diff --git a/packages/ui/icons/vendor.svg b/packages/ui/icons/vendor.svg
new file mode 100644
index 000000000..128990158
--- /dev/null
+++ b/packages/ui/icons/vendor.svg
@@ -0,0 +1 @@
+
\ No newline at end of file