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 ( + <> + + setDialogOpen(false)} title="Edit Vendor"> + + + + ); +}; + +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 ( + onSubmit(undefined)} title="Search Achievement" open={open}> +
+ +
+ + {achievements === 'loading' ? ( + + ) : achievements.length === 0 ? ( +

No achievements found

+ ) : ( + + + + Achievement + Select + + + + {achievements.map((achievement) => ( + + + + + ))} + +
+ + + +
+ )} +
+ ); +}; 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 -
+
{title}
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