Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
pontusab committed Oct 17, 2024
1 parent 2f0db30 commit 69c4bc6
Show file tree
Hide file tree
Showing 21 changed files with 555 additions and 121 deletions.
5 changes: 4 additions & 1 deletion apps/dashboard/.env-example
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,7 @@ AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT=
AZURE_DOCUMENT_INTELLIGENCE_KEY=

# Google Maps
NEXT_PUBLIC_GOOGLE_API_KEY=
NEXT_PUBLIC_GOOGLE_API_KEY=

# VatCheckAPI
VATCHECKAPI_API_KEY=
55 changes: 55 additions & 0 deletions apps/dashboard/src/actions/ai/editor/generate-editor-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"use server";

import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import { createStreamableValue } from "ai/rsc";

type Params = {
input: string;
context?: string;
};

export async function generateEditorContent({ input, context }: Params) {
console.log(context);
const stream = createStreamableValue("");

(async () => {
const { textStream } = await streamText({
model: openai("gpt-4o-mini"),
prompt: input,
temperature: 0.8,
system: `
You are an expert AI assistant specializing in invoice-related content generation and improvement. Your task is to enhance or modify invoice text based on specific instructions. Follow these guidelines:
1. Language: Always respond in the same language as the input prompt.
2. Conciseness: Keep responses brief and precise, with a maximum of 200 characters.
3. Context-awareness: Utilize any provided due date to inform payment terms.
You will perform one of these primary functions:
- Fix grammar: Rectify any grammatical errors while preserving the original meaning.
- Improve text: Refine the text to improve clarity and professionalism.
- Condense text: Remove any unnecessary text and only keep the invoice-related content and make it more concise.
If a due date is provided, incorporate it into the payment terms.
Adjust payment terms based on the due date, ensuring they are realistic and aligned with standard business practices.
Format your response as plain text, using '\n' for line breaks when necessary.
Do not include any titles or headings in your response.
Provide only invoice-relevant content without any extraneous information.
Begin your response directly with the relevant invoice text or information.
For custom prompts, maintain focus on invoice-related content. Ensure all generated text is appropriate for formal business communications and adheres to standard invoice practices.
Current date is: ${new Date().toISOString().split("T")[0]} \n
${context}
`,
});

for await (const delta of textStream) {
stream.update(delta);
}

stream.done();
})();

return { output: stream.value };
}
6 changes: 3 additions & 3 deletions apps/dashboard/src/actions/invoice/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ export const updateInvoiceTemplateSchema = z.object({
tax_label: z.string().optional(),
payment_details_label: z.string().optional(),
note_label: z.string().optional(),
logo_url: z.string().optional(),
logo_url: z.string().optional().nullable(),
currency: z.string().optional(),
payment_details: z.any(),
from_details: z.any(),
payment_details: z.any().nullable(),
from_details: z.any().nullable(),
});

export const lineItemSchema = z.object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const updateInvoiceTemplateAction = authActionClient
.action(async ({ parsedInput: setting, ctx: { user, supabase } }) => {
const teamId = user.team_id;

const { data, error } = await supabase
const { data } = await supabase
.from("invoice_templates")
.upsert({ team_id: teamId, ...setting }, { onConflict: "team_id" })
.eq("team_id", teamId)
Expand Down
31 changes: 31 additions & 0 deletions apps/dashboard/src/actions/validate-vat-number-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use server";

import { z } from "zod";
import { authActionClient } from "./safe-action";

const ENDPOINT = "https://api.vatcheckapi.com/v2/check";

export const validateVatNumberAction = authActionClient
.schema(
z.object({
vat_number: z.string().min(7),
country_code: z.string(),
}),
)
.metadata({
name: "validate-vat-number",
})
.action(async ({ parsedInput: { vat_number, country_code } }) => {
const response = await fetch(
`${ENDPOINT}?vat_number=${vat_number}&country_code=${country_code}&apikey=${process.env.VATCHECKAPI_API_KEY}`,
{
method: "GET",
},
);

const data = await response.json();

console.log(data);

return data;
});
14 changes: 10 additions & 4 deletions apps/dashboard/src/components/country-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,32 @@ import {
} from "@midday/ui/popover";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import * as React from "react";
import { useEffect } from "react";

type Props = {
defaultValue: string;
onSelect: (countryCode: string) => void;
onSelect: (countryCode: string, countryName: string) => void;
};

export function CountrySelector({ defaultValue, onSelect }: Props) {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState(defaultValue);

useEffect(() => {
if (value !== defaultValue) {
setValue(defaultValue);
}
}, [defaultValue, value]);

const selected = Object.values(countries).find(
(country) => country.code === value,
(country) => country.code === value || country.name === value,
);

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal truncate bg-accent"
>
Expand All @@ -53,7 +59,7 @@ export function CountrySelector({ defaultValue, onSelect }: Props) {
value={country.name}
onSelect={() => {
setValue(country.code);
onSelect?.(country.code);
onSelect?.(country.code, country.name);
setOpen(false);
}}
>
Expand Down
63 changes: 9 additions & 54 deletions apps/dashboard/src/components/editor/extensions.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,11 @@
import {
Color,
HorizontalRule,
Placeholder,
StarterKit,
TaskItem,
TaskList,
TextStyle,
TiptapLink,
UpdatedImage,
} from "novel/extensions";
import { Placeholder, StarterKit, TextStyle } from "novel/extensions";

import { cx } from "class-variance-authority";
const starterKit = StarterKit.configure();

// TODO I am using cx here to get tailwind autocomplete working, idk if someone else can write a regex to just capture the class key in objects

// You can overwrite the placeholder with your own configuration
const placeholder = Placeholder;
// const tiptapLink = TiptapLink.configure({
// HTMLAttributes: {
// class: cx(
// "text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer",
// ),
// },
// });

const starterKit = StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: cx("list-disc list-outside leading-3 -mt-2"),
},
},
orderedList: {
HTMLAttributes: {
class: cx("list-decimal list-outside leading-3 -mt-2"),
},
},
listItem: {
HTMLAttributes: {
class: cx("leading-normal -mb-2"),
},
},
blockquote: {
HTMLAttributes: {
class: cx("border-l-4 border-primary"),
},
},
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
width: 4,
},
gapcursor: false,
});

export const defaultExtensions = [starterKit, placeholder, TextStyle, Color];
export function setupExtensions({ placeholder }: { placeholder?: string }) {
return [
starterKit,
placeholder ? Placeholder.configure({ placeholder }) : null,
TextStyle,
];
}
84 changes: 61 additions & 23 deletions apps/dashboard/src/components/editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,62 +4,100 @@ import { cn } from "@midday/ui/cn";
import {
EditorBubble,
EditorContent,
type EditorInstance,
EditorRoot,
type JSONContent,
} from "novel";
import { useState } from "react";
import { defaultExtensions } from "./extensions";
import { useCallback, useEffect, useState } from "react";
import { setupExtensions } from "./extensions";
import { AISelector } from "./selectors/ai-selector";
import { AskAI } from "./selectors/ask-ai";
import { LinkSelector } from "./selectors/link-selector";
import { TextButtons } from "./selectors/text-buttons";

type Props = {
initialContent?: JSONContent;
className?: string;
onChange?: (content: JSONContent) => void;
onBlur?: (content: JSONContent) => void;
onChange?: (content?: JSONContent) => void;
onBlur?: (content: JSONContent | null) => void;
placeholder?: string;
context?: Record<string, string>;
};

export function Editor({ initialContent, className, onChange, onBlur }: Props) {
export function Editor({
initialContent,
className,
onChange,
onBlur,
placeholder,
context,
}: Props) {
const [isFocused, setIsFocused] = useState(false);
const [thinking, setThinking] = useState(false);
const [showAI, setShowAI] = useState(false);
const [isEmpty, setIsEmpty] = useState(false);
const [openLink, setOpenLink] = useState(false);
const [content, setContent] = useState<JSONContent | undefined>(
initialContent,
);

const isEmpty = content?.content?.length;
const handleUpdate = useCallback(
({ editor }: { editor: EditorInstance }) => {
const json = editor.getJSON();
const newIsEmpty = editor.state.doc.textContent.length === 0;

setIsEmpty(newIsEmpty);
setContent(newIsEmpty ? null : json);
onChange?.(newIsEmpty ? null : json);
},
[onChange],
);

const handleBlur = useCallback(() => {
setIsFocused(false);
onBlur?.(content ?? null);
}, [content, onBlur]);

useEffect(() => {
if (!content?.content?.length) {
setIsEmpty(true);
}
}, [content]);

const showPlaceholder = isEmpty && !isFocused && !thinking;

return (
<EditorRoot>
<EditorContent
className={cn(
"font-mono text-[11px] text-primary leading-[18px] invoice-editor",
!isEmpty &&
!isFocused &&
showPlaceholder &&
"w-full bg-[repeating-linear-gradient(-60deg,#DBDBDB,#DBDBDB_1px,background_1px,background_5px)] dark:bg-[repeating-linear-gradient(-60deg,#2C2C2C,#2C2C2C_1px,background_1px,background_5px)]",
className,
)}
extensions={defaultExtensions}
extensions={setupExtensions({ placeholder })}
initialContent={content}
onUpdate={({ editor }) => {
const json = editor.getJSON();
setContent(json);
onChange?.(json);
}}
onUpdate={handleUpdate}
onFocus={() => setIsFocused(true)}
onBlur={() => {
setIsFocused(false);

if (content) {
onBlur?.(content);
}
}}
onBlur={handleBlur}
>
<EditorBubble
pluginKey="editor"
className="flex w-fit overflow-hidden rounded-full border border-border bg-background shadow-xl"
>
<TextButtons />
<LinkSelector open={openLink} onOpenChange={setOpenLink} />
{showAI ? (
<AISelector
onOpenChange={setShowAI}
context={context}
setThinking={setThinking}
/>
) : (
<>
<TextButtons />
<LinkSelector open={openLink} onOpenChange={setOpenLink} />
<AskAI onSelect={() => setShowAI(true)} />
</>
)}
</EditorBubble>
</EditorContent>
</EditorRoot>
Expand Down
Loading

0 comments on commit 69c4bc6

Please sign in to comment.