initial commit

This commit is contained in:
Dmitriy Pleshevskiy 2023-01-31 12:01:27 +03:00
commit 9b30f3595d
Signed by: pleshevskiy
GPG Key ID: 1B59187B161C0215
87 changed files with 12838 additions and 0 deletions

21
.eslintrc.cjs Normal file
View File

@ -0,0 +1,21 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
root: true,
extends: [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/eslint-config-typescript",
"@vue/eslint-config-prettier",
],
overrides: [
{
files: ["cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}"],
extends: ["plugin:cypress/recommended"],
},
],
parserOptions: {
ecmaVersion: "latest",
},
};

22
.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# direnv
.direnv
.envrc

1
.prettierrc.json Normal file
View File

@ -0,0 +1 @@
{}

68
README.md Normal file
View File

@ -0,0 +1,68 @@
# vue-project
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
### Run End-to-End Tests with [Cypress](https://www.cypress.io/)
```sh
npm run test:e2e:dev
```
This runs the end-to-end tests against the Vite development server.
It is much faster than the production build.
But it's still recommended to test the production build with `test:e2e` before deploying (e.g. in CI environments):
```sh
npm run build
npm run test:e2e
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

8
cypress.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
specPattern: "cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}",
baseUrl: "http://localhost:4173",
},
});

View File

@ -0,0 +1,8 @@
// https://docs.cypress.io/api/introduction/api.html
describe("My First Test", () => {
it("visits the app root url", () => {
cy.visit("/");
cy.contains("h1", "You did it!");
});
});

10
cypress/e2e/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["./**/*", "../support/**/*"],
"compilerOptions": {
"isolatedModules": false,
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
}
}

View File

@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@ -0,0 +1,39 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
export {};

20
cypress/support/e2e.ts Normal file
View File

@ -0,0 +1,20 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import "./commands";
// Alternatively you can use CommonJS syntax:
// require('./commands')

1
env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

43
flake.lock Normal file
View File

@ -0,0 +1,43 @@
{
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1667395993,
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1674487464,
"narHash": "sha256-Jgq50e4S4JVCYpWLqrabBzDp/1mfaxHCh8/OOorHTy0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3954218cf613eba8e0dcefa9abe337d26bc48fd0",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

23
flake.nix Normal file
View File

@ -0,0 +1,23 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
in
{
devShells.default = pkgs.mkShell {
packages = with pkgs; [
nodejs-18_x
nodePackages.vue-cli
nodePackages.vls # vue
nodePackages.typescript-language-server # typescript
nodePackages.vscode-langservers-extracted # html, css, json, eslint
];
};
});
}

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

10854
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "vue-project",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "run-p type-check build-only",
"preview": "vite preview",
"test:unit": "vitest --environment jsdom --root src/",
"test:e2e": "start-server-and-test preview :4173 'cypress run --e2e'",
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' :4173 'cypress open --e2e'",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"pinia": "^2.0.29",
"vue": "^3.2.45",
"vue-router": "^4.1.6"
},
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@rushstack/eslint-patch": "^1.1.4",
"@types/jsdom": "^20.0.1",
"@types/node": "^18.11.12",
"@vitejs/plugin-vue": "^4.0.0",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.0",
"@vue/test-utils": "^2.2.6",
"@vue/tsconfig": "^0.1.3",
"cypress": "^12.0.2",
"eslint": "^8.22.0",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-vue": "^9.3.0",
"jsdom": "^20.0.3",
"npm-run-all": "^4.1.5",
"postcss-nested": "^6.0.0",
"prettier": "^2.7.1",
"start-server-and-test": "^1.15.2",
"typescript": "~4.9.3",
"vite": "^4.0.0",
"vitest": "^0.25.6",
"vue-tsc": "^1.0.12"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

9
src/App.vue Normal file
View File

@ -0,0 +1,9 @@
<script setup lang="ts">
import TheHeader from "@/app/common/components/TheHeader.vue";
</script>
<template>
<TheHeader />
<RouterView />
</template>

View File

@ -0,0 +1,9 @@
<template>
<b>SM</b>
</template>
<style scoped>
b {
font-size: 20px;
}
</style>

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import TheBrandLogo from "./TheBrandLogo.vue";
</script>
<template>
<header>
<div class="container">
<TheBrandLogo class="logo" />
<BaseNav>
<RouterLink :to="{ name: 'email_files' }">Файлы</RouterLink>
<RouterLink :to="{ name: 'audience' }">Аудитория</RouterLink>
</BaseNav>
</div>
</header>
</template>
<style scoped>
header {
line-height: 1.5;
background: var(--color-background-header);
padding: 0.7rem 0;
.container {
display: flex;
flex-flow: row nowrap;
align-items: center;
}
.logo {
margin-right: 2rem;
}
}
</style>

9
src/app/common/ports.ts Normal file
View File

@ -0,0 +1,9 @@
import type { Entity } from "@/domain/common/entity";
export interface BaseFetchManyApiPort<E extends Entity, P = unknown> {
fetchMany(props: P): Promise<E[]>;
}
export interface BaseCreateApiPort<E extends Entity, P = unknown> {
create(props: P): Promise<E>;
}

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
import { ref, defineEmits } from "vue";
import { ContactFormApiCreateProps } from "./ports";
import { useContactFormStore } from "./store";
const store = useContactFormStore();
const emit = defineEmits(["submitForm"]);
const contactData = ref({
email: "",
} as ContactFormApiCreateProps);
async function submitForm() {
await store.create(contactData.value);
emit("submitForm");
contactData.value = { email: "" };
}
</script>
<template>
<div>
<div>
<label>Email</label>
<input v-model="contactData.email" />
</div>
<BaseButton
:isPrimary="true"
:isLoading="store.loading"
@click="submitForm"
>
Добавить
</BaseButton>
</div>
</template>

View File

@ -0,0 +1,3 @@
export { default as AudienceContactForm } from "./AudienceContactForm.vue";
export * from "./ports";
export { CONTACT_FORM_API_PROVIDE_KEY } from "./store";

View File

@ -0,0 +1,11 @@
import type { BaseCreateApiPort } from "@/app/common/ports";
import type { Contact } from "@/domain/entities/contact";
export interface ContactFormApiCreateProps {
email: string;
}
export type ContactFormApiPort = BaseCreateApiPort<
Contact,
ContactFormApiCreateProps
>;

View File

@ -0,0 +1,27 @@
import { useLoader } from "@/shared/lib/composables/loader";
import { validInject } from "@/shared/lib/di";
import { reactive } from "vue";
import { useContactsStore } from "../store";
import type { ContactFormApiCreateProps, ContactFormApiPort } from "./ports";
export const CONTACT_FORM_API_PROVIDE_KEY = Symbol("ContactFormApi");
export function useContactFormStore() {
const api = validInject<ContactFormApiPort>(
CONTACT_FORM_API_PROVIDE_KEY,
"ContactFormApiPort"
);
const contactsStore = useContactsStore();
const loader = useLoader();
async function create(data: ContactFormApiCreateProps) {
const contact = await loader.wait(api.create(data));
contactsStore.addContact(contact);
}
return reactive({
loading: loader.loading,
create,
});
}

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed, watch } from "vue";
import { formatDate } from "@/utils";
import { useContactsTableStore } from "./store";
const columns = computed(() => [
{ key: "email", title: "Email" },
//{ key: "openedEmailCount", title: "Открыто" },
//{ key: "sentEmailCount", title: "Отправлено" },
{ key: "createdAt", title: "Добавлен" },
]);
const store = useContactsTableStore();
const props = defineProps({
listId: { type: String, default: "" },
});
watch(
() => props.listId,
(listId, prevListId) => {
if (listId === undefined || listId === prevListId) return;
return store.fetchMany({ listId: listId ? listId : undefined });
},
{ immediate: true }
);
</script>
<template>
<BaseTable
:loading="store.loading"
:columns="columns"
:dataItems="store.contacts"
>
<template #openedEmailCount="{ dataItem }">
{{ dataItem.openedEmailCount }}
({{ dataItem.openedEmailPercent.toFixed(2) }}%)
</template>
<template #createdAt="{ dataItem }">
{{ formatDate(dataItem.createdAt) }}
</template>
</BaseTable>
</template>

View File

@ -0,0 +1,3 @@
export { default as AudienceContactsTable } from "./AudienceContactsTable.vue";
export * from "./ports";
export { CONTACTS_TABLE_API_PROVIDE_KEY } from "./store";

View File

@ -0,0 +1,12 @@
import type { BaseFetchManyApiPort } from "@/app/common/ports";
import type { EntityId } from "@/domain/common/entity";
import type { Contact } from "@/domain/entities/contact";
export interface ContactsTableApiFetchManyProps {
listId?: EntityId;
}
export type ContactsTableApiPort = BaseFetchManyApiPort<
Contact,
ContactsTableApiFetchManyProps
>;

View File

@ -0,0 +1,33 @@
import { useLoader } from "@/shared/lib/composables/loader";
import { validInject } from "@/shared/lib/di";
import { storeToRefs } from "pinia";
import { reactive } from "vue";
import { useContactsStore } from "../store";
import type {
ContactsTableApiFetchManyProps,
ContactsTableApiPort,
} from "./ports";
export const CONTACTS_TABLE_API_PROVIDE_KEY = Symbol("ContactsTableApi");
export function useContactsTableStore() {
const api = validInject<ContactsTableApiPort>(
CONTACTS_TABLE_API_PROVIDE_KEY,
"ContactsTableApiPort"
);
const contactsStore = useContactsStore();
const { contacts } = storeToRefs(contactsStore);
const loader = useLoader();
async function fetchMany(query: ContactsTableApiFetchManyProps) {
const contacts = await loader.wait(api.fetchMany(query));
contactsStore.setContacts(contacts);
}
return reactive({
loading: loader.loading,
contacts,
fetchMany,
});
}

View File

@ -0,0 +1,6 @@
import type { ContactFormApiPort } from "./ContactForm";
import type { ContactsTableApiPort } from "./ContactsTable";
export type { ContactFormApiCreateProps } from "./ContactForm";
export type { ContactsTableApiFetchManyProps } from "./ContactsTable";
export type ContactApiPort = ContactFormApiPort & ContactsTableApiPort;

17
src/app/contacts/store.ts Normal file
View File

@ -0,0 +1,17 @@
import type { Contact } from "@/domain/entities/contact";
import { defineStore } from "pinia";
import { ref } from "vue";
export const useContactsStore = defineStore("contacts", () => {
const data = ref([] as Contact[]);
function setContacts(contacts: Contact[]): void {
data.value = contacts;
}
function addContact(contact: Contact): void {
data.value.unshift(contact);
}
return { contacts: data, setContacts, addContact };
});

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from "vue";
import { useFilesTableStore } from "./store";
import { formatDate } from "@/utils";
const filesStore = useFilesTableStore();
const columns = computed(() => [
{ key: "name", title: "Название" },
{ key: "createdAt", title: "Дата создания" },
]);
filesStore.fetchMany();
</script>
<template>
<BaseTable
:loading="filesStore.loading"
:columns="columns"
:dataItems="filesStore.files"
>
<template #createdAt="{ dataItem }">
{{ formatDate(dataItem.createdAt) }}
</template>
</BaseTable>
</template>

View File

@ -0,0 +1,3 @@
export { default as FilesTable } from "./FilesTable.vue";
export * from "./ports";
export { FILES_TABLE_API_PROVIDE_KEY } from "./store";

View File

@ -0,0 +1,4 @@
import type { BaseFetchManyApiPort } from "@/app/common/ports";
import type { AppFile } from "@/domain/entities/file";
export type FilesTableApiPort = BaseFetchManyApiPort<AppFile>;

View File

@ -0,0 +1,30 @@
import { useLoader } from "@/shared/lib/composables/loader";
import { validInject } from "@/shared/lib/di";
import { storeToRefs } from "pinia";
import { reactive } from "vue";
import { useFilesStore } from "../store";
import type { FilesTableApiPort } from "./ports";
export const FILES_TABLE_API_PROVIDE_KEY = Symbol("FilesTableApi");
export function useFilesTableStore() {
const api = validInject<FilesTableApiPort>(
FILES_TABLE_API_PROVIDE_KEY,
"ContactsTableApiPort"
);
const filesStore = useFilesStore();
const { files } = storeToRefs(filesStore);
const loader = useLoader();
async function fetchMany() {
const files = await loader.wait(api.fetchMany({}));
filesStore.setFiles(files);
}
return reactive({
loading: loader.loading,
files,
fetchMany,
});
}

3
src/app/files/index.ts Normal file
View File

@ -0,0 +1,3 @@
import type { FilesTableApiPort } from "./FilesTable";
export type FileApiPort = FilesTableApiPort;

17
src/app/files/store.ts Normal file
View File

@ -0,0 +1,17 @@
import type { AppFile } from "@/domain/entities/file";
import { defineStore } from "pinia";
import { ref } from "vue";
export const useFilesStore = defineStore("files", () => {
const data = ref([] as AppFile[]);
function setFiles(files: AppFile[]): void {
data.value = files;
}
function addFile(file: AppFile): void {
data.value.unshift(file);
}
return { files: data, setFiles, addFile };
});

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
import { ref, defineEmits } from "vue";
import { useListFormStore } from "./store";
import type { CreateListData } from "@/app_core/ports/list";
const emit = defineEmits(["submitForm"]);
const store = useListFormStore();
const listData = ref({
displayName: "",
} as CreateListData);
async function submitForm() {
await store.create(listData.value);
emit("submitForm");
listData.value = { displayName: "" };
}
</script>
<template>
<div>
<div>
<label>Отображаемое имя</label>
<input v-model="listData.displayName" />
</div>
<BaseButton
:isPrimary="true"
:isLoading="store.loading"
@click="submitForm"
>
Добавить
</BaseButton>
</div>
</template>

View File

@ -0,0 +1,3 @@
export { default as AudienceListForm } from "./AudienceListForm.vue";
export * from "./ports";
export { LIST_FORM_API_PROVIDE_KEY } from "./store";

View File

@ -0,0 +1,8 @@
import type { BaseCreateApiPort } from "@/app/common/ports";
import type { List } from "@/domain/entities/list";
export interface ListFormApiCreateProps {
displayName: string;
}
export type ListFormApiPort = BaseCreateApiPort<List, ListFormApiCreateProps>;

View File

@ -0,0 +1,27 @@
import { useLoader } from "@/shared/lib/composables/loader";
import { validInject } from "@/shared/lib/di";
import { reactive } from "vue";
import { useListsStore } from "../store";
import type { ListFormApiCreateProps, ListFormApiPort } from "./ports";
export const LIST_FORM_API_PROVIDE_KEY = Symbol("ListFormApi");
export function useListFormStore() {
const api = validInject<ListFormApiPort>(
LIST_FORM_API_PROVIDE_KEY,
"ListFormApiPort"
);
const listsStore = useListsStore();
const loader = useLoader();
async function create(data: ListFormApiCreateProps) {
const list = await loader.wait(api.create(data));
listsStore.addList(list);
}
return reactive({
loading: loader.loading,
create,
});
}

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from "vue";
import { useListsSelectStore } from "./store";
const store = useListsSelectStore();
const props = defineProps({
extraItems: {
type: Array,
default: () => [],
},
// define props for the v-model directive
moduleValue: String,
});
const emit = defineEmits(["update:moduleValue"]);
const value = computed({
get() {
return props.moduleValue;
},
set(value) {
emit("update:moduleValue", value);
},
});
const selectListItems = computed(() => {
return props.extraItems.concat(store.selectItems);
});
store.fetchMany();
</script>
<template>
<BaseSelect v-model="value" :items="selectListItems" />
</template>

View File

@ -0,0 +1,3 @@
export { default as AudienceListsSelect } from "./AudienceListsSelect.vue";
export * from "./ports";
export { LISTS_SELECT_API_PROVIDE_KEY } from "./store";

View File

@ -0,0 +1,4 @@
import type { BaseFetchManyApiPort } from "@/app/common/ports";
import type { List } from "@/domain/entities/list";
export type ListsSelectApiPort = BaseFetchManyApiPort<List>;

View File

@ -0,0 +1,35 @@
import { useLoader } from "@/shared/lib/composables/loader";
import { validInject } from "@/shared/lib/di";
import { computed, reactive } from "vue";
import { useListsStore } from "../store";
import type { ListsSelectApiPort } from "./ports";
export const LISTS_SELECT_API_PROVIDE_KEY = Symbol("ListsTableApi");
export function useListsSelectStore() {
const api = validInject<ListsSelectApiPort>(
LISTS_SELECT_API_PROVIDE_KEY,
"ListsSelectApiPort"
);
const listsStore = useListsStore();
const selectItems = computed(() =>
listsStore.lists.map((l) => ({
value: l.id,
label: l.displayName,
}))
);
const loader = useLoader();
async function fetchMany() {
const lists = await loader.wait(api.fetchMany({}));
listsStore.setLists(lists);
}
return reactive({
loading: loader.loading,
selectItems,
fetchMany,
});
}

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from "vue";
import { useListsTableStore } from "./store";
const store = useListsTableStore();
const columns = computed(() => [{ key: "displayName", title: "Название" }]);
store.fetchMany();
</script>
<template>
<BaseTable
:loading="store.loading"
:columns="columns"
:dataItems="store.lists"
>
<template #displayName="{ dataItem }">
<RouterLink
:to="{ name: 'audience_contacts', params: { listId: dataItem.id } }"
>
{{ dataItem.displayName }}
</RouterLink>
</template>
</BaseTable>
</template>

View File

@ -0,0 +1,3 @@
export { default as AudienceListsTable } from "./AudienceListsTable.vue";
export * from "./ports";
export { LISTS_TABLE_API_PROVIDE_KEY } from "./store";

View File

@ -0,0 +1,4 @@
import type { BaseFetchManyApiPort } from "@/app/common/ports";
import type { List } from "@/domain/entities/list";
export type ListsTableApiPort = BaseFetchManyApiPort<List>;

View File

@ -0,0 +1,30 @@
import { useLoader } from "@/shared/lib/composables/loader";
import { validInject } from "@/shared/lib/di";
import { storeToRefs } from "pinia";
import { reactive } from "vue";
import { useListsStore } from "../store";
import type { ListsTableApiPort } from "./ports";
export const LISTS_TABLE_API_PROVIDE_KEY = Symbol("ListsTableApi");
export function useListsTableStore() {
const api = validInject<ListsTableApiPort>(
LISTS_TABLE_API_PROVIDE_KEY,
"ContactsTableApiPort"
);
const listsStore = useListsStore();
const { lists } = storeToRefs(listsStore);
const loader = useLoader();
async function fetchMany() {
const lists = await loader.wait(api.fetchMany({}));
listsStore.setLists(lists);
}
return reactive({
loading: loader.loading,
lists,
fetchMany,
});
}

8
src/app/lists/index.ts Normal file
View File

@ -0,0 +1,8 @@
import type { ListsTableApiPort } from "./ListsTable";
import type { ListsSelectApiPort } from "./ListsSelect";
import type { ListFormApiPort } from "./ListForm";
export type { ListFormApiCreateProps } from "./ListForm";
export type ListApiPort = ListsTableApiPort &
ListsSelectApiPort &
ListFormApiPort;

17
src/app/lists/store.ts Normal file
View File

@ -0,0 +1,17 @@
import type { List } from "@/domain/entities/list";
import { defineStore } from "pinia";
import { ref } from "vue";
export const useListsStore = defineStore("lists", () => {
const data = ref([] as List[]);
function setLists(lists: List[]): void {
data.value = lists;
}
function addList(list: List): void {
data.value.unshift(list);
}
return { lists: data, setLists, addList };
});

176
src/assets/base.css Normal file
View File

@ -0,0 +1,176 @@
/* catppuccin latte color palette from <https://github.com/catppuccin/catppuccin> */
:root {
/* Rosewater #dc8a78 rgb(220, 138, 120) hsl(11, 59%, 67%) */
/* Flamingo #dd7878 rgb(221, 120, 120) hsl(0, 60%, 67%) */
/* Pink #ea76cb rgb(234, 118, 203) hsl(316, 73%, 69%) */
/* Mauve #8839ef rgb(136, 57, 239) hsl(266, 85%, 58%) */
/* Red #d20f39 rgb(210, 15, 57) hsl(347, 87%, 44%) */
/* Maroon #e64553 rgb(230, 69, 83) hsl(355, 76%, 59%) */
/* Peach #fe640b rgb(254, 100, 11) hsl(22, 99%, 52%) */
/* Yellow #df8e1d rgb(223, 142, 29) hsl(35, 77%, 49%) */
/* Green #40a02b rgb(64, 160, 43) hsl(109, 58%, 40%) */
/* Teal #179299 rgb(23, 146, 153) hsl(183, 74%, 35%) */
/* Sky #04a5e5 rgb(4, 165, 229) hsl(197, 97%, 46%) */
/* Sapphire #209fb5 rgb(32, 159, 181) hsl(189, 70%, 42%) */
/* Blue #1e66f5 rgb(30, 102, 245) hsl(220, 91%, 54%) */
/* Lavender #7287fd rgb(114, 135, 253) hsl(231, 97%, 72%) */
/* Text #4c4f69 rgb(76, 79, 105) hsl(234, 16%, 35%) */
/* Subtext1 #5c5f77 rgb(92, 95, 119) hsl(233, 13%, 41%) */
/* Subtext0 #6c6f85 rgb(108, 111, 133) hsl(233, 10%, 47%) */
/* Overlay2 #7c7f93 rgb(124, 127, 147) hsl(232, 10%, 53%) */
/* Overlay1 #8c8fa1 rgb(140, 143, 161) hsl(231, 10%, 59%) */
/* Overlay0 #9ca0b0 rgb(156, 160, 176) hsl(228, 11%, 65%) */
/* Surface2 #acb0be rgb(172, 176, 190) hsl(227, 12%, 71%) */
/* Surface1 #bcc0cc rgb(188, 192, 204) hsl(225, 14%, 77%) */
/* Surface0 #ccd0da rgb(204, 208, 218) hsl(223, 16%, 83%) */
/* Base #eff1f5 rgb(239, 241, 245) hsl(220, 23%, 95%) */
/* Mantle #e6e9ef rgb(230, 233, 239) hsl(220, 22%, 92%) */
/* Crust #dce0e8 rgb(220, 224, 232) hsl(220, 21%, 89%) */
--latte-rosewater: hsl(11, 59%, 67%);
--latte-flamingo: hsl(0, 60%, 67%);
--latte-pink: hsl(316, 73%, 69%);
--latte-mauve: hsl(266, 85%, 58%);
--latte-red: hsl(347, 87%, 44%);
--latte-maroon: hsl(355, 76%, 59%);
--latte-peach: hsl(22, 99%, 52%);
--latte-yellow: hsl(35, 77%, 49%);
--latte-green: hsl(109, 58%, 40%);
--latte-teal: hsl(183, 74%, 35%);
--latte-sky: hsl(197, 97%, 46%);
--latte-sapphire: hsl(189, 70%, 42%);
--latte-blue: hsl(220, 91%, 54%);
--latte-lavender: hsl(231, 97%, 72%);
--latte-text: hsl(234, 16%, 35%);
--latte-subtext1: hsl(233, 13%, 41%);
--latte-subtext0: hsl(233, 10%, 47%);
--latte-overlay2: hsl(232, 10%, 53%);
--latte-overlay1: hsl(231, 10%, 59%);
--latte-overlay0: hsl(228, 11%, 65%);
--latte-surface2: hsl(227, 12%, 71%);
--latte-surface1: hsl(225, 14%, 77%);
--latte-surface0: hsl(223, 16%, 83%);
--latte-base: hsl(220, 23%, 95%);
--latte-mantle: hsl(220, 22%, 92%);
--latte-crust: hsl(220, 21%, 89%);
}
/* catppuccin frappe color palette from <https://github.com/catppuccin/catppuccin> */
:root {
/* Rosewater #f2d5cf rgb(242, 213, 207) hsl(10, 57%, 88%) */
/* Flamingo #eebebe rgb(238, 190, 190) hsl(0, 59%, 84%) */
/* Pink #f4b8e4 rgb(244, 184, 228) hsl(316, 73%, 84%) */
/* Mauve #ca9ee6 rgb(202, 158, 230) hsl(277, 59%, 76%) */
/* Red #e78284 rgb(231, 130, 132) hsl(359, 68%, 71%) */
/* Maroon #ea999c rgb(234, 153, 156) hsl(358, 66%, 76%) */
/* Peach #ef9f76 rgb(239, 159, 118) hsl(20, 79%, 70%) */
/* Yellow #e5c890 rgb(229, 200, 144) hsl(40, 62%, 73%) */
/* Green #a6d189 rgb(166, 209, 137) hsl(96, 44%, 68%) */
/* Teal #81c8be rgb(129, 200, 190) hsl(172, 39%, 65%) */
/* Sky #99d1db rgb(153, 209, 219) hsl(189, 48%, 73%) */
/* Sapphire #85c1dc rgb(133, 193, 220) hsl(199, 55%, 69%) */
/* Blue #8caaee rgb(140, 170, 238) hsl(222, 74%, 74%) */
/* Lavender #babbf1 rgb(186, 187, 241) hsl(239, 66%, 84%) */
/* Text #c6d0f5 rgb(198, 208, 245) hsl(227, 70%, 87%) */
/* Subtext1 #b5bfe2 rgb(181, 191, 226) hsl(227, 44%, 80%) */
/* Subtext0 #a5adce rgb(165, 173, 206) hsl(228, 29%, 73%) */
/* Overlay2 #949cbb rgb(148, 156, 187) hsl(228, 22%, 66%) */
/* Overlay1 #838ba7 rgb(131, 139, 167) hsl(227, 17%, 58%) */
/* Overlay0 #737994 rgb(115, 121, 148) hsl(229, 13%, 52%) */
/* Surface2 #626880 rgb(98, 104, 128) hsl(228, 13%, 44%) */
/* Surface1 #51576d rgb(81, 87, 109) hsl(227, 15%, 37%) */
/* Surface0 #414559 rgb(65, 69, 89) hsl(230, 16%, 30%) */
/* Base #303446 rgb(48, 52, 70) hsl(229, 19%, 23%) */
/* Mantle #292c3c rgb(41, 44, 60) hsl(231, 19%, 20%) */
/* Crust #232634 rgb(35, 38, 52) hsl(229, 20%, 17%) */
--frappe-rosewater: hsl(10, 57%, 88%);
--frappe-flamingo: hsl(0, 59%, 84%);
--frappe-pink: hsl(316, 73%, 84%);
--frappe-mauve: hsl(277, 59%, 76%);
--frappe-red: hsl(359, 68%, 71%);
--frappe-maroon: hsl(358, 66%, 76%);
--frappe-peach: hsl(20, 79%, 70%);
--frappe-yellow: hsl(40, 62%, 73%);
--frappe-green: hsl(96, 44%, 68%);
--frappe-teal: hsl(172, 39%, 65%);
--frappe-sky: hsl(189, 48%, 73%);
--frappe-sapphire: hsl(199, 55%, 69%);
--frappe-blue: hsl(222, 74%, 74%);
--frappe-lavender: hsl(239, 66%, 84%);
--frappe-text: hsl(227, 70%, 87%);
--frappe-subtext1: hsl(227, 44%, 80%);
--frappe-subtext0: hsl(228, 29%, 73%);
--frappe-overlay2: hsl(228, 22%, 66%);
--frappe-overlay1: hsl(227, 17%, 58%);
--frappe-overlay0: hsl(229, 13%, 52%);
--frappe-surface2: hsl(228, 13%, 44%);
--frappe-surface1: hsl(227, 15%, 37%);
--frappe-surface0: hsl(230, 16%, 30%);
--frappe-base: hsl(229, 19%, 23%);
--frappe-mantle: hsl(231, 19%, 20%);
--frappe-crust: hsl(229, 20%, 17%);
}
/* catppuccin style guide https://github.com/catppuccin/catppuccin/blob/main/docs/style-guide.md */
:root {
--color-background: var(--latte-base);
--color-background-header: var(--latte-crust);
--color-text: var(--latte-text);
--color-link: var(--latte-rosewater);
--color-primary: var(--latte-lavender);
--color-button-background: var(--latte-surface2);
--color-button-text: var(--latte-text);
--color-spinner: var(--latte-text);
--color-table-background: var(--latte-surface0);
--color-table-border: var(--latte-overlay0);
}
/*@media (prefers-color-scheme: dark) {*/
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--frappe-base);
--color-background-header: var(--frappe-crust);
--color-text: var(--frappe-text);
--color-link: var(--frappe-rosewater);
--color-primary: var(--frappe-lavender);
--color-button-background: var(--frappe-surface2);
--color-button-text: var(--frappe-text);
--color-spinner: var(--frappe-text);
--color-table-background: var(--frappe-surface0);
--color-table-border: var(--frappe-overlay0);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
position: relative;
font-weight: normal;
transition: 0.3s;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition: color 0.5s, background-color 0.5s;
line-height: 1.6;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

1
src/assets/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69" xmlns:v="https://vecta.io/nano"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 308 B

42
src/assets/main.css Normal file
View File

@ -0,0 +1,42 @@
@import "./base.css";
@keyframes spinAround {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
#app {
width: 100%;
font-weight: normal;
}
.container {
max-width: 1280px;
width: 100%;
margin: 0 auto;
padding: 0 1rem;
}
a {
color: var(--color-link);
transition: 0.4s;
text-decoration: none;
border-bottom: 1px dashed;
}
@media (hover: hover) {
a:hover {
border-bottom-style: solid;
}
}
@media (min-width: 1024px) {
body {
display: flex;
}
}

View File

@ -0,0 +1,5 @@
export type EntityId = string;
export interface Entity {
id: EntityId;
}

View File

@ -0,0 +1,6 @@
import type { Entity } from "../common/entity";
export interface Contact extends Entity {
email: string;
createdAt: Date;
}

View File

@ -0,0 +1,6 @@
import type { Entity } from "../common/entity";
export interface AppFile extends Entity {
name: string;
createdAt: Date;
}

View File

@ -0,0 +1,5 @@
import type { Entity } from "../common/entity";
export interface List extends Entity {
displayName: string;
}

76
src/infra/api/contact.ts Normal file
View File

@ -0,0 +1,76 @@
import type {
ContactApiPort,
ContactFormApiCreateProps,
ContactsTableApiFetchManyProps,
} from "@/app/contacts";
import type { EntityId } from "@/domain/common/entity";
import type { Contact } from "@/domain/entities/contact";
import { delay } from "@/utils";
import { faker } from "@faker-js/faker";
let $instance: MockContactApi | undefined;
export class MockContactApi implements ContactApiPort {
#contactsByListId: Map<EntityId, Contact[]>;
constructor() {
this.#contactsByListId = new Map();
}
static getInstance() {
if (!$instance) {
$instance = new MockContactApi();
}
return $instance;
}
async create(data: ContactFormApiCreateProps): Promise<Contact> {
const contact: Contact = {
...data,
id: faker.internet.email().toLowerCase(),
createdAt: faker.date.birthdate(),
};
this.#getContacts().unshift(contact);
await delay(1000);
return contact;
}
async fetchMany({
listId,
}: ContactsTableApiFetchManyProps): Promise<Contact[]> {
const contacts = this.#getContacts(listId);
await delay(1000);
return contacts.concat();
}
#getContacts(listId?: EntityId): Contact[] {
const key = listId ?? "";
let contacts = this.#contactsByListId.get(key);
if (contacts == null) {
contacts = genMockContacts();
this.#contactsByListId.set(key, contacts);
}
return contacts;
}
}
function genMockContacts() {
return Array.from(new Array(20).keys()).map((_): Contact => {
const id = faker.datatype.uuid();
const email = faker.internet.email().toLowerCase();
const openedEmailCount = Number(faker.random.numeric());
const sentEmailCount = Number(faker.random.numeric(2));
const openedEmailPercent = (openedEmailCount / sentEmailCount) * 100;
const createdAt = faker.date.birthdate();
return {
id,
email,
createdAt,
/*
openedEmailCount,
openedEmailPercent,
sentEmailCount,
*/
};
});
}

31
src/infra/api/file.ts Normal file
View File

@ -0,0 +1,31 @@
import type { FileApiPort } from "@/app/files";
import type { AppFile } from "@/domain/entities/file";
import { delay } from "@/utils";
import { faker } from "@faker-js/faker";
let $instance: FileApiPort | undefined;
export class MockFileApi implements FileApiPort {
#files: AppFile[];
constructor() {
this.#files = Array.from(new Array(20).keys()).map((_) => {
const id = faker.datatype.uuid();
const name = faker.system.fileName();
const createdAt = faker.date.birthdate();
return { id, name, createdAt };
});
}
static getInstance() {
if (!$instance) {
$instance = new MockFileApi();
}
return $instance;
}
async fetchMany(): Promise<AppFile[]> {
await delay(1000);
return this.#files.concat();
}
}

41
src/infra/api/list.ts Normal file
View File

@ -0,0 +1,41 @@
import type { ListApiPort, ListFormApiCreateProps } from "@/app/lists";
import type { List } from "@/domain/entities/list";
import { delay } from "@/utils";
import { faker } from "@faker-js/faker";
let $instance: ListApiPort | undefined;
export class MockListApi implements ListApiPort {
#lists: List[];
constructor() {
this.#lists = Array.from(new Array(20).keys()).map((_) => {
const id = faker.datatype.uuid();
const displayName = faker.commerce.productName();
return { id, displayName };
});
}
static getInstance() {
if (!$instance) {
$instance = new MockListApi();
}
return $instance;
}
async create(createListData: ListFormApiCreateProps): Promise<List> {
const list: List = {
id: faker.datatype.uuid(),
...createListData,
};
this.#lists.unshift(list);
await delay(1000);
return list;
}
async fetchMany(): Promise<List[]> {
await delay(1000);
return this.#lists.concat();
}
}

22
src/infra/api/template.ts Normal file
View File

@ -0,0 +1,22 @@
import type { TemplateApiPort } from "@/app_core/ports/template";
import { delay } from "@/utils";
import { faker } from "@faker-js/faker";
import type { Template } from "../domain/template";
export class MockTemplateApi implements TemplateApiPort {
#templates: Template[];
constructor() {
this.#templates = Array.from(new Array(20).keys()).map((_) => {
const id = faker.datatype.uuid();
const displayName = faker.lorem.words(3);
const createdAt = faker.date.birthdate();
return { id, displayName, createdAt };
});
}
async fetchMany(): Promise<Template[]> {
await delay(1000);
return this.#templates.concat();
}
}

28
src/main.ts Normal file
View File

@ -0,0 +1,28 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import "./assets/main.css";
import {
BaseButton,
BaseNav,
BasePageHeader,
BaseSelect,
BaseTable,
} from "./shared/uikit";
const pinia = createPinia();
const app = createApp(App);
app.use(pinia);
app.use(router);
app.component("BaseNav", BaseNav);
app.component("BaseButton", BaseButton);
app.component("BaseSelect", BaseSelect);
app.component("BasePageHeader", BasePageHeader);
app.component("BaseTable", BaseTable);
app.mount("#app");

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref, watch, provide } from "vue";
import { useRouter, useRoute } from "vue-router";
import {
AudienceListsSelect,
LISTS_SELECT_API_PROVIDE_KEY,
} from "@/app/lists/ListsSelect";
import {
AudienceContactsTable,
CONTACTS_TABLE_API_PROVIDE_KEY,
} from "@/app/contacts/ContactsTable";
import { useMeta } from "@/shared/lib/composables/meta";
import { MockContactApi } from "@/infra/api/contact";
import { MockListApi } from "@/infra/api/list";
useMeta("Контакты | Аудитория | SM");
const router = useRouter();
const route = useRoute();
function openAddContactForm() {
router.push({ name: "audience_create_contact" });
}
const selectedListId = ref("");
watch(
() => route.params.listId,
(listId) => {
selectedListId.value = listId ?? "";
},
{ immediate: true }
);
function selectList(selectedListId) {
if (route.params.listId !== selectedListId) {
router.push({
name: "audience_contacts",
params: { listId: selectedListId },
});
}
}
const contactApi = MockContactApi.getInstance();
provide(CONTACTS_TABLE_API_PROVIDE_KEY, contactApi);
const listApi = MockListApi.getInstance();
provide(LISTS_SELECT_API_PROVIDE_KEY, listApi);
</script>
<template>
<div class="container">
<BasePageHeader>
Контакты
<template #extra>
<BaseButton @click="openAddContactForm">Добавить контакт</BaseButton>
<BaseButton :isPrimary="true">Импортировать контакты</BaseButton>
</template>
</BasePageHeader>
<AudienceListsSelect
:extraItems="[{ value: '', label: 'Все контакты' }]"
:moduleValue="selectedListId"
@update:moduleValue="selectList"
/>
<AudienceContactsTable :listId="selectedListId" />
</div>
</template>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import { provide } from "vue";
import { useRouter } from "vue-router";
import {
AudienceContactForm,
CONTACT_FORM_API_PROVIDE_KEY,
} from "@/app/contacts/ContactForm";
import { useMeta } from "@/shared/lib/composables/meta";
import { MockContactApi } from "@/infra/api/contact";
useMeta("Создание контакта | Аудитория | SM");
const contactApi = MockContactApi.getInstance();
provide(CONTACT_FORM_API_PROVIDE_KEY, contactApi);
const router = useRouter();
function redirectToContacts() {
router.push({ name: "audience_contacts" });
}
</script>
<template>
<div class="container">
<BasePageHeader>
Создание контакта
<template #extra>
<BaseButton @click="redirectToContacts">Отменить</BaseButton>
</template>
</BasePageHeader>
<AudienceContactForm @submitForm="redirectToContacts" />
</div>
</template>

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import { provide } from "vue";
import { useRouter } from "vue-router";
import {
AudienceListForm,
LIST_FORM_API_PROVIDE_KEY,
} from "@/app/lists/ListForm";
import { useMeta } from "@/shared/lib/composables/meta";
import { MockListApi } from "@/infra/api/list";
useMeta("Создание списка | Аудитория | SM");
const listApi = MockListApi.getInstance();
provide(LIST_FORM_API_PROVIDE_KEY, listApi);
const router = useRouter();
function redirectToLists() {
router.push({ name: "audience_lists" });
}
</script>
<template>
<div class="container">
<BasePageHeader>
Создание списка
<template #extra>
<BaseButton @click="redirectToLists">Отменить</BaseButton>
</template>
</BasePageHeader>
<AudienceListForm @submitForm="redirectToLists" />
</div>
</template>

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
import { provide } from "vue";
import { useRouter } from "vue-router";
import {
AudienceListsTable,
LISTS_TABLE_API_PROVIDE_KEY,
} from "@/app/lists/ListsTable";
import { useMeta } from "@/shared/lib/composables/meta";
import { MockListApi } from "@/infra/api/list";
useMeta("Списки | Аудитория | SM");
const router = useRouter();
function openAddListForm() {
router.push({ name: "audience_create_list" });
}
const listsApi = MockListApi.getInstance();
provide(LISTS_TABLE_API_PROVIDE_KEY, listsApi);
</script>
<template>
<div class="container">
<BasePageHeader>
Списки
<template #extra>
<BaseButton :isPrimary="true" @click="openAddListForm">
Добавить список
</BaseButton>
</template>
</BasePageHeader>
<AudienceListsTable />
</div>
</template>

View File

@ -0,0 +1,14 @@
<script>
import { useMeta } from "@/shared/lib/composables/meta";
useMeta("Аудитория | SM");
</script>
<template>
<div class="container">
<BaseNav>
<RouterLink :to="{ name: 'audience_contacts' }">Контакты</RouterLink>
<RouterLink :to="{ name: 'audience_lists' }">Списки</RouterLink>
</BaseNav>
</div>
<RouterView />
</template>

26
src/pages/FilesPage.vue Normal file
View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import { provide } from "vue";
import {
FilesTable,
FILES_TABLE_API_PROVIDE_KEY,
} from "@/app/files/FilesTable";
import { useMeta } from "@/shared/lib/composables/meta";
import { MockFileApi } from "@/infra/api/file";
useMeta("Файлы | SM");
const fileApi = MockFileApi.getInstance();
provide(FILES_TABLE_API_PROVIDE_KEY, fileApi);
</script>
<template>
<div class="container">
<BasePageHeader>
Файлы
<template #extra>
<BaseButton :isPrimary="true">Добавить файл</BaseButton>
</template>
</BasePageHeader>
<FilesTable />
</div>
</template>

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import { useMeta } from "@/shared/lib/composables/meta";
useMeta("404 - Страница не найдена | SM");
</script>
<template>
<div class="container">
<h1>Страница не найдена</h1>
<p>Упс, мы не можем найти эту страницу</p>
</div>
</template>

55
src/router.ts Normal file
View File

@ -0,0 +1,55 @@
import { createRouter, createWebHistory } from "vue-router";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "dashboard",
redirect: { name: "audience" },
},
{
path: "/audience",
name: "audience",
redirect: { name: "audience_contacts" },
component: () => import("./pages/AudiencePage.vue"),
children: [
{
path: "contacts/new",
name: "audience_create_contact",
component: () => import("./pages/AudienceCreateContactPage.vue"),
},
{
path: "contacts/:listId?",
name: "audience_contacts",
component: () => import("./pages/AudienceContactsPage.vue"),
},
{
path: "lists/new",
name: "audience_create_list",
component: () => import("./pages/AudienceCreateListPage.vue"),
},
{
path: "lists",
name: "audience_lists",
component: () => import("./pages/AudienceListsPage.vue"),
},
],
},
{
path: "/files",
name: "email_files",
component: () => import("./pages/FilesPage.vue"),
},
{
path: "/:pathMatch(.*)*",
name: "not_found",
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import("./pages/NotFoundPage.vue"),
},
],
});
export default router;

View File

@ -0,0 +1,14 @@
import { ref } from "vue";
export function useLoader() {
const loading = ref(false);
async function wait<T>(process: Promise<T>): Promise<T> {
loading.value = true;
const res = await process;
loading.value = false;
return res;
}
return { loading, wait };
}

View File

@ -0,0 +1,7 @@
import { onMounted } from "vue";
export function useMeta(title: string) {
onMounted(() => {
document.title = title;
});
}

15
src/shared/lib/di.ts Normal file
View File

@ -0,0 +1,15 @@
import { inject } from "vue";
export function validInject<T>(key: Symbol, interfaceName?: string): T {
const api = inject<T>(key);
if (api == null) {
let errorMessage = `You have to provide "${key.toString()}"`;
if (interfaceName) {
errorMessage += `, that implement ${interfaceName} interface`;
}
throw new Error(errorMessage);
}
// TODO: it would be great if we could check the type for the injected object.
return api;
}

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
defineProps<{
isPrimary: boolean;
isLoading: boolean;
}>();
</script>
<template>
<button
type="button"
:class="{ '--primary': isPrimary, '--loading': isLoading }"
>
<slot />
</button>
</template>
<style scoped>
button {
border: none;
background: var(--color-button-background);
color: var(--color-button-text);
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
&:not(:last-child) {
margin-right: 0.5rem;
}
&.--primary {
background: var(--color-link);
color: var(--color-background);
}
&.--loading {
color: transparent !important;
pointer-events: none;
&::after {
position: absolute;
left: calc(50% - (1em * 0.5));
top: calc(50% - (1em * 0.5));
position: absolute !important;
animation: spinAround 0.5s infinite linear;
border: 2px solid #dbdbdb;
border-top-color: rgb(219, 219, 219);
border-right-color: rgb(219, 219, 219);
border-radius: 9999px;
border-right-color: transparent;
border-top-color: transparent;
content: "";
display: block;
height: 1em;
position: relative;
width: 1em;
}
}
}
</style>

View File

@ -0,0 +1,31 @@
<template>
<nav>
<slot />
</nav>
</template>
<style scoped lang="postcss">
nav {
width: 100%;
font-size: 1rem;
:slotted(a) {
display: inline-block;
border: none;
color: var(--color-text);
&:hover {
color: var(--color-link);
}
&:not(:last-child) {
margin-right: 1rem;
}
&.router-link-active {
cursor: default;
color: var(--color-link);
}
}
}
</style>

View File

@ -0,0 +1,18 @@
<template>
<header>
<h1><slot /></h1>
<div>
<slot name="extra" />
</div>
</header>
</template>
<style scoped>
header {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
width: 100%;
}
</style>

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed, PropType } from "vue";
interface SelectItem {
value: string;
label: string;
disabled?: boolean;
}
const props = defineProps({
items: {
type: Array as PropType<SelectItem[]>,
required: true,
},
// Define props for v-model directive
modelValue: String,
});
const emit = defineEmits(["update:modelValue"]);
const value = computed({
get() {
return props.modelValue;
},
set(value) {
emit("update:modelValue", value);
},
});
</script>
<template>
<select v-model="value">
<option
v-for="selectItem in items"
:key="selectItem.value"
:value="selectItem.value"
:disabled="selectItem.disabled"
>
{{ selectItem.label }}
</option>
</select>
</template>

View File

@ -0,0 +1,107 @@
<script lang="ts">
import { defineComponent, PropType } from "vue";
interface TableColumn {
key: string;
title: string;
}
export default defineComponent({
props: {
loading: Boolean,
columns: {
type: Array as PropType<TableColumn[]>,
required: true,
},
dataItems: {
type: Array,
required: true,
},
},
});
</script>
<template>
<div class="wrapper" :class="{ '--loading': loading }">
<table>
<thead>
<tr>
<th v-for="column in columns" :key="column.key">
{{ column.title }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(dataItem, index) in dataItems" :key="dataItem.id || index">
<td v-for="column in columns" :key="column.key">
<slot :name="column.key" :dataItem="dataItem">{{
dataItem[column.key]
}}</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped lang="postcss">
.wrapper {
width: 100%;
min-height: 200px;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
td,
th {
text-align: left;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--color-table-border);
}
th {
background: var(--color-table-background);
font-weight: bold;
&:not(:last-child) {
border-right: 1px solid var(--color-table-border);
}
}
tbody tr:hover {
background: var(--color-table-background);
}
.--loading {
pointer-events: none;
tbody,
thead {
opacity: 0.7;
}
&::after {
position: absolute;
left: calc(50% - (1em * 0.5));
top: 100px;
position: absolute !important;
animation: spinAround 0.5s infinite linear;
border: 2px solid var(--color-spinner);
border-top-color: rgb(219, 219, 219);
border-right-color: rgb(219, 219, 219);
border-radius: 9999px;
border-right-color: transparent;
border-top-color: transparent;
content: "";
display: block;
height: 2em;
position: relative;
width: 2em;
z-index: 2;
}
}
</style>

View File

@ -0,0 +1,5 @@
export { default as BaseButton } from "./BaseButton.vue";
export { default as BaseNav } from "./BaseNav.vue";
export { default as BasePageHeader } from "./BasePageHeader.vue";
export { default as BaseSelect } from "./BaseSelect.vue";
export { default as BaseTable } from "./BaseTable.vue";

1
src/shims-vue.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module "*.vue";

12
src/utils.ts Normal file
View File

@ -0,0 +1,12 @@
export function formatDate(date: Date): string {
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
// This util is very useful for mocks to emulate http delay
export async function delay(timeout: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, timeout));
}

12
tsconfig.app.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

8
tsconfig.config.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"],
"compilerOptions": {
"composite": true,
"types": ["node"]
}
}

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.config.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}

9
tsconfig.vitest.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"composite": true,
"lib": [],
"types": ["node", "jsdom"]
}
}

20
vite.config.ts Normal file
View File

@ -0,0 +1,20 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
css: {
postcss: {
plugins: [require("postcss-nested")],
},
},
build: { minify: false },
});