Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add vendors #324

Draft
wants to merge 19 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/app/[language]/api/search/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}))
}));
Expand Down
135 changes: 135 additions & 0 deletions apps/web/app/[language]/item/[id]/_edit-vendor/EditVendor.tsx
Original file line number Diff line number Diff line change
@@ -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<EditVendorProps> = ({ }) => {
const [dialogOpen, setDialogOpen] = useState(false);

return (
<>
<Button onClick={() => setDialogOpen(true)}>Edit Vendor</Button>
<Dialog wide open={dialogOpen} onClose={() => setDialogOpen(false)} title="Edit Vendor">
<EditVendorDialog/>
</Dialog>
</>
);
};

export interface EditVendorDialogProps {
// TODO: add props
}

type EditVendorTab = Omit<VendorTab, 'requiresItemId' | 'requiresAchievementId'> & {
requiresItem: WithIcon<Pick<Item, 'id' | 'rarity' | keyof LocalizedEntity>> | null;
requiresAchievement: WithIcon<Pick<Achievement, 'id' | keyof LocalizedEntity>> | null;
}

const EmptyVendorTab: Omit<EditVendorTab, 'id'> = {
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<EditVendorDialogProps> = ({ }) => {
const [tabs, setTabs] = useState<EditVendorTab[]>([{ ...EmptyVendorTab, id: crypto.randomUUID() }]);

const editTabWithId = (id: string) => (update: Partial<EditVendorTab>) => {
setTabs((tabs) => tabs.map(
(t) => t.id === id ? { ...t, ...update } : t
));
};

const [addItemRequirement, setAddItemRequirement] = useState<string>();
const [addAchievementRequirement, setAddAchievementRequirement] = useState<string>();

return (
<>
<TabList actions={<Button icon="add" appearance="menu" onClick={() => setTabs([...tabs, { ...EmptyVendorTab, id: crypto.randomUUID() }])}>Add tab</Button>}>
{tabs.map<ReactElement<TabProps>>((tab) => {
const edit = editTabWithId(tab.id);

return (
<Tab id={tab.id} title={localizedName(tab, 'en') || '[Untitled]'} icon="vendor" key={tab.id}>
<Label label="Title">
<LocalizedTextInput value={{ de: tab.name_de ?? '', en: tab.name_en ?? '', es: tab.name_es ?? '', fr: tab.name_fr ?? '' }} onChange={(language, value) => edit({ [`name_${language}`]: value })}/>
</Label>
<Label label="Daily Purchase Limit">
<NumberInput value={tab.dailyPurchaseLimit} onChange={(dailyPurchaseLimit) => edit({ dailyPurchaseLimit })} min={0} max={255}/>
</Label>
<Label label="Flags" visualOnly>
<Checkbox checked={tab.rotation} onChange={(rotation) => edit({ rotation })}>Items in this tab are on a rotation <Tip tip={<p>Always add all possible items to a vendor.</p>}><Icon icon="info"/></Tip></Checkbox>
</Label>
<Label label="Required Item" visualOnly>
{tab.requiresItem ? (<><ItemLink item={tab.requiresItem}/><Button icon="delete" appearance="menu" onClick={() => edit({ requiresItem: null })}>Delete</Button></>) : <Button icon="item" onClick={() => setAddItemRequirement(tab.id)}>Select Item</Button>}
</Label>
<Label label="Required Achievement" visualOnly>
{tab.requiresAchievement ? (<><AchievementLink achievement={tab.requiresAchievement}/><Button icon="delete" appearance="menu" onClick={() => edit({ requiresAchievement: null })}>Delete</Button></>) : <Button icon="achievement" onClick={() => setAddAchievementRequirement(tab.id)}>Select Achievement</Button>}
</Label>
<Label label="Required Mastery" visualOnly>
<Button icon="mastery" disabled>Select Mastery</Button>
</Label>
<Label label="Unlock Description">
<LocalizedTextInput value={{ de: tab.unlock_de ?? '', en: tab.unlock_en ?? '', es: tab.unlock_es ?? '', fr: tab.unlock_fr ?? '' }} onChange={(language, value) => edit({ [`unlock_${language}`]: value })}/>
</Label>
<Label label="Actions" visualOnly>
<FlexRow>
<Button appearance="menu" icon="delete" intent="delete" onClick={() => setTabs(tabs.filter(({ id }) => id !== tab.id))}>Delete Tab</Button>
</FlexRow>
</Label>
<Headline id="items">Items</Headline>
<Table>
<thead>
<tr>
<Table.HeaderCell>Item</Table.HeaderCell>
<Table.HeaderCell>Cost</Table.HeaderCell>
<Table.HeaderCell>Limits</Table.HeaderCell>
<Table.HeaderCell>Requirements</Table.HeaderCell>
</tr>
</thead>
<tbody>
<TableRowButton onClick={() => {}}><Icon icon="add"/> Add Item</TableRowButton>
</tbody>
</Table>
</Tab>
);
})}
</TabList>
<SearchItemDialog open={addItemRequirement !== undefined} onSubmit={(requiresItem) => { addItemRequirement && editTabWithId(addItemRequirement)({ requiresItem }); setAddItemRequirement(undefined); }}/>
<SearchAchievementDialog open={addAchievementRequirement !== undefined} onSubmit={(requiresAchievement) => { addAchievementRequirement && editTabWithId(addAchievementRequirement)({ requiresAchievement }); setAddAchievementRequirement(undefined); }}/>
</>
);
};
10 changes: 10 additions & 0 deletions apps/web/app/[language]/item/[id]/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -150,6 +151,15 @@ export const ItemPageComponent: AsyncComponent<ItemPageComponentProps> = async (
</>
)}

{(data.details?.vendor_ids?.length ?? 0) > 0 && (
<>
<Headline id="vendor">Vendor</Headline>
<p>Using this item opens a vendor.</p>

<EditVendor/>
</>
)}

{item.recipeOutput && item.recipeOutput.length > 0 && (
<>
<Headline id="crafted-from">Crafted From</Headline>
Expand Down
25 changes: 25 additions & 0 deletions apps/web/components/Achievement/SearchAchievementDialog.actions.ts
Original file line number Diff line number Diff line change
@@ -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<WithIcon<Achievement>[]> {

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
});
}
68 changes: 68 additions & 0 deletions apps/web/components/Achievement/SearchAchievementDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<Pick<Achievement, 'id' | keyof LocalizedEntity>>) => void;

export interface SearchAchievementDialogProps {
onSubmit: SearchAchievementDialogSubmitHandler;
open: boolean;
}

export const SearchAchievementDialog: FC<SearchAchievementDialogProps> = ({ onSubmit, open }) => {
const [searchValue, setSearchValue] = useState('');
const debouncedValue = useDebounce(searchValue, 1000);
const [achievements, setAchievements] = useState<WithIcon<Achievement>[] | 'loading'>('loading');

useEffect(() => {
setAchievements('loading');
searchAchievement(debouncedValue).then(setAchievements);
}, [debouncedValue]);

return (
<Dialog onClose={() => onSubmit(undefined)} title="Search Achievement" open={open}>
<div style={{ display: 'flex', flexDirection: 'column', marginBottom: 16 }}>
<TextInput placeholder="Name / ID" value={searchValue} onChange={setSearchValue}/>
</div>

{achievements === 'loading' ? (
<SkeletonTable columns={['Achievement', 'Select']} rows={2}/>
) : achievements.length === 0 ? (
<p>No achievements found</p>
) : (
<Table>
<thead>
<tr>
<Table.HeaderCell>Achievement</Table.HeaderCell>
<Table.HeaderCell small>Select</Table.HeaderCell>
</tr>
</thead>
<tbody>
{achievements.map((achievement) => (
<tr key={achievement.id}>
<td>
<AchievementLink achievement={achievement}/>
</td>
<td>
<Button onClick={() => onSubmit(getLinkProperties(achievement))}>Select</Button>
</td>
</tr>
))}
</tbody>
</Table>
)}
</Dialog>
);
};
8 changes: 7 additions & 1 deletion apps/web/components/Dialog/Dialog.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,20 @@
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;

--table-sticky-top: -16px;
}

.dialogWide {
composes: dialog;

--dialog-min-width: 1200px;
}

@media(max-width: 640px) {
.dialog {
max-width: none;
Expand Down
5 changes: 3 additions & 2 deletions apps/web/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ export interface DialogProps {
title: ReactNode,
open?: boolean;
onClose: () => void
wide?: boolean;
}

export const Dialog: FC<DialogProps> = ({ children, title, open = true, onClose }) => {
export const Dialog: FC<DialogProps> = ({ children, title, open = true, onClose, wide }) => {
const { refs, context } = useFloating({
open,
onOpenChange: onClose,
Expand All @@ -33,7 +34,7 @@ export const Dialog: FC<DialogProps> = ({ children, title, open = true, onClose
<FloatingPortal>
<FloatingOverlay className={styles.overlay} style={transitionStyles}>
<FloatingFocusManager context={context}>
<div ref={refs.setFloating} aria-labelledby={labelId} className={styles.dialog} {...getFloatingProps()}>
<div ref={refs.setFloating} aria-labelledby={labelId} className={wide ? styles.dialogWide : styles.dialog} {...getFloatingProps()}>
<div className={styles.title}>
<header id={labelId}>{title}</header>
<button type="button" className={styles.close} onClick={onClose}><Icon icon="close"/></button>
Expand Down
61 changes: 61 additions & 0 deletions apps/web/components/Form/LocalizedTextInput.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 25 additions & 0 deletions apps/web/components/Form/LocalizedTextInput.tsx
Original file line number Diff line number Diff line change
@@ -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<Language, string>;
onChange: (language: Language, value: string) => void
}

const languages = Object.keys(Language) as Language[];

export const LocalizedTextInput: FC<LocalizedTextInputProps> = ({ value, onChange }) => {
const id = useId();

return (
<div className={styles.box}>
{languages.map((language) => (
<div key={language} className={styles.row}>
<label className={styles.lang} htmlFor={`${id}_${language}`}>{language.toUpperCase()}</label>
<input className={styles.input} id={`${id}_${language}`} value={value[language]} onChange={(e) => onChange(language, e.target.value)}/>
</div>
))}
</div>
);
};
Loading